Skip to content

Latest commit

History

History
734 lines (589 loc) · 37.3 KB

File metadata and controls

734 lines (589 loc) · 37.3 KB

LabKey Query API Support

The Query API reads and writes data in any LabKey schema. Every method targets a table or query by its schema name and query name — the same pair shown in the server UI under Admin → Go To Module → Query, and in the URL of any data grid (e.g. .../query-executeQuery.view?schemaName=lists&query.queryName=Demographics).

The API is modeled after the LabKey JavaScript client API of the same name, so the method names and payloads correspond closely to their JavaScript counterparts.

Additional details from LabKey Documentation:

Interfaces

The classes below are imported from labkey.query:

fromlabkey.queryimportAuditBehavior, InsertOption, Pagination, QueryFilter

QueryFilter

Represents a single filter clause. Pass a list of them as the filter_array argument to select_rows.

QueryFilter(column, value, filter_type=QueryFilter.Types.EQUAL)
ArgumentTypeDescription
columnstrName of the column to filter on.
valuestrThe value to compare against. Ignored by the "no data value" types below.
filter_typestrOne of QueryFilter.Types. Defaults to EQUAL.

Multiple filters may target the same column; each is applied.

QueryFilter.Types enumerates the available operators:

CategoryTypes
EqualityEQUAL, NEQ / NOT_EQUAL, NEQ_OR_NULL / NOT_EQUAL_OR_MISSING, DATE_EQUAL, DATE_NOT_EQUAL
ComparisonGT / GREATER_THAN, GTE / GREATER_THAN_OR_EQUAL, LT / LESS_THAN, LTE / LESS_THAN_OR_EQUAL, and the DATE_ prefixed equivalents
Ranges and setsBETWEEN, NOT_BETWEEN, IN / EQUALS_ONE_OF, NOT_IN / EQUALS_NONE_OF, MEMBER_OF
StringsSTARTS_WITH, DOES_NOT_START_WITH, CONTAINS, DOES_NOT_CONTAIN, CONTAINS_ONE_OF, CONTAINS_NONE_OF
ArraysARRAY_CONTAINS_ALL, ARRAY_CONTAINS_ANY, ARRAY_CONTAINS_NONE, ARRAY_CONTAINS_EXACT, ARRAY_CONTAINS_NOT_EXACT, ARRAY_ISEMPTY, ARRAY_ISNOTEMPTY
No data valueHAS_ANY_VALUE, IS_BLANK, IS_NOT_BLANK, HAS_MISSING_VALUE, DOES_NOT_HAVE_MISSING_VALUE
Search, ontology, lineageQ (table-wide search), ONTOLOGY_IN_SUBTREE, ONTOLOGY_NOT_IN_SUBTREE, EXP_CHILD_OF, EXP_PARENT_OF, EXP_LINEAGE_OF

Multi-value types are not consistent in how they delimit values — this is a historical artifact of the underlying API. BETWEEN and NOT_BETWEEN take a comma separated pair ("50, 70"); IN and NOT_IN take a semicolon separated list ("Germany;Uganda").

Pagination

Paging styles for the show_rows argument of select_rows: PAGINATED, SELECTED, UNSELECTED, ALL, NONE.

AuditBehavior

Overrides the audit detail level of a write operation: DETAILED, SUMMARY, NONE. DETAILED records the values before and after the change, SUMMARY records only that a change occurred. When omitted, the table's configured behavior applies.

InsertOption

How import_rows applies the rows it reads. Not every table supports every option — the server rejects an unsupported combination with an error.

ValueEffect
IMPORTBulk insert, creating a new row for every row of data. The default.
INSERTInsert one row at a time, reselecting each inserted row.
MERGEInsert new rows; for rows that already exist, update only the columns present in the data.
REPLACELike MERGE, but also nulls the columns of an existing row that the data omits.
UPSERTLike MERGE, but reselects the affected rows.
UPDATEUpdate existing rows only, failing if a row does not exist.
IMPORT_IDENTITYBulk insert that preserves the primary key values supplied in the data.

Command

A TypedDict describing one operation in a save_rows request. Keys use Python style names and are converted to the server's JSON names for you.

KeyTypeRequiredDescription
command"insert" / "update" / "delete"YesThe operation to perform.
schema_namestrYesSchema of the target table.
query_namestrYesTarget table name.
rowsList[dict]YesThe rows to insert, update, or delete.
container_pathstrNoOverrides the container for this command only.
audit_behaviorAuditBehaviorNoAudit detail level for this command.
audit_user_commentstrNoComment attached to detailed audit records.
extra_contextdictNoPassed to the transformation/validation script environment.
skip_reselect_rowsboolNoSkip returning the full detail of the affected rows.

Methods

All methods are available on the query member of an APIWrapper instance.

MethodDescription
select_rows(schema_name, query_name, ...)Query a table or query and return the result set.
execute_sql(schema_name, sql, ...)Execute LabKey SQL against a schema.
insert_rows(schema_name, query_name, rows, ...)Insert rows into a table.
update_rows(schema_name, query_name, rows, ...)Update existing rows. Each row must carry its primary key.
delete_rows(schema_name, query_name, rows, ...)Delete rows. Each row need only carry its primary key.
move_rows(target_container_path, schema_name, query_name, rows, ...)Move rows to another container.
truncate_table(schema_name, query_name, ...)Delete every row in a table.
import_rows(schema_name, query_name, data_file, ...)Bulk insert or merge rows from a file, inline text, or a file already on the server.
save_rows(commands, ...)Perform inserts, updates, and deletes across several tables in one request.
get_queries(schema_name, ...)List the queries available in a schema.

Common arguments

ArgumentDefaultDescription
container_pathNoneOverrides the container path configured on the APIWrapper for this request.
transactedTrueWhether the writes are applied in a single transaction, so that they all succeed or all fail.
audit_behaviorNoneSee AuditBehavior.
audit_user_commentNoneComment attached to certain detailed audit log records.
timeout300Request timeout in seconds. Exceeding it raises requests.exceptions.Timeout.

container_path, transacted, audit_behavior, and audit_user_comment apply to the write methods (insert_rows, update_rows, delete_rows, move_rows); read methods accept container_path and timeout. import_rows accepts container_path, audit_behavior, audit_user_comment, and timeout, but not transacted — an import is always transacted.

Notable per-method arguments

select_rows

ArgumentDefaultDescription
view_nameNoneName of an existing custom view to apply.
filter_arrayNoneList of QueryFilter objects.
columnsNoneComma separated list of columns to retrieve. Lookups may be traversed with /, e.g. "Sample/Name".
max_rows-1Maximum rows to return. -1 means unlimited.
offsetNoneNumber of rows to skip.
sortNoneComma separated column list. Prefix a column with - to sort descending.
show_rowsNoneA Pagination value.
include_total_countNoneInclude the total row count in the response, independent of paging.
include_details_columnNoneInclude a Details link column in the results.
include_update_columnNoneInclude an Update link column in the results.
container_filterNoneBroadens the query beyond the target container. See the link at the top of this page.
parametersNoneValues for a parameterized query, as a dict.
ignore_filterNoneWhen True, filters saved on the chosen view are ignored.
required_versionNoneResponse format version.

execute_sql accepts container_filter, max_rows, offset, sort, parameters, required_version, and:

ArgumentDefaultDescription
save_in_sessionNoneSave the query in the session. The response's queryName can then be passed to select_rows as query_name.
waf_encode_sqlTrueEncode the SQL so that web application firewalls do not reject the request. Rarely needs to change.

import_rows

The rows come from one of four sources. The server uses the first one supplied in this order — text, path, module_resource, data_file — and ignores the others, so pass exactly one.

ArgumentDefaultDescription
data_fileNoneAn open file handle, uploaded as multipart form data. Its column headers must match the LabKey column names.
textNoneThe rows as inline delimited text, including the header row.
pathNonePath of a file already on the server, resolved against the WebDAV root, e.g. "_webdav/MyProject/@files/data.tsv". The current user must be able to read it.
module_resourceNonePath of a TSV resource inside a module, relative to the module root. A value with no / is resolved under the module's schemas/dbscripts directory.
moduleNoneName of the module to resolve module_resource against. Only used with module_resource; defaults to the module owning the target table's schema.

The remaining arguments control how the rows are applied:

ArgumentDefaultDescription
insert_option"IMPORT"An InsertOption value. "IMPORT" creates a new row for every row of data; "MERGE" updates rows that already exist and inserts the rest. When merging you only need to supply the columns you want to change.
audit_behaviorNone"SUMMARY" or "DETAILED". Defaults to the setting on the LabKey query.
audit_user_commentNoneComment attached to certain detailed audit log records.
audit_detailsNoneA dict of extra detail to record on the import's transaction audit event, serialized to JSON for you. Keys are matched case insensitively against the server's transaction detail names ("Product", "EditMethod", "RequestSource", …); unrecognized keys are ignored.
import_lookup_by_alternate_keyFalseResolve lookup targets by value rather than by primary key. Only works for lookups configured with unique column information.
import_identityFalseInsert the primary key values present in the data instead of letting the server assign them. Requires an administrator, and only applies to tables with an auto incrementing primary key.
format"tsv"Delimiter of text, either "csv" or "tsv". Ignored by the other sources, whose format comes from the file itself.
save_to_pipelineFalseCopy the uploaded file into a QueryImportFiles directory under the container's pipeline root rather than discarding it once the import completes. Requires a pipeline root.
use_asyncFalseRun the import in a background pipeline job, which also saves the file to the pipeline root. The response holds jobId instead of a row count, and not every table supports it.
import_urlNoneFull URL of an alternate import action to post to, replacing the default query-import.api. Use it to reach an import action on another controller that accepts the same parameters.

save_rows

ArgumentDefaultDescription
commandsrequiredA list of Command dicts.
api_versionNoneWhen 13.2 or higher, a request that fails validation is returned as a successful response — check errorCount and committed — instead of raising.
transactedNoneWhether all commands are applied in one transaction. Defaults to True on the server.
validate_onlyNoneRun every command but commit nothing. Useful for incremental validation of a UI form.
extra_contextNonePassed to the transformation/validation script environment for all commands.

get_queries accepts include_columns, include_system_queries, include_title, include_user_queries, include_view_data_url (all default True), and query_detail_columns (default False, and only meaningful when include_columns is True).

Responses

All methods return the decoded JSON response as a dict.

select_rows and execute_sql return:

KeyDescription
rowsA list of dicts, one per row, keyed by column name.
rowCountNumber of rows. Reflects the total row count when include_total_count is True.
columnModelMetadata for each returned column, including its header.
metaDataResult set metadata, including id — the name of the primary key column.
schemaNameThe queried schema. execute_sql reports queryName as "sql".
queryNameThe queried table, or the session query name when save_in_session is used.

insert_rows, update_rows, delete_rows, and move_rows return rowsAffected and a rows list holding the affected rows as they exist after the operation. truncate_table returns deletedRows. import_rows returns success and rowCount, or success: False with errorCount and errors — it reports validation failures in the response rather than raising. With use_async=True it returns success and jobId instead, since the rows are loaded after the response is sent. save_rows returns committed, errorCount, and result, a list parallel to commands where each entry has its own rowsAffected and rows. get_queries returns schemaName and queries.

Note that keys in write responses are lower cased by the server, so a RowId column is read back as rowid.

Exceptions

Errors are raised as subclasses of labkey.exceptions.RequestError, which extends requests.exceptions.RequestException. All of them expose a message attribute containing the HTTP status code and the server's error text.

ExceptionRaised when
RequestErrorBase class. Catch this to handle any server error.
QueryNotFoundErrorThe schema or query does not exist.
RequestAuthorizationErrorThe user is not authorized for the request.
ServerNotFoundErrorThe server resource was not found — usually a bad context path or container path.
UnexpectedRedirectErrorThe server redirected the request, e.g. from http to https.
ServerContextErrorThe request could not be completed — connection, SSL, or URL parsing failure — or the server rejected the operation with an error message.

Examples

Every example below uses an APIWrapper instance to make its requests. See api_wrapper.md for the full set of APIWrapper arguments, including how to configure the container path, context path, SSL, and authentication.

Select rows

fromlabkey.api_wrapperimportAPIWrapperfromlabkey.queryimportPaginationlabkey_server="www.example.com"container_path="Tutorials/HIV Study"# Full project/folder container pathcontext_path="labkey"api=APIWrapper(labkey_server, container_path, context_path)
schema="lists"table="Demographics"#################### Basic select_rows###################result=api.query.select_rows(schema, table)
ifresultisnotNone:
print(result["rows"][0])
print("select_rows: There are "+str(result["rowCount"]) +" rows.")
else:
print("select_rows: Failed to load results from "+schema+"."+table)
#################### Page the results and read the response metadata###################result=api.query.select_rows(
schema,
table,
max_rows=5,
offset=10,
include_total_count=True,
include_details_column=True,
include_update_column=True,
)
print("select_rows: There are "+str(len(result["rows"])) +" rows.")
print("select_rows: There are "+str(result["rowCount"]) +" total rows.")
columns= [column["header"] forcolumninresult["columnModel"]]
print("select_rows: Included columns: "+", ".join(columns))
key_column=result["metaData"]["id"]
print("select_rows: The first row key is: "+str(result["rows"][0][key_column]))
#################### Retrieve every row, regardless of the default page size###################result=api.query.select_rows(schema, table, show_rows=Pagination.ALL, include_total_count=True)
#################### Select specific columns, sorted ascending by one and descending by another###################result=api.query.select_rows(
schema,
table,
columns="Group Assignment, Participant ID",
sort="Group Assignment, -Participant ID", # use '-' to sort descending
)
forrowinresult["rows"]:
print("\t"+str(row["Group Assignment"]) +", "+str(row["Participant ID"]))

Filter rows

fromlabkey.api_wrapperimportAPIWrapperfromlabkey.queryimportQueryFilterapi=APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
filters= [
QueryFilter("Group Assignment", "Group 2: HIV-1 Negative"),
QueryFilter("Height (inches)", "50, 70", QueryFilter.Types.BETWEEN),
QueryFilter("Country", "Germany;Uganda", QueryFilter.Types.IN),
]
result=api.query.select_rows("lists", "Demographics", filter_array=filters)
print("select_rows: There are "+str(result["rowCount"]) +" rows.")

Execute LabKey SQL

fromlabkey.api_wrapperimportAPIWrapperapi=APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
schema="lists"sql="SELECT * FROM lists.Demographics"result=api.query.execute_sql(schema, sql)
print("execute_sql: There are "+str(result["rowCount"]) +" rows.")
#################### Paging and sorting are applied the same way as in select_rows###################result=api.query.execute_sql(schema, sql, max_rows=5, offset=10, sort="Country")
#################### Save the results in the session, then query them by name###################result=api.query.execute_sql(schema, sql, save_in_session=True)
session_query=result["queryName"]
print("execute_sql: query saved as [ "+session_query+" ]")
result=api.query.select_rows(schema, session_query)

Insert, update, and delete rows

fromlabkey.api_wrapperimportAPIWrapperfromlabkey.queryimportAuditBehaviorapi=APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
schema="lists"table="Demographics"#################### Insert. The response holds the inserted rows, including their new keys.###################result=api.query.insert_rows(schema, table, [{"Country": "Antarctica"}])
new_key=result["rows"][0]["Key"]
print("insert_rows: new rowId [ "+str(new_key) +" ]")
#################### Update. Supply the primary key plus only the columns being changed.###################result=api.query.update_rows(
schema,
table,
[{"Key": new_key, "Country": "Pangea"}],
audit_behavior=AuditBehavior.DETAILED,
audit_user_comment="Corrected the country of origin.",
)
print("update_rows: updated value [ "+result["rows"][0]["Country"] +" ]")
#################### Delete. The primary key is all that is required.###################result=api.query.delete_rows(schema, table, [{"Key": new_key}])
print("delete_rows: deleted rowId [ "+str(result["rows"][0]["Key"]) +" ]")
#################### Delete every row in the table###################result=api.query.truncate_table(schema, table)
print("truncate_table: [ "+str(result["deletedRows"]) +" ] rows deleted")

Save changes to several tables in one request

save_rows applies any mix of inserts, updates, and deletes in a single transaction, across as many tables as needed. Values in the MaterialInputs/<SampleType> and DataInputs/<DataClass> form register lineage on the inserted rows.

fromlabkey.api_wrapperimportAPIWrapperapi=APIWrapper("www.example.com", "Biologics")
commands= [
{
"command": "insert",
"schema_name": "samples",
"query_name": "Blood",
"rows": [
{"name": "BL-3", "MaterialInputs/Tissues": "T-1"},
{"name": "BL-4", "MaterialInputs/Blood": "BL-2"},
],
},
{
"command": "update",
"schema_name": "samples",
"query_name": "Tissues",
"rows": [{"rowId": 1234, "ReceivedDate": "2025-07-07 12:34:56"}],
},
{
"command": "delete",
"schema_name": "samples",
"query_name": "Blood",
"rows": [{"rowId": 5678}],
},
]
result=api.query.save_rows(commands=commands)
print("save_rows: committed [ "+str(result["committed"]) +" ]")
forindex, command_resultinenumerate(result["result"]):
print("command "+str(index) +": "+str(command_result["rowsAffected"]) +" rows affected")

By default a command that fails validation raises a ServerContextError. Pass api_version=13.2 to receive the failure as a normal response instead, which is useful when you want to report every error rather than just the first one.

result=api.query.save_rows(api_version=13.2, commands=commands)
ifnotresult["committed"]:
print("save_rows: "+str(result["errorCount"]) +" error(s), nothing was committed")
forcommand_resultinresult["result"]:
if"errors"incommand_result:
print(command_result["errors"]["exception"])

Import rows from a file, from text, or from the server

import_rows is the efficient way to load a large number of rows. Unlike the other write methods it reports validation problems in its response rather than raising.

fromlabkey.api_wrapperimportAPIWrapperapi=APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
withopen("demographics.csv", "r") asdata_file:
result=api.query.import_rows("lists", "Demographics", data_file=data_file)
ifresult["success"]:
print("import_rows: imported "+str(result["rowCount"]) +" rows")
else:
print("import_rows: "+str(result["errorCount"]) +" error(s)")
forerrorinresult["errors"]:
print(error["exception"])

To update existing rows from the same file, import with the "MERGE" option. If the file identifies lookup values by name rather than by row id — a parent column holding parent_one instead of 1 — set import_lookup_by_alternate_key so the server resolves them.

fromlabkey.queryimportInsertOptionwithopen("child_data.csv", "r") asdata_file:
result=api.query.import_rows(
"lists",
"child_list",
data_file=data_file,
insert_option=InsertOption.MERGE,
import_lookup_by_alternate_key=True,
)

A small set of rows can be passed inline as text instead of a file. format selects the delimiter; it applies only to text.

result=api.query.import_rows(
"lists",
"Demographics",
text="Participant ID,Country\n2001,Antarctica\n2002,Pangea\n",
format="csv",
)

A file that is already on the server does not have to be uploaded at all. Pass its WebDAV path — the same path the file browser shows — and the server reads it in place.

result=api.query.import_rows(
"lists",
"Demographics",
path="_webdav/Tutorials/HIV Study/@files/demographics.tsv",
)

For an import large enough that the request would time out, use use_async to hand it to a pipeline job. The response carries a jobId rather than a row count, and progress and errors show up in the container's pipeline status. save_to_pipeline keeps the uploaded file under the pipeline root without moving the import itself into the background. Both require a pipeline root to be configured for the container.

withopen("large_demographics.tsv", "r") asdata_file:
result=api.query.import_rows(
"lists",
"Demographics",
data_file=data_file,
use_async=True,
audit_user_comment="Nightly load of the demographics extract.",
audit_details={"Product": "python", "RequestSource": "nightly_etl.py"},
)
print("import_rows: queued pipeline job [ "+str(result["jobId"]) +" ]")

Move rows to another container

move_rows takes the destination container as its first argument. The source container is the one configured on the APIWrapper, or whatever is passed as container_path.

fromlabkey.api_wrapperimportAPIWrapperapi=APIWrapper("www.example.com", "Biologics")
result=api.query.move_rows(
"Biologics/Archive",
"samples",
"Blood",
[{"rowId": 1234}, {"rowId": 5678}],
audit_user_comment="Archiving samples from the completed study.",
)
print("move_rows: moved "+str(result["rowsAffected"]) +" rows")

List the queries in a schema

fromlabkey.api_wrapperimportAPIWrapperapi=APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
result=api.query.get_queries("core")
forqueryinresult["queries"]:
print(query["name"] +" — "+query["title"])
#################### Limit the results to queries defined by a module###################result=api.query.get_queries("core", include_system_queries=False, include_user_queries=False)

Handle errors

fromlabkey.api_wrapperimportAPIWrapperfromlabkey.exceptionsimportQueryNotFoundError, RequestError, ServerContextErrorfromrequests.exceptionsimportTimeoutapi=APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
# A missing schema or querytry:
api.query.select_rows("lists", "NoSuchTable")
exceptQueryNotFoundErrorase:
print("Query not found: "+e.message)
# An operation the server rejectstry:
api.query.delete_rows("core", "datastates", [{"rowid": 1}])
exceptServerContextErrorase:
print("Server rejected the request: "+e.message)
# Any server errortry:
api.query.select_rows("badSchema", "Demographics")
exceptRequestErrorase:
print("Request failed: "+e.message)
# A request that takes too longtry:
api.query.execute_sql("lists", "SELECT * FROM lists.Demographics", timeout=0.001)
exceptTimeout:
print("Request timed out")

In depth: managing QC states

This example is longer than the others and combines several of the methods above. It walks through the full life cycle of a QC state definition in a study folder: creating states, renaming one, assigning one to a dataset row, and cleaning up. Along the way it shows how the server's constraints surface through the API.

QC state definitions live in core.DataStates. The related core.QCState table is a read-only view over the same rows that excludes LIMS sample statuses (rows with a non-null StateType), so writes must target core.DataStates.

fromlabkey.api_wrapperimportAPIWrapperfromlabkey.exceptionsimportServerContextErrorfromlabkey.queryimportAuditBehavior, QueryFilterapi=APIWrapper("www.example.com", "Tutorials/HIV Study", "labkey")
#################### Create two QC state definitions. publicData controls whether data in this# state is visible to users who lack permission to view unapproved data.###################qc_states= [
{
"label": "needs verification",
"description": "that can not be right",
"publicData": False,
},
{"label": "approved", "publicData": True},
]
result=api.query.insert_rows("core", "DataStates", qc_states)
print("Created "+str(result["rowsAffected"]) +" QC states")
# Note the lower cased keys in write responsesneeds_verification_id=result["rows"][0]["rowid"]
approved_id=result["rows"][1]["rowid"]
#################### Labels are unique per container, so re-creating one is an error###################try:
api.query.insert_rows("core", "DataStates", [{"label": "approved", "publicData": True}])
exceptServerContextErrorase:
print("Duplicate label rejected: "+e.message)
#################### Update a definition. Only the primary key and the changed columns are needed.###################result=api.query.update_rows(
"core",
"DataStates",
[{"rowid": needs_verification_id, "description": "for sure that is not right"}],
audit_behavior=AuditBehavior.DETAILED,
audit_user_comment="Clarified the description for reviewers.",
)
print("Updated description: "+result["rows"][0]["description"])
#################### Assign the state to a dataset row. QCState is a lookup to core.DataStates,# so it takes the state's rowId.###################result=api.query.insert_rows(
"study",
"Lab Results",
[
{
"ParticipantId": "2",
"SequenceNum": "345",
"Value": 4,
"QCState": needs_verification_id,
}
],
)
dataset_row_lsid=result["rows"][0]["lsid"]
#################### List the states that are defined, and which are public###################result=api.query.select_rows(
"core",
"DataStates",
columns="RowId, Label, Description, PublicData",
filter_array=[QueryFilter("StateType", "", QueryFilter.Types.IS_BLANK)],
sort="Label",
)
forrowinresult["rows"]:
print(row["Label"] +" (public: "+str(row["PublicData"]) +")")
#################### A state that is in use cannot be deleted###################try:
api.query.delete_rows("core", "DataStates", [{"rowid": needs_verification_id}])
exceptServerContextErrorase:
# 400: State 'needs verification' cannot be deleted as it is currently in use.print("Delete blocked: "+e.message)
#################### Stop using the state, then clean up both definitions. Dataset rows are keyed# by LSID rather than by an integer row id.###################api.query.delete_rows("study", "Lab Results", [{"lsid": dataset_row_lsid}])
api.query.delete_rows(
"core",
"DataStates",
[{"rowid": needs_verification_id}, {"rowid": approved_id}],
)

The same sequence can be expressed as a single save_rows request when the operations do not depend on ids returned by earlier steps — for example deleting the dataset row and its QC state together, so that neither is applied if the other fails.

result=api.query.save_rows(
commands=[
{
"command": "delete",
"schema_name": "study",
"query_name": "Lab Results",
"rows": [{"lsid": dataset_row_lsid}],
},
{
"command": "delete",
"schema_name": "core",
"query_name": "DataStates",
"rows": [{"rowid": needs_verification_id}],
},
]
)
print("save_rows: committed [ "+str(result["committed"]) +" ]")