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 +280 -1
- package/dist/generators/coreGenerator.js +230 -19
- package/dist/generators/typeGenerator.js +58 -2
- package/dist/parser.js +16 -2
- package/package.json +1 -1
- package/prisma/schema.prisma +10 -0
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
|
-
//
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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(
|
|
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)
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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]); //
|
|
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 =
|
|
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+)(\[\])
|
|
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
package/prisma/schema.prisma
CHANGED
|
@@ -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
|
}
|