suparisma 1.0.6 → 1.0.8

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
@@ -23,6 +23,7 @@ A powerful, typesafe React hook generator for Supabase, driven by your Prisma sc
23
23
  - [Filtering Data](#filtering-data)
24
24
  - [⚠️ IMPORTANT: Using Dynamic Filters with React](#️-important-using-dynamic-filters-with-react)
25
25
  - [Array Filtering](#array-filtering)
26
+ - [OR/AND Conditions](#orand-conditions)
26
27
  - [Sorting Data](#sorting-data)
27
28
  - [Pagination](#pagination)
28
29
  - [Search Functionality](#search-functionality)
@@ -61,6 +62,7 @@ Suparisma bridges this gap by:
61
62
  - 🔄 **Real-time updates by default** for all tables (with opt-out capability)
62
63
  - 🔒 **Type-safe interfaces** for all database operations
63
64
  - 🔍 **Full-text search** with configurable annotations *(currently under maintenance)*
65
+ - ⚡ **Advanced filtering** with OR/AND conditions for complex queries
64
66
  - 🔢 **Pagination and sorting** built into every hook
65
67
  - 🧩 **Prisma-like API** that feels familiar if you already use Prisma
66
68
  - 📱 **Works with any React framework** including Next.js, Remix, etc.
@@ -580,6 +582,215 @@ function ProductFilter() {
580
582
  }
581
583
  ```
582
584
 
585
+ ### OR/AND Conditions
586
+
587
+ Suparisma supports powerful logical operations to combine multiple filter conditions:
588
+
589
+ #### OR Conditions - Match ANY condition
590
+
591
+ Use `OR` to find records that match **any** of the provided conditions:
592
+
593
+ ```tsx
594
+ // Find users with name "John" OR email containing "@admin"
595
+ const { data: users } = useSuparisma.user({
596
+ where: {
597
+ OR: [
598
+ { name: "John" },
599
+ { email: { contains: "@admin" } }
600
+ ]
601
+ }
602
+ });
603
+
604
+ // Complex OR with multiple field types
605
+ const { data: posts } = useSuparisma.post({
606
+ where: {
607
+ OR: [
608
+ { title: { contains: "react" } },
609
+ { tags: { has: ["typescript"] } },
610
+ { author: { name: "John Doe" } },
611
+ { publishedAt: { gte: new Date('2024-01-01') } }
612
+ ]
613
+ }
614
+ });
615
+
616
+ // OR with array operators
617
+ const { data: products } = useSuparisma.product({
618
+ where: {
619
+ OR: [
620
+ { categories: { has: ["electronics"] } },
621
+ { categories: { has: ["gaming"] } },
622
+ { price: { lt: 100 } }
623
+ ]
624
+ }
625
+ });
626
+ ```
627
+
628
+ #### AND Conditions - Match ALL conditions
629
+
630
+ Use `AND` for explicit conjunction (though regular object properties are ANDed by default):
631
+
632
+ ```tsx
633
+ // Explicit AND conditions
634
+ const { data: premiumUsers } = useSuparisma.user({
635
+ where: {
636
+ AND: [
637
+ { active: true },
638
+ { subscriptionTier: "premium" },
639
+ { lastLoginAt: { gte: new Date('2024-01-01') } }
640
+ ]
641
+ }
642
+ });
643
+
644
+ // Mix AND with other conditions
645
+ const { data: qualifiedPosts } = useSuparisma.post({
646
+ where: {
647
+ published: true, // Regular condition (implicit AND)
648
+ AND: [
649
+ { views: { gte: 1000 } },
650
+ { likes: { gte: 100 } }
651
+ ]
652
+ }
653
+ });
654
+ ```
655
+
656
+ #### Combining OR and AND
657
+
658
+ Create complex nested logic by combining OR and AND:
659
+
660
+ ```tsx
661
+ // Advanced search: (Premium users OR moderators) AND active
662
+ const { data: privilegedUsers } = useSuparisma.user({
663
+ where: {
664
+ active: true, // Must be active
665
+ OR: [
666
+ { role: "premium" },
667
+ { role: "moderator" }
668
+ ]
669
+ }
670
+ });
671
+
672
+ // Content filtering: (Recent posts OR popular posts) AND published
673
+ const { data: featuredPosts } = useSuparisma.post({
674
+ where: {
675
+ published: true,
676
+ OR: [
677
+ { createdAt: { gte: new Date('2024-01-01') } }, // Recent
678
+ { views: { gte: 10000 } } // Popular
679
+ ]
680
+ }
681
+ });
682
+
683
+ // Complex search across multiple fields and conditions
684
+ const { data: searchResults } = useSuparisma.product({
685
+ where: {
686
+ active: true,
687
+ AND: [
688
+ {
689
+ OR: [
690
+ { name: { contains: searchTerm } },
691
+ { description: { contains: searchTerm } },
692
+ { tags: { has: [searchTerm] } }
693
+ ]
694
+ },
695
+ {
696
+ OR: [
697
+ { price: { between: [minPrice, maxPrice] } },
698
+ { onSale: true }
699
+ ]
700
+ }
701
+ ]
702
+ }
703
+ });
704
+ ```
705
+
706
+ #### Real-World Examples
707
+
708
+ **Multi-field search for e-commerce:**
709
+ ```tsx
710
+ const { data: products } = useSuparisma.product({
711
+ where: {
712
+ OR: [
713
+ { name: { contains: "laptop" } },
714
+ { description: { contains: "laptop" } },
715
+ { categories: { has: ["computers", "electronics"] } },
716
+ { brand: { in: ["Apple", "Dell", "HP"] } }
717
+ ]
718
+ }
719
+ });
720
+ ```
721
+
722
+ **User permission filtering:**
723
+ ```tsx
724
+ const { data: accessiblePosts } = useSuparisma.post({
725
+ where: {
726
+ OR: [
727
+ { public: true },
728
+ { authorId: currentUserId },
729
+ {
730
+ AND: [
731
+ { published: true },
732
+ { collaborators: { has: [currentUserId] } }
733
+ ]
734
+ }
735
+ ]
736
+ }
737
+ });
738
+ ```
739
+
740
+ **Dynamic search with React state:**
741
+ ```tsx
742
+ import { useMemo } from 'react';
743
+
744
+ function SearchComponent() {
745
+ const [searchTerm, setSearchTerm] = useState("");
746
+ const [category, setCategory] = useState("");
747
+ const [priceRange, setPriceRange] = useState({ min: 0, max: 1000 });
748
+
749
+ // ✅ Use useMemo for stable object reference
750
+ const searchFilter = useMemo(() => {
751
+ const conditions = [];
752
+
753
+ // Add search term condition
754
+ if (searchTerm) {
755
+ conditions.push({
756
+ OR: [
757
+ { name: { contains: searchTerm } },
758
+ { description: { contains: searchTerm } },
759
+ { tags: { has: [searchTerm] } }
760
+ ]
761
+ });
762
+ }
763
+
764
+ // Add category condition
765
+ if (category) {
766
+ conditions.push({
767
+ categories: { has: [category] }
768
+ });
769
+ }
770
+
771
+ // Add price range condition
772
+ conditions.push({
773
+ price: { gte: priceRange.min, lte: priceRange.max }
774
+ });
775
+
776
+ return conditions.length > 0 ? { AND: conditions } : undefined;
777
+ }, [searchTerm, category, priceRange]);
778
+
779
+ const { data: products } = useSuparisma.product({
780
+ where: searchFilter
781
+ });
782
+
783
+ // ... rest of component
784
+ }
785
+ ```
786
+
787
+ #### Important Notes
788
+
789
+ 1. **Performance**: OR conditions may require client-side filtering for realtime updates
790
+ 2. **Realtime**: Complex OR/AND combinations automatically enable client-side filtering mode
791
+ 3. **Memory**: Use `useMemo` for dynamic OR/AND filters to maintain stable object references
792
+ 4. **Nesting**: You can nest OR within AND and vice versa for complex logic
793
+
583
794
  ### Sorting Data
584
795
 
585
796
  Sort data using Prisma-like ordering:
@@ -94,6 +94,21 @@ export type FilterOperators<T> = {
94
94
  // Type for a single field in an advanced where filter
95
95
  export type AdvancedWhereInput<T> = {
96
96
  [K in keyof T]?: T[K] | FilterOperators<T[K]>;
97
+ } & {
98
+ /**
99
+ * OR condition - match records that satisfy ANY of the provided conditions
100
+ * @example
101
+ * // Find users where name is "John" OR email contains "@admin"
102
+ * { OR: [{ name: "John" }, { email: { contains: "@admin" } }] }
103
+ */
104
+ OR?: AdvancedWhereInput<T>[];
105
+ /**
106
+ * AND condition - match records that satisfy ALL of the provided conditions (default behavior)
107
+ * @example
108
+ * // Find users where name is "John" AND age > 18
109
+ * { AND: [{ name: "John" }, { age: { gt: 18 } }] }
110
+ */
111
+ AND?: AdvancedWhereInput<T>[];
97
112
  };
98
113
 
99
114
  /**
@@ -177,6 +192,19 @@ export type SearchState = {
177
192
  clearQueries: () => void;
178
193
  };
179
194
 
195
+ /**
196
+ * Escape values for Supabase filter strings
197
+ */
198
+ function escapeFilterValue(value: any): string {
199
+ if (typeof value === 'string') {
200
+ // If the string contains spaces or special characters, wrap it in quotes
201
+ if (value.includes(' ') || value.includes(',') || value.includes('(') || value.includes(')')) {
202
+ return \`"\${value.replace(/"/g, '\\\\"')}"\`;
203
+ }
204
+ }
205
+ return String(value);
206
+ }
207
+
180
208
  /**
181
209
  * Compare two values for sorting with proper type handling
182
210
  */
@@ -215,9 +243,73 @@ export function buildFilterString<T>(where?: T): string | undefined {
215
243
  if (!where) return undefined;
216
244
 
217
245
  const filters: string[] = [];
246
+ const orConditions: string[] = [];
218
247
 
219
248
  for (const [key, value] of Object.entries(where)) {
220
249
  if (value !== undefined) {
250
+ // Handle OR conditions
251
+ if (key === 'OR' && Array.isArray(value)) {
252
+ const orParts: string[] = [];
253
+ for (const orCondition of value) {
254
+ // Build individual OR condition parts using proper dot notation
255
+ for (const [orKey, orValue] of Object.entries(orCondition)) {
256
+ if (orValue !== undefined) {
257
+ if (typeof orValue === 'object' && orValue !== null) {
258
+ // Handle advanced operators in OR conditions
259
+ const advancedOps = orValue as unknown as FilterOperators<any>;
260
+
261
+ if ('equals' in advancedOps && advancedOps.equals !== undefined) {
262
+ orParts.push(\`\${orKey}.eq.\${advancedOps.equals}\`);
263
+ } else if ('contains' in advancedOps && advancedOps.contains !== undefined) {
264
+ orParts.push(\`\${orKey}.ilike.*\${advancedOps.contains}*\`);
265
+ } else if ('has' in advancedOps && advancedOps.has !== undefined) {
266
+ // Use PostgreSQL array syntax: {item1,item2} not ["item1","item2"]
267
+ const arrayValue = \`{\${advancedOps.has.map((item: any) => escapeFilterValue(item)).join(',')}}\`;
268
+ orParts.push(\`\${orKey}.ov.\${arrayValue}\`);
269
+ } else if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
270
+ // Use PostgreSQL array syntax: {item1,item2} not ["item1","item2"]
271
+ const arrayValue = \`{\${advancedOps.hasEvery.map((item: any) => escapeFilterValue(item)).join(',')}}\`;
272
+ orParts.push(\`\${orKey}.cs.\${arrayValue}\`);
273
+ } else if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
274
+ // Use PostgreSQL array syntax: {item1,item2} not ["item1","item2"]
275
+ const arrayValue = \`{\${advancedOps.hasSome.map((item: any) => escapeFilterValue(item)).join(',')}}\`;
276
+ orParts.push(\`\${orKey}.ov.\${arrayValue}\`);
277
+ } else if ('gt' in advancedOps && advancedOps.gt !== undefined) {
278
+ orParts.push(\`\${orKey}.gt.\${advancedOps.gt}\`);
279
+ } else if ('gte' in advancedOps && advancedOps.gte !== undefined) {
280
+ orParts.push(\`\${orKey}.gte.\${advancedOps.gte}\`);
281
+ } else if ('lt' in advancedOps && advancedOps.lt !== undefined) {
282
+ orParts.push(\`\${orKey}.lt.\${advancedOps.lt}\`);
283
+ } else if ('lte' in advancedOps && advancedOps.lte !== undefined) {
284
+ orParts.push(\`\${orKey}.lte.\${advancedOps.lte}\`);
285
+ } else if ('in' in advancedOps && advancedOps.in?.length) {
286
+ orParts.push(\`\${orKey}.in.(\${advancedOps.in.join(',')})\`);
287
+ }
288
+ } else {
289
+ // Simple equality in OR condition
290
+ orParts.push(\`\${orKey}.eq.\${escapeFilterValue(orValue)}\`);
291
+ }
292
+ }
293
+ }
294
+ }
295
+
296
+ if (orParts.length > 0) {
297
+ orConditions.push(\`or(\${orParts.join(',')})\`);
298
+ }
299
+ continue;
300
+ }
301
+
302
+ // Handle AND conditions (explicit)
303
+ if (key === 'AND' && Array.isArray(value)) {
304
+ for (const andCondition of value) {
305
+ const andFilter = buildFilterString(andCondition);
306
+ if (andFilter) {
307
+ filters.push(andFilter);
308
+ }
309
+ }
310
+ continue;
311
+ }
312
+
221
313
  if (typeof value === 'object' && value !== null) {
222
314
  // Handle advanced operators
223
315
  const advancedOps = value as unknown as FilterOperators<any>;
@@ -265,19 +357,22 @@ export function buildFilterString<T>(where?: T): string | undefined {
265
357
  // Array-specific operators
266
358
  if ('has' in advancedOps && advancedOps.has !== undefined) {
267
359
  // Array contains ANY of the specified items (overlaps)
268
- const arrayValue = JSON.stringify(advancedOps.has);
360
+ // Use PostgreSQL array syntax: {item1,item2} not ["item1","item2"]
361
+ const arrayValue = \`{\${advancedOps.has.map((item: any) => escapeFilterValue(item)).join(',')}}\`;
269
362
  filters.push(\`\${key}=ov.\${arrayValue}\`);
270
363
  }
271
364
 
272
365
  if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
273
366
  // Array contains ALL of the specified items (contains)
274
- const arrayValue = JSON.stringify(advancedOps.hasEvery);
367
+ // Use PostgreSQL array syntax: {item1,item2} not ["item1","item2"]
368
+ const arrayValue = \`{\${advancedOps.hasEvery.map((item: any) => escapeFilterValue(item)).join(',')}}\`;
275
369
  filters.push(\`\${key}=cs.\${arrayValue}\`);
276
370
  }
277
371
 
278
372
  if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
279
373
  // Array contains ANY of the specified items (overlaps)
280
- const arrayValue = JSON.stringify(advancedOps.hasSome);
374
+ // Use PostgreSQL array syntax: {item1,item2} not ["item1","item2"]
375
+ const arrayValue = \`{\${advancedOps.hasSome.map((item: any) => escapeFilterValue(item)).join(',')}}\`;
281
376
  filters.push(\`\${key}=ov.\${arrayValue}\`);
282
377
  }
283
378
 
@@ -297,7 +392,18 @@ export function buildFilterString<T>(where?: T): string | undefined {
297
392
  }
298
393
  }
299
394
 
300
- return filters.length > 0 ? filters.join(',') : undefined;
395
+ // Combine filters with OR conditions
396
+ const allConditions: string[] = [];
397
+
398
+ if (filters.length > 0) {
399
+ allConditions.push(filters.join(','));
400
+ }
401
+
402
+ if (orConditions.length > 0) {
403
+ allConditions.push(\`or(\${orConditions.join(',')})\`);
404
+ }
405
+
406
+ return allConditions.length > 0 ? allConditions.join(',') : undefined;
301
407
  }
302
408
 
303
409
  /**
@@ -314,6 +420,69 @@ export function applyFilter<T>(
314
420
  // Apply each filter condition
315
421
  for (const [key, value] of Object.entries(where)) {
316
422
  if (value !== undefined) {
423
+ // Handle OR conditions
424
+ if (key === 'OR' && Array.isArray(value)) {
425
+ // For OR conditions, build the proper dot notation filter string
426
+ const orParts: string[] = [];
427
+
428
+ for (const orCondition of value) {
429
+ for (const [orKey, orValue] of Object.entries(orCondition)) {
430
+ if (orValue !== undefined) {
431
+ if (typeof orValue === 'object' && orValue !== null) {
432
+ // Handle advanced operators in OR conditions
433
+ const advancedOps = orValue as unknown as FilterOperators<any>;
434
+
435
+ if ('equals' in advancedOps && advancedOps.equals !== undefined) {
436
+ orParts.push(\`\${orKey}.eq.\${advancedOps.equals}\`);
437
+ } else if ('contains' in advancedOps && advancedOps.contains !== undefined) {
438
+ orParts.push(\`\${orKey}.ilike.*\${advancedOps.contains}*\`);
439
+ } else if ('has' in advancedOps && advancedOps.has !== undefined) {
440
+ // Use PostgreSQL array syntax: {item1,item2} not ["item1","item2"]
441
+ const arrayValue = \`{\${advancedOps.has.map((item: any) => escapeFilterValue(item)).join(',')}}\`;
442
+ orParts.push(\`\${orKey}.ov.\${arrayValue}\`);
443
+ } else if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
444
+ // Use PostgreSQL array syntax: {item1,item2} not ["item1","item2"]
445
+ const arrayValue = \`{\${advancedOps.hasEvery.map((item: any) => escapeFilterValue(item)).join(',')}}\`;
446
+ orParts.push(\`\${orKey}.cs.\${arrayValue}\`);
447
+ } else if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
448
+ // Use PostgreSQL array syntax: {item1,item2} not ["item1","item2"]
449
+ const arrayValue = \`{\${advancedOps.hasSome.map((item: any) => escapeFilterValue(item)).join(',')}}\`;
450
+ orParts.push(\`\${orKey}.ov.\${arrayValue}\`);
451
+ } else if ('gt' in advancedOps && advancedOps.gt !== undefined) {
452
+ orParts.push(\`\${orKey}.gt.\${advancedOps.gt}\`);
453
+ } else if ('gte' in advancedOps && advancedOps.gte !== undefined) {
454
+ orParts.push(\`\${orKey}.gte.\${advancedOps.gte}\`);
455
+ } else if ('lt' in advancedOps && advancedOps.lt !== undefined) {
456
+ orParts.push(\`\${orKey}.lt.\${advancedOps.lt}\`);
457
+ } else if ('lte' in advancedOps && advancedOps.lte !== undefined) {
458
+ orParts.push(\`\${orKey}.lte.\${advancedOps.lte}\`);
459
+ } else if ('in' in advancedOps && advancedOps.in?.length) {
460
+ orParts.push(\`\${orKey}.in.(\${advancedOps.in.join(',')})\`);
461
+ }
462
+ } else {
463
+ // Simple equality in OR condition
464
+ orParts.push(\`\${orKey}.eq.\${escapeFilterValue(orValue)}\`);
465
+ }
466
+ }
467
+ }
468
+ }
469
+
470
+ if (orParts.length > 0) {
471
+ // @ts-ignore: Supabase typing issue
472
+ filteredQuery = filteredQuery.or(orParts.join(','));
473
+ }
474
+ continue;
475
+ }
476
+
477
+ // Handle AND conditions (explicit)
478
+ if (key === 'AND' && Array.isArray(value)) {
479
+ // For explicit AND conditions, apply each condition normally
480
+ for (const andCondition of value) {
481
+ filteredQuery = applyFilter(filteredQuery, andCondition);
482
+ }
483
+ continue;
484
+ }
485
+
317
486
  if (typeof value === 'object' && value !== null) {
318
487
  // Handle advanced operators
319
488
  const advancedOps = value as unknown as FilterOperators<any>;
@@ -445,6 +614,112 @@ export function applyOrderBy<T>(
445
614
  return orderedQuery;
446
615
  }
447
616
 
617
+ /**
618
+ * Client-side filter validation for realtime events
619
+ */
620
+ function clientSideFilterCheck<T>(record: any, where: T): boolean {
621
+ if (!where) return true;
622
+
623
+ for (const [key, value] of Object.entries(where)) {
624
+ if (value !== undefined) {
625
+ // Handle OR conditions
626
+ if (key === 'OR' && Array.isArray(value)) {
627
+ // For OR, at least one condition must match
628
+ const orMatches = value.some(orCondition => clientSideFilterCheck(record, orCondition));
629
+ if (!orMatches) {
630
+ return false;
631
+ }
632
+ continue;
633
+ }
634
+
635
+ // Handle AND conditions (explicit)
636
+ if (key === 'AND' && Array.isArray(value)) {
637
+ // For AND, all conditions must match
638
+ const andMatches = value.every(andCondition => clientSideFilterCheck(record, andCondition));
639
+ if (!andMatches) {
640
+ return false;
641
+ }
642
+ continue;
643
+ }
644
+
645
+ if (typeof value === 'object' && value !== null) {
646
+ // Handle complex array filters client-side
647
+ const advancedOps = value as any;
648
+ const recordValue = record[key as keyof typeof record] as any;
649
+
650
+ // Array-specific operators validation
651
+ if ('has' in advancedOps && advancedOps.has !== undefined) {
652
+ // Array contains ANY of the specified items
653
+ if (!Array.isArray(recordValue) || !advancedOps.has.some((item: any) => recordValue.includes(item))) {
654
+ return false;
655
+ }
656
+ } else if ('hasEvery' in advancedOps && advancedOps.hasEvery !== undefined) {
657
+ // Array contains ALL of the specified items
658
+ if (!Array.isArray(recordValue) || !advancedOps.hasEvery.every((item: any) => recordValue.includes(item))) {
659
+ return false;
660
+ }
661
+ } else if ('hasSome' in advancedOps && advancedOps.hasSome !== undefined) {
662
+ // Array contains ANY of the specified items
663
+ if (!Array.isArray(recordValue) || !advancedOps.hasSome.some((item: any) => recordValue.includes(item))) {
664
+ return false;
665
+ }
666
+ } else if ('isEmpty' in advancedOps && advancedOps.isEmpty !== undefined) {
667
+ // Array is empty or not empty
668
+ const isEmpty = !Array.isArray(recordValue) || recordValue.length === 0;
669
+ if (isEmpty !== advancedOps.isEmpty) {
670
+ return false;
671
+ }
672
+ } else if ('equals' in advancedOps && advancedOps.equals !== undefined) {
673
+ if (recordValue !== advancedOps.equals) {
674
+ return false;
675
+ }
676
+ } else if ('not' in advancedOps && advancedOps.not !== undefined) {
677
+ if (recordValue === advancedOps.not) {
678
+ return false;
679
+ }
680
+ } else if ('gt' in advancedOps && advancedOps.gt !== undefined) {
681
+ if (!(recordValue > advancedOps.gt)) {
682
+ return false;
683
+ }
684
+ } else if ('gte' in advancedOps && advancedOps.gte !== undefined) {
685
+ if (!(recordValue >= advancedOps.gte)) {
686
+ return false;
687
+ }
688
+ } else if ('lt' in advancedOps && advancedOps.lt !== undefined) {
689
+ if (!(recordValue < advancedOps.lt)) {
690
+ return false;
691
+ }
692
+ } else if ('lte' in advancedOps && advancedOps.lte !== undefined) {
693
+ if (!(recordValue <= advancedOps.lte)) {
694
+ return false;
695
+ }
696
+ } else if ('in' in advancedOps && advancedOps.in !== undefined) {
697
+ if (!advancedOps.in.includes(recordValue)) {
698
+ return false;
699
+ }
700
+ } else if ('contains' in advancedOps && advancedOps.contains !== undefined) {
701
+ if (!String(recordValue).toLowerCase().includes(String(advancedOps.contains).toLowerCase())) {
702
+ return false;
703
+ }
704
+ } else if ('startsWith' in advancedOps && advancedOps.startsWith !== undefined) {
705
+ if (!String(recordValue).toLowerCase().startsWith(String(advancedOps.startsWith).toLowerCase())) {
706
+ return false;
707
+ }
708
+ } else if ('endsWith' in advancedOps && advancedOps.endsWith !== undefined) {
709
+ if (!String(recordValue).toLowerCase().endsWith(String(advancedOps.endsWith).toLowerCase())) {
710
+ return false;
711
+ }
712
+ }
713
+ } else if (record[key as keyof typeof record] !== value) {
714
+ console.log(\`Filter mismatch on \${key}\`, { expected: value, actual: record[key as keyof typeof record] });
715
+ return false;
716
+ }
717
+ }
718
+ }
719
+
720
+ return true;
721
+ }
722
+
448
723
  /**
449
724
  * Core hook factory function that creates a type-safe realtime hook for a specific model.
450
725
  * This is the foundation for all Suparisma hooks.
@@ -488,12 +763,23 @@ export function createSuparismaHook<
488
763
  * const users = useSuparismaUser();
489
764
  * const { data, loading, error } = users;
490
765
  *
491
- * @example
492
- * // With filtering
493
- * const users = useSuparismaUser({
494
- * where: { role: 'admin' },
495
- * orderBy: { created_at: 'desc' }
496
- * });
766
+ * @example
767
+ * // With filtering
768
+ * const users = useSuparismaUser({
769
+ * where: { role: 'admin' },
770
+ * orderBy: { created_at: 'desc' }
771
+ * });
772
+ *
773
+ * @example
774
+ * // With OR conditions for search
775
+ * const users = useSuparismaUser({
776
+ * where: {
777
+ * OR: [
778
+ * { name: { contains: "john" } },
779
+ * { email: { contains: "john" } }
780
+ * ]
781
+ * }
782
+ * });
497
783
  */
498
784
  return function useSuparismaHook(options: SuparismaOptions<TWhereInput, TOrderByInput> = {}) {
499
785
  const {
@@ -532,7 +818,7 @@ export function createSuparismaHook<
532
818
  // Single data collection for holding results
533
819
  const [data, setData] = useState<TWithRelations[]>([]);
534
820
  const [error, setError] = useState<Error | null>(null);
535
- const [loading, setLoading] = useState<boolean>(false);
821
+ const [loading, setLoading] = useState<boolean>(true);
536
822
 
537
823
  // This is the total count, unaffected by pagination limits
538
824
  const [count, setCount] = useState<number>(0);
@@ -704,19 +990,7 @@ export function createSuparismaHook<
704
990
 
705
991
  // Apply any where conditions client-side
706
992
  if (where) {
707
- results = results.filter((item) => {
708
- for (const [key, value] of Object.entries(where)) {
709
- if (typeof value === 'object' && value !== null) {
710
- // Skip complex filters for now
711
- continue;
712
- }
713
-
714
- if (item[key as keyof typeof item] !== value) {
715
- return false;
716
- }
717
- }
718
- return true;
719
- });
993
+ results = results.filter((item) => clientSideFilterCheck(item, where));
720
994
  }
721
995
 
722
996
  // Set count directly for search results
@@ -912,19 +1186,33 @@ export function createSuparismaHook<
912
1186
 
913
1187
  const channelId = channelName || \`changes_to_\${tableName}_\${Math.random().toString(36).substring(2, 15)}\`;
914
1188
 
915
- // Check if we have complex array filters that should be handled client-side only
1189
+ // Check if we have complex array filters or OR/AND conditions that should be handled client-side only
916
1190
  let hasComplexArrayFilters = false;
917
1191
  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;
1192
+ const checkForComplexFilters = (whereClause: any): boolean => {
1193
+ for (const [key, value] of Object.entries(whereClause)) {
1194
+ // OR/AND conditions require client-side filtering
1195
+ if (key === 'OR' || key === 'AND') {
1196
+ return true;
1197
+ }
1198
+
1199
+ if (typeof value === 'object' && value !== null) {
1200
+ // Check if it's an array of conditions (nested OR/AND)
1201
+ if (Array.isArray(value)) {
1202
+ return true;
1203
+ }
1204
+
1205
+ const advancedOps = value as any;
1206
+ // Check for complex array operators
1207
+ if ('has' in advancedOps || 'hasEvery' in advancedOps || 'hasSome' in advancedOps || 'isEmpty' in advancedOps) {
1208
+ return true;
1209
+ }
925
1210
  }
926
1211
  }
927
- }
1212
+ return false;
1213
+ };
1214
+
1215
+ hasComplexArrayFilters = checkForComplexFilters(where);
928
1216
  }
929
1217
 
930
1218
  // For complex array filters, use no database filter and rely on client-side filtering
@@ -981,49 +1269,7 @@ export function createSuparismaHook<
981
1269
  // ALWAYS check if this record matches our filter client-side
982
1270
  // This is especially important for complex array filters
983
1271
  if (currentWhere) { // Use ref value
984
- let matchesFilter = true;
985
-
986
- // Check each filter condition client-side for complex filters
987
- for (const [key, value] of Object.entries(currentWhere)) {
988
- if (typeof value === 'object' && value !== null) {
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
1021
- } else if (newRecord[key as keyof typeof newRecord] !== value) {
1022
- matchesFilter = false;
1023
- console.log(\`Filter mismatch on \${key}\`, { expected: value, actual: newRecord[key as keyof typeof newRecord] });
1024
- break;
1025
- }
1026
- }
1272
+ const matchesFilter = clientSideFilterCheck(newRecord, currentWhere);
1027
1273
 
1028
1274
  if (!matchesFilter) {
1029
1275
  console.log('New record does not match filter criteria, skipping');
@@ -1109,46 +1355,7 @@ export function createSuparismaHook<
1109
1355
 
1110
1356
  // Check if the updated record still matches our current filter
1111
1357
  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
- }
1358
+ const matchesFilter = clientSideFilterCheck(updatedRecord, currentWhere);
1152
1359
 
1153
1360
  // If the updated record doesn't match the filter, remove it from the list
1154
1361
  if (!matchesFilter) {
@@ -1874,6 +2081,7 @@ export function createSuparismaHook<
1874
2081
  : api;
1875
2082
  };
1876
2083
  }
2084
+
1877
2085
  `; // Ensure template literal is closed
1878
2086
  // Output to the UTILS_DIR
1879
2087
  const outputPath = path_1.default.join(config_1.UTILS_DIR, 'core.ts');
@@ -118,40 +118,44 @@ 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}'`;
121
+ try {
122
+ // Get the zod schemas file path from environment variable
123
+ const zodSchemasPath = process.env.ZOD_SCHEMAS_FILE_PATH || '../commonTypes';
124
+ // Add custom imports with environment variable path, but make them conditional
125
+ const importStatements = model.zodImports
126
+ .map(zodImport => {
127
+ // Extract the types from the original import statement
128
+ const typeMatch = zodImport.importStatement.match(/import\s+{\s*([^}]+)\s*}\s+from/);
129
+ if (typeMatch) {
130
+ const types = typeMatch[1].trim();
131
+ return `// Optional: install zod and create the commonTypes file if you want type safety for JSON fields\n// import { ${types} } from '${zodSchemasPath}'`;
132
+ }
133
+ return `// ${zodImport.importStatement}`;
134
+ })
135
+ .join('\n') + '\n\n';
136
+ customImports += importStatements;
137
+ // Add type definitions for imported zod schemas as simple types
138
+ const customTypeDefinitions = model.zodImports
139
+ .flatMap(zodImport => zodImport.types)
140
+ .filter((type, index, array) => array.indexOf(type) === index) // Remove duplicates
141
+ .map(type => {
142
+ // If it ends with 'Schema', create a corresponding type as any for now
143
+ if (type.endsWith('Schema')) {
144
+ const typeName = type.replace('Schema', '');
145
+ return `// Fallback type - replace with actual type if you implement zod schemas\nexport type ${typeName} = any;`;
146
+ }
147
+ return '';
148
+ })
149
+ .filter(Boolean)
150
+ .join('\n');
151
+ if (customTypeDefinitions) {
152
+ customImports += customTypeDefinitions + '\n\n';
133
153
  }
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
140
- const customTypeDefinitions = model.zodImports
141
- .flatMap(zodImport => zodImport.types)
142
- .filter((type, index, array) => array.indexOf(type) === index) // Remove duplicates
143
- .map(type => {
144
- // If it ends with 'Schema', create a corresponding type
145
- if (type.endsWith('Schema')) {
146
- const typeName = type.replace('Schema', '');
147
- return `export type ${typeName} = z.infer<typeof ${type}>;`;
148
- }
149
- return '';
150
- })
151
- .filter(Boolean)
152
- .join('\n');
153
- if (customTypeDefinitions) {
154
- customImports += customTypeDefinitions + '\n\n';
154
+ }
155
+ catch (error) {
156
+ // If there's any error with zod imports, just use a fallback
157
+ console.warn('Warning: Skipping custom zod imports due to configuration issues');
158
+ customImports = '';
155
159
  }
156
160
  }
157
161
  // Generate the type content with TSDoc comments
@@ -264,6 +268,28 @@ ${withRelationsProps
264
268
  .join(',\n')}
265
269
  * }
266
270
  * });
271
+ *
272
+ * @example
273
+ * // OR conditions - find records matching ANY condition
274
+ * ${modelName.toLowerCase()}.findMany({
275
+ * where: {
276
+ * OR: [
277
+ * { name: "John" },
278
+ * { email: { contains: "@admin" } }
279
+ * ]
280
+ * }
281
+ * });
282
+ *
283
+ * @example
284
+ * // AND conditions - find records matching ALL conditions
285
+ * ${modelName.toLowerCase()}.findMany({
286
+ * where: {
287
+ * AND: [
288
+ * { active: true },
289
+ * { age: { gte: 18 } }
290
+ * ]
291
+ * }
292
+ * });
267
293
  */
268
294
  export type ${modelName}WhereInput = {
269
295
  ${model.fields
@@ -285,6 +311,12 @@ ${model.fields
285
311
  }
286
312
  return '';
287
313
  }).filter(Boolean))
314
+ .concat([
315
+ ' /** OR condition - match records that satisfy ANY of the provided conditions */',
316
+ ` OR?: ${modelName}WhereInput[];`,
317
+ ' /** AND condition - match records that satisfy ALL of the provided conditions */',
318
+ ` AND?: ${modelName}WhereInput[];`
319
+ ])
288
320
  .join('\n')}
289
321
  };
290
322
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suparisma",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
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": {