Coverage for pepdbagent/modules/sample.py: 92%

90 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-01 22:07 +0000

1import datetime 

2import logging 

3 

4import peprs 

5from sqlalchemy import and_, select 

6from sqlalchemy.orm import Session 

7from sqlalchemy.orm.attributes import flag_modified 

8 

9from pepdbagent.const import DEFAULT_TAG, PKG_NAME, SAMPLE_TABLE_INDEX_KEY 

10from pepdbagent.db_utils import BaseEngine, Projects, Samples 

11from pepdbagent.exceptions import SampleAlreadyExistsError, SampleNotFoundError 

12from pepdbagent.utils import generate_guid, order_samples 

13 

14_LOGGER = logging.getLogger(PKG_NAME) 

15 

16 

17class PEPDatabaseSample: 

18 """ 

19 Class that represents Project in Database. 

20 

21 While using this class, user can create, retrieve, delete, and update projects from database 

22 """ 

23 

24 def __init__(self, pep_db_engine: BaseEngine): 

25 """ 

26 Args: 

27 pep_db_engine: PEPDatabaseAgent engine object. 

28 """ 

29 self._sa_engine = pep_db_engine.engine 

30 self._pep_db_engine = pep_db_engine 

31 

32 def get( 

33 self, 

34 namespace: str, 

35 name: str, 

36 sample_name: str, 

37 tag: str = DEFAULT_TAG, 

38 raw: bool = True, 

39 ) -> peprs.Sample | dict | None: 

40 """Retrieve a sample from the database. 

41 

42 Args: 

43 namespace: Namespace of the project. 

44 name: Name of the project. 

45 sample_name: Name of the sample. 

46 tag: Tag of the project. 

47 raw: Return raw dict if True, peprs.Sample object if False. 

48 

49 Returns: 

50 Raw sample dict or peprs.Sample object. 

51 

52 Raises: 

53 SampleNotFoundError: If the sample does not exist. 

54 """ 

55 statement_sample = select(Samples).where( 

56 and_( 

57 Samples.project_id 

58 == select(Projects.id) 

59 .where( 

60 and_( 

61 Projects.namespace == namespace, 

62 Projects.name == name, 

63 Projects.tag == tag, 

64 ), 

65 ) 

66 .scalar_subquery(), 

67 Samples.sample_name == sample_name, 

68 ) 

69 ) 

70 project_config_statement = select(Projects.config).where( 

71 and_( 

72 Projects.namespace == namespace, 

73 Projects.name == name, 

74 Projects.tag == tag, 

75 ) 

76 ) 

77 

78 with Session(self._sa_engine) as session: 

79 result = session.scalar(statement_sample) 

80 if result: 

81 if not raw: 

82 config = session.execute(project_config_statement).one_or_none()[0] 

83 project = peprs.Project.from_dict( 

84 pep_dictionary={ 

85 "config": config, 

86 "samples": [result.sample], 

87 } 

88 ) 

89 return project.samples[0] 

90 else: 

91 return result.sample 

92 else: 

93 raise SampleNotFoundError( 

94 f"Sample {namespace}/{name}:{tag}?{sample_name} not found in the database" 

95 ) 

96 

97 def update( 

98 self, 

99 namespace: str, 

100 name: str, 

101 tag: str, 

102 sample_name: str, 

103 update_dict: dict, 

104 full_update: bool = False, 

105 ) -> None: 

106 """Update a sample in the database. 

107 

108 Args: 

109 namespace: Namespace of the project. 

110 name: Name of the project. 

111 tag: Tag of the project. 

112 sample_name: Name of the sample. 

113 update_dict: Dict of fields to update, e.g., {"sample_name": "s1", "protocol": "rna"}. 

114 full_update: Replace all sample fields if True, merge if False. 

115 

116 Raises: 

117 SampleNotFoundError: If the sample does not exist. 

118 """ 

119 statement = select(Samples).where( 

120 and_( 

121 Samples.project_id 

122 == select(Projects.id) 

123 .where( 

124 and_( 

125 Projects.namespace == namespace, 

126 Projects.name == name, 

127 Projects.tag == tag, 

128 ), 

129 ) 

130 .scalar_subquery(), 

131 Samples.sample_name == sample_name, 

132 ) 

133 ) 

134 project_statement = select(Projects).where( 

135 and_( 

136 Projects.namespace == namespace, 

137 Projects.name == name, 

138 Projects.tag == tag, 

139 ) 

140 ) 

141 with Session(self._sa_engine) as session: 

142 sample_mapping = session.scalar(statement) 

143 project_mapping = session.scalar(project_statement) 

144 

145 if sample_mapping: 

146 if full_update: 

147 sample_mapping.sample = update_dict 

148 else: 

149 sample_mapping.sample.update(update_dict) 

150 try: 

151 sample_mapping.sample_name = sample_mapping.sample[ 

152 project_mapping.config.get( 

153 SAMPLE_TABLE_INDEX_KEY, "sample_name" 

154 ) 

155 ] 

156 except KeyError: 

157 raise KeyError( 

158 f"Sample index key {project_mapping.config.get('sample_table_index', 'sample_name')} not found in sample dict" 

159 ) 

160 

161 # This line needed due to: https://github.com/sqlalchemy/sqlalchemy/issues/5218 

162 flag_modified(sample_mapping, "sample") 

163 

164 project_mapping.last_update_date = datetime.datetime.now( 

165 datetime.timezone.utc 

166 ) 

167 

168 session.commit() 

169 else: 

170 raise SampleNotFoundError( 

171 f"Sample {namespace}/{name}:{tag}?{sample_name} not found in the database" 

172 ) 

173 

174 def add( 

175 self, 

176 namespace: str, 

177 name: str, 

178 tag: str, 

179 sample_dict: dict, 

180 overwrite: bool = False, 

181 ) -> None: 

182 """Add a sample to a project in the database. 

183 

184 Args: 

185 namespace: Namespace of the project. 

186 name: Name of the project. 

187 tag: Tag of the project. 

188 sample_dict: Sample data, e.g., {"sample_name": "s1", "protocol": "rna"}. 

189 overwrite: Overwrite the sample if it already exists. 

190 

191 Raises: 

192 SampleAlreadyExistsError: If the sample exists and overwrite is False. 

193 """ 

194 

195 with Session(self._sa_engine) as session: 

196 project_statement = select(Projects).where( 

197 and_( 

198 Projects.namespace == namespace, 

199 Projects.name == name, 

200 Projects.tag == tag, 

201 ) 

202 ) 

203 # project mapping is needed to update number of samples, last_update_date and get sample_index_key 

204 project_mapping = session.scalar(project_statement) 

205 try: 

206 sample_name = sample_dict[ 

207 project_mapping.config.get(SAMPLE_TABLE_INDEX_KEY, "sample_name") 

208 ] 

209 except KeyError: 

210 raise KeyError( 

211 f"Sample index key {project_mapping.config.get('sample_table_index', 'sample_name')} not found in sample dict" 

212 ) 

213 statement = select(Samples).where( 

214 and_( 

215 Samples.project_id == project_mapping.id, 

216 Samples.sample_name == sample_name, 

217 ) 

218 ) 

219 sample_mapping = session.scalar(statement) 

220 

221 if sample_mapping and not overwrite: 

222 raise SampleAlreadyExistsError( 

223 f"Sample {namespace}/{name}:{tag}?{sample_name} already exists in the database" 

224 ) 

225 elif sample_mapping and overwrite: 

226 self.update( 

227 namespace=namespace, 

228 name=name, 

229 tag=tag, 

230 sample_name=sample_name, 

231 update_dict=sample_dict, 

232 full_update=True, 

233 ) 

234 return None 

235 else: 

236 sample_mapping = Samples( 

237 sample=sample_dict, 

238 project_id=project_mapping.id, 

239 sample_name=sample_name, 

240 guid=generate_guid(), 

241 parent_guid=self._get_last_sample_guid(project_mapping.id), 

242 ) 

243 project_mapping.number_of_samples += 1 

244 project_mapping.last_update_date = datetime.datetime.now( 

245 datetime.timezone.utc 

246 ) 

247 

248 session.add(sample_mapping) 

249 session.commit() 

250 

251 def _get_last_sample_guid(self, project_id: int) -> str: 

252 """Get the guid of the last sample in the project chain. 

253 

254 Args: 

255 project_id: Database id of the project. 

256 

257 Returns: 

258 GUID of the last sample. 

259 """ 

260 statement = select(Samples).where(Samples.project_id == project_id) 

261 with Session(self._sa_engine) as session: 

262 samples_results = session.scalars(statement) 

263 

264 result_dict = {} 

265 for sample in samples_results: 

266 sample_dict = sample.sample 

267 

268 result_dict[sample.guid] = { 

269 "sample": sample_dict, 

270 "guid": sample.guid, 

271 "parent_guid": sample.parent_guid, 

272 } 

273 return order_samples(result_dict)[-1]["guid"] 

274 

275 def delete( 

276 self, 

277 namespace: str, 

278 name: str, 

279 tag: str, 

280 sample_name: str, 

281 ) -> None: 

282 """Delete a sample from the database. 

283 

284 Args: 

285 namespace: Namespace of the project. 

286 name: Name of the project. 

287 tag: Tag of the project. 

288 sample_name: Name of the sample. 

289 

290 Raises: 

291 SampleNotFoundError: If the sample does not exist. 

292 """ 

293 statement = select(Samples).where( 

294 and_( 

295 Samples.project_id 

296 == select(Projects.id) 

297 .where( 

298 and_( 

299 Projects.namespace == namespace, 

300 Projects.name == name, 

301 Projects.tag == tag, 

302 ), 

303 ) 

304 .scalar_subquery(), 

305 Samples.sample_name == sample_name, 

306 ) 

307 ) 

308 project_statement = select(Projects).where( 

309 and_( 

310 Projects.namespace == namespace, 

311 Projects.name == name, 

312 Projects.tag == tag, 

313 ) 

314 ) 

315 with Session(self._sa_engine) as session: 

316 sample_mapping = session.scalar(statement) 

317 project_mapping = session.scalar(project_statement) 

318 

319 if sample_mapping: 

320 parent_mapping = sample_mapping.parent_mapping 

321 child_mapping = sample_mapping.child_mapping 

322 session.delete(sample_mapping) 

323 if child_mapping: 

324 child_mapping.parent_mapping = parent_mapping 

325 project_mapping.number_of_samples -= 1 

326 project_mapping.last_update_date = datetime.datetime.now( 

327 datetime.timezone.utc 

328 ) 

329 session.commit() 

330 else: 

331 raise SampleNotFoundError( 

332 f"Sample {namespace}/{name}:{tag}?{sample_name} not found in the database" 

333 )