Skip to content

Commit 3d5c593

Browse files
nnpvaanxispa
andauthored
Support second-level precision on searches against DateIndex (#81)
* Enhance date filtering in catalog search to support second-level accuracy * Add doctest * Changelog * Improve the date filter by accepting ranges in the query * Improve/Expand doctest * Fix doctest * Redux doctest sections * Generalize second-level precision to all DateIndex indexes * Redux * Added doctest for custom DateIndex sorting (getDateReceived) * Added doctest for custom DateIndex filtering * Make linter happy * Undo change in users.rst doctest * Fix doctest users.rst * Unregister dummy test adapters * Changelog --------- Co-authored-by: Jordi Puiggené <jp@naralabs.com>
1 parent 4496cfa commit 3d5c593

3 files changed

Lines changed: 262 additions & 18 deletions

File tree

docs/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Changelog
44
2.7.0 (unreleased)
55
------------------
66

7+
- #81 Support second-level precision on searches against DateIndex
78
- #83 Fetch multiple items by UID
89
- #80 Precise timestamp filtering and sorting for created/modified fields
910
- #79 Fix WrongType for UIDReferenceField

src/senaite/jsonapi/catalog.py

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from bika.lims import api as senaiteapi
2222
from DateTime import DateTime
2323
from Products.ZCTextIndex.ZCTextIndex import ZCTextIndex
24+
from senaite.core.api import dtime
2425
from senaite.jsonapi import api
2526
from senaite.jsonapi import logger
2627
from senaite.jsonapi import request as req
@@ -64,27 +65,45 @@ def search(self, query):
6465
else:
6566
brains = senaiteapi.search(query, catalog=catalog.getId())
6667

68+
# if no brains found, no need to go further
69+
if not brains:
70+
return []
71+
72+
# DateIndex only supports minute-level precision, so we must
73+
# post-filter results for second-level accuracy
74+
catalog = brains[0].aq_parent
75+
indexes = catalog.Indexes
76+
schema = catalog.schema()
77+
78+
# get the DateIndex indexes we've searched against
79+
date_indexes = [idx.id for idx in indexes.values()
80+
if query.get(idx.id) and idx.meta_type == "DateIndex"]
81+
82+
# remove those for which there is no metadata column
83+
date_fields = [idx for idx in date_indexes if idx in schema]
84+
if date_fields:
85+
# filter the brains their date indexes are out of date range
86+
brains = [brain for brain in brains
87+
if self.in_date_range(brain, date_fields, query)]
88+
89+
# no need to do manual sort if only one brain
6790
if len(brains) < 2:
6891
return brains
6992

70-
# DateIndex only supports minute-level precision, so if the data is
71-
# sorted by a DateIndex index, we must manually sort the results to
72-
# achieve second-level accuracy
93+
# check if the sort_on is a DateIndex
7394
sort_on = query.get("sort_on")
7495
if not sort_on:
7596
return brains
7697

77-
# check if the sort_on is a DateIndex
78-
catalog = brains[0].aq_parent
79-
index = catalog.Indexes.get(sort_on, None)
98+
index = indexes.get(sort_on, None)
8099
if index is None or index.meta_type != "DateIndex":
81100
return brains
82101

83102
# check if a metadata column exists with same name
84-
if sort_on not in catalog.schema():
103+
if sort_on not in schema:
85104
return brains
86105

87-
# sort brains by sort_on
106+
# sort brains by sort_on to ensure second-level accuracy
88107
reverse = query.get("sort_order") == "descending"
89108
return sorted(brains, key=lambda brain: getattr(brain, sort_on, None),
90109
reverse=reverse)
@@ -173,6 +192,41 @@ def to_index_value(self, value, index):
173192

174193
return value
175194

195+
def in_date_range(self, brain, fields, query):
196+
"""Check if brain's date metadata falls within the queried ranges
197+
198+
DateIndex only stores minute-level precision, so the catalog may
199+
return brains that don't match at second-level. This method
200+
re-checks the actual metadata values for exact filtering.
201+
202+
Returns False as soon as any date field is out of range.
203+
"""
204+
for field in fields:
205+
value = getattr(brain, field, None)
206+
if not value:
207+
continue
208+
209+
query_spec = query.get(field) or {}
210+
date_value = query_spec.get("query")
211+
dates = [dtime.to_DT(d) for d in senaiteapi.to_list(date_value)]
212+
dates = list(filter(None, dates))
213+
if not dates:
214+
continue
215+
216+
date_range = query_spec.get("range", "min").lower()
217+
if date_range == "min:max":
218+
# inclusive lower bound, exclusive upper bound
219+
valid = min(dates) <= value < max(dates)
220+
elif date_range == "max":
221+
# exclusive upper bound
222+
valid = value < max(dates)
223+
else:
224+
# inclusive lower bound
225+
valid = value >= min(dates)
226+
if not valid:
227+
return False
228+
return True
229+
176230

177231
class CatalogQuery(object):
178232
"""Catalog query adapter

src/senaite/jsonapi/tests/doctests/search.rst

Lines changed: 199 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,17 +145,16 @@ But Sample Types are not stored in "senaite_catalog":
145145
[]
146146

147147

148-
Sorting by created and modified with second-level precision
149-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
148+
Date sorting and filtering with second-level precision
149+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
150150

151-
When sorting by 'created' or 'modified' indexes, the sorting should have
152-
second-level precision, not just minute-level precision. This ensures accurate
153-
ordering of items created or modified within the same minute.
154-
155-
Let's test this by creating multiple sample types in quick succession:
151+
DateIndex only stores minute-level precision, so objects created or modified
152+
seconds apart may share the same index value. The catalog post-processes
153+
results to achieve second-level accuracy for both sorting and filtering.
156154

157155
>>> import time
158156
>>> from DateTime import DateTime
157+
>>> from urllib import quote
159158

160159
Create sample types with controlled timestamps to test second-level precision:
161160

@@ -205,10 +204,45 @@ Now test descending order:
205204
>>> [it["title"] for it in recent_items]
206205
[u'Gamma', u'Beta', u'Alpha']
207206

208-
Now let's test sorting by 'modified'. The catalog implementation sorts DateIndex
209-
results manually to achieve better precision than the default minute-level precision.
207+
The ``created_since`` parameter post-filters by the actual metadata value.
208+
Using Beta's creation time as cutoff returns Beta and Gamma (inclusive):
209+
210+
>>> cutoff = quote(created2.ISO8601())
211+
>>> response = get("sampletype?created_since={}".format(cutoff))
212+
>>> data = json.loads(response)
213+
>>> items = data.get("items") or []
214+
>>> sorted([it["title"] for it in items if it["title"] in titles])
215+
[u'Beta', u'Gamma']
210216

211-
First, let's create new samples to modify with clear time separation:
217+
Using Gamma's creation time as cutoff returns only Gamma:
218+
219+
>>> cutoff = quote(created3.ISO8601())
220+
>>> response = get("sampletype?created_since={}".format(cutoff))
221+
>>> data = json.loads(response)
222+
>>> items = data.get("items") or []
223+
>>> sorted([it["title"] for it in items if it["title"] in titles])
224+
[u'Gamma']
225+
226+
Using Alpha's creation time as cutoff returns all three:
227+
228+
>>> cutoff = quote(created1.ISO8601())
229+
>>> response = get("sampletype?created_since={}".format(cutoff))
230+
>>> data = json.loads(response)
231+
>>> items = data.get("items") or []
232+
>>> sorted([it["title"] for it in items if it["title"] in titles])
233+
[u'Alpha', u'Beta', u'Gamma']
234+
235+
A future cutoff returns none of our items:
236+
237+
>>> future = quote(DateTime(created3 + 1.0 / 86400).ISO8601())
238+
>>> response = get("sampletype?created_since={}".format(future))
239+
>>> data = json.loads(response)
240+
>>> items = data.get("items") or []
241+
>>> [it["title"] for it in items if it["title"] in titles]
242+
[]
243+
244+
Sorting by ``modified`` also uses second-level precision. Create sample types
245+
and modify them with clear time separation:
212246

213247
>>> stm1 = api.create(portal.setup.sampletypes, "SampleType", title="ModAlpha", Prefix="MA")
214248
>>> time.sleep(2)
@@ -260,3 +294,158 @@ Verify timestamps are in descending order:
260294
>>> mod_times_desc = [DateTime(it["modified"]) for it in mod_items_desc]
261295
>>> mod_times_desc[0] >= mod_times_desc[1] >= mod_times_desc[2]
262296
True
297+
298+
299+
Custom DateIndex sorting (getDateReceived)
300+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
301+
302+
The second-level precision post-processing applies to any DateIndex field, not
303+
just ``created`` and ``modified``. Here we test with ``getDateReceived``, a
304+
custom DateIndex on the sample catalog.
305+
306+
Create the setup objects needed for sample creation:
307+
308+
>>> from bika.lims.utils.analysisrequest import create_analysisrequest
309+
>>> from bika.lims.workflow import doActionFor as do_action_for
310+
311+
>>> client = portal.clients["client-1"]
312+
>>> contact = api.create(client, "Contact", Firstname="Lab", Surname="User")
313+
>>> sampletype = portal.setup.sampletypes["sampletype-1"]
314+
>>> category = api.create(portal.setup.analysiscategories,
315+
... "AnalysisCategory", title="Chemistry")
316+
>>> service = api.create(portal.bika_setup.bika_analysisservices,
317+
... "AnalysisService", title="pH", Keyword="pH",
318+
... Category=category, Price="10")
319+
>>> transaction.commit()
320+
321+
Create three samples and receive them with 2-second gaps so that
322+
``getDateReceived`` has distinct second-level timestamps:
323+
324+
>>> values = {
325+
... "Contact": api.get_uid(contact),
326+
... "DateSampled": DateTime().ISO8601(),
327+
... "SampleType": api.get_uid(sampletype),
328+
... }
329+
330+
>>> request = self.request
331+
>>> s1 = create_analysisrequest(client, request, values, [api.get_uid(service)])
332+
>>> do_action_for(s1, "receive")
333+
(...)
334+
>>> time.sleep(2)
335+
>>> s2 = create_analysisrequest(client, request, values, [api.get_uid(service)])
336+
>>> do_action_for(s2, "receive")
337+
(...)
338+
>>> time.sleep(2)
339+
>>> s3 = create_analysisrequest(client, request, values, [api.get_uid(service)])
340+
>>> do_action_for(s3, "receive")
341+
(...)
342+
>>> transaction.commit()
343+
344+
Verify the receive dates differ at second level:
345+
346+
>>> dr1 = s1.getDateReceived()
347+
>>> dr2 = s2.getDateReceived()
348+
>>> dr3 = s3.getDateReceived()
349+
>>> dr1 < dr2 < dr3
350+
True
351+
352+
>>> sample_ids = [s1.getId(), s2.getId(), s3.getId()]
353+
354+
Sorting by ``getDateReceived`` ascending returns samples in receive order:
355+
356+
>>> response = get("search?portal_type=AnalysisRequest&sort_on=getDateReceived&sort_order=asc")
357+
>>> data = json.loads(response)
358+
>>> items = data.get("items")
359+
>>> received = [it for it in items if it["id"] in sample_ids]
360+
>>> [it["id"] for it in received] == sample_ids
361+
True
362+
363+
Descending order:
364+
365+
>>> response = get("search?portal_type=AnalysisRequest&sort_on=getDateReceived&sort_order=desc")
366+
>>> data = json.loads(response)
367+
>>> items = data.get("items")
368+
>>> received = [it for it in items if it["id"] in sample_ids]
369+
>>> [it["id"] for it in received] == list(reversed(sample_ids))
370+
True
371+
372+
373+
Custom DateIndex filtering (getDateReceived)
374+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
375+
376+
The second-level precision post-filtering also works for date range queries
377+
on custom DateIndex fields. We reuse the samples created above (s1, s2, s3)
378+
with their distinct ``getDateReceived`` timestamps.
379+
380+
Using s2's receive date as the ``min`` cutoff returns s2 and s3 (inclusive
381+
lower bound):
382+
383+
>>> cutoff = quote(dr2.ISO8601())
384+
>>> url = "search?portal_type=AnalysisRequest&getDateReceived.query:record:list={}&getDateReceived.range:record=min".format(cutoff)
385+
>>> response = get(url)
386+
>>> data = json.loads(response)
387+
>>> items = data.get("items") or []
388+
>>> filtered = [it for it in items if it["id"] in sample_ids]
389+
>>> sorted([it["id"] for it in filtered]) == sorted([s2.getId(), s3.getId()])
390+
True
391+
392+
Using s3's receive date as the cutoff returns only s3:
393+
394+
>>> cutoff = quote(dr3.ISO8601())
395+
>>> url = "search?portal_type=AnalysisRequest&getDateReceived.query:record:list={}&getDateReceived.range:record=min".format(cutoff)
396+
>>> response = get(url)
397+
>>> data = json.loads(response)
398+
>>> items = data.get("items") or []
399+
>>> filtered = [it for it in items if it["id"] in sample_ids]
400+
>>> [it["id"] for it in filtered]
401+
[u'...']
402+
>>> filtered[0]["id"] == s3.getId()
403+
True
404+
405+
Using s1's receive date returns all three:
406+
407+
>>> cutoff = quote(dr1.ISO8601())
408+
>>> url = "search?portal_type=AnalysisRequest&getDateReceived.query:record:list={}&getDateReceived.range:record=min".format(cutoff)
409+
>>> response = get(url)
410+
>>> data = json.loads(response)
411+
>>> items = data.get("items") or []
412+
>>> filtered = [it for it in items if it["id"] in sample_ids]
413+
>>> sorted([it["id"] for it in filtered]) == sorted(sample_ids)
414+
True
415+
416+
Using ``max`` range with s2's receive date returns only s1 (exclusive upper
417+
bound):
418+
419+
>>> cutoff = quote(dr2.ISO8601())
420+
>>> url = "search?portal_type=AnalysisRequest&getDateReceived.query:record:list={}&getDateReceived.range:record=max".format(cutoff)
421+
>>> response = get(url)
422+
>>> data = json.loads(response)
423+
>>> items = data.get("items") or []
424+
>>> filtered = [it for it in items if it["id"] in sample_ids]
425+
>>> [it["id"] for it in filtered]
426+
[u'...']
427+
>>> filtered[0]["id"] == s1.getId()
428+
True
429+
430+
Using ``min:max`` range with s1 and s3's receive dates returns s1 and s2
431+
(inclusive lower, exclusive upper):
432+
433+
>>> date_from = quote(dr1.ISO8601())
434+
>>> date_to = quote(dr3.ISO8601())
435+
>>> url = "search?portal_type=AnalysisRequest&getDateReceived.query:record:list={}&getDateReceived.query:record:list={}&getDateReceived.range:record=min:max".format(date_from, date_to)
436+
>>> response = get(url)
437+
>>> data = json.loads(response)
438+
>>> items = data.get("items") or []
439+
>>> filtered = [it for it in items if it["id"] in sample_ids]
440+
>>> sorted([it["id"] for it in filtered]) == sorted([s1.getId(), s2.getId()])
441+
True
442+
443+
A future cutoff returns none of our samples:
444+
445+
>>> future = quote(DateTime(dr3 + 1.0 / 86400).ISO8601())
446+
>>> url = "search?portal_type=AnalysisRequest&getDateReceived.query:record:list={}&getDateReceived.range:record=min".format(future)
447+
>>> response = get(url)
448+
>>> data = json.loads(response)
449+
>>> items = data.get("items") or []
450+
>>> [it["id"] for it in items if it["id"] in sample_ids]
451+
[]

0 commit comments

Comments
 (0)