Skip to content
66 changes: 64 additions & 2 deletions qiita_db/artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -1463,8 +1463,22 @@ def prep_templates(self):
FROM qiita.preparation_artifact
WHERE artifact_id = %s"""
qdb.sql_connection.TRN.add(sql, [self.id])
return [qdb.metadata_template.prep_template.PrepTemplate(pt_id)
for pt_id in qdb.sql_connection.TRN.execute_fetchflatten()]
templates = [qdb.metadata_template.prep_template.PrepTemplate(pt_id)
for pt_id in qdb.sql_connection.TRN.execute_fetchflatten()]

if len(templates) > 1:
# We never expect an artifact to be associated with multiple
# preparations
ids = [p.id for p in templates]
msg = f"Artifact({self.id}) associated with preps: {sorted(ids)}"
raise ValueError(msg)

if len(templates) == 0:
# An artifact must be associated with a template
msg = f"Artifact({self.id}) is not associated with a template"
raise ValueError(msg)

return templates

@property
def study(self):
Expand Down Expand Up @@ -1744,3 +1758,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 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 map_artifact_sample_idx
JOIN map_prep_sample_idx USING (prep_sample_idx)
JOIN 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
15 changes: 15 additions & 0 deletions qiita_db/metadata_template/base_metadata_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,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
Expand Down
46 changes: 46 additions & 0 deletions qiita_db/metadata_template/prep_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,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 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 map_prep_sample_idx
JOIN 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

Expand Down
39 changes: 39 additions & 0 deletions qiita_db/metadata_template/sample_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,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 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 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,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):
Expand Down
9 changes: 9 additions & 0 deletions qiita_db/metadata_template/test/test_prep_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,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)
Expand Down
9 changes: 9 additions & 0 deletions qiita_db/metadata_template/test/test_sample_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,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(
Expand Down
30 changes: 30 additions & 0 deletions qiita_db/support_files/patches/95.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- Dec 12, 2025
-- Adding SEQUENCEs and support tables for sample_idx, prep_sample_idx,
-- and artifact_sample_idx

CREATE SEQUENCE sequence_sample_idx AS BIGINT;
CREATE TABLE map_sample_idx (
sample_name VARCHAR NOT NULL PRIMARY KEY,
study_idx BIGINT NOT NULL,
sample_idx BIGINT DEFAULT NEXTVAL('sequence_sample_idx') NOT NULL,
UNIQUE (sample_idx),
CONSTRAINT fk_study FOREIGN KEY (study_idx) REFERENCES qiita.study (study_id)
);

CREATE SEQUENCE sequence_prep_sample_idx AS BIGINT;
CREATE TABLE map_prep_sample_idx (
prep_sample_idx BIGINT NOT NULL PRIMARY KEY DEFAULT NEXTVAL('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 sequence_artifact_sample_idx AS BIGINT;
CREATE TABLE map_artifact_sample_idx (
artifact_sample_idx BIGINT NOT NULL PRIMARY KEY DEFAULT NEXTVAL('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)
);
10 changes: 10 additions & 0 deletions qiita_db/test/test_artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,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")
Expand Down
2 changes: 2 additions & 0 deletions qiita_pet/handlers/rest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# -----------------------------------------------------------------------------

from .study import StudyHandler, StudyCreatorHandler, StudyStatusHandler
from .study_association import StudyAssociationHandler
from .study_samples import (StudySamplesHandler, StudySamplesInfoHandler,
StudySamplesCategoriesHandler,
StudySamplesDetailHandler,
Expand All @@ -25,6 +26,7 @@
ENDPOINTS = (
(r"/api/v1/study$", StudyCreatorHandler),
(r"/api/v1/study/([0-9]+)$", StudyHandler),
(r"/api/v1/study/([0-9]+)/associations$", StudyAssociationHandler),
(r"/api/v1/study/([0-9]+)/samples/categories=([a-zA-Z\-0-9\.:,_]*)",
StudySamplesCategoriesHandler),
(r"/api/v1/study/([0-9]+)/samples", StudySamplesHandler),
Expand Down
Loading
Loading