@statezero/core 0.1.0

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.
Files changed (89) hide show
  1. package/dist/adaptors/react/composables.d.ts +1 -0
  2. package/dist/adaptors/react/composables.js +4 -0
  3. package/dist/adaptors/react/index.d.ts +1 -0
  4. package/dist/adaptors/react/index.js +1 -0
  5. package/dist/adaptors/vue/composables.d.ts +2 -0
  6. package/dist/adaptors/vue/composables.js +36 -0
  7. package/dist/adaptors/vue/index.d.ts +2 -0
  8. package/dist/adaptors/vue/index.js +2 -0
  9. package/dist/adaptors/vue/reactivity.d.ts +18 -0
  10. package/dist/adaptors/vue/reactivity.js +125 -0
  11. package/dist/cli/commands/syncModels.d.ts +132 -0
  12. package/dist/cli/commands/syncModels.js +1040 -0
  13. package/dist/cli/configFileLoader.d.ts +10 -0
  14. package/dist/cli/configFileLoader.js +85 -0
  15. package/dist/cli/index.d.ts +2 -0
  16. package/dist/cli/index.js +14 -0
  17. package/dist/config.d.ts +52 -0
  18. package/dist/config.js +242 -0
  19. package/dist/core/eventReceivers.d.ts +179 -0
  20. package/dist/core/eventReceivers.js +210 -0
  21. package/dist/core/utils.d.ts +8 -0
  22. package/dist/core/utils.js +62 -0
  23. package/dist/filtering/localFiltering.d.ts +116 -0
  24. package/dist/filtering/localFiltering.js +834 -0
  25. package/dist/flavours/django/dates.d.ts +33 -0
  26. package/dist/flavours/django/dates.js +99 -0
  27. package/dist/flavours/django/errors.d.ts +138 -0
  28. package/dist/flavours/django/errors.js +187 -0
  29. package/dist/flavours/django/f.d.ts +6 -0
  30. package/dist/flavours/django/f.js +91 -0
  31. package/dist/flavours/django/files.d.ts +76 -0
  32. package/dist/flavours/django/files.js +338 -0
  33. package/dist/flavours/django/makeApiCall.d.ts +20 -0
  34. package/dist/flavours/django/makeApiCall.js +169 -0
  35. package/dist/flavours/django/manager.d.ts +197 -0
  36. package/dist/flavours/django/manager.js +222 -0
  37. package/dist/flavours/django/model.d.ts +112 -0
  38. package/dist/flavours/django/model.js +253 -0
  39. package/dist/flavours/django/operationFactory.d.ts +65 -0
  40. package/dist/flavours/django/operationFactory.js +216 -0
  41. package/dist/flavours/django/q.d.ts +70 -0
  42. package/dist/flavours/django/q.js +43 -0
  43. package/dist/flavours/django/queryExecutor.d.ts +131 -0
  44. package/dist/flavours/django/queryExecutor.js +468 -0
  45. package/dist/flavours/django/querySet.d.ts +412 -0
  46. package/dist/flavours/django/querySet.js +601 -0
  47. package/dist/flavours/django/tempPk.d.ts +19 -0
  48. package/dist/flavours/django/tempPk.js +48 -0
  49. package/dist/flavours/django/utils.d.ts +19 -0
  50. package/dist/flavours/django/utils.js +29 -0
  51. package/dist/index.d.ts +38 -0
  52. package/dist/index.js +38 -0
  53. package/dist/react-entry.d.ts +2 -0
  54. package/dist/react-entry.js +2 -0
  55. package/dist/reactiveAdaptor.d.ts +24 -0
  56. package/dist/reactiveAdaptor.js +38 -0
  57. package/dist/setup.d.ts +15 -0
  58. package/dist/setup.js +22 -0
  59. package/dist/syncEngine/cache/cache.d.ts +75 -0
  60. package/dist/syncEngine/cache/cache.js +341 -0
  61. package/dist/syncEngine/metrics/metricOptCalcs.d.ts +79 -0
  62. package/dist/syncEngine/metrics/metricOptCalcs.js +284 -0
  63. package/dist/syncEngine/registries/metricRegistry.d.ts +53 -0
  64. package/dist/syncEngine/registries/metricRegistry.js +162 -0
  65. package/dist/syncEngine/registries/modelStoreRegistry.d.ts +11 -0
  66. package/dist/syncEngine/registries/modelStoreRegistry.js +56 -0
  67. package/dist/syncEngine/registries/querysetStoreRegistry.d.ts +55 -0
  68. package/dist/syncEngine/registries/querysetStoreRegistry.js +244 -0
  69. package/dist/syncEngine/stores/metricStore.d.ts +55 -0
  70. package/dist/syncEngine/stores/metricStore.js +222 -0
  71. package/dist/syncEngine/stores/modelStore.d.ts +40 -0
  72. package/dist/syncEngine/stores/modelStore.js +405 -0
  73. package/dist/syncEngine/stores/operation.d.ts +99 -0
  74. package/dist/syncEngine/stores/operation.js +224 -0
  75. package/dist/syncEngine/stores/operationEventHandlers.d.ts +8 -0
  76. package/dist/syncEngine/stores/operationEventHandlers.js +239 -0
  77. package/dist/syncEngine/stores/querysetStore.d.ts +32 -0
  78. package/dist/syncEngine/stores/querysetStore.js +200 -0
  79. package/dist/syncEngine/stores/reactivity.d.ts +3 -0
  80. package/dist/syncEngine/stores/reactivity.js +4 -0
  81. package/dist/syncEngine/stores/utils.d.ts +14 -0
  82. package/dist/syncEngine/stores/utils.js +32 -0
  83. package/dist/syncEngine/sync.d.ts +32 -0
  84. package/dist/syncEngine/sync.js +169 -0
  85. package/dist/vue-entry.d.ts +6 -0
  86. package/dist/vue-entry.js +2 -0
  87. package/license.md +116 -0
  88. package/package.json +123 -0
  89. package/readme.md +222 -0
@@ -0,0 +1,834 @@
1
+ import sift, { createEqualsOperation } from 'sift';
2
+ import { configInstance } from '../config.js';
3
+ import { DateTime } from 'luxon';
4
+ /**
5
+ * Gets the backend timezone for a model class
6
+ * @param {Class} ModelClass - The model class
7
+ * @returns {string} The backend timezone or 'UTC' as fallback
8
+ */
9
+ function getBackendTimezone(ModelClass) {
10
+ if (!ModelClass || !ModelClass.configKey) {
11
+ return 'UTC'; // Default fallback
12
+ }
13
+ const config = configInstance.getConfig();
14
+ const backendConfig = config.backendConfigs[ModelClass.configKey] || config.backendConfigs.default;
15
+ return backendConfig.BACKEND_TZ || 'UTC';
16
+ }
17
+ /**
18
+ * Process a Django-style field path with relationships to match Django ORM behavior.
19
+ * This handles nested relationships by traversing the model schema and properly
20
+ * resolving relationship fields to their primary keys.
21
+ *
22
+ * @param {string} fieldPath - The Django-style field path (e.g., 'level2__level3__name')
23
+ * @param {any} value - The value to filter by
24
+ * @param {Class} ModelClass - The root model class to start traversal from
25
+ * @param {Object} options - Additional options
26
+ * @returns {Object} An object with processed field path and operator
27
+ */
28
+ function processFieldPath(fieldPath, value, ModelClass, options = {}) {
29
+ // Split the field path into parts
30
+ const parts = fieldPath.split('__');
31
+ // Check if the last part is a lookup operator
32
+ const knownLookups = [
33
+ 'exact', 'iexact', 'contains', 'icontains', 'startswith',
34
+ 'istartswith', 'endswith', 'iendswith', 'in', 'gt', 'gte',
35
+ 'lt', 'lte', 'isnull', 'regex', 'iregex', 'year', 'month',
36
+ 'day', 'week_day', 'hour', 'minute', 'second'
37
+ ];
38
+ // Date part lookups that can be followed by comparison lookups
39
+ const dateParts = ['year', 'month', 'day', 'week_day', 'hour', 'minute', 'second'];
40
+ // Comparison lookups that can follow date parts
41
+ const comparisonLookups = ['gt', 'gte', 'lt', 'lte', 'exact'];
42
+ let lookupChain = [];
43
+ let fieldParts = [...parts];
44
+ // Check for date part + comparison operator pattern (e.g., hour__gt)
45
+ if (parts.length >= 3) {
46
+ const potentialDatePart = parts[parts.length - 2];
47
+ const potentialComparison = parts[parts.length - 1];
48
+ if (dateParts.includes(potentialDatePart) && comparisonLookups.includes(potentialComparison)) {
49
+ // We have a date part followed by a comparison (e.g., created_at__hour__gt)
50
+ lookupChain = [potentialDatePart, potentialComparison];
51
+ fieldParts = parts.slice(0, -2);
52
+ }
53
+ }
54
+ // If no date part + comparison found, check for a single lookup
55
+ let lookup = null;
56
+ if (lookupChain.length === 0 && parts.length > 1 && knownLookups.includes(parts[parts.length - 1])) {
57
+ lookup = parts[parts.length - 1];
58
+ fieldParts = parts.slice(0, -1);
59
+ }
60
+ // Process the field parts to build the final path
61
+ let currentModel = ModelClass;
62
+ let processedPath = [];
63
+ let isRelationship = false;
64
+ for (let i = 0; i < fieldParts.length; i++) {
65
+ let part = fieldParts[i];
66
+ if (part === 'pk' && currentModel)
67
+ part = currentModel.primaryKeyField;
68
+ const isLastPart = i === fieldParts.length - 1;
69
+ // Check if this part refers to a relationship field
70
+ if (currentModel && currentModel.relationshipFields &&
71
+ currentModel.relationshipFields instanceof Map &&
72
+ currentModel.relationshipFields.has(part)) {
73
+ // This is a relationship field
74
+ const relationship = currentModel.relationshipFields.get(part);
75
+ const relatedModel = relationship.ModelClass();
76
+ // Add this relationship field to the path
77
+ processedPath.push(part);
78
+ // If this is not the last part, update the current model to the related model
79
+ if (!isLastPart) {
80
+ currentModel = relatedModel;
81
+ }
82
+ else {
83
+ // This is the last part and it's a relationship
84
+ // We need to add the primary key field of the related model
85
+ isRelationship = true;
86
+ // For foreign key relationships, we need to append the primary key field
87
+ // to properly match Django's behavior
88
+ const pkField = relatedModel.primaryKeyField || 'id';
89
+ processedPath.push(pkField);
90
+ }
91
+ }
92
+ else if (currentModel && currentModel.fields && currentModel.fields.includes(part)) {
93
+ // This is a regular field
94
+ processedPath.push(part);
95
+ // If it's the last part, we're done
96
+ if (isLastPart) {
97
+ break;
98
+ }
99
+ // If it's not the last part but it's a regular field,
100
+ // we can't continue traversal
101
+ throw new Error(`Field '${part}' in '${fieldPath}' is not a relationship field and cannot be traversed.`);
102
+ }
103
+ else {
104
+ // Field not found in the model
105
+ throw new Error(`Field '${part}' in '${fieldPath}' not found in model ${currentModel.modelName}.`);
106
+ }
107
+ }
108
+ // Join the processed path parts, using dot notation for sift
109
+ const finalPath = processedPath.join('.');
110
+ // Handle the date part + comparison chain if present
111
+ if (lookupChain.length === 2) {
112
+ const [datePart, comparisonOperator] = lookupChain;
113
+ return createDatePartComparisonOperator(finalPath, datePart, comparisonOperator, value, isRelationship);
114
+ }
115
+ // Handle the single lookup operation if present
116
+ if (lookup) {
117
+ return createOperatorFromLookup(finalPath, lookup, value, isRelationship);
118
+ }
119
+ // If there's no explicit lookup and this is a relationship field,
120
+ // we've already appended the PK field name to the path
121
+ // so we just need to apply the equality operator to the value
122
+ if (isRelationship) {
123
+ // In case the user passed in a raw model as the query value
124
+ let raw = value;
125
+ if (value && typeof value === 'object' && 'pk' in value) {
126
+ raw = value.pk;
127
+ }
128
+ return { field: finalPath, operator: { $eq: raw } };
129
+ }
130
+ // Default to direct equality
131
+ return { field: finalPath, operator: { $eq: value } };
132
+ }
133
+ /**
134
+ * Creates a special operator for date part comparison (e.g., created_at__hour__gt: 12)
135
+ * @param {string} field - Processed field path
136
+ * @param {string} datePart - The date part to extract ('year', 'month', etc.)
137
+ * @param {string} comparisonOperator - The comparison operator ('gt', 'lt', etc.)
138
+ * @param {any} value - Value to filter by
139
+ * @param {boolean} isRelationship - Whether the field is a relationship
140
+ * @returns {Object} Object with field name and custom operator
141
+ */
142
+ function createDatePartComparisonOperator(field, datePart, comparisonOperator, value, isRelationship) {
143
+ // If this is a relationship field, we handle it differently
144
+ if (isRelationship) {
145
+ console.warn(`Date part comparison on relationship fields may not work as expected: ${field}`);
146
+ // Fallback to direct equality as a safer option
147
+ return { field, operator: { $eq: value } };
148
+ }
149
+ // Create a custom operation name that combines the date part and comparison
150
+ // This will be handled by our custom operations in sift
151
+ return {
152
+ field,
153
+ operator: { [`$${datePart}_${comparisonOperator}`]: value }
154
+ };
155
+ }
156
+ /**
157
+ * Creates a sift operator from a Django-style lookup
158
+ * @param {string} field - Processed field path
159
+ * @param {string} lookup - Django-style lookup (e.g., 'contains', 'iexact')
160
+ * @param {any} value - Value to filter by
161
+ * @param {boolean} isRelationship - Whether the field is a relationship
162
+ * @returns {Object} Object with field name and sift operator
163
+ */
164
+ function createOperatorFromLookup(field, lookup, value, isRelationship) {
165
+ // Helper function to escape special characters in regex
166
+ function escapeRegExp(string) {
167
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
168
+ }
169
+ // Handle relationship fields differently
170
+ if (isRelationship) {
171
+ // For relationship fields with lookups, we need special handling
172
+ if (lookup === 'isnull') {
173
+ return {
174
+ field,
175
+ operator: value ? { $exists: false } : { $exists: true }
176
+ };
177
+ }
178
+ else if (lookup === 'in') {
179
+ return { field, operator: { $in: value } };
180
+ }
181
+ else {
182
+ // Default handling for relationship fields
183
+ return { field, operator: { $eq: value } };
184
+ }
185
+ }
186
+ // Handle date-related lookups
187
+ if (['year', 'month', 'day', 'week_day', 'hour', 'minute', 'second'].includes(lookup)) {
188
+ // For date part lookups, we'll use a custom operation
189
+ return {
190
+ field,
191
+ operator: { [`$${lookup}`]: value },
192
+ isDatePart: true // Add a flag to identify date part operators
193
+ };
194
+ }
195
+ // Regular field lookups (same as in the original code)
196
+ if (lookup === 'isnull') {
197
+ return {
198
+ field,
199
+ operator: value ? { $exists: false } : { $exists: true }
200
+ };
201
+ }
202
+ else if (lookup === 'exact') {
203
+ return { field, operator: { $eq: value } };
204
+ }
205
+ else if (lookup === 'iexact' && typeof value === 'string') {
206
+ return {
207
+ field,
208
+ operator: { $regex: new RegExp(`^${escapeRegExp(value)}$`, 'i') }
209
+ };
210
+ }
211
+ else if (lookup === 'contains' && typeof value === 'string') {
212
+ return {
213
+ field,
214
+ operator: { $regex: new RegExp(escapeRegExp(value)) }
215
+ };
216
+ }
217
+ else if (lookup === 'icontains' && typeof value === 'string') {
218
+ return {
219
+ field,
220
+ operator: { $regex: new RegExp(escapeRegExp(value), 'i') }
221
+ };
222
+ }
223
+ else if (lookup === 'startswith' && typeof value === 'string') {
224
+ return {
225
+ field,
226
+ operator: { $regex: new RegExp(`^${escapeRegExp(value)}`) }
227
+ };
228
+ }
229
+ else if (lookup === 'istartswith' && typeof value === 'string') {
230
+ return {
231
+ field,
232
+ operator: { $regex: new RegExp(`^${escapeRegExp(value)}`, 'i') }
233
+ };
234
+ }
235
+ else if (lookup === 'endswith' && typeof value === 'string') {
236
+ return {
237
+ field,
238
+ operator: { $regex: new RegExp(`${escapeRegExp(value)}$`) }
239
+ };
240
+ }
241
+ else if (lookup === 'iendswith' && typeof value === 'string') {
242
+ return {
243
+ field,
244
+ operator: { $regex: new RegExp(`${escapeRegExp(value)}$`, 'i') }
245
+ };
246
+ }
247
+ else if (lookup === 'in') {
248
+ return { field, operator: { $in: value } };
249
+ }
250
+ else if (lookup === 'gt') {
251
+ return { field, operator: { $gt: value } };
252
+ }
253
+ else if (lookup === 'gte') {
254
+ return { field, operator: { $gte: value } };
255
+ }
256
+ else if (lookup === 'lt') {
257
+ return { field, operator: { $lt: value } };
258
+ }
259
+ else if (lookup === 'lte') {
260
+ return { field, operator: { $lte: value } };
261
+ }
262
+ else {
263
+ // Default to direct equality if lookup not recognized
264
+ return { field, operator: { $eq: value } };
265
+ }
266
+ }
267
+ /**
268
+ * Creates custom operations for date parts to be used with Sift
269
+ * @param {string} timezone - The timezone to use for date operations
270
+ * @returns {Object} Object containing custom operations
271
+ */
272
+ function createDateOperations(timezone = 'UTC') {
273
+ // Helper function to extract date parts with Django-compatible behavior
274
+ const getDatePart = (value, partExtractor) => {
275
+ if (!value)
276
+ return null;
277
+ const dateValue = value instanceof Date ? value : new Date(value);
278
+ if (isNaN(dateValue.getTime()))
279
+ return null;
280
+ // Convert to timezone using Luxon
281
+ const luxonDate = DateTime.fromJSDate(dateValue).setZone(timezone);
282
+ // Extract the part using the provided function
283
+ return partExtractor(luxonDate);
284
+ };
285
+ // Create operations with Django-compatible extractors
286
+ const operations = {
287
+ // Year - same in both
288
+ $year(params, ownerQuery, options) {
289
+ return createEqualsOperation((value) => {
290
+ const year = getDatePart(value, dt => dt.year);
291
+ return year !== null && year === params;
292
+ }, ownerQuery, options);
293
+ },
294
+ // Month - Luxon is 1-indexed like Django
295
+ $month(params, ownerQuery, options) {
296
+ return createEqualsOperation((value) => {
297
+ const month = getDatePart(value, dt => dt.month); // Already 1-indexed
298
+ return month !== null && month === params;
299
+ }, ownerQuery, options);
300
+ },
301
+ // Day of month - same in both
302
+ $day(params, ownerQuery, options) {
303
+ return createEqualsOperation((value) => {
304
+ const day = getDatePart(value, dt => dt.day);
305
+ return day !== null && day === params;
306
+ }, ownerQuery, options);
307
+ },
308
+ // Day of week - convert to Django's 1=Sunday format
309
+ $week_day(params, ownerQuery, options) {
310
+ return createEqualsOperation((value) => {
311
+ // Convert from Luxon (1=Monday, 7=Sunday) to Django (1=Sunday, 7=Saturday)
312
+ const weekDay = getDatePart(value, dt => dt.weekday === 7 ? 1 : dt.weekday + 1);
313
+ return weekDay !== null && weekDay === params;
314
+ }, ownerQuery, options);
315
+ },
316
+ // Hour - same in both
317
+ $hour(params, ownerQuery, options) {
318
+ return createEqualsOperation((value) => {
319
+ const hour = getDatePart(value, dt => dt.hour);
320
+ return hour !== null && hour === params;
321
+ }, ownerQuery, options);
322
+ },
323
+ // Minute - same in both
324
+ $minute(params, ownerQuery, options) {
325
+ return createEqualsOperation((value) => {
326
+ const minute = getDatePart(value, dt => dt.minute);
327
+ return minute !== null && minute === params;
328
+ }, ownerQuery, options);
329
+ },
330
+ // Second - same in both
331
+ $second(params, ownerQuery, options) {
332
+ return createEqualsOperation((value) => {
333
+ const second = getDatePart(value, dt => dt.second);
334
+ return second !== null && second === params;
335
+ }, ownerQuery, options);
336
+ }
337
+ };
338
+ // Define part extractors for each date part with Django compatibility
339
+ const partExtractors = {
340
+ 'year': (dt) => dt.year,
341
+ 'month': (dt) => dt.month, // Already 1-indexed in Luxon
342
+ 'day': (dt) => dt.day,
343
+ 'week_day': (dt) => dt.weekday === 7 ? 1 : dt.weekday + 1, // Convert to Django's format
344
+ 'hour': (dt) => dt.hour,
345
+ 'minute': (dt) => dt.minute,
346
+ 'second': (dt) => dt.second
347
+ };
348
+ // Generate comparison operations for each date part (year_gt, month_lt, etc.)
349
+ const datePartComparisons = ['gt', 'gte', 'lt', 'lte', 'exact'];
350
+ // For each date part, create operations with each comparison operator
351
+ Object.keys(partExtractors).forEach(part => {
352
+ const extractor = partExtractors[part];
353
+ datePartComparisons.forEach(op => {
354
+ // Create a custom operation for each combination (e.g., $year_gt, $month_lte)
355
+ operations[`$${part}_${op}`] = (params, ownerQuery, options) => {
356
+ return createEqualsOperation((value) => {
357
+ if (!value)
358
+ return false;
359
+ const dateValue = value instanceof Date ? value : new Date(value);
360
+ if (isNaN(dateValue.getTime()))
361
+ return false;
362
+ // Convert to timezone and extract part
363
+ const luxonDate = DateTime.fromJSDate(dateValue).setZone(timezone);
364
+ const partValue = extractor(luxonDate);
365
+ // Apply the appropriate comparison
366
+ switch (op) {
367
+ case 'gt': return partValue > params;
368
+ case 'gte': return partValue >= params;
369
+ case 'lt': return partValue < params;
370
+ case 'lte': return partValue <= params;
371
+ case 'exact': return partValue === params;
372
+ default: return false;
373
+ }
374
+ }, ownerQuery, options);
375
+ };
376
+ });
377
+ });
378
+ return operations;
379
+ }
380
+ /**
381
+ * Process a Django-style filter query to use with sift, including date part operations
382
+ * @param {Object} criteria - Sift criteria with possible date operations
383
+ * @param {Class} ModelClass - The model class for schema traversal
384
+ * @returns {Function} Sift filter function with date operations support
385
+ */
386
+ function createFilterWithDateOperations(criteria, ModelClass) {
387
+ const timezone = getBackendTimezone(ModelClass);
388
+ return sift(criteria, {
389
+ operations: createDateOperations(timezone)
390
+ });
391
+ }
392
+ /**
393
+ * Convert Django-style filter conditions to Sift-compatible criteria
394
+ * @param {Object} conditions - Filter conditions
395
+ * @param {Class} ModelClass - The model class for schema traversal
396
+ * @returns {Object} Sift-compatible criteria
397
+ */
398
+ function convertToSiftCriteria(conditions, ModelClass) {
399
+ const result = {};
400
+ const datePartFilters = new Map(); // Map to collect date part filters by field
401
+ for (const [key, value] of Object.entries(conditions)) {
402
+ try {
403
+ const processedResult = processFieldPath(key, value, ModelClass);
404
+ const { field, operator, isDatePart } = processedResult;
405
+ if (isDatePart) {
406
+ // Handle date part operators separately
407
+ if (!datePartFilters.has(field)) {
408
+ datePartFilters.set(field, []);
409
+ }
410
+ datePartFilters.get(field).push({ [field]: operator });
411
+ }
412
+ else {
413
+ // For regular operators, merge if we already have criteria for this field
414
+ if (result[field]) {
415
+ result[field] = { ...result[field], ...operator };
416
+ }
417
+ else {
418
+ result[field] = operator;
419
+ }
420
+ }
421
+ }
422
+ catch (error) {
423
+ throw new Error(`Failed to process field '${key}': ${error.message}`);
424
+ }
425
+ }
426
+ // If we have date part filters, combine them with the result
427
+ if (datePartFilters.size > 0) {
428
+ const andConditions = [];
429
+ let hasRegularFilters = Object.keys(result).length > 0;
430
+ // Add regular filters if any
431
+ if (hasRegularFilters) {
432
+ andConditions.push(result);
433
+ }
434
+ // Add each date part filter
435
+ for (const [field, operators] of datePartFilters.entries()) {
436
+ if (operators.length === 1) {
437
+ // If there's only one date filter for this field
438
+ if (hasRegularFilters || andConditions.length > 0) {
439
+ andConditions.push(operators[0]);
440
+ }
441
+ else {
442
+ // If this is the only filter, return it directly
443
+ return operators[0];
444
+ }
445
+ }
446
+ else {
447
+ // Multiple date filters for the same field
448
+ andConditions.push(...operators);
449
+ }
450
+ }
451
+ // If we need to combine multiple conditions
452
+ if (andConditions.length > 1) {
453
+ return { $and: andConditions };
454
+ }
455
+ }
456
+ return result;
457
+ }
458
+ /**
459
+ * Processes a Q object array to match the backend AST structure
460
+ * @param {Array} qConditions - Array of Q objects or conditions
461
+ * @param {Class} ModelClass - The model class for schema traversal
462
+ * @returns {Object} Sift criteria for Q conditions
463
+ */
464
+ function processQConditions(qConditions, ModelClass) {
465
+ if (!qConditions || !qConditions.length)
466
+ return null;
467
+ // Convert each Q condition to sift criteria and combine with $or
468
+ return {
469
+ $or: qConditions.map(q => {
470
+ if ('operator' in q && 'conditions' in q) {
471
+ const op = q.operator === 'AND' ? '$and' : '$or';
472
+ return { [op]: q.conditions.map(c => convertToSiftCriteria(c, ModelClass)) };
473
+ }
474
+ else {
475
+ return convertToSiftCriteria(q, ModelClass);
476
+ }
477
+ })
478
+ };
479
+ }
480
+ /**
481
+ * Convert a filter node to sift criteria with proper relationship traversal
482
+ * @param {Object} filterNode - The filter node to convert
483
+ * @param {Class} ModelClass - The model class for schema traversal
484
+ * @returns {Object} Sift criteria object
485
+ */
486
+ function convertFilterNodeToSiftCriteria(filterNode, ModelClass) {
487
+ if (!filterNode)
488
+ return null;
489
+ // For simple filter nodes with conditions
490
+ if (filterNode.type === 'filter') {
491
+ const { conditions, Q: qConditions } = filterNode;
492
+ let criteria = {};
493
+ // Add regular conditions
494
+ if (conditions && Object.keys(conditions).length > 0) {
495
+ criteria = convertToSiftCriteria(conditions, ModelClass);
496
+ }
497
+ // Add Q conditions if present
498
+ if (qConditions && qConditions.length > 0) {
499
+ const qCriteria = processQConditions(qConditions, ModelClass);
500
+ if (qCriteria) {
501
+ // Combine with AND if both types of conditions exist
502
+ if (Object.keys(criteria).length > 0) {
503
+ return { $and: [criteria, qCriteria] };
504
+ }
505
+ return qCriteria;
506
+ }
507
+ }
508
+ return criteria;
509
+ }
510
+ // For compound AND nodes
511
+ if (filterNode.type === 'and' && filterNode.children) {
512
+ const childCriteria = filterNode.children
513
+ .map(child => convertFilterNodeToSiftCriteria(child, ModelClass))
514
+ .filter(c => c != null);
515
+ if (childCriteria.length === 0)
516
+ return null;
517
+ if (childCriteria.length === 1)
518
+ return childCriteria[0];
519
+ return { $and: childCriteria };
520
+ }
521
+ // For compound OR nodes
522
+ if (filterNode.type === 'or' && filterNode.children) {
523
+ const childCriteria = filterNode.children
524
+ .map(child => convertFilterNodeToSiftCriteria(child, ModelClass))
525
+ .filter(c => c != null);
526
+ if (childCriteria.length === 0)
527
+ return null;
528
+ if (childCriteria.length === 1)
529
+ return childCriteria[0];
530
+ return { $or: childCriteria };
531
+ }
532
+ // Handle exclude nodes using $not
533
+ if (filterNode.type === 'exclude' && filterNode.child) {
534
+ const excludeCriteria = convertFilterNodeToSiftCriteria(filterNode.child, ModelClass);
535
+ if (!excludeCriteria)
536
+ return null;
537
+ return { $not: excludeCriteria };
538
+ }
539
+ return null;
540
+ }
541
+ /**
542
+ * Apply search criteria to a dataset
543
+ * @param {Array} data - Collection of objects to search
544
+ * @param {Object} searchNode - Search node from query
545
+ * @param {Class} ModelClass - The model class for schema traversal
546
+ * @returns {Array} Filtered results
547
+ */
548
+ function applySearch(data, searchNode, ModelClass) {
549
+ if (!searchNode || !searchNode.searchQuery)
550
+ return data;
551
+ // Default to all string fields if searchFields not specified
552
+ const searchFields = searchNode.searchFields ||
553
+ (data[0] ? Object.keys(data[0]).filter(key => typeof data[0][key] === 'string') : []);
554
+ if (!searchFields.length)
555
+ return data;
556
+ // Helper function to escape RegExp characters
557
+ function escapeRegExp(string) {
558
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
559
+ }
560
+ // Process each search field through the model schema to handle relationships
561
+ const processedSearchConditions = searchFields.map(field => {
562
+ try {
563
+ // Convert field path to a dot notation path for Sift
564
+ const { field: processedField } = processFieldPath(field, '', ModelClass);
565
+ return {
566
+ [processedField]: {
567
+ $regex: new RegExp(escapeRegExp(searchNode.searchQuery), 'i')
568
+ }
569
+ };
570
+ }
571
+ catch (error) {
572
+ console.error(`Error processing search field '${field}':`, error.message);
573
+ // Fall back to using field as is
574
+ return {
575
+ [field.replace(/__/g, '.')]: {
576
+ $regex: new RegExp(escapeRegExp(searchNode.searchQuery), 'i')
577
+ }
578
+ };
579
+ }
580
+ });
581
+ return data.filter(sift({ $or: processedSearchConditions }));
582
+ }
583
+ /**
584
+ * Gets a nested value from an object using dot notation
585
+ * @param {Object} obj - Object to get value from
586
+ * @param {string} path - Path to value (dot notation)
587
+ * @returns {any} Value at path
588
+ */
589
+ function getNestedValue(obj, path) {
590
+ const parts = path.split('.');
591
+ let current = obj;
592
+ for (const part of parts) {
593
+ if (current === null || current === undefined) {
594
+ return undefined;
595
+ }
596
+ current = current[part];
597
+ }
598
+ return current;
599
+ }
600
+ /**
601
+ * Process Django-style ordering fields
602
+ * @param {Array<string>} orderBy - Fields to order by (prefix with - for descending)
603
+ * @param {Class} ModelClass - The model class for schema traversal
604
+ * @returns {Array<Object>} Processed ordering specifications
605
+ */
606
+ function processOrderBy(orderBy, ModelClass) {
607
+ return orderBy.map(field => {
608
+ const isDescending = field.startsWith('-');
609
+ const fieldName = isDescending ? field.substring(1) : field;
610
+ try {
611
+ // Process the field path to handle relationships
612
+ const { field: processedField } = processFieldPath(fieldName, '', ModelClass);
613
+ return {
614
+ field: processedField,
615
+ desc: isDescending
616
+ };
617
+ }
618
+ catch (error) {
619
+ console.error(`Error processing order field '${fieldName}':`, error.message);
620
+ // Fall back to using the field as is with dot notation
621
+ return {
622
+ field: fieldName.replace(/__/g, '.'),
623
+ desc: isDescending
624
+ };
625
+ }
626
+ });
627
+ }
628
+ /**
629
+ * Applies ordering to a dataset based on a list of fields
630
+ * @param {Array} data - Collection of objects to order
631
+ * @param {Array<string>} orderBy - Fields to order by (prefix with - for descending)
632
+ * @param {Class} ModelClass - The model class for schema traversal
633
+ * @returns {Array} Ordered results
634
+ */
635
+ function applyOrderBy(data, orderBy, ModelClass) {
636
+ if (!orderBy || !orderBy.length)
637
+ return [...data];
638
+ const processedOrdering = processOrderBy(orderBy, ModelClass);
639
+ return [...data].sort((a, b) => {
640
+ for (const { field, desc } of processedOrdering) {
641
+ // Get values
642
+ const value1 = getNestedValue(a, field);
643
+ const value2 = getNestedValue(b, field);
644
+ if (value1 === value2)
645
+ continue;
646
+ // Handle nulls - null values should come last in ascending order
647
+ if (value1 === null && value2 !== null)
648
+ return desc ? -1 : 1;
649
+ if (value1 !== null && value2 === null)
650
+ return desc ? 1 : -1;
651
+ // Handle dates
652
+ if (value1 instanceof Date && value2 instanceof Date) {
653
+ return desc
654
+ ? (value2.getTime() - value1.getTime())
655
+ : (value1.getTime() - value2.getTime());
656
+ }
657
+ // Default comparison
658
+ if (value1 > value2)
659
+ return desc ? -1 : 1;
660
+ if (value1 < value2)
661
+ return desc ? 1 : -1;
662
+ }
663
+ return 0;
664
+ });
665
+ }
666
+ /**
667
+ * Process an array of denormalized objects to filter & order them,
668
+ * then return just the matching primary-keys in order.
669
+ *
670
+ * @param {Array<Object>} data – denormalized rows, e.g. [{ id:1, name:"A", related:{ name:"B" } }, …]
671
+ * @param {Object} queryBuild – the result of QuerySet.build()
672
+ * @param {Class} ModelClass – your model class (for fieldPath resolution & date-ops)
673
+ * @returns {Array<*>} – the primary keys of matching rows, in order
674
+ */
675
+ function processQuery(data, queryBuild, ModelClass) {
676
+ if (!Array.isArray(data) || data.length === 0) {
677
+ return [];
678
+ }
679
+ if (!ModelClass) {
680
+ throw new Error('ModelClass is required for proper relationship traversal');
681
+ }
682
+ const pkField = ModelClass.primaryKeyField;
683
+ let results = [...data]; // assume already denormalized objects
684
+ // 1) Apply filtering
685
+ if (queryBuild.filter) {
686
+ const criteria = convertFilterNodeToSiftCriteria(queryBuild.filter, ModelClass);
687
+ if (criteria && Object.keys(criteria).length) {
688
+ results = results.filter(createFilterWithDateOperations(criteria, ModelClass));
689
+ }
690
+ }
691
+ // 2) Apply search
692
+ if (queryBuild.search && queryBuild.search.searchQuery) {
693
+ results = applySearch(results, queryBuild.search, ModelClass);
694
+ }
695
+ // 3) Apply ordering
696
+ if (Array.isArray(queryBuild.orderBy) && queryBuild.orderBy.length) {
697
+ results = applyOrderBy(results, queryBuild.orderBy, ModelClass);
698
+ }
699
+ // 4) Return only the primary-keys, in the filtered & ordered sequence
700
+ return results.map(item => item[pkField]);
701
+ }
702
+ /**
703
+ * Inspect a QuerySet.build() and collect every field path
704
+ * you’ll need to fetch before running sift.
705
+ *
706
+ * @param {Object} queryBuild – result of QuerySet.build()
707
+ * @param {Class} ModelClass – the root model class
708
+ * @returns {string[]} Array of dot-notation paths, e.g. ['author.id','createdAt.year']
709
+ */
710
+ export function getRequiredFields(queryBuild, ModelClass) {
711
+ const paths = new Set();
712
+ const pkField = ModelClass.primaryKeyField;
713
+ // Always include the PK so we can re-map results
714
+ paths.add(pkField);
715
+ // Try to turn a Django-style key into the final dot path
716
+ function addPath(rawKey) {
717
+ try {
718
+ // We pass `null` as the value, since we only care about .field
719
+ const { field } = processFieldPath(rawKey, null, ModelClass);
720
+ paths.add(field);
721
+ }
722
+ catch (err) {
723
+ // if a key doesn’t map, warn and skip it
724
+ console.warn(`getRequiredFields: couldn’t process "${rawKey}": ${err.message}`);
725
+ }
726
+ }
727
+ // Recursively walk your filter AST
728
+ function walkFilter(node) {
729
+ if (!node)
730
+ return;
731
+ switch (node.type) {
732
+ case 'filter':
733
+ // simple conditions
734
+ Object.keys(node.conditions || {}).forEach(addPath);
735
+ // any Q-objects
736
+ (node.Q || []).forEach(q => {
737
+ Object.keys(q.conditions || {}).forEach(addPath);
738
+ });
739
+ break;
740
+ case 'and':
741
+ case 'or':
742
+ (node.children || []).forEach(walkFilter);
743
+ break;
744
+ case 'exclude':
745
+ walkFilter(node.child);
746
+ break;
747
+ }
748
+ }
749
+ // collect from filter
750
+ if (queryBuild.filter) {
751
+ walkFilter(queryBuild.filter);
752
+ }
753
+ // collect from search
754
+ if (queryBuild.search && Array.isArray(queryBuild.search.searchFields)) {
755
+ queryBuild.search.searchFields.forEach(addPath);
756
+ }
757
+ // collect from orderBy
758
+ if (Array.isArray(queryBuild.orderBy)) {
759
+ queryBuild.orderBy.forEach(field => {
760
+ // strip leading '-' for desc sorts
761
+ addPath(field.replace(/^-/, ''));
762
+ });
763
+ }
764
+ return Array.from(paths);
765
+ }
766
+ /**
767
+ * Pick out only the required fields from a (possibly nested) model object.
768
+ *
769
+ * @param {string[]} requiredPaths – e.g. ['id','related.name','related.age']
770
+ * @param {Object} instance – e.g. { id: 3, related: { name: 'bob', age: 12, foo: 'bar' } }
771
+ * @returns {Object} – e.g. { id: 3, related: { name: 'bob', age: 12 } }
772
+ */
773
+ export function pickRequiredFields(requiredPaths, instance) {
774
+ const result = {};
775
+ requiredPaths.forEach(path => {
776
+ const parts = path.split('.');
777
+ // Traverse the source instance to get the value
778
+ let value = instance;
779
+ for (const key of parts) {
780
+ if (value == null || !(key in value)) {
781
+ value = undefined;
782
+ break;
783
+ }
784
+ value = value[key];
785
+ }
786
+ if (value === undefined) {
787
+ // skip missing
788
+ return;
789
+ }
790
+ // Build nested structure in the result
791
+ let cursor = result;
792
+ parts.forEach((key, idx) => {
793
+ if (idx === parts.length - 1) {
794
+ cursor[key] = value;
795
+ }
796
+ else {
797
+ if (!(key in cursor)) {
798
+ cursor[key] = {};
799
+ }
800
+ cursor = cursor[key];
801
+ }
802
+ });
803
+ });
804
+ return result;
805
+ }
806
+ /**
807
+ * Filter and order a collection of data objects according to a QuerySet's AST.
808
+ * This combines getRequiredFields, pickRequiredFields, and processQuery in one function.
809
+ *
810
+ * @param {Array<Object>} data - Collection of objects to filter and order
811
+ * @param {Object} ast - Abstract Syntax Tree from QuerySet.build()
812
+ * @param {Class} ModelClass - The model class for schema traversal
813
+ * @param {boolean} [returnFullObjects=false] - If true, returns full objects instead of just primary keys
814
+ * @returns {Array} Filtered and ordered results (primary keys or full objects based on returnFullObjects)
815
+ */
816
+ export function filter(data, ast, ModelClass, returnFullObjects = false) {
817
+ if (!Array.isArray(data) || data.length === 0) {
818
+ return [];
819
+ }
820
+ if (!ModelClass) {
821
+ throw new Error('ModelClass is required for proper relationship traversal');
822
+ }
823
+ const pkField = ModelClass.primaryKeyField || 'id';
824
+ let requiredFields = getRequiredFields(ast, ModelClass);
825
+ let denormalizedData = data.map(item => pickRequiredFields(requiredFields, item));
826
+ const resultKeys = processQuery(denormalizedData, ast, ModelClass);
827
+ if (returnFullObjects) {
828
+ const dataMap = new Map(data.map(item => [item[pkField], item]));
829
+ return resultKeys.map(key => dataMap.get(key));
830
+ }
831
+ return resultKeys;
832
+ }
833
+ // Export the utility functions for testing and usage
834
+ export { processFieldPath, convertToSiftCriteria, processQConditions, convertFilterNodeToSiftCriteria, applySearch, applyOrderBy, processQuery, createDateOperations, createFilterWithDateOperations, createDatePartComparisonOperator, getBackendTimezone };