@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.
- package/dist/adaptors/react/composables.d.ts +1 -0
- package/dist/adaptors/react/composables.js +4 -0
- package/dist/adaptors/react/index.d.ts +1 -0
- package/dist/adaptors/react/index.js +1 -0
- package/dist/adaptors/vue/composables.d.ts +2 -0
- package/dist/adaptors/vue/composables.js +36 -0
- package/dist/adaptors/vue/index.d.ts +2 -0
- package/dist/adaptors/vue/index.js +2 -0
- package/dist/adaptors/vue/reactivity.d.ts +18 -0
- package/dist/adaptors/vue/reactivity.js +125 -0
- package/dist/cli/commands/syncModels.d.ts +132 -0
- package/dist/cli/commands/syncModels.js +1040 -0
- package/dist/cli/configFileLoader.d.ts +10 -0
- package/dist/cli/configFileLoader.js +85 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +14 -0
- package/dist/config.d.ts +52 -0
- package/dist/config.js +242 -0
- package/dist/core/eventReceivers.d.ts +179 -0
- package/dist/core/eventReceivers.js +210 -0
- package/dist/core/utils.d.ts +8 -0
- package/dist/core/utils.js +62 -0
- package/dist/filtering/localFiltering.d.ts +116 -0
- package/dist/filtering/localFiltering.js +834 -0
- package/dist/flavours/django/dates.d.ts +33 -0
- package/dist/flavours/django/dates.js +99 -0
- package/dist/flavours/django/errors.d.ts +138 -0
- package/dist/flavours/django/errors.js +187 -0
- package/dist/flavours/django/f.d.ts +6 -0
- package/dist/flavours/django/f.js +91 -0
- package/dist/flavours/django/files.d.ts +76 -0
- package/dist/flavours/django/files.js +338 -0
- package/dist/flavours/django/makeApiCall.d.ts +20 -0
- package/dist/flavours/django/makeApiCall.js +169 -0
- package/dist/flavours/django/manager.d.ts +197 -0
- package/dist/flavours/django/manager.js +222 -0
- package/dist/flavours/django/model.d.ts +112 -0
- package/dist/flavours/django/model.js +253 -0
- package/dist/flavours/django/operationFactory.d.ts +65 -0
- package/dist/flavours/django/operationFactory.js +216 -0
- package/dist/flavours/django/q.d.ts +70 -0
- package/dist/flavours/django/q.js +43 -0
- package/dist/flavours/django/queryExecutor.d.ts +131 -0
- package/dist/flavours/django/queryExecutor.js +468 -0
- package/dist/flavours/django/querySet.d.ts +412 -0
- package/dist/flavours/django/querySet.js +601 -0
- package/dist/flavours/django/tempPk.d.ts +19 -0
- package/dist/flavours/django/tempPk.js +48 -0
- package/dist/flavours/django/utils.d.ts +19 -0
- package/dist/flavours/django/utils.js +29 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +38 -0
- package/dist/react-entry.d.ts +2 -0
- package/dist/react-entry.js +2 -0
- package/dist/reactiveAdaptor.d.ts +24 -0
- package/dist/reactiveAdaptor.js +38 -0
- package/dist/setup.d.ts +15 -0
- package/dist/setup.js +22 -0
- package/dist/syncEngine/cache/cache.d.ts +75 -0
- package/dist/syncEngine/cache/cache.js +341 -0
- package/dist/syncEngine/metrics/metricOptCalcs.d.ts +79 -0
- package/dist/syncEngine/metrics/metricOptCalcs.js +284 -0
- package/dist/syncEngine/registries/metricRegistry.d.ts +53 -0
- package/dist/syncEngine/registries/metricRegistry.js +162 -0
- package/dist/syncEngine/registries/modelStoreRegistry.d.ts +11 -0
- package/dist/syncEngine/registries/modelStoreRegistry.js +56 -0
- package/dist/syncEngine/registries/querysetStoreRegistry.d.ts +55 -0
- package/dist/syncEngine/registries/querysetStoreRegistry.js +244 -0
- package/dist/syncEngine/stores/metricStore.d.ts +55 -0
- package/dist/syncEngine/stores/metricStore.js +222 -0
- package/dist/syncEngine/stores/modelStore.d.ts +40 -0
- package/dist/syncEngine/stores/modelStore.js +405 -0
- package/dist/syncEngine/stores/operation.d.ts +99 -0
- package/dist/syncEngine/stores/operation.js +224 -0
- package/dist/syncEngine/stores/operationEventHandlers.d.ts +8 -0
- package/dist/syncEngine/stores/operationEventHandlers.js +239 -0
- package/dist/syncEngine/stores/querysetStore.d.ts +32 -0
- package/dist/syncEngine/stores/querysetStore.js +200 -0
- package/dist/syncEngine/stores/reactivity.d.ts +3 -0
- package/dist/syncEngine/stores/reactivity.js +4 -0
- package/dist/syncEngine/stores/utils.d.ts +14 -0
- package/dist/syncEngine/stores/utils.js +32 -0
- package/dist/syncEngine/sync.d.ts +32 -0
- package/dist/syncEngine/sync.js +169 -0
- package/dist/vue-entry.d.ts +6 -0
- package/dist/vue-entry.js +2 -0
- package/license.md +116 -0
- package/package.json +123 -0
- 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 };
|