@statezero/core 0.2.36 → 0.2.38

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 (301) hide show
  1. package/dist/adaptors/vue/components/LayoutRenderer.js +166 -0
  2. package/dist/adaptors/vue/components/defaults/AlertElement.js +31 -0
  3. package/dist/adaptors/vue/components/defaults/DisplayElement.js +44 -0
  4. package/dist/adaptors/vue/components/defaults/DividerElement.js +10 -0
  5. package/dist/adaptors/vue/components/defaults/ErrorBlock.js +24 -0
  6. package/dist/adaptors/vue/components/defaults/GroupElement.js +41 -0
  7. package/dist/adaptors/vue/components/defaults/LabelElement.js +21 -0
  8. package/dist/adaptors/vue/components/defaults/TabsElement.js +38 -0
  9. package/package.json +7 -6
  10. package/dist/actions/backend1/django_app/calculate-hash.d.ts +0 -57
  11. package/dist/actions/backend1/django_app/calculate-hash.js +0 -80
  12. package/dist/actions/backend1/django_app/calculate-hash.schema.json +0 -148
  13. package/dist/actions/backend1/django_app/get-current-username.d.ts +0 -29
  14. package/dist/actions/backend1/django_app/get-current-username.js +0 -65
  15. package/dist/actions/backend1/django_app/get-current-username.schema.json +0 -47
  16. package/dist/actions/backend1/django_app/get-server-status.d.ts +0 -38
  17. package/dist/actions/backend1/django_app/get-server-status.js +0 -68
  18. package/dist/actions/backend1/django_app/get-server-status.schema.json +0 -93
  19. package/dist/actions/backend1/django_app/get-user-info.d.ts +0 -44
  20. package/dist/actions/backend1/django_app/get-user-info.js +0 -70
  21. package/dist/actions/backend1/django_app/get-user-info.schema.json +0 -127
  22. package/dist/actions/backend1/django_app/index.d.ts +0 -1
  23. package/dist/actions/backend1/django_app/index.js +0 -6
  24. package/dist/actions/backend1/django_app/process-data.d.ts +0 -51
  25. package/dist/actions/backend1/django_app/process-data.js +0 -78
  26. package/dist/actions/backend1/django_app/process-data.schema.json +0 -117
  27. package/dist/actions/backend1/django_app/send-notification.d.ts +0 -55
  28. package/dist/actions/backend1/django_app/send-notification.js +0 -81
  29. package/dist/actions/backend1/django_app/send-notification.schema.json +0 -175
  30. package/dist/actions/backend1/index.d.ts +0 -1
  31. package/dist/actions/backend1/index.js +0 -1
  32. package/dist/actions/default/django_app/calculate-hash.d.ts +0 -57
  33. package/dist/actions/default/django_app/calculate-hash.js +0 -80
  34. package/dist/actions/default/django_app/calculate-hash.schema.json +0 -148
  35. package/dist/actions/default/django_app/get-current-username.d.ts +0 -29
  36. package/dist/actions/default/django_app/get-current-username.js +0 -65
  37. package/dist/actions/default/django_app/get-current-username.schema.json +0 -47
  38. package/dist/actions/default/django_app/get-server-status.d.ts +0 -38
  39. package/dist/actions/default/django_app/get-server-status.js +0 -68
  40. package/dist/actions/default/django_app/get-server-status.schema.json +0 -93
  41. package/dist/actions/default/django_app/get-user-info.d.ts +0 -44
  42. package/dist/actions/default/django_app/get-user-info.js +0 -70
  43. package/dist/actions/default/django_app/get-user-info.schema.json +0 -127
  44. package/dist/actions/default/django_app/index.d.ts +0 -1
  45. package/dist/actions/default/django_app/index.js +0 -6
  46. package/dist/actions/default/django_app/process-data.d.ts +0 -51
  47. package/dist/actions/default/django_app/process-data.js +0 -78
  48. package/dist/actions/default/django_app/process-data.schema.json +0 -117
  49. package/dist/actions/default/django_app/send-notification.d.ts +0 -55
  50. package/dist/actions/default/django_app/send-notification.js +0 -81
  51. package/dist/actions/default/django_app/send-notification.schema.json +0 -175
  52. package/dist/actions/default/index.d.ts +0 -1
  53. package/dist/actions/default/index.js +0 -1
  54. package/dist/actions/index.d.ts +0 -1
  55. package/dist/actions/index.js +0 -5
  56. package/dist/adaptors/react/composables.d.ts +0 -1
  57. package/dist/adaptors/react/composables.js +0 -4
  58. package/dist/adaptors/react/index.d.ts +0 -1
  59. package/dist/adaptors/react/index.js +0 -1
  60. package/dist/adaptors/vue/components/defaults/index.d.ts +0 -7
  61. package/dist/adaptors/vue/components/defaults/index.js +0 -31
  62. package/dist/adaptors/vue/components/index.d.ts +0 -1
  63. package/dist/adaptors/vue/components/index.js +0 -7
  64. package/dist/adaptors/vue/composables.d.ts +0 -2
  65. package/dist/adaptors/vue/composables.js +0 -44
  66. package/dist/adaptors/vue/index.d.ts +0 -3
  67. package/dist/adaptors/vue/index.js +0 -4
  68. package/dist/adaptors/vue/reactivity.d.ts +0 -18
  69. package/dist/adaptors/vue/reactivity.js +0 -132
  70. package/dist/cli/commands/sync.d.ts +0 -6
  71. package/dist/cli/commands/sync.js +0 -30
  72. package/dist/cli/commands/syncActions.d.ts +0 -46
  73. package/dist/cli/commands/syncActions.js +0 -717
  74. package/dist/cli/commands/syncModels.d.ts +0 -132
  75. package/dist/cli/commands/syncModels.js +0 -1120
  76. package/dist/cli/configFileLoader.d.ts +0 -10
  77. package/dist/cli/configFileLoader.js +0 -85
  78. package/dist/cli/index.d.ts +0 -2
  79. package/dist/cli/index.js +0 -22
  80. package/dist/config.d.ts +0 -57
  81. package/dist/config.js +0 -273
  82. package/dist/core/eventReceivers.d.ts +0 -185
  83. package/dist/core/eventReceivers.js +0 -266
  84. package/dist/core/utils.d.ts +0 -8
  85. package/dist/core/utils.js +0 -62
  86. package/dist/errorHandler.d.ts +0 -21
  87. package/dist/errorHandler.js +0 -27
  88. package/dist/filtering/localFiltering.d.ts +0 -110
  89. package/dist/filtering/localFiltering.js +0 -1080
  90. package/dist/flavours/django/dates.d.ts +0 -34
  91. package/dist/flavours/django/dates.js +0 -113
  92. package/dist/flavours/django/errors.d.ts +0 -138
  93. package/dist/flavours/django/errors.js +0 -195
  94. package/dist/flavours/django/f.d.ts +0 -6
  95. package/dist/flavours/django/f.js +0 -91
  96. package/dist/flavours/django/files.d.ts +0 -62
  97. package/dist/flavours/django/files.js +0 -355
  98. package/dist/flavours/django/makeApiCall.d.ts +0 -36
  99. package/dist/flavours/django/makeApiCall.js +0 -169
  100. package/dist/flavours/django/manager.d.ts +0 -204
  101. package/dist/flavours/django/manager.js +0 -222
  102. package/dist/flavours/django/model.d.ts +0 -137
  103. package/dist/flavours/django/model.js +0 -366
  104. package/dist/flavours/django/operationFactory.d.ts +0 -73
  105. package/dist/flavours/django/operationFactory.js +0 -248
  106. package/dist/flavours/django/q.d.ts +0 -70
  107. package/dist/flavours/django/q.js +0 -43
  108. package/dist/flavours/django/queryExecutor.d.ts +0 -149
  109. package/dist/flavours/django/queryExecutor.js +0 -590
  110. package/dist/flavours/django/querySet.d.ts +0 -301
  111. package/dist/flavours/django/querySet.js +0 -736
  112. package/dist/flavours/django/serializers.d.ts +0 -39
  113. package/dist/flavours/django/serializers.js +0 -296
  114. package/dist/flavours/django/tempPk.d.ts +0 -31
  115. package/dist/flavours/django/tempPk.js +0 -92
  116. package/dist/flavours/django/utils.d.ts +0 -19
  117. package/dist/flavours/django/utils.js +0 -29
  118. package/dist/index.d.ts +0 -46
  119. package/dist/index.js +0 -48
  120. package/dist/models/backend1/django_app/comprehensivemodel.d.ts +0 -894
  121. package/dist/models/backend1/django_app/comprehensivemodel.js +0 -71
  122. package/dist/models/backend1/django_app/comprehensivemodel.schema.json +0 -870
  123. package/dist/models/backend1/django_app/custompkmodel.d.ts +0 -92
  124. package/dist/models/backend1/django_app/custompkmodel.js +0 -69
  125. package/dist/models/backend1/django_app/custompkmodel.schema.json +0 -71
  126. package/dist/models/backend1/django_app/dailyrate.d.ts +0 -230
  127. package/dist/models/backend1/django_app/dailyrate.js +0 -71
  128. package/dist/models/backend1/django_app/dailyrate.schema.json +0 -212
  129. package/dist/models/backend1/django_app/deepmodellevel1.d.ts +0 -140
  130. package/dist/models/backend1/django_app/deepmodellevel1.js +0 -72
  131. package/dist/models/backend1/django_app/deepmodellevel1.schema.json +0 -114
  132. package/dist/models/backend1/django_app/deepmodellevel2.d.ts +0 -118
  133. package/dist/models/backend1/django_app/deepmodellevel2.js +0 -71
  134. package/dist/models/backend1/django_app/deepmodellevel2.schema.json +0 -92
  135. package/dist/models/backend1/django_app/deepmodellevel3.d.ts +0 -92
  136. package/dist/models/backend1/django_app/deepmodellevel3.js +0 -69
  137. package/dist/models/backend1/django_app/deepmodellevel3.schema.json +0 -69
  138. package/dist/models/backend1/django_app/dummymodel.d.ts +0 -134
  139. package/dist/models/backend1/django_app/dummymodel.js +0 -71
  140. package/dist/models/backend1/django_app/dummymodel.schema.json +0 -109
  141. package/dist/models/backend1/django_app/dummyrelatedmodel.d.ts +0 -92
  142. package/dist/models/backend1/django_app/dummyrelatedmodel.js +0 -69
  143. package/dist/models/backend1/django_app/dummyrelatedmodel.schema.json +0 -69
  144. package/dist/models/backend1/django_app/filetest.d.ts +0 -140
  145. package/dist/models/backend1/django_app/filetest.js +0 -69
  146. package/dist/models/backend1/django_app/filetest.schema.json +0 -111
  147. package/dist/models/backend1/django_app/index.d.ts +0 -1
  148. package/dist/models/backend1/django_app/index.js +0 -21
  149. package/dist/models/backend1/django_app/m2mdepthtestlevel1.d.ts +0 -118
  150. package/dist/models/backend1/django_app/m2mdepthtestlevel1.js +0 -71
  151. package/dist/models/backend1/django_app/m2mdepthtestlevel1.schema.json +0 -94
  152. package/dist/models/backend1/django_app/m2mdepthtestlevel2.d.ts +0 -118
  153. package/dist/models/backend1/django_app/m2mdepthtestlevel2.js +0 -71
  154. package/dist/models/backend1/django_app/m2mdepthtestlevel2.schema.json +0 -94
  155. package/dist/models/backend1/django_app/m2mdepthtestlevel3.d.ts +0 -134
  156. package/dist/models/backend1/django_app/m2mdepthtestlevel3.js +0 -71
  157. package/dist/models/backend1/django_app/m2mdepthtestlevel3.schema.json +0 -112
  158. package/dist/models/backend1/django_app/modelwithcustompkrelation.d.ts +0 -118
  159. package/dist/models/backend1/django_app/modelwithcustompkrelation.js +0 -71
  160. package/dist/models/backend1/django_app/modelwithcustompkrelation.schema.json +0 -93
  161. package/dist/models/backend1/django_app/modelwithrestrictedfields.d.ts +0 -134
  162. package/dist/models/backend1/django_app/modelwithrestrictedfields.js +0 -71
  163. package/dist/models/backend1/django_app/modelwithrestrictedfields.schema.json +0 -111
  164. package/dist/models/backend1/django_app/namefiltercustompkmodel.d.ts +0 -92
  165. package/dist/models/backend1/django_app/namefiltercustompkmodel.js +0 -69
  166. package/dist/models/backend1/django_app/namefiltercustompkmodel.schema.json +0 -71
  167. package/dist/models/backend1/django_app/order.d.ts +0 -220
  168. package/dist/models/backend1/django_app/order.js +0 -71
  169. package/dist/models/backend1/django_app/order.schema.json +0 -203
  170. package/dist/models/backend1/django_app/orderitem.d.ts +0 -172
  171. package/dist/models/backend1/django_app/orderitem.js +0 -72
  172. package/dist/models/backend1/django_app/orderitem.schema.json +0 -149
  173. package/dist/models/backend1/django_app/product.d.ts +0 -254
  174. package/dist/models/backend1/django_app/product.js +0 -71
  175. package/dist/models/backend1/django_app/product.schema.json +0 -277
  176. package/dist/models/backend1/django_app/productcategory.d.ts +0 -92
  177. package/dist/models/backend1/django_app/productcategory.js +0 -69
  178. package/dist/models/backend1/django_app/productcategory.schema.json +0 -70
  179. package/dist/models/backend1/django_app/rateplan.d.ts +0 -92
  180. package/dist/models/backend1/django_app/rateplan.js +0 -69
  181. package/dist/models/backend1/django_app/rateplan.schema.json +0 -70
  182. package/dist/models/backend1/django_app/restrictedfieldrelatedmodel.d.ts +0 -108
  183. package/dist/models/backend1/django_app/restrictedfieldrelatedmodel.js +0 -69
  184. package/dist/models/backend1/django_app/restrictedfieldrelatedmodel.schema.json +0 -87
  185. package/dist/models/backend1/fileobject.d.ts +0 -4
  186. package/dist/models/backend1/fileobject.js +0 -9
  187. package/dist/models/backend1/index.d.ts +0 -2
  188. package/dist/models/backend1/index.js +0 -2
  189. package/dist/models/default/django_app/comprehensivemodel.d.ts +0 -894
  190. package/dist/models/default/django_app/comprehensivemodel.js +0 -71
  191. package/dist/models/default/django_app/comprehensivemodel.schema.json +0 -870
  192. package/dist/models/default/django_app/custompkmodel.d.ts +0 -92
  193. package/dist/models/default/django_app/custompkmodel.js +0 -69
  194. package/dist/models/default/django_app/custompkmodel.schema.json +0 -71
  195. package/dist/models/default/django_app/dailyrate.d.ts +0 -230
  196. package/dist/models/default/django_app/dailyrate.js +0 -71
  197. package/dist/models/default/django_app/dailyrate.schema.json +0 -212
  198. package/dist/models/default/django_app/deepmodellevel1.d.ts +0 -128
  199. package/dist/models/default/django_app/deepmodellevel1.js +0 -72
  200. package/dist/models/default/django_app/deepmodellevel1.schema.json +0 -102
  201. package/dist/models/default/django_app/deepmodellevel2.d.ts +0 -106
  202. package/dist/models/default/django_app/deepmodellevel2.js +0 -71
  203. package/dist/models/default/django_app/deepmodellevel2.schema.json +0 -80
  204. package/dist/models/default/django_app/deepmodellevel3.d.ts +0 -80
  205. package/dist/models/default/django_app/deepmodellevel3.js +0 -69
  206. package/dist/models/default/django_app/deepmodellevel3.schema.json +0 -57
  207. package/dist/models/default/django_app/dummymodel.d.ts +0 -122
  208. package/dist/models/default/django_app/dummymodel.js +0 -71
  209. package/dist/models/default/django_app/dummymodel.schema.json +0 -97
  210. package/dist/models/default/django_app/dummyrelatedmodel.d.ts +0 -80
  211. package/dist/models/default/django_app/dummyrelatedmodel.js +0 -69
  212. package/dist/models/default/django_app/dummyrelatedmodel.schema.json +0 -57
  213. package/dist/models/default/django_app/filetest.d.ts +0 -128
  214. package/dist/models/default/django_app/filetest.js +0 -69
  215. package/dist/models/default/django_app/filetest.schema.json +0 -99
  216. package/dist/models/default/django_app/index.d.ts +0 -1
  217. package/dist/models/default/django_app/index.js +0 -21
  218. package/dist/models/default/django_app/m2mdepthtestlevel1.d.ts +0 -118
  219. package/dist/models/default/django_app/m2mdepthtestlevel1.js +0 -71
  220. package/dist/models/default/django_app/m2mdepthtestlevel1.schema.json +0 -94
  221. package/dist/models/default/django_app/m2mdepthtestlevel2.d.ts +0 -118
  222. package/dist/models/default/django_app/m2mdepthtestlevel2.js +0 -71
  223. package/dist/models/default/django_app/m2mdepthtestlevel2.schema.json +0 -94
  224. package/dist/models/default/django_app/m2mdepthtestlevel3.d.ts +0 -134
  225. package/dist/models/default/django_app/m2mdepthtestlevel3.js +0 -71
  226. package/dist/models/default/django_app/m2mdepthtestlevel3.schema.json +0 -112
  227. package/dist/models/default/django_app/modelwithcustompkrelation.d.ts +0 -118
  228. package/dist/models/default/django_app/modelwithcustompkrelation.js +0 -71
  229. package/dist/models/default/django_app/modelwithcustompkrelation.schema.json +0 -93
  230. package/dist/models/default/django_app/modelwithrestrictedfields.d.ts +0 -134
  231. package/dist/models/default/django_app/modelwithrestrictedfields.js +0 -71
  232. package/dist/models/default/django_app/modelwithrestrictedfields.schema.json +0 -111
  233. package/dist/models/default/django_app/namefiltercustompkmodel.d.ts +0 -92
  234. package/dist/models/default/django_app/namefiltercustompkmodel.js +0 -69
  235. package/dist/models/default/django_app/namefiltercustompkmodel.schema.json +0 -71
  236. package/dist/models/default/django_app/order.d.ts +0 -220
  237. package/dist/models/default/django_app/order.js +0 -71
  238. package/dist/models/default/django_app/order.schema.json +0 -203
  239. package/dist/models/default/django_app/orderitem.d.ts +0 -172
  240. package/dist/models/default/django_app/orderitem.js +0 -72
  241. package/dist/models/default/django_app/orderitem.schema.json +0 -149
  242. package/dist/models/default/django_app/product.d.ts +0 -254
  243. package/dist/models/default/django_app/product.js +0 -71
  244. package/dist/models/default/django_app/product.schema.json +0 -277
  245. package/dist/models/default/django_app/productcategory.d.ts +0 -92
  246. package/dist/models/default/django_app/productcategory.js +0 -69
  247. package/dist/models/default/django_app/productcategory.schema.json +0 -70
  248. package/dist/models/default/django_app/rateplan.d.ts +0 -92
  249. package/dist/models/default/django_app/rateplan.js +0 -69
  250. package/dist/models/default/django_app/rateplan.schema.json +0 -70
  251. package/dist/models/default/django_app/restrictedfieldrelatedmodel.d.ts +0 -108
  252. package/dist/models/default/django_app/restrictedfieldrelatedmodel.js +0 -69
  253. package/dist/models/default/django_app/restrictedfieldrelatedmodel.schema.json +0 -87
  254. package/dist/models/default/fileobject.d.ts +0 -4
  255. package/dist/models/default/fileobject.js +0 -9
  256. package/dist/models/default/index.d.ts +0 -2
  257. package/dist/models/default/index.js +0 -2
  258. package/dist/models/index.d.ts +0 -1
  259. package/dist/models/index.js +0 -5
  260. package/dist/react-entry.d.ts +0 -2
  261. package/dist/react-entry.js +0 -2
  262. package/dist/reactiveAdaptor.d.ts +0 -24
  263. package/dist/reactiveAdaptor.js +0 -38
  264. package/dist/reset.d.ts +0 -15
  265. package/dist/reset.js +0 -97
  266. package/dist/setup.d.ts +0 -15
  267. package/dist/setup.js +0 -33
  268. package/dist/syncEngine/cache/cache.d.ts +0 -75
  269. package/dist/syncEngine/cache/cache.js +0 -355
  270. package/dist/syncEngine/metrics/metricOptCalcs.d.ts +0 -79
  271. package/dist/syncEngine/metrics/metricOptCalcs.js +0 -284
  272. package/dist/syncEngine/registries/metricRegistry.d.ts +0 -58
  273. package/dist/syncEngine/registries/metricRegistry.js +0 -171
  274. package/dist/syncEngine/registries/modelStoreRegistry.d.ts +0 -11
  275. package/dist/syncEngine/registries/modelStoreRegistry.js +0 -63
  276. package/dist/syncEngine/registries/querysetStoreGraph.d.ts +0 -41
  277. package/dist/syncEngine/registries/querysetStoreGraph.js +0 -174
  278. package/dist/syncEngine/registries/querysetStoreRegistry.d.ts +0 -72
  279. package/dist/syncEngine/registries/querysetStoreRegistry.js +0 -335
  280. package/dist/syncEngine/stores/metricStore.d.ts +0 -55
  281. package/dist/syncEngine/stores/metricStore.js +0 -222
  282. package/dist/syncEngine/stores/modelStore.d.ts +0 -53
  283. package/dist/syncEngine/stores/modelStore.js +0 -565
  284. package/dist/syncEngine/stores/operation.d.ts +0 -139
  285. package/dist/syncEngine/stores/operation.js +0 -291
  286. package/dist/syncEngine/stores/operationEventHandlers.d.ts +0 -8
  287. package/dist/syncEngine/stores/operationEventHandlers.js +0 -322
  288. package/dist/syncEngine/stores/querysetStore.d.ts +0 -60
  289. package/dist/syncEngine/stores/querysetStore.js +0 -294
  290. package/dist/syncEngine/stores/reactivity.d.ts +0 -3
  291. package/dist/syncEngine/stores/reactivity.js +0 -4
  292. package/dist/syncEngine/stores/utils.d.ts +0 -14
  293. package/dist/syncEngine/stores/utils.js +0 -32
  294. package/dist/syncEngine/sync.d.ts +0 -46
  295. package/dist/syncEngine/sync.js +0 -389
  296. package/dist/testing.d.ts +0 -63
  297. package/dist/testing.js +0 -175
  298. package/dist/vue-entry.d.ts +0 -15
  299. package/dist/vue-entry.js +0 -7
  300. /package/{src → dist}/adaptors/vue/components/layout.css +0 -0
  301. /package/{src → dist}/adaptors/vue/components/layout.tailwind.css +0 -0
@@ -1,1080 +0,0 @@
1
- import sift from 'sift';
2
- const { createEqualsOperation } = sift;
3
- import { configInstance } from '../config.js';
4
- import { DateTime } from 'luxon';
5
- import { ModelSerializer } from '../flavours/django/serializers.js';
6
- /**
7
- * Gets the backend timezone for a model class
8
- * @param {Class} ModelClass - The model class
9
- * @returns {string} The backend timezone or 'UTC' as fallback
10
- */
11
- function getBackendTimezone(ModelClass) {
12
- if (!ModelClass || !ModelClass.configKey) {
13
- return 'UTC'; // Default fallback
14
- }
15
- const config = configInstance.getConfig();
16
- const backendConfig = config.backendConfigs[ModelClass.configKey] || config.backendConfigs.default;
17
- return backendConfig.BACKEND_TZ || 'UTC';
18
- }
19
- /**
20
- * Normalizes a filter value to match the live representation used in fetched data.
21
- * This ensures filter values match the format of data returned by .fetch().
22
- *
23
- * @param {string} fieldName - The field name to check in the schema
24
- * @param {any} value - The filter value to normalize
25
- * @param {Class} ModelClass - The model class containing the field
26
- * @returns {any} The normalized value in live format
27
- */
28
- function normalizeFilterValue(fieldName, value, ModelClass) {
29
- // If no schema or field name, return value as-is
30
- if (!ModelClass?.schema || !fieldName)
31
- return value;
32
- // Use the model's serializer to convert to live format (what .fetch() returns)
33
- const serializer = new ModelSerializer(ModelClass);
34
- // Handle array values (for 'in' lookups)
35
- if (Array.isArray(value)) {
36
- return value.map(v => serializer.toLiveField(fieldName, v));
37
- }
38
- return serializer.toLiveField(fieldName, value);
39
- }
40
- /**
41
- * Process a Django-style field path with relationships to match Django ORM behavior.
42
- * This handles nested relationships by traversing the model schema and properly
43
- * resolving relationship fields to their primary keys.
44
- *
45
- * @param {string} fieldPath - The Django-style field path (e.g., 'level2__level3__name')
46
- * @param {any} value - The value to filter by
47
- * @param {Class} ModelClass - The root model class to start traversal from
48
- * @param {Object} options - Additional options
49
- * @returns {Object} An object with processed field path and operator
50
- */
51
- function processFieldPath(fieldPath, value, ModelClass, options = {}) {
52
- // Split the field path into parts
53
- const parts = fieldPath.split('__');
54
- // Check if the last part is a lookup operator
55
- const knownLookups = [
56
- 'exact', 'iexact', 'contains', 'icontains', 'startswith',
57
- 'istartswith', 'endswith', 'iendswith', 'in', 'gt', 'gte',
58
- 'lt', 'lte', 'isnull', 'regex', 'iregex', 'year', 'month',
59
- 'day', 'week_day', 'hour', 'minute', 'second', 'date', 'time'
60
- ];
61
- // Date part lookups that can be followed by comparison lookups
62
- const dateParts = ['year', 'month', 'day', 'week_day', 'hour', 'minute', 'second', 'date', 'time'];
63
- // Comparison lookups that can follow date parts
64
- const comparisonLookups = ['gt', 'gte', 'lt', 'lte', 'exact'];
65
- let lookupChain = [];
66
- let fieldParts = [...parts];
67
- // Check for date part + comparison operator pattern (e.g., hour__gt)
68
- if (parts.length >= 3) {
69
- const potentialDatePart = parts[parts.length - 2];
70
- const potentialComparison = parts[parts.length - 1];
71
- if (dateParts.includes(potentialDatePart) && comparisonLookups.includes(potentialComparison)) {
72
- // We have a date part followed by a comparison (e.g., created_at__hour__gt)
73
- lookupChain = [potentialDatePart, potentialComparison];
74
- fieldParts = parts.slice(0, -2);
75
- }
76
- }
77
- // If no date part + comparison found, check for a single lookup
78
- let lookup = null;
79
- if (lookupChain.length === 0 && parts.length > 1 && knownLookups.includes(parts[parts.length - 1])) {
80
- lookup = parts[parts.length - 1];
81
- fieldParts = parts.slice(0, -1);
82
- }
83
- // Process the field parts to build the final path
84
- let currentModel = ModelClass;
85
- let processedPath = [];
86
- let isRelationship = false;
87
- let isM2M = false; // Track if this is a many-to-many relationship
88
- let finalFieldName = null; // Track the actual field name for schema lookup
89
- for (let i = 0; i < fieldParts.length; i++) {
90
- let part = fieldParts[i];
91
- if (part === 'pk' && currentModel)
92
- part = currentModel.primaryKeyField;
93
- const isLastPart = i === fieldParts.length - 1;
94
- // Check if this part refers to a relationship field
95
- if (currentModel && currentModel.relationshipFields &&
96
- currentModel.relationshipFields instanceof Map &&
97
- currentModel.relationshipFields.has(part)) {
98
- // This is a relationship field
99
- const relationship = currentModel.relationshipFields.get(part);
100
- const relatedModel = relationship.ModelClass();
101
- const relationshipType = relationship.relationshipType;
102
- // If this is not the last part and it's M2M, recursively process the remaining path
103
- if (!isLastPart && relationshipType === 'many-to-many') {
104
- // Build the remaining path including any lookup operators
105
- const remainingFieldParts = fieldParts.slice(i + 1);
106
- let fullRemainingPath = remainingFieldParts.join('__');
107
- if (lookupChain.length > 0) {
108
- fullRemainingPath += '__' + lookupChain.join('__');
109
- }
110
- else if (lookup) {
111
- fullRemainingPath += '__' + lookup;
112
- }
113
- // Recursively process the remaining path with the related model
114
- const innerResult = processFieldPath(fullRemainingPath, value, relatedModel, options);
115
- // Build the full field path including any FK traversal before this M2M field
116
- // e.g., for owner__roles__name, processedPath=['owner'], part='roles'
117
- // so fullFieldPath becomes 'owner.roles'
118
- const fullFieldPath = processedPath.length > 0
119
- ? processedPath.join('.') + '.' + part
120
- : part;
121
- // Build the required path for data picking (full dot-notation path)
122
- const innerRequiredPath = innerResult.requiredPath || innerResult.field;
123
- const requiredPath = `${fullFieldPath}.${innerRequiredPath}`;
124
- // Wrap the inner result in $elemMatch for this M2M field
125
- return {
126
- field: fullFieldPath,
127
- operator: { $elemMatch: { [innerResult.field]: innerResult.operator } },
128
- isM2M: true,
129
- requiredPath // Full path for data picking
130
- };
131
- }
132
- // Add this relationship field to the path
133
- processedPath.push(part);
134
- // If this is not the last part, update the current model to the related model
135
- if (!isLastPart) {
136
- currentModel = relatedModel;
137
- }
138
- else {
139
- // This is the last part and it's a relationship
140
- isRelationship = true;
141
- // For many-to-many relationships, don't append the primary key field
142
- // M2M fields store an array of PKs directly, not objects
143
- if (relationshipType === 'many-to-many') {
144
- isM2M = true;
145
- // Keep path as-is (e.g., 'comprehensive_models')
146
- // Sift will handle array membership check
147
- finalFieldName = part;
148
- }
149
- else {
150
- // For foreign key relationships, we need to append the primary key field
151
- // to properly match Django's behavior
152
- const pkField = relatedModel.primaryKeyField || 'id';
153
- processedPath.push(pkField);
154
- finalFieldName = pkField;
155
- }
156
- currentModel = relatedModel;
157
- }
158
- }
159
- else if (currentModel && currentModel.fields && currentModel.fields.includes(part)) {
160
- // This is a regular field
161
- processedPath.push(part);
162
- finalFieldName = part;
163
- // If it's the last part, we're done
164
- if (isLastPart) {
165
- break;
166
- }
167
- // Check if this is a JSON field - if so, we can traverse into it
168
- const fieldSchema = currentModel.schema?.properties?.[part];
169
- if (fieldSchema && fieldSchema.format === 'json') {
170
- // This is a JSON field - add remaining parts as nested key access
171
- // e.g., json_field__nested__active becomes json_field.nested.active
172
- const remainingParts = fieldParts.slice(i + 1);
173
- processedPath.push(...remainingParts);
174
- // The final field name for schema lookup is still the JSON field itself
175
- // (remaining parts are just keys within the JSON)
176
- break;
177
- }
178
- // If it's not the last part and not a JSON field,
179
- // we can't continue traversal
180
- throw new Error(`Field '${part}' in '${fieldPath}' is not a relationship field and cannot be traversed.`);
181
- }
182
- else {
183
- // Field not found in the model
184
- throw new Error(`Field '${part}' in '${fieldPath}' not found in model ${currentModel.modelName}.`);
185
- }
186
- }
187
- // Join the processed path parts, using dot notation for sift
188
- const finalPath = processedPath.join('.');
189
- // For date part lookups, don't normalize the value - keep it as-is for numeric/string comparison
190
- // Date parts like 'year', 'month', etc. expect numeric values, not Date objects
191
- const isDatePartLookup = dateParts.includes(lookup) || (lookupChain.length === 2 && dateParts.includes(lookupChain[0]));
192
- // Normalize the value based on the field schema (skip for date part lookups)
193
- const normalizedValue = isDatePartLookup ? value : normalizeFilterValue(finalFieldName, value, currentModel);
194
- // Handle the date part + comparison chain if present
195
- if (lookupChain.length === 2) {
196
- const [datePart, comparisonOperator] = lookupChain;
197
- return createDatePartComparisonOperator(finalPath, datePart, comparisonOperator, normalizedValue, isRelationship);
198
- }
199
- // Handle the single lookup operation if present
200
- if (lookup) {
201
- return createOperatorFromLookup(finalPath, lookup, normalizedValue, isRelationship, currentModel, finalFieldName, isM2M);
202
- }
203
- // If there's no explicit lookup and this is a relationship field,
204
- // we've already appended the PK field name to the path
205
- // so we just need to apply the equality operator to the value
206
- if (isRelationship) {
207
- // In case the user passed in a raw model as the query value
208
- let raw = normalizedValue;
209
- if (normalizedValue && typeof normalizedValue === 'object' && 'pk' in normalizedValue) {
210
- raw = normalizedValue.pk;
211
- }
212
- // For M2M fields, use $elemMatch to check if any element's pk matches
213
- if (isM2M) {
214
- return { field: finalPath, operator: { $elemMatch: { pk: { $eq: raw } } }, isM2M: true };
215
- }
216
- return { field: finalPath, operator: { $eq: raw } };
217
- }
218
- // Default to direct equality
219
- return { field: finalPath, operator: { $eq: normalizedValue } };
220
- }
221
- /**
222
- * Creates a special operator for date part comparison (e.g., created_at__hour__gt: 12)
223
- * @param {string} field - Processed field path
224
- * @param {string} datePart - The date part to extract ('year', 'month', etc.)
225
- * @param {string} comparisonOperator - The comparison operator ('gt', 'lt', etc.)
226
- * @param {any} value - Value to filter by
227
- * @param {boolean} isRelationship - Whether the field is a relationship
228
- * @returns {Object} Object with field name and custom operator
229
- */
230
- function createDatePartComparisonOperator(field, datePart, comparisonOperator, value, isRelationship) {
231
- // If this is a relationship field, we handle it differently
232
- if (isRelationship) {
233
- console.warn(`Date part comparison on relationship fields may not work as expected: ${field}`);
234
- // Fallback to direct equality as a safer option
235
- return { field, operator: { $eq: value } };
236
- }
237
- // Create a custom operation name that combines the date part and comparison
238
- // This will be handled by our custom operations in sift
239
- return {
240
- field,
241
- operator: { [`$${datePart}_${comparisonOperator}`]: value }
242
- };
243
- }
244
- /**
245
- * Creates a sift operator from a Django-style lookup
246
- * @param {string} field - Processed field path
247
- * @param {string} lookup - Django-style lookup (e.g., 'contains', 'iexact')
248
- * @param {any} value - Value to filter by (already normalized)
249
- * @param {boolean} isRelationship - Whether the field is a relationship
250
- * @param {Class} ModelClass - The model class (unused, for future extensibility)
251
- * @param {string} finalFieldName - The final field name (unused, for future extensibility)
252
- * @param {boolean} isM2M - Whether the field is a many-to-many relationship
253
- * @returns {Object} Object with field name and sift operator
254
- */
255
- function createOperatorFromLookup(field, lookup, value, isRelationship, ModelClass, finalFieldName, isM2M = false) {
256
- // Helper function to escape special characters in regex
257
- function escapeRegExp(string) {
258
- return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
259
- }
260
- // Handle relationship fields differently
261
- if (isRelationship) {
262
- // For relationship fields with lookups, we need special handling
263
- if (lookup === 'isnull') {
264
- if (isM2M) {
265
- // For M2M fields, isnull=True means null OR empty array
266
- // isnull=False means has at least one item
267
- // Use document-level $where to check the array itself (not iterate elements)
268
- const m2mField = field;
269
- const checkEmpty = value;
270
- return {
271
- field: '$where', // document-level operator
272
- requiredPath: field, // need the M2M field itself for data picking
273
- operator: function (doc) {
274
- const fieldValue = doc[m2mField];
275
- const isEmpty = fieldValue === null ||
276
- (Array.isArray(fieldValue) && fieldValue.length === 0);
277
- return checkEmpty ? isEmpty : !isEmpty;
278
- }
279
- };
280
- }
281
- // For FK/O2O, check for both undefined and null values
282
- return {
283
- field,
284
- operator: value ? { $in: [null, undefined] } : { $nin: [null, undefined] }
285
- };
286
- }
287
- else if (lookup === 'in') {
288
- // For M2M, check if any element's pk is in the provided array
289
- if (isM2M) {
290
- return { field, operator: { $elemMatch: { pk: { $in: value } } }, isM2M: true };
291
- }
292
- return { field, operator: { $in: value } };
293
- }
294
- else {
295
- // Default handling for relationship fields
296
- if (isM2M) {
297
- return { field, operator: { $elemMatch: { pk: { $eq: value } } }, isM2M: true };
298
- }
299
- return { field, operator: { $eq: value } };
300
- }
301
- }
302
- // Handle date-related lookups
303
- if (['year', 'month', 'day', 'week_day', 'hour', 'minute', 'second', 'date', 'time'].includes(lookup)) {
304
- // For date part lookups, we'll use a custom operation
305
- return {
306
- field,
307
- operator: { [`$${lookup}`]: value },
308
- isDatePart: true // Add a flag to identify date part operators
309
- };
310
- }
311
- // Regular field lookups (same as in the original code)
312
- if (lookup === 'isnull') {
313
- // Check for both undefined and null values
314
- return {
315
- field,
316
- operator: value ? { $in: [null, undefined] } : { $nin: [null, undefined] }
317
- };
318
- }
319
- else if (lookup === 'exact') {
320
- return { field, operator: { $eq: value } };
321
- }
322
- else if (lookup === 'iexact' && typeof value === 'string') {
323
- return {
324
- field,
325
- operator: { $regex: new RegExp(`^${escapeRegExp(value)}$`, 'i') }
326
- };
327
- }
328
- else if (lookup === 'contains' && typeof value === 'string') {
329
- return {
330
- field,
331
- operator: { $regex: new RegExp(escapeRegExp(value)) }
332
- };
333
- }
334
- else if (lookup === 'icontains' && typeof value === 'string') {
335
- return {
336
- field,
337
- operator: { $regex: new RegExp(escapeRegExp(value), 'i') }
338
- };
339
- }
340
- else if (lookup === 'startswith' && typeof value === 'string') {
341
- return {
342
- field,
343
- operator: { $regex: new RegExp(`^${escapeRegExp(value)}`) }
344
- };
345
- }
346
- else if (lookup === 'istartswith' && typeof value === 'string') {
347
- return {
348
- field,
349
- operator: { $regex: new RegExp(`^${escapeRegExp(value)}`, 'i') }
350
- };
351
- }
352
- else if (lookup === 'endswith' && typeof value === 'string') {
353
- return {
354
- field,
355
- operator: { $regex: new RegExp(`${escapeRegExp(value)}$`) }
356
- };
357
- }
358
- else if (lookup === 'iendswith' && typeof value === 'string') {
359
- return {
360
- field,
361
- operator: { $regex: new RegExp(`${escapeRegExp(value)}$`, 'i') }
362
- };
363
- }
364
- else if (lookup === 'in') {
365
- return { field, operator: { $in: value } };
366
- }
367
- else if (lookup === 'gt') {
368
- // Exclude null/undefined to match Django (NULL comparisons return no results)
369
- return { field, operator: { $nin: [null, undefined], $gt: value } };
370
- }
371
- else if (lookup === 'gte') {
372
- return { field, operator: { $nin: [null, undefined], $gte: value } };
373
- }
374
- else if (lookup === 'lt') {
375
- return { field, operator: { $nin: [null, undefined], $lt: value } };
376
- }
377
- else if (lookup === 'lte') {
378
- return { field, operator: { $nin: [null, undefined], $lte: value } };
379
- }
380
- else {
381
- // Default to direct equality if lookup not recognized
382
- return { field, operator: { $eq: value } };
383
- }
384
- }
385
- /**
386
- * Creates custom operations for date parts to be used with Sift
387
- * @param {string} timezone - The timezone to use for date operations
388
- * @param {Class} ModelClass - The model class for serialization
389
- * @returns {Object} Object containing custom operations
390
- */
391
- function createDateOperations(timezone = 'UTC', ModelClass = null) {
392
- const serializer = ModelClass ? new ModelSerializer(ModelClass) : null;
393
- // Helper function to convert value to Date object using serializer
394
- const toDateObject = (value, fieldName = null) => {
395
- if (!value)
396
- return null;
397
- // If already a Date, return it
398
- if (value instanceof Date)
399
- return value;
400
- // Use serializer to convert internal format to Date object
401
- if (serializer && fieldName && typeof value === 'string') {
402
- return serializer.toLiveField(fieldName, value);
403
- }
404
- // Fallback - should not happen in normal operation
405
- console.warn('Date conversion without serializer context:', value);
406
- return null;
407
- };
408
- // Helper function to extract date parts with Django-compatible behavior
409
- const getDatePart = (value, partExtractor, fieldName = null) => {
410
- const dateValue = toDateObject(value, fieldName);
411
- if (!dateValue || !(dateValue instanceof Date) || isNaN(dateValue.getTime())) {
412
- return null;
413
- }
414
- // Convert to timezone using Luxon
415
- const luxonDate = DateTime.fromJSDate(dateValue).setZone(timezone);
416
- // Extract the part using the provided function
417
- return partExtractor(luxonDate);
418
- };
419
- // Create operations with Django-compatible extractors
420
- const operations = {
421
- // Year - same in both
422
- $year(params, ownerQuery, options) {
423
- const compareValue = typeof params === 'string' ? Number(params) : params;
424
- return createEqualsOperation((value) => {
425
- const year = getDatePart(value, dt => dt.year);
426
- return year !== null && year === compareValue;
427
- }, ownerQuery, options);
428
- },
429
- // Month - Luxon is 1-indexed like Django
430
- $month(params, ownerQuery, options) {
431
- const compareValue = typeof params === 'string' ? Number(params) : params;
432
- return createEqualsOperation((value) => {
433
- const month = getDatePart(value, dt => dt.month); // Already 1-indexed
434
- return month !== null && month === compareValue;
435
- }, ownerQuery, options);
436
- },
437
- // Day of month - same in both
438
- $day(params, ownerQuery, options) {
439
- const compareValue = typeof params === 'string' ? Number(params) : params;
440
- return createEqualsOperation((value) => {
441
- const day = getDatePart(value, dt => dt.day);
442
- return day !== null && day === compareValue;
443
- }, ownerQuery, options);
444
- },
445
- // Day of week - convert to Django's 1=Sunday format
446
- $week_day(params, ownerQuery, options) {
447
- const compareValue = typeof params === 'string' ? Number(params) : params;
448
- return createEqualsOperation((value) => {
449
- // Convert from Luxon (1=Monday, 7=Sunday) to Django (1=Sunday, 7=Saturday)
450
- const weekDay = getDatePart(value, dt => dt.weekday === 7 ? 1 : dt.weekday + 1);
451
- return weekDay !== null && weekDay === compareValue;
452
- }, ownerQuery, options);
453
- },
454
- // Hour - same in both
455
- $hour(params, ownerQuery, options) {
456
- const compareValue = typeof params === 'string' ? Number(params) : params;
457
- return createEqualsOperation((value) => {
458
- const hour = getDatePart(value, dt => dt.hour);
459
- return hour !== null && hour === compareValue;
460
- }, ownerQuery, options);
461
- },
462
- // Minute - same in both
463
- $minute(params, ownerQuery, options) {
464
- const compareValue = typeof params === 'string' ? Number(params) : params;
465
- return createEqualsOperation((value) => {
466
- const minute = getDatePart(value, dt => dt.minute);
467
- return minute !== null && minute === compareValue;
468
- }, ownerQuery, options);
469
- },
470
- // Second - same in both
471
- $second(params, ownerQuery, options) {
472
- const compareValue = typeof params === 'string' ? Number(params) : params;
473
- return createEqualsOperation((value) => {
474
- const second = getDatePart(value, dt => dt.second);
475
- return second !== null && second === compareValue;
476
- }, ownerQuery, options);
477
- },
478
- // Date - extract date portion (ignore time)
479
- $date(params, ownerQuery, options) {
480
- return createEqualsOperation((value, fieldName) => {
481
- const dateValue = toDateObject(value, fieldName);
482
- if (!dateValue || !(dateValue instanceof Date) || isNaN(dateValue.getTime())) {
483
- return false;
484
- }
485
- // Convert params to Date using serializer
486
- // The serializer now parses date strings in the backend timezone
487
- const paramDate = toDateObject(params, fieldName);
488
- if (!paramDate || isNaN(paramDate.getTime()))
489
- return false;
490
- // Convert both to timezone and get date portions
491
- const luxonDate = DateTime.fromJSDate(dateValue).setZone(timezone);
492
- const luxonParam = DateTime.fromJSDate(paramDate).setZone(timezone);
493
- // Compare year, month, and day
494
- return luxonDate.year === luxonParam.year &&
495
- luxonDate.month === luxonParam.month &&
496
- luxonDate.day === luxonParam.day;
497
- }, ownerQuery, options);
498
- },
499
- // Time - extract time portion (ignore date)
500
- $time(params, ownerQuery, options) {
501
- return createEqualsOperation((value, fieldName) => {
502
- const dateValue = toDateObject(value, fieldName);
503
- if (!dateValue || !(dateValue instanceof Date) || isNaN(dateValue.getTime())) {
504
- return false;
505
- }
506
- // Convert to timezone
507
- const luxonDate = DateTime.fromJSDate(dateValue).setZone(timezone);
508
- // Parse the time string (format: "HH:MM:SS" or a Date object)
509
- let paramHour, paramMinute, paramSecond;
510
- if (typeof params === 'string') {
511
- // Parse time string like "10:30:45"
512
- const timeParts = params.split(':');
513
- paramHour = parseInt(timeParts[0], 10);
514
- paramMinute = parseInt(timeParts[1], 10);
515
- paramSecond = parseInt(timeParts[2], 10);
516
- }
517
- else {
518
- // Convert params to Date using serializer
519
- const paramDate = toDateObject(params, fieldName);
520
- if (!paramDate)
521
- return false;
522
- const luxonParam = DateTime.fromJSDate(paramDate).setZone(timezone);
523
- paramHour = luxonParam.hour;
524
- paramMinute = luxonParam.minute;
525
- paramSecond = luxonParam.second;
526
- }
527
- // Compare hour, minute, and second
528
- return luxonDate.hour === paramHour &&
529
- luxonDate.minute === paramMinute &&
530
- luxonDate.second === paramSecond;
531
- }, ownerQuery, options);
532
- }
533
- };
534
- // Define part extractors for each date part with Django compatibility
535
- const partExtractors = {
536
- 'year': (dt) => dt.year,
537
- 'month': (dt) => dt.month, // Already 1-indexed in Luxon
538
- 'day': (dt) => dt.day,
539
- 'week_day': (dt) => dt.weekday === 7 ? 1 : dt.weekday + 1, // Convert to Django's format
540
- 'hour': (dt) => dt.hour,
541
- 'minute': (dt) => dt.minute,
542
- 'second': (dt) => dt.second,
543
- 'date': (dt) => dt.toISODate(),
544
- 'time': (dt) => dt.hour * 3600 + dt.minute * 60 + dt.second
545
- };
546
- // Generate comparison operations for each date part (year_gt, month_lt, etc.)
547
- const datePartComparisons = ['gt', 'gte', 'lt', 'lte', 'exact'];
548
- // For each date part, create operations with each comparison operator
549
- Object.keys(partExtractors).forEach(part => {
550
- const extractor = partExtractors[part];
551
- datePartComparisons.forEach(op => {
552
- // Create a custom operation for each combination (e.g., $year_gt, $month_lte)
553
- operations[`$${part}_${op}`] = (params, ownerQuery, options) => {
554
- return createEqualsOperation((value, fieldName) => {
555
- const dateValue = toDateObject(value, fieldName);
556
- if (!dateValue || !(dateValue instanceof Date) || isNaN(dateValue.getTime())) {
557
- return false;
558
- }
559
- // Convert to timezone and extract part
560
- const luxonDate = DateTime.fromJSDate(dateValue).setZone(timezone);
561
- const partValue = extractor(luxonDate);
562
- // Coerce params to number for numeric comparisons (all parts except 'date' which is a string)
563
- // date part returns ISO date string like "2022-03-15", time returns number (seconds)
564
- const compareValue = part === 'date' ? params : (typeof params === 'string' ? Number(params) : params);
565
- // Apply the appropriate comparison
566
- switch (op) {
567
- case 'gt': return partValue > compareValue;
568
- case 'gte': return partValue >= compareValue;
569
- case 'lt': return partValue < compareValue;
570
- case 'lte': return partValue <= compareValue;
571
- case 'exact': return partValue === compareValue;
572
- default: return false;
573
- }
574
- }, ownerQuery, options);
575
- };
576
- });
577
- });
578
- return operations;
579
- }
580
- /**
581
- * Process a Django-style filter query to use with sift, including date part operations
582
- * @param {Object} criteria - Sift criteria with possible date operations
583
- * @param {Class} ModelClass - The model class for schema traversal
584
- * @returns {Function} Sift filter function with date operations support
585
- */
586
- function createFilterWithDateOperations(criteria, ModelClass) {
587
- const timezone = getBackendTimezone(ModelClass);
588
- return sift(criteria, {
589
- operations: createDateOperations(timezone, ModelClass)
590
- });
591
- }
592
- /**
593
- * Convert Django-style filter conditions to Sift-compatible criteria
594
- * @param {Object} conditions - Filter conditions
595
- * @param {Class} ModelClass - The model class for schema traversal
596
- * @returns {Object} Sift-compatible criteria
597
- */
598
- function convertToSiftCriteria(conditions, ModelClass) {
599
- const result = {};
600
- const datePartFilters = new Map(); // Map to collect date part filters by field
601
- const m2mConditions = new Map(); // Map to collect M2M $elemMatch conditions by field
602
- for (const [key, value] of Object.entries(conditions)) {
603
- try {
604
- const processedResult = processFieldPath(key, value, ModelClass);
605
- const { field, operator, isDatePart, isM2M } = processedResult;
606
- if (isDatePart) {
607
- // Handle date part operators separately
608
- if (!datePartFilters.has(field)) {
609
- datePartFilters.set(field, []);
610
- }
611
- datePartFilters.get(field).push({ [field]: operator });
612
- }
613
- else if (isM2M && operator.$elemMatch) {
614
- // Collect M2M conditions to merge into single $elemMatch
615
- if (!m2mConditions.has(field)) {
616
- m2mConditions.set(field, []);
617
- }
618
- m2mConditions.get(field).push(operator.$elemMatch);
619
- }
620
- else {
621
- // For regular operators, merge if we already have criteria for this field
622
- if (result[field]) {
623
- result[field] = { ...result[field], ...operator };
624
- }
625
- else {
626
- result[field] = operator;
627
- }
628
- }
629
- }
630
- catch (error) {
631
- throw new Error(`Failed to process field '${key}': ${error.message}`);
632
- }
633
- }
634
- // Merge M2M conditions: all conditions on same M2M field go into single $elemMatch
635
- // so the SAME related object must match ALL conditions
636
- for (const [field, elemMatchConditions] of m2mConditions.entries()) {
637
- if (elemMatchConditions.length === 1) {
638
- result[field] = { $elemMatch: elemMatchConditions[0] };
639
- }
640
- else {
641
- // Multiple conditions - wrap in $and so same element must match all
642
- result[field] = { $elemMatch: { $and: elemMatchConditions } };
643
- }
644
- }
645
- // If we have date part filters, combine them with the result
646
- if (datePartFilters.size > 0) {
647
- const andConditions = [];
648
- let hasRegularFilters = Object.keys(result).length > 0;
649
- // Add regular filters if any
650
- if (hasRegularFilters) {
651
- andConditions.push(result);
652
- }
653
- // Add each date part filter
654
- for (const [field, operators] of datePartFilters.entries()) {
655
- if (operators.length === 1) {
656
- // If there's only one date filter for this field
657
- if (hasRegularFilters || andConditions.length > 0) {
658
- andConditions.push(operators[0]);
659
- }
660
- else {
661
- // If this is the only filter, return it directly
662
- return operators[0];
663
- }
664
- }
665
- else {
666
- // Multiple date filters for the same field
667
- andConditions.push(...operators);
668
- }
669
- }
670
- // If we need to combine multiple conditions
671
- if (andConditions.length > 1) {
672
- return { $and: andConditions };
673
- }
674
- }
675
- return result;
676
- }
677
- /**
678
- * Processes a Q object array to match the backend AST structure
679
- * @param {Array} qConditions - Array of Q objects or conditions
680
- * @param {Class} ModelClass - The model class for schema traversal
681
- * @returns {Object} Sift criteria for Q conditions
682
- */
683
- function processQConditions(qConditions, ModelClass) {
684
- if (!qConditions || !qConditions.length)
685
- return null;
686
- // Convert each Q condition to sift criteria and combine with $or
687
- return {
688
- $or: qConditions.map(q => {
689
- if ('operator' in q && 'conditions' in q) {
690
- const op = q.operator === 'AND' ? '$and' : '$or';
691
- return { [op]: q.conditions.map(c => convertToSiftCriteria(c, ModelClass)) };
692
- }
693
- else {
694
- return convertToSiftCriteria(q, ModelClass);
695
- }
696
- })
697
- };
698
- }
699
- /**
700
- * Convert a filter node to sift criteria with proper relationship traversal
701
- * @param {Object} filterNode - The filter node to convert
702
- * @param {Class} ModelClass - The model class for schema traversal
703
- * @returns {Object} Sift criteria object
704
- */
705
- function convertFilterNodeToSiftCriteria(filterNode, ModelClass) {
706
- if (!filterNode)
707
- return null;
708
- // For simple filter nodes with conditions
709
- if (filterNode.type === 'filter') {
710
- const { conditions, Q: qConditions } = filterNode;
711
- let criteria = {};
712
- // Add regular conditions
713
- if (conditions && Object.keys(conditions).length > 0) {
714
- criteria = convertToSiftCriteria(conditions, ModelClass);
715
- }
716
- // Add Q conditions if present
717
- if (qConditions && qConditions.length > 0) {
718
- const qCriteria = processQConditions(qConditions, ModelClass);
719
- if (qCriteria) {
720
- // Combine with AND if both types of conditions exist
721
- if (Object.keys(criteria).length > 0) {
722
- return { $and: [criteria, qCriteria] };
723
- }
724
- return qCriteria;
725
- }
726
- }
727
- return criteria;
728
- }
729
- // For compound AND nodes
730
- if (filterNode.type === 'and' && filterNode.children) {
731
- const childCriteria = filterNode.children
732
- .map(child => convertFilterNodeToSiftCriteria(child, ModelClass))
733
- .filter(c => c != null);
734
- if (childCriteria.length === 0)
735
- return null;
736
- if (childCriteria.length === 1)
737
- return childCriteria[0];
738
- // Chained filters use $and at top level - this gives ANY/ANY semantics for M2M
739
- // (each $elemMatch can be satisfied by different elements)
740
- // This matches Django's chained .filter() behavior
741
- return { $and: childCriteria };
742
- }
743
- // For compound OR nodes
744
- if (filterNode.type === 'or' && filterNode.children) {
745
- const childCriteria = filterNode.children
746
- .map(child => convertFilterNodeToSiftCriteria(child, ModelClass))
747
- .filter(c => c != null);
748
- if (childCriteria.length === 0)
749
- return null;
750
- if (childCriteria.length === 1)
751
- return childCriteria[0];
752
- return { $or: childCriteria };
753
- }
754
- // Handle exclude nodes using $not
755
- if (filterNode.type === 'exclude' && filterNode.child) {
756
- const excludeCriteria = convertFilterNodeToSiftCriteria(filterNode.child, ModelClass);
757
- if (!excludeCriteria)
758
- return null;
759
- return { $not: excludeCriteria };
760
- }
761
- return null;
762
- }
763
- /**
764
- * Apply search criteria to a dataset
765
- * @param {Array} data - Collection of objects to search
766
- * @param {Object} searchNode - Search node from query
767
- * @param {Class} ModelClass - The model class for schema traversal
768
- * @returns {Array} Filtered results
769
- */
770
- function applySearch(data, searchNode, ModelClass) {
771
- if (!searchNode || !searchNode.searchQuery)
772
- return data;
773
- // Default to all string fields if searchFields not specified
774
- const searchFields = searchNode.searchFields ||
775
- (data[0] ? Object.keys(data[0]).filter(key => typeof data[0][key] === 'string') : []);
776
- if (!searchFields.length)
777
- return data;
778
- // Helper function to escape RegExp characters
779
- function escapeRegExp(string) {
780
- return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
781
- }
782
- // Process each search field through the model schema to handle relationships
783
- const processedSearchConditions = searchFields.map(field => {
784
- try {
785
- // Convert field path to a dot notation path for Sift
786
- const { field: processedField } = processFieldPath(field, '', ModelClass);
787
- return {
788
- [processedField]: {
789
- $regex: new RegExp(escapeRegExp(searchNode.searchQuery), 'i')
790
- }
791
- };
792
- }
793
- catch (error) {
794
- console.error(`Error processing search field '${field}':`, error.message);
795
- // Fall back to using field as is
796
- return {
797
- [field.replace(/__/g, '.')]: {
798
- $regex: new RegExp(escapeRegExp(searchNode.searchQuery), 'i')
799
- }
800
- };
801
- }
802
- });
803
- return data.filter(sift({ $or: processedSearchConditions }));
804
- }
805
- /**
806
- * Gets a nested value from an object using dot notation
807
- * @param {Object} obj - Object to get value from
808
- * @param {string} path - Path to value (dot notation)
809
- * @returns {any} Value at path
810
- */
811
- function getNestedValue(obj, path) {
812
- const parts = path.split('.');
813
- let current = obj;
814
- for (const part of parts) {
815
- if (current === null || current === undefined) {
816
- return undefined;
817
- }
818
- current = current[part];
819
- }
820
- return current;
821
- }
822
- /**
823
- * Process Django-style ordering fields
824
- * @param {Array<string>} orderBy - Fields to order by (prefix with - for descending)
825
- * @param {Class} ModelClass - The model class for schema traversal
826
- * @returns {Array<Object>} Processed ordering specifications
827
- */
828
- function processOrderBy(orderBy, ModelClass) {
829
- return orderBy.map(field => {
830
- const isDescending = field.startsWith('-');
831
- const fieldName = isDescending ? field.substring(1) : field;
832
- try {
833
- // Process the field path to handle relationships
834
- const { field: processedField } = processFieldPath(fieldName, '', ModelClass);
835
- return {
836
- field: processedField,
837
- desc: isDescending
838
- };
839
- }
840
- catch (error) {
841
- console.error(`Error processing order field '${fieldName}':`, error.message);
842
- // Fall back to using the field as is with dot notation
843
- return {
844
- field: fieldName.replace(/__/g, '.'),
845
- desc: isDescending
846
- };
847
- }
848
- });
849
- }
850
- /**
851
- * Applies ordering to a dataset based on a list of fields
852
- * @param {Array} data - Collection of objects to order
853
- * @param {Array<string>} orderBy - Fields to order by (prefix with - for descending)
854
- * @param {Class} ModelClass - The model class for schema traversal
855
- * @returns {Array} Ordered results
856
- */
857
- function applyOrderBy(data, orderBy, ModelClass) {
858
- if (!orderBy || !orderBy.length)
859
- return [...data];
860
- const processedOrdering = processOrderBy(orderBy, ModelClass);
861
- return [...data].sort((a, b) => {
862
- for (const { field, desc } of processedOrdering) {
863
- // Get values
864
- const value1 = getNestedValue(a, field);
865
- const value2 = getNestedValue(b, field);
866
- if (value1 === value2)
867
- continue;
868
- // Handle nulls - null values should come last in ascending order
869
- if (value1 === null && value2 !== null)
870
- return desc ? -1 : 1;
871
- if (value1 !== null && value2 === null)
872
- return desc ? 1 : -1;
873
- // Handle dates
874
- if (value1 instanceof Date && value2 instanceof Date) {
875
- return desc
876
- ? (value2.getTime() - value1.getTime())
877
- : (value1.getTime() - value2.getTime());
878
- }
879
- // Default comparison
880
- if (value1 > value2)
881
- return desc ? -1 : 1;
882
- if (value1 < value2)
883
- return desc ? 1 : -1;
884
- }
885
- return 0;
886
- });
887
- }
888
- /**
889
- * Process an array of denormalized objects to filter & order them,
890
- * then return just the matching primary-keys in order.
891
- *
892
- * @param {Array<Object>} data – denormalized rows, e.g. [{ id:1, name:"A", related:{ name:"B" } }, …]
893
- * @param {Object} queryBuild – the result of QuerySet.build()
894
- * @param {Class} ModelClass – your model class (for fieldPath resolution & date-ops)
895
- * @returns {Array<*>} – the primary keys of matching rows, in order
896
- */
897
- function processQuery(data, queryBuild, ModelClass) {
898
- if (!Array.isArray(data) || data.length === 0) {
899
- return [];
900
- }
901
- if (!ModelClass) {
902
- throw new Error('ModelClass is required for proper relationship traversal');
903
- }
904
- const pkField = ModelClass.primaryKeyField;
905
- let results = [...data]; // assume already denormalized objects
906
- // 1) Apply filtering
907
- if (queryBuild.filter) {
908
- const criteria = convertFilterNodeToSiftCriteria(queryBuild.filter, ModelClass);
909
- if (criteria && Object.keys(criteria).length) {
910
- results = results.filter(createFilterWithDateOperations(criteria, ModelClass));
911
- }
912
- }
913
- // 2) Apply search
914
- if (queryBuild.search && queryBuild.search.searchQuery) {
915
- results = applySearch(results, queryBuild.search, ModelClass);
916
- }
917
- // 3) Apply ordering
918
- if (Array.isArray(queryBuild.orderBy) && queryBuild.orderBy.length) {
919
- results = applyOrderBy(results, queryBuild.orderBy, ModelClass);
920
- }
921
- // 4) Return only the primary-keys, in the filtered & ordered sequence
922
- return results.map(item => item[pkField]);
923
- }
924
- /**
925
- * Inspect a QuerySet.build() and collect every field path
926
- * you’ll need to fetch before running sift.
927
- *
928
- * @param {Object} queryBuild – result of QuerySet.build()
929
- * @param {Class} ModelClass – the root model class
930
- * @returns {string[]} Array of dot-notation paths, e.g. ['author.id','createdAt.year']
931
- */
932
- export function getRequiredFields(queryBuild, ModelClass) {
933
- const paths = new Set();
934
- const pkField = ModelClass.primaryKeyField;
935
- // Always include the PK so we can re-map results
936
- paths.add(pkField);
937
- // Try to turn a Django-style key into the final dot path
938
- function addPath(rawKey) {
939
- try {
940
- // We pass `null` as the value, since we only care about .field
941
- const { field, isM2M, requiredPath } = processFieldPath(rawKey, null, ModelClass);
942
- // Use requiredPath if available (for M2M traversal), otherwise use field
943
- // For M2M fields at the end of a path (no requiredPath), we need the pk field
944
- let finalPath;
945
- if (requiredPath) {
946
- finalPath = requiredPath;
947
- }
948
- else if (isM2M) {
949
- finalPath = `${field}.pk`;
950
- }
951
- else {
952
- finalPath = field;
953
- }
954
- paths.add(finalPath);
955
- }
956
- catch (err) {
957
- // if a key doesn't map, warn and skip it
958
- console.warn(`getRequiredFields: couldn't process "${rawKey}": ${err.message}`);
959
- }
960
- }
961
- // Recursively walk your filter AST
962
- function walkFilter(node) {
963
- if (!node)
964
- return;
965
- switch (node.type) {
966
- case 'filter':
967
- // simple conditions
968
- Object.keys(node.conditions || {}).forEach(addPath);
969
- // any Q-objects
970
- (node.Q || []).forEach(q => {
971
- Object.keys(q.conditions || {}).forEach(addPath);
972
- });
973
- break;
974
- case 'and':
975
- case 'or':
976
- (node.children || []).forEach(walkFilter);
977
- break;
978
- case 'exclude':
979
- walkFilter(node.child);
980
- break;
981
- }
982
- }
983
- // collect from filter
984
- if (queryBuild.filter) {
985
- walkFilter(queryBuild.filter);
986
- }
987
- // collect from search
988
- if (queryBuild.search && Array.isArray(queryBuild.search.searchFields)) {
989
- queryBuild.search.searchFields.forEach(addPath);
990
- }
991
- // collect from orderBy
992
- if (Array.isArray(queryBuild.orderBy)) {
993
- queryBuild.orderBy.forEach(field => {
994
- // strip leading '-' for desc sorts
995
- addPath(field.replace(/^-/, ''));
996
- });
997
- }
998
- return Array.from(paths);
999
- }
1000
- /**
1001
- * Pick out only the required fields from a (possibly nested) model object.
1002
- *
1003
- * @param {string[]} requiredPaths – e.g. ['id','related.name','related.age']
1004
- * @param {Object} instance – e.g. { id: 3, related: { name: 'bob', age: 12, foo: 'bar' } }
1005
- * @returns {Object} – e.g. { id: 3, related: { name: 'bob', age: 12 } }
1006
- */
1007
- /**
1008
- * Recursively sets a value in a result object following a path.
1009
- * Handles arrays (M2M fields) by mapping over each element.
1010
- */
1011
- function setNestedValueRecursive(result, source, pathParts) {
1012
- if (source == null || pathParts.length === 0) {
1013
- return;
1014
- }
1015
- const [current, ...rest] = pathParts;
1016
- const sourceValue = source[current];
1017
- if (sourceValue === undefined) {
1018
- return;
1019
- }
1020
- if (rest.length === 0) {
1021
- // Last part - set the value directly, keeping M2M as full objects
1022
- result[current] = sourceValue;
1023
- }
1024
- else if (Array.isArray(sourceValue)) {
1025
- // M2M array with nested path - recursively extract from each element
1026
- if (!(current in result)) {
1027
- result[current] = [];
1028
- }
1029
- sourceValue.forEach((item, idx) => {
1030
- if (result[current][idx] === undefined) {
1031
- result[current][idx] = {};
1032
- }
1033
- setNestedValueRecursive(result[current][idx], item, rest);
1034
- });
1035
- }
1036
- else if (typeof sourceValue === 'object') {
1037
- // Regular nested object (FK)
1038
- if (!(current in result)) {
1039
- result[current] = {};
1040
- }
1041
- setNestedValueRecursive(result[current], sourceValue, rest);
1042
- }
1043
- }
1044
- export function pickRequiredFields(requiredPaths, instance) {
1045
- const result = {};
1046
- requiredPaths.forEach(path => {
1047
- const parts = path.split('.');
1048
- setNestedValueRecursive(result, instance, parts);
1049
- });
1050
- return result;
1051
- }
1052
- /**
1053
- * Filter and order a collection of data objects according to a QuerySet's AST.
1054
- * This combines getRequiredFields, pickRequiredFields, and processQuery in one function.
1055
- *
1056
- * @param {Array<Object>} data - Collection of objects to filter and order
1057
- * @param {Object} ast - Abstract Syntax Tree from QuerySet.build()
1058
- * @param {Class} ModelClass - The model class for schema traversal
1059
- * @param {boolean} [returnFullObjects=false] - If true, returns full objects instead of just primary keys
1060
- * @returns {Array} Filtered and ordered results (primary keys or full objects based on returnFullObjects)
1061
- */
1062
- export function filter(data, ast, ModelClass, returnFullObjects = false) {
1063
- if (!Array.isArray(data) || data.length === 0) {
1064
- return [];
1065
- }
1066
- if (!ModelClass) {
1067
- throw new Error('ModelClass is required for proper relationship traversal');
1068
- }
1069
- const pkField = ModelClass.primaryKeyField || 'id';
1070
- let requiredFields = getRequiredFields(ast, ModelClass);
1071
- let denormalizedData = data.map(item => pickRequiredFields(requiredFields, item));
1072
- const resultKeys = processQuery(denormalizedData, ast, ModelClass);
1073
- if (returnFullObjects) {
1074
- const dataMap = new Map(data.map(item => [item[pkField], item]));
1075
- return resultKeys.map(key => dataMap.get(key));
1076
- }
1077
- return resultKeys;
1078
- }
1079
- // Export the utility functions for testing and usage
1080
- export { processFieldPath, convertToSiftCriteria, processQConditions, convertFilterNodeToSiftCriteria, applySearch, applyOrderBy, processQuery, createDateOperations, createFilterWithDateOperations, createDatePartComparisonOperator, getBackendTimezone };