suparisma 1.0.4 → 1.0.6
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 +82 -1
- package/dist/generators/coreGenerator.js +148 -19
- package/dist/generators/typeGenerator.js +70 -48
- package/dist/parser.js +118 -3
- package/package.json +1 -1
- package/prisma/schema.prisma +14 -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,83 @@ 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
|
+
|
|
306
385
|
### Array Filtering
|
|
307
386
|
|
|
308
387
|
Suparisma provides powerful operators for filtering array fields (e.g., `String[]`, `Int[]`, etc.):
|
|
@@ -543,6 +622,8 @@ const { data, count } = useSuparisma.thing();
|
|
|
543
622
|
|
|
544
623
|
### Search Functionality
|
|
545
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
|
+
|
|
546
627
|
For fields annotated with `// @enableSearch`, you can use full-text search:
|
|
547
628
|
|
|
548
629
|
```tsx
|
|
@@ -912,24 +912,52 @@ export function createSuparismaHook<
|
|
|
912
912
|
|
|
913
913
|
const channelId = channelName || \`changes_to_\${tableName}_\${Math.random().toString(36).substring(2, 15)}\`;
|
|
914
914
|
|
|
915
|
-
//
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
+
}
|
|
920
953
|
|
|
921
954
|
const channel = supabase
|
|
922
955
|
.channel(channelId)
|
|
923
956
|
.on(
|
|
924
957
|
'postgres_changes',
|
|
925
|
-
|
|
926
|
-
event: '*',
|
|
927
|
-
schema: 'public',
|
|
928
|
-
table: tableName,
|
|
929
|
-
filter: initialComputedFilter, // Subscription filter uses initial state
|
|
930
|
-
},
|
|
958
|
+
subscriptionConfig,
|
|
931
959
|
(payload) => {
|
|
932
|
-
console.log(
|
|
960
|
+
console.log(\`🔥 REALTIME EVENT RECEIVED for \${tableName}:\`, payload.eventType, payload);
|
|
933
961
|
|
|
934
962
|
// Access current options via refs inside the event handler
|
|
935
963
|
const currentWhere = whereRef.current;
|
|
@@ -938,7 +966,10 @@ export function createSuparismaHook<
|
|
|
938
966
|
const currentOffset = offsetRef.current; // Not directly used in handlers but good for consistency
|
|
939
967
|
|
|
940
968
|
// Skip realtime updates when search is active
|
|
941
|
-
if (isSearchingRef.current)
|
|
969
|
+
if (isSearchingRef.current) {
|
|
970
|
+
console.log('⏭️ Skipping realtime update - search is active');
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
942
973
|
|
|
943
974
|
if (payload.eventType === 'INSERT') {
|
|
944
975
|
// Process insert event
|
|
@@ -947,14 +978,46 @@ export function createSuparismaHook<
|
|
|
947
978
|
const newRecord = payload.new as TWithRelations;
|
|
948
979
|
console.log(\`Processing INSERT for \${tableName}\`, { newRecord });
|
|
949
980
|
|
|
950
|
-
//
|
|
981
|
+
// ALWAYS check if this record matches our filter client-side
|
|
982
|
+
// This is especially important for complex array filters
|
|
951
983
|
if (currentWhere) { // Use ref value
|
|
952
984
|
let matchesFilter = true;
|
|
953
985
|
|
|
954
|
-
// Check each filter condition
|
|
986
|
+
// Check each filter condition client-side for complex filters
|
|
955
987
|
for (const [key, value] of Object.entries(currentWhere)) {
|
|
956
988
|
if (typeof value === 'object' && value !== null) {
|
|
957
|
-
//
|
|
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
|
|
958
1021
|
} else if (newRecord[key as keyof typeof newRecord] !== value) {
|
|
959
1022
|
matchesFilter = false;
|
|
960
1023
|
console.log(\`Filter mismatch on \${key}\`, { expected: value, actual: newRecord[key as keyof typeof newRecord] });
|
|
@@ -1035,12 +1098,68 @@ export function createSuparismaHook<
|
|
|
1035
1098
|
// Access current options via refs
|
|
1036
1099
|
const currentOrderBy = orderByRef.current;
|
|
1037
1100
|
const currentLimit = limitRef.current; // If needed for re-fetch logic on update
|
|
1101
|
+
const currentWhere = whereRef.current;
|
|
1038
1102
|
|
|
1039
1103
|
// Skip if search is active
|
|
1040
1104
|
if (isSearchingRef.current) {
|
|
1041
1105
|
return prev;
|
|
1042
1106
|
}
|
|
1043
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
|
+
|
|
1044
1163
|
const newData = prev.map((item) =>
|
|
1045
1164
|
// @ts-ignore: Supabase typing issue
|
|
1046
1165
|
'id' in item && 'id' in payload.new && item.id === payload.new.id
|
|
@@ -1092,7 +1211,10 @@ export function createSuparismaHook<
|
|
|
1092
1211
|
});
|
|
1093
1212
|
} else if (payload.eventType === 'DELETE') {
|
|
1094
1213
|
// Process delete event
|
|
1214
|
+
console.log('🗑️ Processing DELETE event for', tableName);
|
|
1095
1215
|
setData((prev) => {
|
|
1216
|
+
console.log('🗑️ DELETE: Current data before deletion:', prev.length, 'items');
|
|
1217
|
+
|
|
1096
1218
|
// Access current options via refs
|
|
1097
1219
|
const currentWhere = whereRef.current;
|
|
1098
1220
|
const currentOrderBy = orderByRef.current;
|
|
@@ -1101,6 +1223,7 @@ export function createSuparismaHook<
|
|
|
1101
1223
|
|
|
1102
1224
|
// Skip if search is active
|
|
1103
1225
|
if (isSearchingRef.current) {
|
|
1226
|
+
console.log('⏭️ DELETE: Skipping - search is active');
|
|
1104
1227
|
return prev;
|
|
1105
1228
|
}
|
|
1106
1229
|
|
|
@@ -1110,15 +1233,21 @@ export function createSuparismaHook<
|
|
|
1110
1233
|
// Filter out the deleted item
|
|
1111
1234
|
const filteredData = prev.filter((item) => {
|
|
1112
1235
|
// @ts-ignore: Supabase typing issue
|
|
1113
|
-
|
|
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;
|
|
1114
1241
|
});
|
|
1115
1242
|
|
|
1243
|
+
console.log('🗑️ DELETE: Data after deletion:', filteredData.length, 'items (was', currentSize, ')');
|
|
1244
|
+
|
|
1116
1245
|
// Fetch the updated count after the data changes
|
|
1117
1246
|
setTimeout(() => fetchTotalCount(), 0);
|
|
1118
1247
|
|
|
1119
1248
|
// If we need to maintain the size with a limit
|
|
1120
1249
|
if (currentLimit && currentLimit > 0 && filteredData.length < currentSize && currentSize === currentLimit) { // Use ref value
|
|
1121
|
-
console.log(
|
|
1250
|
+
console.log(\`🗑️ DELETE: Record deleted with limit \${currentLimit}, will fetch additional record to maintain size\`);
|
|
1122
1251
|
|
|
1123
1252
|
// Use setTimeout to ensure this state update completes first
|
|
1124
1253
|
setTimeout(() => {
|
|
@@ -1195,7 +1324,7 @@ export function createSuparismaHook<
|
|
|
1195
1324
|
searchTimeoutRef.current = null;
|
|
1196
1325
|
}
|
|
1197
1326
|
};
|
|
1198
|
-
}, [realtime, channelName, tableName, initialLoadRef]); //
|
|
1327
|
+
}, [realtime, channelName, tableName, where, initialLoadRef]); // Added 'where' back so subscription updates when filter changes
|
|
1199
1328
|
|
|
1200
1329
|
// Create a memoized options object to prevent unnecessary re-renders
|
|
1201
1330
|
const optionsRef = useRef({ where, orderBy, limit, offset });
|
|
@@ -41,12 +41,25 @@ function generateModelTypesFile(model) {
|
|
|
41
41
|
// Find the actual field names for createdAt and updatedAt
|
|
42
42
|
const createdAtField = model.fields.find(field => field.isCreatedAt)?.name || 'createdAt';
|
|
43
43
|
const updatedAtField = model.fields.find(field => field.isUpdatedAt)?.name || 'updatedAt';
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
.filter((field) => !relationObjectFields.includes(field.name) && !foreignKeyFields.includes(field.name))
|
|
47
|
-
.map((field) => {
|
|
48
|
-
const isOptional = field.isOptional;
|
|
44
|
+
// Helper function to get TypeScript type for a field, considering zod directives
|
|
45
|
+
const getFieldType = (field) => {
|
|
49
46
|
let baseType;
|
|
47
|
+
// Check if field has a custom zod directive
|
|
48
|
+
if (field.zodDirective && field.type === 'Json') {
|
|
49
|
+
// For Json fields with zod directives, we need to infer the type from the zod schema
|
|
50
|
+
// This is a simplified approach - you might want to make this more sophisticated
|
|
51
|
+
if (field.zodDirective.includes('z.array(') && field.zodDirective.includes('LLMNodeSchema')) {
|
|
52
|
+
// Handle z.array(LLMNodeSchema).nullable() case
|
|
53
|
+
return field.zodDirective.includes('.nullable()') ? 'LLMNode[] | null' : 'LLMNode[]';
|
|
54
|
+
}
|
|
55
|
+
else if (field.zodDirective.includes('LLMNodeSchema')) {
|
|
56
|
+
// Handle single LLMNodeSchema case
|
|
57
|
+
return field.zodDirective.includes('.nullable()') ? 'LLMNode | null' : 'LLMNode';
|
|
58
|
+
}
|
|
59
|
+
// For other custom zod directives on Json fields, default to any for now
|
|
60
|
+
return 'any';
|
|
61
|
+
}
|
|
62
|
+
// Standard type mapping
|
|
50
63
|
switch (field.type) {
|
|
51
64
|
case 'Int':
|
|
52
65
|
case 'Float':
|
|
@@ -65,7 +78,14 @@ function generateModelTypesFile(model) {
|
|
|
65
78
|
// Covers String, Enum names (e.g., "SomeEnum"), Bytes, Decimal, etc.
|
|
66
79
|
baseType = 'string';
|
|
67
80
|
}
|
|
68
|
-
|
|
81
|
+
return field.isList ? `${baseType}[]` : baseType;
|
|
82
|
+
};
|
|
83
|
+
// Create a manual property list for WithRelations interface
|
|
84
|
+
const withRelationsProps = model.fields
|
|
85
|
+
.filter((field) => !relationObjectFields.includes(field.name) && !foreignKeyFields.includes(field.name))
|
|
86
|
+
.map((field) => {
|
|
87
|
+
const isOptional = field.isOptional;
|
|
88
|
+
const finalType = getFieldType(field);
|
|
69
89
|
return ` ${field.name}${isOptional ? '?' : ''}: ${finalType};`;
|
|
70
90
|
});
|
|
71
91
|
// Add foreign key fields
|
|
@@ -83,26 +103,7 @@ function generateModelTypesFile(model) {
|
|
|
83
103
|
.map((field) => {
|
|
84
104
|
// Make fields with default values optional in CreateInput
|
|
85
105
|
const isOptional = field.isOptional || defaultValueFields.includes(field.name);
|
|
86
|
-
|
|
87
|
-
switch (field.type) {
|
|
88
|
-
case 'Int':
|
|
89
|
-
case 'Float':
|
|
90
|
-
baseType = 'number';
|
|
91
|
-
break;
|
|
92
|
-
case 'Boolean':
|
|
93
|
-
baseType = 'boolean';
|
|
94
|
-
break;
|
|
95
|
-
case 'DateTime':
|
|
96
|
-
baseType = 'string'; // ISO date string
|
|
97
|
-
break;
|
|
98
|
-
case 'Json':
|
|
99
|
-
baseType = 'any'; // Or a more specific structured type if available
|
|
100
|
-
break;
|
|
101
|
-
default:
|
|
102
|
-
// Covers String, Enum names (e.g., "SomeEnum"), Bytes, Decimal, etc.
|
|
103
|
-
baseType = 'string';
|
|
104
|
-
}
|
|
105
|
-
const finalType = field.isList ? `${baseType}[]` : baseType;
|
|
106
|
+
const finalType = getFieldType(field);
|
|
106
107
|
return ` ${field.name}${isOptional ? '?' : ''}: ${finalType};`;
|
|
107
108
|
});
|
|
108
109
|
// Add foreign key fields to CreateInput
|
|
@@ -114,11 +115,50 @@ function generateModelTypesFile(model) {
|
|
|
114
115
|
createInputProps.push(` ${field}${isOptional ? '?' : ''}: ${fieldInfo.type === 'Int' ? 'number' : 'string'};`);
|
|
115
116
|
}
|
|
116
117
|
});
|
|
118
|
+
// Generate imports section for zod custom types
|
|
119
|
+
let customImports = '';
|
|
120
|
+
if (model.zodImports && model.zodImports.length > 0) {
|
|
121
|
+
// Add zod import for type inference
|
|
122
|
+
customImports = 'import { z } from \'zod\';\n';
|
|
123
|
+
// Get the zod schemas file path from environment variable
|
|
124
|
+
const zodSchemasPath = process.env.ZOD_SCHEMAS_FILE_PATH || '../commonTypes';
|
|
125
|
+
// Add custom imports with environment variable path
|
|
126
|
+
customImports += model.zodImports
|
|
127
|
+
.map(zodImport => {
|
|
128
|
+
// Extract the types from the original import statement
|
|
129
|
+
const typeMatch = zodImport.importStatement.match(/import\s+{\s*([^}]+)\s*}\s+from/);
|
|
130
|
+
if (typeMatch) {
|
|
131
|
+
const types = typeMatch[1].trim();
|
|
132
|
+
return `import { ${types} } from '${zodSchemasPath}'`;
|
|
133
|
+
}
|
|
134
|
+
// Fallback to original import if parsing fails
|
|
135
|
+
return zodImport.importStatement;
|
|
136
|
+
})
|
|
137
|
+
.join('\n') + '\n\n';
|
|
138
|
+
// Add type definitions for imported zod schemas if needed
|
|
139
|
+
// This is a simplified approach - you might want to make this more sophisticated
|
|
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';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
117
157
|
// Generate the type content with TSDoc comments
|
|
118
158
|
const typeContent = `// THIS FILE IS AUTO-GENERATED - DO NOT EDIT DIRECTLY
|
|
119
159
|
// Edit the generator script instead
|
|
120
160
|
|
|
121
|
-
import type { ${modelName} } from '@prisma/client';
|
|
161
|
+
${customImports}import type { ${modelName} } from '@prisma/client';
|
|
122
162
|
import type { ModelResult, SuparismaOptions, SearchQuery, SearchState, FilterOperators } from '../utils/core';
|
|
123
163
|
|
|
124
164
|
/**
|
|
@@ -230,26 +270,7 @@ ${model.fields
|
|
|
230
270
|
.filter((field) => !relationObjectFields.includes(field.name) && !foreignKeyFields.includes(field.name))
|
|
231
271
|
.map((field) => {
|
|
232
272
|
const isOptional = true; // All where fields are optional
|
|
233
|
-
|
|
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;
|
|
273
|
+
const finalType = getFieldType(field);
|
|
253
274
|
const filterType = `${finalType} | FilterOperators<${finalType}>`;
|
|
254
275
|
return ` ${field.name}${isOptional ? '?' : ''}: ${filterType};`;
|
|
255
276
|
})
|
|
@@ -643,6 +664,7 @@ ${createInputProps
|
|
|
643
664
|
searchFields,
|
|
644
665
|
defaultValues: Object.keys(defaultValues).length > 0 ? defaultValues : undefined,
|
|
645
666
|
createdAtField,
|
|
646
|
-
updatedAtField
|
|
667
|
+
updatedAtField,
|
|
668
|
+
zodImports: model.zodImports // Pass through zod imports
|
|
647
669
|
};
|
|
648
670
|
}
|
package/dist/parser.js
CHANGED
|
@@ -6,12 +6,82 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.parsePrismaSchema = parsePrismaSchema;
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
/**
|
|
9
|
-
* Parse
|
|
9
|
+
* Parse zod directive from comment
|
|
10
|
+
* Supports formats like:
|
|
11
|
+
* /// @zod.custom.use(z.string().refine(...))
|
|
12
|
+
* /// @zod.string.min(3).max(10)
|
|
13
|
+
*/
|
|
14
|
+
function parseZodDirective(comment) {
|
|
15
|
+
// Remove leading /// and whitespace
|
|
16
|
+
const cleanComment = comment.replace(/^\/\/\/?\s*/, '').trim();
|
|
17
|
+
// Look for @zod.custom.use() format
|
|
18
|
+
const customUseMatch = cleanComment.match(/@zod\.custom\.use\((.+)\)$/);
|
|
19
|
+
if (customUseMatch) {
|
|
20
|
+
return customUseMatch[1].trim();
|
|
21
|
+
}
|
|
22
|
+
// Look for other @zod patterns like @zod.string.min(3)
|
|
23
|
+
const zodMatch = cleanComment.match(/@zod\.(.+)$/);
|
|
24
|
+
if (zodMatch) {
|
|
25
|
+
const zodChain = zodMatch[1].trim();
|
|
26
|
+
// Convert to actual zod syntax - this is a basic implementation
|
|
27
|
+
// For more complex cases, you might want to build a more sophisticated parser
|
|
28
|
+
return `z.${zodChain}`;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse zod import from comment
|
|
34
|
+
* Supports format like:
|
|
35
|
+
* /// @zod.import(["import { LLMNodeSchema } from '../commonTypes'"])
|
|
36
|
+
*/
|
|
37
|
+
function parseZodImport(comment) {
|
|
38
|
+
const cleanComment = comment.replace(/^\/\/\/?\s*/, '').trim();
|
|
39
|
+
// Look for @zod.import([...]) format
|
|
40
|
+
const importMatch = cleanComment.match(/@zod\.import\(\[(.*)\]\)/);
|
|
41
|
+
if (!importMatch) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const importsString = importMatch[1];
|
|
45
|
+
const imports = [];
|
|
46
|
+
// Parse individual import statements within the array
|
|
47
|
+
// Handle nested quotes properly - look for quoted strings that start with "import"
|
|
48
|
+
const importRegex = /"(import\s+[^"]+)"|'(import\s+[^']+)'/g;
|
|
49
|
+
let match;
|
|
50
|
+
while ((match = importRegex.exec(importsString)) !== null) {
|
|
51
|
+
// Get the import statement from either the double-quoted or single-quoted group
|
|
52
|
+
const importStatement = match[1] || match[2];
|
|
53
|
+
// Extract types from import statement
|
|
54
|
+
// e.g., "import { LLMNodeSchema, AnotherType } from '../commonTypes'"
|
|
55
|
+
const typeMatch = importStatement.match(/import\s+{\s*([^}]+)\s*}\s+from/);
|
|
56
|
+
const types = [];
|
|
57
|
+
if (typeMatch) {
|
|
58
|
+
// Split by comma and clean up whitespace
|
|
59
|
+
types.push(...typeMatch[1].split(',').map(t => t.trim()));
|
|
60
|
+
}
|
|
61
|
+
imports.push({
|
|
62
|
+
importStatement,
|
|
63
|
+
types
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return imports;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Parse Prisma schema to extract model information including search annotations and zod directives
|
|
10
70
|
*/
|
|
11
71
|
function parsePrismaSchema(schemaPath) {
|
|
12
72
|
const schema = fs_1.default.readFileSync(schemaPath, 'utf-8');
|
|
13
73
|
const modelRegex = /model\s+(\w+)\s+{([^}]*)}/gs;
|
|
14
74
|
const models = [];
|
|
75
|
+
// Extract enum names from the schema
|
|
76
|
+
const enumRegex = /enum\s+(\w+)\s+{[^}]*}/gs;
|
|
77
|
+
const enumNames = [];
|
|
78
|
+
let enumMatch;
|
|
79
|
+
while ((enumMatch = enumRegex.exec(schema)) !== null) {
|
|
80
|
+
const enumName = enumMatch[1];
|
|
81
|
+
if (enumName) {
|
|
82
|
+
enumNames.push(enumName);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
15
85
|
let match;
|
|
16
86
|
while ((match = modelRegex.exec(schema)) !== null) {
|
|
17
87
|
const modelName = match[1] || '';
|
|
@@ -23,9 +93,12 @@ function parsePrismaSchema(schemaPath) {
|
|
|
23
93
|
const fields = [];
|
|
24
94
|
// Track fields with @enableSearch annotation
|
|
25
95
|
const searchFields = [];
|
|
96
|
+
// Track zod imports at model level
|
|
97
|
+
const zodImports = [];
|
|
26
98
|
const lines = modelBody.split('\n');
|
|
27
99
|
let lastFieldName = '';
|
|
28
100
|
let lastFieldType = '';
|
|
101
|
+
let pendingZodDirective;
|
|
29
102
|
for (let i = 0; i < lines.length; i++) {
|
|
30
103
|
const line = lines[i]?.trim();
|
|
31
104
|
// Skip blank lines and non-field lines
|
|
@@ -42,10 +115,18 @@ function parsePrismaSchema(schemaPath) {
|
|
|
42
115
|
}
|
|
43
116
|
// Check if line is a comment
|
|
44
117
|
if (line.startsWith('//')) {
|
|
118
|
+
// Parse zod directives from comments
|
|
119
|
+
const zodDirective = parseZodDirective(line);
|
|
120
|
+
if (zodDirective) {
|
|
121
|
+
pendingZodDirective = zodDirective;
|
|
122
|
+
}
|
|
123
|
+
// Parse zod imports from comments
|
|
124
|
+
const zodImportInfos = parseZodImport(line);
|
|
125
|
+
zodImports.push(...zodImportInfos);
|
|
45
126
|
continue;
|
|
46
127
|
}
|
|
47
128
|
// Parse field definition - Updated to handle array types
|
|
48
|
-
const fieldMatch = line.match(/\s*(\w+)\s+(\w+)(\[\])
|
|
129
|
+
const fieldMatch = line.match(/\s*(\w+)\s+(\w+)(\[\])?(\?)?\s*(?:@[^)]+)?/);
|
|
49
130
|
if (fieldMatch) {
|
|
50
131
|
const fieldName = fieldMatch[1];
|
|
51
132
|
const baseFieldType = fieldMatch[2]; // e.g., "String" from "String[]"
|
|
@@ -67,9 +148,13 @@ function parsePrismaSchema(schemaPath) {
|
|
|
67
148
|
defaultValue = defaultMatch[1];
|
|
68
149
|
}
|
|
69
150
|
}
|
|
151
|
+
// Improved relation detection
|
|
152
|
+
const primitiveTypes = ['String', 'Int', 'Float', 'Boolean', 'DateTime', 'Json', 'Bytes', 'Decimal', 'BigInt'];
|
|
70
153
|
const isRelation = line.includes('@relation') ||
|
|
71
154
|
(!!fieldName &&
|
|
72
|
-
(fieldName.endsWith('_id') || fieldName === 'userId' || fieldName === 'user_id'))
|
|
155
|
+
(fieldName.endsWith('_id') || fieldName === 'userId' || fieldName === 'user_id')) ||
|
|
156
|
+
// Also detect relation fields by checking if the type is not a primitive type and not an enum
|
|
157
|
+
(!!baseFieldType && !primitiveTypes.includes(baseFieldType) && !enumNames.includes(baseFieldType));
|
|
73
158
|
// Check for inline @enableSearch comment
|
|
74
159
|
if (line.includes('// @enableSearch')) {
|
|
75
160
|
searchFields.push({
|
|
@@ -77,6 +162,14 @@ function parsePrismaSchema(schemaPath) {
|
|
|
77
162
|
type: baseFieldType || '',
|
|
78
163
|
});
|
|
79
164
|
}
|
|
165
|
+
// Check for inline zod directive
|
|
166
|
+
let fieldZodDirective = pendingZodDirective;
|
|
167
|
+
if (line.includes('/// @zod.')) {
|
|
168
|
+
const inlineZodDirective = parseZodDirective(line);
|
|
169
|
+
if (inlineZodDirective) {
|
|
170
|
+
fieldZodDirective = inlineZodDirective;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
80
173
|
if (fieldName && baseFieldType) {
|
|
81
174
|
fields.push({
|
|
82
175
|
name: fieldName,
|
|
@@ -91,8 +184,11 @@ function parsePrismaSchema(schemaPath) {
|
|
|
91
184
|
defaultValue, // Add the extracted default value
|
|
92
185
|
isRelation,
|
|
93
186
|
isList: isArray, // Add the isList property
|
|
187
|
+
zodDirective: fieldZodDirective, // Add zod directive
|
|
94
188
|
});
|
|
95
189
|
}
|
|
190
|
+
// Clear pending zod directive after using it
|
|
191
|
+
pendingZodDirective = undefined;
|
|
96
192
|
}
|
|
97
193
|
}
|
|
98
194
|
// Check for model-level @enableSearch before the model definition
|
|
@@ -108,11 +204,30 @@ function parsePrismaSchema(schemaPath) {
|
|
|
108
204
|
}
|
|
109
205
|
});
|
|
110
206
|
}
|
|
207
|
+
// Also check for model-level zod imports before the model definition
|
|
208
|
+
const modelStartIndex = schema.indexOf(`model ${modelName}`);
|
|
209
|
+
if (modelStartIndex !== -1) {
|
|
210
|
+
// Look backwards for any /// @zod.import directives before this model
|
|
211
|
+
const beforeModel = schema.substring(0, modelStartIndex);
|
|
212
|
+
const lines = beforeModel.split('\n').reverse(); // Start from model and go backwards
|
|
213
|
+
for (const line of lines) {
|
|
214
|
+
const trimmedLine = line.trim();
|
|
215
|
+
if (trimmedLine.startsWith('///') && trimmedLine.includes('@zod.import')) {
|
|
216
|
+
const modelLevelImports = parseZodImport(trimmedLine);
|
|
217
|
+
zodImports.push(...modelLevelImports);
|
|
218
|
+
}
|
|
219
|
+
else if (trimmedLine && !trimmedLine.startsWith('///') && !trimmedLine.startsWith('//')) {
|
|
220
|
+
// Stop if we hit a non-comment line (another model or other content)
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
111
225
|
models.push({
|
|
112
226
|
name: modelName,
|
|
113
227
|
mappedName: mappedName || '',
|
|
114
228
|
fields,
|
|
115
229
|
searchFields: searchFields.length > 0 ? searchFields : undefined,
|
|
230
|
+
zodImports: zodImports.length > 0 ? zodImports : undefined,
|
|
116
231
|
});
|
|
117
232
|
}
|
|
118
233
|
return models;
|
package/package.json
CHANGED
package/prisma/schema.prisma
CHANGED
|
@@ -19,7 +19,16 @@ 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
|
|
|
31
|
+
/// @zod.import(["import { LLMNodeSchema } from '../commonTypes'"])
|
|
23
32
|
model Thing {
|
|
24
33
|
id String @id @default(uuid())
|
|
25
34
|
// @enableSearch
|
|
@@ -27,6 +36,11 @@ model Thing {
|
|
|
27
36
|
stringArray String[]
|
|
28
37
|
someEnum SomeEnum @default(ONE)
|
|
29
38
|
someNumber Int?
|
|
39
|
+
|
|
40
|
+
/// [Json]
|
|
41
|
+
someJson Json? /// @zod.custom.use(z.array(LLMNodeSchema).nullable())
|
|
42
|
+
userId String?
|
|
43
|
+
user User? @relation(fields: [userId], references: [id])
|
|
30
44
|
createdAt DateTime @default(now())
|
|
31
45
|
updatedAt DateTime @updatedAt
|
|
32
46
|
}
|