diff --git a/qiita_db/artifact.py b/qiita_db/artifact.py index 4ea95160a..f1b88cf47 100644 --- a/qiita_db/artifact.py +++ b/qiita_db/artifact.py @@ -1853,3 +1853,51 @@ def human_reads_filter_method(self, value): SET human_reads_filter_method_id = %s WHERE artifact_id = %s""" qdb.sql_connection.TRN.add(sql, [idx[0], self.id]) + + def unique_ids(self): + r"""Return a stable mapping of sample_name to integers + + Obtain a map from a sample_name to an integer. The association is + unique Qiita-wide and 1-1. + + This method is idempotent. + + Returns + ------ + dict + {sample_name: integer_index} + """ + if len(self.prep_templates) == 0: + raise ValueError("No associated prep template") + + if len(self.prep_templates) > 1: + raise ValueError("Cannot assign against multiple prep templates") + + paired = [[self._id, ps_idx] for ps_idx in sorted(self.prep_templates[0].unique_ids().values())] + + with qdb.sql_connection.TRN: + # insert any IDs not present + sql = """INSERT INTO qiita.map_artifact_sample_idx (artifact_idx, prep_sample_idx) + VALUES (%s, %s) + ON CONFLICT (artifact_idx, prep_sample_idx) + DO NOTHING""" + qdb.sql_connection.TRN.add(sql, paired, many=True) + + # obtain the association + sql = """SELECT + sample_name, + artifact_sample_idx + FROM qiita.map_artifact_sample_idx + JOIN qiita.map_prep_sample_idx USING (prep_sample_idx) + JOIN qiita.map_sample_idx USING (sample_idx) + WHERE artifact_idx=%s + """ + qdb.sql_connection.TRN.add(sql, [self._id, ]) + + # form into a dict + mapping = {r[0]: r[1] for r in qdb.sql_connection.TRN.execute_fetchindex()} + + # commit in the event changes were made + qdb.sql_connection.TRN.commit() + + return mapping diff --git a/qiita_db/metadata_template/base_metadata_template.py b/qiita_db/metadata_template/base_metadata_template.py index 36db2d207..49f3cd62c 100644 --- a/qiita_db/metadata_template/base_metadata_template.py +++ b/qiita_db/metadata_template/base_metadata_template.py @@ -945,6 +945,21 @@ def _common_extend_steps(self, md_template): return new_samples, new_cols + def unique_ids(self): + r"""Return a stable mapping of sample_name to integers + + Obtain a map from a sample_name to an integer. The association is + unique Qiita-wide and 1-1. + + This method is idempotent. + + Returns + ------ + dict + {sample_name: integer_index} + """ + raise IncompetentQiitaDeveloperError() + @classmethod def exists(cls, obj_id): r"""Checks if already exists a MetadataTemplate for the provided object diff --git a/qiita_db/metadata_template/prep_template.py b/qiita_db/metadata_template/prep_template.py index 3808c4efd..5678a934a 100644 --- a/qiita_db/metadata_template/prep_template.py +++ b/qiita_db/metadata_template/prep_template.py @@ -341,6 +341,52 @@ def delete(cls, id_): qdb.sql_connection.TRN.execute() + def unique_ids(self): + r"""Return a stable mapping of sample_name to integers + + Obtain a map from a sample_name to an integer. The association is + unique Qiita-wide and 1-1. + + This method is idempotent. + + Returns + ------ + dict + {sample_name: integer_index} + """ + sample_idx = qdb.study.Study(self.study_id).sample_template.unique_ids() + + paired = [] + for p_id in sorted(self.keys()): + if p_id in sample_idx: + paired.append([self._id, sample_idx[p_id]]) + + with qdb.sql_connection.TRN: + # insert any IDs not present + sql = """INSERT INTO qiita.map_prep_sample_idx (prep_idx, sample_idx) + VALUES (%s, %s) + ON CONFLICT (prep_idx, sample_idx) + DO NOTHING""" + qdb.sql_connection.TRN.add(sql, paired, many=True) + + # obtain the association + sql = """SELECT + sample_name, + prep_sample_idx + FROM qiita.map_prep_sample_idx + JOIN qiita.map_sample_idx USING (sample_idx) + WHERE prep_idx=%s + """ + qdb.sql_connection.TRN.add(sql, [self._id, ]) + + # form into a dict + mapping = {r[0]: r[1] for r in qdb.sql_connection.TRN.execute_fetchindex()} + + # commit in the event changes were made + qdb.sql_connection.TRN.commit() + + return mapping + def data_type(self, ret_id=False): """Returns the data_type or the data_type id diff --git a/qiita_db/metadata_template/sample_template.py b/qiita_db/metadata_template/sample_template.py index e8bac7b25..9bfb19a48 100644 --- a/qiita_db/metadata_template/sample_template.py +++ b/qiita_db/metadata_template/sample_template.py @@ -176,6 +176,45 @@ def columns_restrictions(self): """ return qdb.metadata_template.constants.SAMPLE_TEMPLATE_COLUMNS + def unique_ids(self): + r"""Return a stable mapping of sample_name to integers + + Obtain a map from a sample_name to an integer. The association is + unique Qiita-wide and 1-1. + + This method is idempotent. + + Returns + ------ + dict + {sample_name: integer_index} + """ + samples = [[self._id, s_id] for s_id in sorted(self.keys())] + with qdb.sql_connection.TRN: + # insert any IDs not present + sql = """INSERT INTO qiita.map_sample_idx (study_idx, sample_name) + VALUES (%s, %s) + ON CONFLICT (sample_name) + DO NOTHING""" + qdb.sql_connection.TRN.add(sql, samples, many=True) + + # obtain the association + sql = """SELECT + sample_name, + sample_idx + FROM qiita.map_sample_idx + WHERE study_idx=%s + """ + qdb.sql_connection.TRN.add(sql, [self._id, ]) + + # form into a dict + mapping = {r[0]: r[1] for r in qdb.sql_connection.TRN.execute_fetchindex()} + + # commit in the event changes were made + qdb.sql_connection.TRN.commit() + + return mapping + def delete_samples(self, sample_names): """Delete `sample_names` from sample information file diff --git a/qiita_db/metadata_template/test/test_base_metadata_template.py b/qiita_db/metadata_template/test/test_base_metadata_template.py index d2142231b..c543316ce 100644 --- a/qiita_db/metadata_template/test/test_base_metadata_template.py +++ b/qiita_db/metadata_template/test/test_base_metadata_template.py @@ -42,7 +42,14 @@ def test_init(self): with self.assertRaises(IncompetentQiitaDeveloperError): MT(1) - def test_exist(self): + def test_unique_ids(self): + """Unique IDs raises an error because it's not called from a subclass + """ + MT = qdb.metadata_template.base_metadata_template.MetadataTemplate + with self.assertRaises(IncompetentQiitaDeveloperError): + MT.unique_ids(self.study) + + def test_exists(self): """Exists raises an error because it's not called from a subclass""" MT = qdb.metadata_template.base_metadata_template.MetadataTemplate with self.assertRaises(IncompetentQiitaDeveloperError): diff --git a/qiita_db/metadata_template/test/test_prep_template.py b/qiita_db/metadata_template/test/test_prep_template.py index 2e61229b6..6c6a73541 100644 --- a/qiita_db/metadata_template/test/test_prep_template.py +++ b/qiita_db/metadata_template/test/test_prep_template.py @@ -540,6 +540,15 @@ def test_init(self): st = qdb.metadata_template.prep_template.PrepTemplate(1) self.assertTrue(st.id, 1) + def test_unique_ids(self): + obs = self.tester.unique_ids() + exp = {name: idx for idx, name in enumerate(sorted(self.tester.keys()), 1)} + self.assertEqual(obs, exp) + + # verify a repeat call is unchanged + obs = self.tester.unique_ids() + self.assertEqual(obs, exp) + def test_table_name(self): """Table name return the correct string""" obs = qdb.metadata_template.prep_template.PrepTemplate._table_name(1) diff --git a/qiita_db/metadata_template/test/test_sample_template.py b/qiita_db/metadata_template/test/test_sample_template.py index 06281a095..5bc6a8ec5 100644 --- a/qiita_db/metadata_template/test/test_sample_template.py +++ b/qiita_db/metadata_template/test/test_sample_template.py @@ -624,6 +624,15 @@ def test_init(self): st = qdb.metadata_template.sample_template.SampleTemplate(1) self.assertTrue(st.id, 1) + def test_unique_ids(self): + obs = self.tester.unique_ids() + exp = {name: idx for idx, name in enumerate(sorted(self.tester.keys()), 1)} + self.assertEqual(obs, exp) + + # verify a repeat call is unchanged + obs = self.tester.unique_ids() + self.assertEqual(obs, exp) + def test_table_name(self): """Table name return the correct string""" obs = qdb.metadata_template.sample_template.SampleTemplate._table_name( diff --git a/qiita_db/support_files/patches/95.sql b/qiita_db/support_files/patches/95.sql new file mode 100644 index 000000000..b9fc7cbcc --- /dev/null +++ b/qiita_db/support_files/patches/95.sql @@ -0,0 +1,30 @@ +-- Dec 12, 2025 +-- Adding SEQUENCEs and support tables for sample_idx, prep_sample_idx, +-- and artifact_sample_idx + +CREATE SEQUENCE qiita.sequence_sample_idx AS BIGINT; +CREATE TABLE qiita.map_sample_idx ( + sample_name VARCHAR NOT NULL PRIMARY KEY, + study_idx BIGINT NOT NULL, + sample_idx BIGINT DEFAULT NEXTVAL('qiita.sequence_sample_idx') NOT NULL, + UNIQUE (sample_idx), + CONSTRAINT fk_study FOREIGN KEY (study_idx) REFERENCES qiita.study (study_id) +); + +CREATE SEQUENCE qiita.sequence_prep_sample_idx AS BIGINT; +CREATE TABLE qiita.map_prep_sample_idx ( + prep_sample_idx BIGINT NOT NULL PRIMARY KEY DEFAULT NEXTVAL('qiita.sequence_prep_sample_idx'), + prep_idx BIGINT NOT NULL, + sample_idx BIGINT NOT NULL, + CONSTRAINT uc_prep_sample UNIQUE(prep_idx, sample_idx), + CONSTRAINT fk_prep_template FOREIGN KEY (prep_idx) REFERENCES qiita.prep_template (prep_template_id) +); + +CREATE SEQUENCE qiita.sequence_artifact_sample_idx AS BIGINT; +CREATE TABLE qiita.map_artifact_sample_idx ( + artifact_sample_idx BIGINT NOT NULL PRIMARY KEY DEFAULT NEXTVAL('qiita.sequence_artifact_sample_idx'), + artifact_idx BIGINT NOT NULL, + prep_sample_idx BIGINT NOT NULL, + CONSTRAINT uc_artifact_sample UNIQUE(artifact_idx, prep_sample_idx), + CONSTRAINT fk_artifact FOREIGN KEY (artifact_idx) REFERENCES qiita.artifact (artifact_id) +); diff --git a/qiita_db/test/test_artifact.py b/qiita_db/test/test_artifact.py index 4b47db89a..a537a9178 100644 --- a/qiita_db/test/test_artifact.py +++ b/qiita_db/test/test_artifact.py @@ -1357,6 +1357,16 @@ def test_delete_as_output_job(self): with self.assertRaises(qdb.exceptions.QiitaDBUnknownIDError): qdb.artifact.Artifact(artifact.id) + def test_unique_ids(self): + art = qdb.artifact.Artifact(1) + obs = art.unique_ids() + exp = {name: idx for idx, name in enumerate(sorted(art.prep_templates[0].keys()), 1)} + self.assertEqual(obs, exp) + + # verify repeat calls are unchanged + obs = art.unique_ids() + self.assertEqual(obs, exp) + def test_name_setter(self): a = qdb.artifact.Artifact(1) self.assertEqual(a.name, "Raw data 1")