Skip to content

Commit 32e6a2d

Browse files
authored
Allow field projection (#85)
* Allow to select fields * Added doctest * Changelog updated
1 parent 3d5c593 commit 32e6a2d

4 files changed

Lines changed: 209 additions & 0 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+
- #85 Allow field projection
78
- #81 Support second-level precision on searches against DateIndex
89
- #83 Fetch multiple items by UID
910
- #80 Precise timestamp filtering and sorting for created/modified fields

src/senaite/jsonapi/api.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,10 +298,16 @@ def make_items_for(brains_or_objects, endpoint=None, complete=False):
298298
# check if the user wants to include children
299299
include_children = req.get_children(False)
300300

301+
# optional field projection: ?fields=uid,id,title,...
302+
# empty set means no projection (full object returned)
303+
fields = req.get_fields()
304+
301305
def extract_data(brain_or_object):
302306
info = get_info(brain_or_object, endpoint=endpoint, complete=complete)
303307
if include_children and is_folderish(brain_or_object):
304308
info.update(get_children_info(brain_or_object, complete=complete))
309+
if fields:
310+
info = {k: info[k] for k in fields if k in info}
305311
return info
306312

307313
return map(extract_data, brains_or_objects)

src/senaite/jsonapi/request.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,20 @@ def get_uids():
228228
return [v.strip() for v in value.split(",") if v.strip()]
229229

230230

231+
def get_fields():
232+
"""Returns the set of field names requested via the 'fields' parameter.
233+
234+
Accepts a comma-separated string, e.g. ``?fields=uid,id,title``.
235+
Returns an empty set when the parameter is absent or blank.
236+
An empty set means no projection is applied and the full object is
237+
returned (unchanged behaviour).
238+
"""
239+
value = get("fields", "")
240+
if not value:
241+
return set()
242+
return set(v.strip() for v in value.split(",") if v.strip())
243+
244+
231245
def get_request_data():
232246
""" extract and convert the json data from the request
233247
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
FIELD PROJECTION
2+
----------------
3+
4+
Running this test from the buildout directory:
5+
6+
bin/test test_doctests -t field_projection
7+
8+
9+
Test Setup
10+
~~~~~~~~~~
11+
12+
Needed Imports:
13+
14+
>>> import json
15+
>>> import transaction
16+
>>> from plone.app.testing import setRoles
17+
>>> from plone.app.testing import TEST_USER_ID
18+
>>> from bika.lims import api
19+
20+
Functional Helpers:
21+
22+
>>> def get(url):
23+
... browser.open("{}/{}".format(api_url, url))
24+
... return browser.contents
25+
26+
>>> def get_count(response):
27+
... data = json.loads(response)
28+
... return data.get("count")
29+
30+
>>> def get_item_keys(response, index=0):
31+
... data = json.loads(response)
32+
... items = data.get("items", [])
33+
... if not items:
34+
... return []
35+
... return sorted(items[index].keys())
36+
37+
Variables:
38+
39+
>>> portal = self.portal
40+
>>> portal_url = portal.absolute_url()
41+
>>> api_url = "{}/@@API/senaite/v1".format(portal_url)
42+
>>> browser = self.getBrowser()
43+
>>> setRoles(portal, TEST_USER_ID, ["LabManager", "Manager"])
44+
45+
Create two Client objects and commit so they are indexed:
46+
47+
>>> c1 = api.create(portal.clients, "Client", title="Alpha Lab", ClientID="AL")
48+
>>> c2 = api.create(portal.clients, "Client", title="Beta Lab", ClientID="BL")
49+
>>> uid1 = api.get_uid(c1)
50+
>>> uid2 = api.get_uid(c2)
51+
>>> transaction.commit()
52+
53+
54+
Basic projection — only requested fields are returned
55+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
56+
57+
Requesting ``uid,id,title`` returns exactly those three keys in each item:
58+
59+
>>> response = get("client?fields=uid,id,title")
60+
>>> keys = get_item_keys(response)
61+
>>> "uid" in keys
62+
True
63+
>>> "id" in keys
64+
True
65+
>>> "title" in keys
66+
True
67+
68+
Fields that were not requested are absent:
69+
70+
>>> "portal_type" in keys
71+
False
72+
>>> "url" in keys
73+
False
74+
>>> "path" in keys
75+
False
76+
77+
78+
Projection with complete=1
79+
~~~~~~~~~~~~~~~~~~~~~~~~~~
80+
81+
Field projection applies after object wake-up, so ``complete=1`` fields
82+
are also subject to it:
83+
84+
>>> response = get("client?complete=1&fields=uid,title,ClientID")
85+
>>> keys = get_item_keys(response)
86+
>>> "uid" in keys
87+
True
88+
>>> "title" in keys
89+
True
90+
>>> "ClientID" in keys
91+
True
92+
93+
Fields outside the requested set are still excluded:
94+
95+
>>> "id" in keys
96+
False
97+
>>> "portal_type" in keys
98+
False
99+
100+
101+
Requested content is correct
102+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
103+
104+
The projected values match the objects that were created:
105+
106+
>>> response = get("client?fields=uid,title")
107+
>>> data = json.loads(response)
108+
>>> titles = sorted(item["title"] for item in data["items"])
109+
>>> titles
110+
[u'Alpha Lab', u'Beta Lab']
111+
112+
113+
Projection with a single requested field
114+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
115+
116+
Requesting only ``title`` returns items with exactly one key:
117+
118+
>>> response = get("client?fields=title")
119+
>>> keys = get_item_keys(response)
120+
>>> keys
121+
[u'title']
122+
123+
124+
Unknown fields are silently omitted
125+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
126+
127+
A field name that does not exist on the object is ignored rather than
128+
raising an error:
129+
130+
>>> response = get("client?fields=uid,nonexistent_field")
131+
>>> keys = get_item_keys(response)
132+
>>> "uid" in keys
133+
True
134+
>>> "nonexistent_field" in keys
135+
False
136+
137+
138+
No projection when fields parameter is absent
139+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
140+
141+
Without a ``fields`` parameter the full object is returned, including
142+
the standard set of metadata fields:
143+
144+
>>> response = get("client?uids={}".format(uid1))
145+
>>> keys = get_item_keys(response)
146+
>>> "uid" in keys
147+
True
148+
>>> "url" in keys
149+
True
150+
>>> "portal_type" in keys
151+
True
152+
153+
154+
Projection combined with batch UID fetch
155+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
156+
157+
``?fields=`` and ``?uids=`` can be combined in a single request:
158+
159+
>>> response = get("client?uids={},{}&fields=uid,title".format(uid1, uid2))
160+
>>> get_count(response)
161+
2
162+
>>> data = json.loads(response)
163+
>>> keys = sorted(data["items"][0].keys())
164+
>>> keys
165+
[u'title', u'uid']
166+
>>> "Alpha Lab" in response
167+
True
168+
>>> "Beta Lab" in response
169+
True
170+
171+
172+
Projection on the generic /search route
173+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
174+
175+
The ``fields`` parameter works on the ``/search`` endpoint as well:
176+
177+
>>> response = get(
178+
... "search?portal_type=Client&fields=uid,id,title"
179+
... )
180+
>>> keys = get_item_keys(response)
181+
>>> "uid" in keys
182+
True
183+
>>> "id" in keys
184+
True
185+
>>> "title" in keys
186+
True
187+
>>> "portal_type" in keys
188+
False

0 commit comments

Comments
 (0)