Skip to content

Commit 55146ef

Browse files
committed
Add endpoint wrapper for query-saveRows.api
1 parent c06ac6a commit 55146ef

File tree

3 files changed

+464
-18
lines changed

3 files changed

+464
-18
lines changed

labkey/query.py

Lines changed: 122 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@
1515
#
1616
"""
1717
############################################################################
18-
NAME:
19-
LabKey Query API
18+
NAME:
19+
LabKey Query API
2020
21-
SUMMARY:
21+
SUMMARY:
2222
This module provides functions for interacting with data on a LabKey Server.
2323
2424
DESCRIPTION:
25-
This module is designed to simplify querying and manipulating data in LabKey Server.
26-
Its APIs are modeled after the LabKey Server JavaScript APIs of the same names.
25+
This module is designed to simplify querying and manipulating data in LabKey Server.
26+
Its APIs are modeled after the LabKey Server JavaScript APIs of the same names.
2727
2828
Installation and Setup for the LabKey Python API:
2929
https://github.com/LabKey/labkey-api-python/blob/master/README.md
@@ -41,7 +41,7 @@
4141
############################################################################
4242
"""
4343
import functools
44-
from typing import List, TextIO
44+
from typing import List, Literal, NotRequired, TextIO, TypedDict
4545

4646
from .server_context import ServerContext
4747
from .utils import waf_encode
@@ -196,7 +196,7 @@ def delete_rows(
196196
:param transacted: whether all of the updates should be done in a single transaction
197197
:param audit_behavior: used to override the audit behavior for the update. See class query.AuditBehavior
198198
:param audit_user_comment: used to provide a comment that will be attached to certain detailed audit log records
199-
:param timeout: timeout of request in seconds (defaults to 30s)
199+
:param timeout: timeout of request in seconds (defaults to 300s)
200200
:return:
201201
"""
202202
url = server_context.build_url("query", "deleteRows.api", container_path=container_path)
@@ -232,7 +232,7 @@ def truncate_table(
232232
:param schema_name: schema of table
233233
:param query_name: table name to delete from
234234
:param container_path: labkey container path if not already set in context
235-
:param timeout: timeout of request in seconds (defaults to 30s)
235+
:param timeout: timeout of request in seconds (defaults to 300s)
236236
:return:
237237
"""
238238
url = server_context.build_url("query", "truncateTable.api", container_path=container_path)
@@ -275,7 +275,7 @@ def execute_sql(
275275
:param save_in_session: save query result as a named view to the session
276276
:param parameters: parameter values to pass through to a parameterized query
277277
:param required_version: Api version of response
278-
:param timeout: timeout of request in seconds (defaults to 30s)
278+
:param timeout: timeout of request in seconds (defaults to 300s)
279279
:param waf_encode_sql: WAF encode sql in request (defaults to True)
280280
:return:
281281
"""
@@ -331,7 +331,7 @@ def insert_rows(
331331
:param transacted: whether all of the updates should be done in a single transaction
332332
:param audit_behavior: used to override the audit behavior for the update. See class query.AuditBehavior
333333
:param audit_user_comment: used to provide a comment that will be attached to certain detailed audit log records
334-
:param timeout: timeout of request in seconds (defaults to 30s)
334+
:param timeout: timeout of request in seconds (defaults to 300s)
335335
:return:
336336
"""
337337
url = server_context.build_url("query", "insertRows.api", container_path=container_path)
@@ -407,6 +407,93 @@ def import_rows(
407407
return server_context.make_request(url, payload, method="POST", file_payload=file_payload)
408408

409409

410+
class Command(TypedDict):
411+
"""
412+
TypedDict representing a command for saveRows API.
413+
"""
414+
415+
audit_behavior: NotRequired[AuditBehavior]
416+
audit_user_comment: NotRequired[str]
417+
command: Literal["insert", "update", "delete"]
418+
container_path: NotRequired[str]
419+
extra_context: NotRequired[dict]
420+
query_name: str
421+
rows: List[any]
422+
schema_name: str
423+
skip_reselect_rows: NotRequired[bool]
424+
425+
426+
def save_rows(
427+
server_context: ServerContext,
428+
commands: List[Command],
429+
api_version: float = None,
430+
container_path: str = None,
431+
extra_context: dict = None,
432+
timeout: int = _default_timeout,
433+
transacted: bool = None,
434+
validate_only: bool = None,
435+
):
436+
"""
437+
Save inserts, updates, and/or deletes to potentially multiple tables with a single request.
438+
:param server_context: A LabKey server context. See utils.create_server_context.
439+
:param commands: A List of the update/insert/delete operations to be performed.
440+
:param api_version: decimal value that indicates the response version of the api. If this is 13.2 or higher, a
441+
request that fails validation will be returned as a successful response. Use the 'errorCount' and 'committed'
442+
properties in the response to tell if it committed or not.
443+
:param container_path: folder path if not already part of server_context
444+
:param extra_context: Extra context object passed into the transformation/validation script environment.
445+
:param timeout: Request timeout in seconds (defaults to 300s)
446+
:param transacted: Whether all the commands should be done in a single transaction, so they all succeed or all
447+
fail. Defaults to true.
448+
:param validate_only: Whether the server should attempt to proceed through all the commands but not commit them to
449+
the database. Useful for scenarios like giving incremental validation feedback as a user fills out a UI form but
450+
does not save anything until they explicitly request a save.
451+
"""
452+
url = server_context.build_url("query", "saveRows.api", container_path=container_path)
453+
454+
json_commands = []
455+
for command in commands:
456+
json_command = {
457+
"command": command["command"],
458+
"queryName": command["query_name"],
459+
"schemaName": command["schema_name"],
460+
"rows": command["rows"],
461+
}
462+
463+
if command.get("audit_behavior") is not None:
464+
json_command["auditBehavior"] = command["audit_behavior"]
465+
466+
if command.get("audit_user_comment") is not None:
467+
json_command["auditUserComment"] = command["audit_user_comment"]
468+
469+
if command.get("container_path") is not None:
470+
json_command["containerPath"] = command["container_path"]
471+
472+
if command.get("extra_context") is not None:
473+
json_command["extraContext"] = command["extra_context"]
474+
475+
if command.get("skip_reselect_rows") is not None:
476+
json_command["skipReselectRows"] = command["skip_reselect_rows"]
477+
478+
json_commands.append(json_command)
479+
480+
payload = {"commands": json_commands}
481+
482+
if api_version is not None:
483+
payload["apiVersion"] = api_version
484+
485+
if extra_context is not None:
486+
payload["extraContext"] = extra_context
487+
488+
if transacted is not None:
489+
payload["transacted"] = transacted
490+
491+
if validate_only is not None:
492+
payload["validateOnly"] = validate_only
493+
494+
return server_context.make_request(url, json=payload, timeout=timeout)
495+
496+
410497
def select_rows(
411498
server_context: ServerContext,
412499
schema_name: str,
@@ -450,7 +537,7 @@ def select_rows(
450537
:param include_update_column: Boolean value that indicates whether to include an Update link column in results
451538
:param selection_key:
452539
:param required_version: decimal value that indicates the response version of the api
453-
:param timeout: Request timeout in seconds (defaults to 30s)
540+
:param timeout: Request timeout in seconds (defaults to 300s)
454541
:param ignore_filter: Boolean, if true, the command will ignore any filter that may be part of the chosen view.
455542
:return:
456543
"""
@@ -534,7 +621,7 @@ def update_rows(
534621
:param transacted: whether all of the updates should be done in a single transaction
535622
:param audit_behavior: used to override the audit behavior for the update. See class query.AuditBehavior
536623
:param audit_user_comment: used to provide a comment that will be attached to certain detailed audit log records
537-
:param timeout: timeout of request in seconds (defaults to 30s)
624+
:param timeout: timeout of request in seconds (defaults to 300s)
538625
:return:
539626
"""
540627
url = server_context.build_url("query", "updateRows.api", container_path=container_path)
@@ -580,7 +667,7 @@ def move_rows(
580667
:param transacted: whether all of the updates should be done in a single transaction
581668
:param audit_behavior: used to override the audit behavior for the update. See class query.AuditBehavior
582669
:param audit_user_comment: used to provide a comment that will be attached to certain detailed audit log records
583-
:param timeout: timeout of request in seconds (defaults to 30s)
670+
:param timeout: timeout of request in seconds (defaults to 300s)
584671
:return:
585672
"""
586673
url = server_context.build_url("query", "moveRows.api", container_path=container_path)
@@ -726,6 +813,28 @@ def import_rows(
726813
import_lookup_by_alternate_key,
727814
)
728815

816+
@functools.wraps(save_rows)
817+
def save_rows(
818+
self,
819+
commands: List[Command],
820+
api_version: float = None,
821+
container_path: str = None,
822+
extra_context: dict = None,
823+
timeout: int = _default_timeout,
824+
transacted: bool = None,
825+
validate_only: bool = None,
826+
):
827+
return save_rows(
828+
self.server_context,
829+
commands,
830+
api_version,
831+
container_path,
832+
extra_context,
833+
timeout,
834+
transacted,
835+
validate_only,
836+
)
837+
729838
@functools.wraps(select_rows)
730839
def select_rows(
731840
self,

test/integration/test_query.py

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,14 +242,14 @@ def test_import_rows(api: APIWrapper, parent_list_fixture, child_list_fixture, t
242242

243243
# Should succeed
244244
parent_file = parent_data_path.open()
245-
resp = api.query.import_rows("lists", PARENT_LIST_NAME, data_file=parent_file)
245+
resp = api.query.import_rows(LISTS_SCHEMA, PARENT_LIST_NAME, data_file=parent_file)
246246
parent_file.close()
247247
assert resp["success"] == True
248248
assert resp["rowCount"] == 3
249249

250250
# Should fail, because data doesn't use rowIds and import_lookup_by_alternate_key defaults to False
251251
child_file = child_data_path.open()
252-
resp = api.query.import_rows("lists", CHILD_LIST_NAME, data_file=child_file)
252+
resp = api.query.import_rows(LISTS_SCHEMA, CHILD_LIST_NAME, data_file=child_file)
253253
child_file.close()
254254
assert resp["success"] == False
255255
assert resp["errorCount"] == 1
@@ -261,8 +261,152 @@ def test_import_rows(api: APIWrapper, parent_list_fixture, child_list_fixture, t
261261
# Should pass, because import_lookup_by_alternate_key is True
262262
child_file = child_data_path.open()
263263
resp = api.query.import_rows(
264-
"lists", CHILD_LIST_NAME, data_file=child_file, import_lookup_by_alternate_key=True
264+
LISTS_SCHEMA, CHILD_LIST_NAME, data_file=child_file, import_lookup_by_alternate_key=True
265265
)
266266
child_file.close()
267267
assert resp["success"] == True
268268
assert resp["rowCount"] == 3
269+
270+
271+
SAMPLES_SCHEMA = "samples"
272+
BLOOD_SAMPLE_TYPE = "Blood"
273+
TISSUE_SAMPLE_TYPE = "Tissues"
274+
275+
276+
@pytest.fixture
277+
def blood_sample_type_fixture(api: APIWrapper):
278+
api.domain.create(
279+
{
280+
"kind": "SampleSet",
281+
"domainDesign": {
282+
"name": BLOOD_SAMPLE_TYPE,
283+
"description": "Blood samples.",
284+
"fields": [
285+
{"name": "Name", "rangeURI": "string"},
286+
{"name": "volume_mL", "rangeURI": "int"},
287+
{"name": "DrawDate", "rangeURI": "dateTime"},
288+
{"name": "ReceivedDate", "rangeURI": "dateTime"},
289+
{"name": "ProblemWithTube", "rangeURI": "boolean"},
290+
],
291+
},
292+
}
293+
)
294+
created_sample_type = api.domain.get(SAMPLES_SCHEMA, BLOOD_SAMPLE_TYPE)
295+
yield created_sample_type
296+
# clean up
297+
api.domain.drop(SAMPLES_SCHEMA, BLOOD_SAMPLE_TYPE)
298+
299+
300+
@pytest.fixture
301+
def tissue_sample_type_fixture(api: APIWrapper):
302+
api.domain.create(
303+
{
304+
"kind": "SampleSet",
305+
"domainDesign": {
306+
"name": TISSUE_SAMPLE_TYPE,
307+
"description": "Tissue samples.",
308+
"fields": [
309+
{"name": "Name", "rangeURI": "string"},
310+
{"name": "mass_mg", "rangeURI": "int"},
311+
{"name": "ReceivedDate", "rangeURI": "dateTime"},
312+
],
313+
},
314+
}
315+
)
316+
created_sample_type = api.domain.get(SAMPLES_SCHEMA, TISSUE_SAMPLE_TYPE)
317+
yield created_sample_type
318+
# clean up
319+
api.domain.drop(SAMPLES_SCHEMA, TISSUE_SAMPLE_TYPE)
320+
321+
322+
def test_api_save_rows(api: APIWrapper, blood_sample_type_fixture, tissue_sample_type_fixture):
323+
commands = [
324+
{
325+
"command": "insert",
326+
"schema_name": SAMPLES_SCHEMA,
327+
"query_name": BLOOD_SAMPLE_TYPE,
328+
"rows": [{"name": "BL-1"}, {"description": "Should be BL-2 but I forgot to name it"}],
329+
},
330+
{
331+
"command": "insert",
332+
"schema_name": SAMPLES_SCHEMA,
333+
"query_name": TISSUE_SAMPLE_TYPE,
334+
"rows": [{"name": "T-1"}],
335+
},
336+
]
337+
338+
# Expect to fail this request since the sample name was not specified for one of the rows
339+
with pytest.raises(ServerContextError) as e:
340+
api.query.save_rows(commands=commands)
341+
assert e.value.message == "400: SampleID or Name is required for sample on row 2"
342+
343+
# Attempt the same request but with a 13.2 api version
344+
resp = api.query.save_rows(api_version=13.2, commands=commands)
345+
assert resp["committed"] == False
346+
assert resp["errorCount"] == 1
347+
assert (
348+
resp["result"][0]["errors"]["exception"]
349+
== "SampleID or Name is required for sample on row 2"
350+
)
351+
352+
# Fix the first command by specifying a name for the sample
353+
commands[0]["rows"][1]["name"] = "BL-2"
354+
355+
resp = api.query.save_rows(commands=commands)
356+
assert resp["committed"] == True
357+
assert resp["errorCount"] == 0
358+
assert len(resp["result"][0]["rows"]) == 2
359+
assert len(resp["result"][1]["rows"]) == 1
360+
361+
first_blood_row_id = resp["result"][0]["rows"][0]["rowid"]
362+
assert first_blood_row_id > 0
363+
364+
first_tissue_row_id = resp["result"][1]["rows"][0]["rowid"]
365+
assert first_tissue_row_id > 0
366+
367+
# Perform an insert, update, and delete all in the same request
368+
commands = [
369+
{
370+
"command": "insert",
371+
"schema_name": SAMPLES_SCHEMA,
372+
"query_name": BLOOD_SAMPLE_TYPE,
373+
"rows": [
374+
{"name": "BL-3", "MaterialInputs/Tissues": "T-1"},
375+
{"name": "BL-4", "MaterialInputs/Blood": "BL-2"},
376+
],
377+
},
378+
{
379+
"command": "delete",
380+
"schema_name": SAMPLES_SCHEMA,
381+
"query_name": BLOOD_SAMPLE_TYPE,
382+
"rows": [
383+
{"rowId": first_blood_row_id},
384+
],
385+
},
386+
{
387+
"command": "update",
388+
"schema_name": SAMPLES_SCHEMA,
389+
"query_name": TISSUE_SAMPLE_TYPE,
390+
"rows": [
391+
{"rowId": first_tissue_row_id, "ReceivedDate": "2025-07-07 12:34:56"},
392+
],
393+
},
394+
]
395+
396+
resp = api.query.save_rows(commands=commands)
397+
assert resp["committed"] == True
398+
assert resp["errorCount"] == 0
399+
400+
# Verify insert
401+
assert resp["result"][0]["rowsAffected"] == 2
402+
assert resp["result"][0]["rows"][0]["name"] == "BL-3"
403+
assert resp["result"][0]["rows"][1]["name"] == "BL-4"
404+
405+
# Verify delete
406+
assert resp["result"][1]["rowsAffected"] == 1
407+
assert resp["result"][1]["rows"][0]["rowid"] == first_blood_row_id
408+
409+
# Verify update
410+
assert resp["result"][2]["rowsAffected"] == 1
411+
assert resp["result"][2]["rows"][0]["rowid"] == first_tissue_row_id
412+
assert resp["result"][2]["rows"][0]["receiveddate"] == "2025-07-07 12:34:56.000"

0 commit comments

Comments
 (0)