diff --git a/CHANGELOG b/CHANGELOG index 7648cee..9cadfef 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,6 +5,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +## [1.2.0] 2026-09-16 + +### Added + +* `validate_studies` and `get_sample_id_for_name` functions are added to + `npgmlwarehouse.db.product`. Both are needed for extending phase2 Ultimagen + pipeline to walkup runs. + ## [1.1.1] - 2026-05-20 ### Fixed diff --git a/src/npgmlwarehouse/db/product.py b/src/npgmlwarehouse/db/product.py index d522c38..aaa1ea1 100644 --- a/src/npgmlwarehouse/db/product.py +++ b/src/npgmlwarehouse/db/product.py @@ -15,11 +15,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.dialects.mysql import insert from sqlalchemy.orm import Session -from npgmlwarehouse.db.schema import SeqProductIrodsLocations, UseqProductMetrics +from npgmlwarehouse.db.schema import ( + Sample, + SeqProductIrodsLocations, + Study, + UseqProductMetrics, +) def get_ultimagen_target_product_records(session: Session, id_run: int): @@ -47,6 +52,85 @@ def get_ultimagen_target_product_records(session: Session, id_run: int): return records.all() +def validate_studies(session: Session, study_ids: list[str]): + """ + Validates a list of string study IDs. + + Validates a list of study IDs by checking whether the `study` table + record is available for each of the IDs. The input least can contain + duplicate values. An empty input list will cause an error. + + Errors if any of the given study IDs + * is not a string, + * is invalid, the error message lists all invalid IDs, + * maps to multiple database rows. + + Args: + session (Session): + Database session. + study_ids (str): + Study IDs to validate. + Returns: + True if all sudies have been validated. + """ + + if len(study_ids) == 0: + raise ValueError("A non-empty list of string study IDs is required") + if len([id for id in study_ids if isinstance(id, str)]) < len(study_ids): + raise TypeError("All study IDs should be strings") + + unique_ids = set(study_ids) + + q = select(Study.id_study_lims).where(Study.id_study_lims.in_(unique_ids)) + available = session.scalars(q).all() + missing = [study_id for study_id in unique_ids if study_id not in available] + if len(missing): + raise ValueError( + "Invalid stud{end}: {list_of}".format( + end="y" if len(missing) == 1 else "ies", + list_of=", ".join(sorted(missing)), + ) + ) + if len(available) > len(unique_ids): + raise Exception( + ( + "Duplicate MLWH rows for some IDs in " + f"{', '.join(sorted(list(unique_ids)))}" + ) + ) + + return True + + +def get_sample_id_for_name(session: Session, sample_name: str) -> str | None: + """ + Retrieves sample ID for the sample name given as the argument. + + Searches the `sample` table for a record with the given name. Examines + values in two columns - `name` and `supplier_name`. + + Errors if multiple records are found. Returns sample's ID if a single + record is found or `None` if no record is found. + + Args: + session (Session): + Database session. + name (name): + Sample name. + Returns: + String sample ID if found or None + """ + if not isinstance(sample_name, str): + raise TypeError("sample_name argument should be a string") + if not sample_name: + raise ValueError("sample_name argument should be a non-empty string") + + q = select(Sample.id_sample_lims).where( + or_(Sample.name == sample_name, Sample.supplier_name == sample_name) + ) + return session.scalars(q).one_or_none() + + def create_upload_irods_location_records( session: Session, product_data: dict[str, dict], diff --git a/tests/data/db_fixtures/000-Sample.yml b/tests/data/db_fixtures/000-Sample.yml index d003c14..f12e729 100644 --- a/tests/data/db_fixtures/000-Sample.yml +++ b/tests/data/db_fixtures/000-Sample.yml @@ -9,6 +9,7 @@ id_sample_lims: 10000 id_sample_tmp: 1 last_updated: 2012-08-03 10:07:02 + name: 'Burkholderia 1' organism: Burkholderia pseudomallei recorded_at: 2014-11-18 13:06:19 reference_genome: ' ' @@ -27,6 +28,8 @@ id_sample_lims: 10001 id_sample_tmp: 2 last_updated: 2012-08-03 10:07:02 + name: 'Burkholderia 22' + supplier_name: 'Burkholderia 2' organism: Burkholderia pseudomallei recorded_at: 2014-11-18 13:06:19 reference_genome: ' ' diff --git a/tests/data/db_fixtures/000-Study.yml b/tests/data/db_fixtures/000-Study.yml index e055b2d..80cc604 100644 --- a/tests/data/db_fixtures/000-Study.yml +++ b/tests/data/db_fixtures/000-Study.yml @@ -1,7 +1,7 @@ --- - abbreviation: 619STDY abstract: For further information on this study please see - accession_number: ERP000251 + accession_number: 251 aligned: 1 array_express_accession_number: ~ contains_human_dna: 0 @@ -33,3 +33,105 @@ study_type: Whole Genome Sequencing study_visibility: Hold uuid_study_lims: 1231de50-6ffb-11e4-b9db-68b59977951c +- abbreviation: 622STDY + abstract: For further information on this study please see + accession_number: 34 + aligned: 1 + array_express_accession_number: ~ + contains_human_dna: 0 + contaminated_human_dna: 0 + created: 2010-06-25 11:19:52 + data_access_group: ~ + data_release_delay_period: ~ + data_release_delay_reason: ~ + data_release_sort_of_study: genomic sequencing + data_release_strategy: open + data_release_timing: standard + deleted_at: ~ + description: 'For further information on this study please' + ega_dac_accession_number: ~ + ega_policy_accession_number: ~ + ena_project_id: 0 + ethically_approved: ~ + id_lims: SQSCP + id_study_lims: 622 + id_study_tmp: 197 + last_updated: 2012-11-26 14:15:32 + name: Burkholderia pseudomallei + recorded_at: 2014-11-19 14:48:06 + reference_genome: ' ' + remove_x_and_autosomes: 0 + separate_y_chromosome_data: 0 + state: active + study_title: Burkholderia pseudomallei + study_type: Whole Genome Sequencing + study_visibility: Hold + uuid_study_lims: 1231de50-6ffb-11e4-b9db-68b59977951b +- abbreviation: 623STDY + abstract: For further information on this study please see + accession_number: 34 + aligned: 1 + array_express_accession_number: ~ + contains_human_dna: 0 + contaminated_human_dna: 0 + created: 2010-06-25 11:19:52 + data_access_group: ~ + data_release_delay_period: ~ + data_release_delay_reason: ~ + data_release_sort_of_study: genomic sequencing + data_release_strategy: open + data_release_timing: standard + deleted_at: ~ + description: 'For further information on this study please' + ega_dac_accession_number: ~ + ega_policy_accession_number: ~ + ena_project_id: 0 + ethically_approved: ~ + id_lims: SQSCP + id_study_lims: 623 + id_study_tmp: 198 + last_updated: 2012-11-26 14:15:32 + name: Burkholderia pseudomallei + recorded_at: 2014-11-19 14:48:06 + reference_genome: ' ' + remove_x_and_autosomes: 0 + separate_y_chromosome_data: 0 + state: active + study_title: Burkholderia pseudomallei + study_type: Whole Genome Sequencing + study_visibility: Hold + uuid_study_lims: 1231de50-6ffb-11e4-b9db-68b59977951d +- abbreviation: 623STDY + abstract: For further information on this study please see + accession_number: 34 + aligned: 1 + array_express_accession_number: ~ + contains_human_dna: 0 + contaminated_human_dna: 0 + created: 2010-06-25 11:19:52 + data_access_group: ~ + data_release_delay_period: ~ + data_release_delay_reason: ~ + data_release_sort_of_study: genomic sequencing + data_release_strategy: open + data_release_timing: standard + deleted_at: ~ + description: 'For further information on this study please' + ega_dac_accession_number: ~ + ega_policy_accession_number: ~ + ena_project_id: 0 + ethically_approved: ~ + id_lims: Sapio + id_study_lims: 623 + id_study_tmp: 199 + last_updated: 2012-11-26 14:15:32 + name: Burkholderia pseudomallei + recorded_at: 2014-11-19 14:48:06 + reference_genome: ' ' + remove_x_and_autosomes: 0 + separate_y_chromosome_data: 0 + state: active + study_title: Burkholderia pseudomallei + study_type: Whole Genome Sequencing + study_visibility: Hold + uuid_study_lims: 1231de50-6ffb-11e4-b9db-68b59977951k diff --git a/tests/test_product.py b/tests/test_product.py index a3eb2f8..d6cd717 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -1,11 +1,14 @@ from time import sleep from pytest import mark as m +from pytest import raises from sqlalchemy import select from npgmlwarehouse.db.product import ( create_upload_irods_location_records, + get_sample_id_for_name, get_ultimagen_target_product_records, + validate_studies, ) from npgmlwarehouse.db.schema import SeqProductIrodsLocations @@ -385,3 +388,49 @@ def test_create_upload_irods_location_records_duplicate_unique_key_part_input_up record = records.pop() assert record.irods_data_relative_path == None assert record.irods_secondary_data_relative_path == None + + +@m.describe("Test Sample Retrieval") +class TestSampleRetrieval(object): + def test_get_sample_id(self, testdb): + + with raises(TypeError): + get_sample_id_for_name(testdb) + with raises(TypeError, match=r"sample_name argument should be a string"): + get_sample_id_for_name(testdb, 45) + with raises(TypeError, match=r"sample_name argument should be a string"): + get_sample_id_for_name(testdb, None) + with raises( + ValueError, match=r"sample_name argument should be a non-empty string" + ): + get_sample_id_for_name(testdb, "") + + assert get_sample_id_for_name(testdb, "Burkholderia 1") == "10000" + assert get_sample_id_for_name(testdb, "Burkholderia 2") == "10001" + assert get_sample_id_for_name(testdb, "Burkholderia 25") is None + + +@m.describe("Test Study Validation") +class TestStudyValidation(object): + def test_validate_studies(self, testdb): + + with raises(TypeError): + assert validate_studies(testdb) + with raises( + ValueError, match=r"A non-empty list of string study IDs is required" + ): + assert validate_studies(testdb, []) + with raises(TypeError, match=r"All study IDs should be strings"): + assert validate_studies(testdb, ["1234", 34]) + with raises(Exception, match=r"Duplicate MLWH rows for some IDs in 619, 623"): + assert validate_studies(testdb, ["623", "619", "623"]) + + assert validate_studies(testdb, ["619"]) is True + assert validate_studies(testdb, ["619", "622"]) is True + + with raises(ValueError, match=r"Invalid study: 620"): + assert validate_studies(testdb, ["620"]) + with raises(ValueError, match=r"Invalid studies: 620, 621"): + assert validate_studies(testdb, ["621", "620"]) + with raises(ValueError, match=r"Invalid study: 620"): + assert validate_studies(testdb, ["619", "620"])