Coverage for python/lsst/daf/butler/registry/datasets/byDimensions/_manager.py: 92%

583 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-22 09:23 +0000

1from __future__ import annotations 

2 

3__all__ = ("ByDimensionsDatasetRecordStorageManagerUUID",) 

4 

5import dataclasses 

6import datetime 

7import logging 

8from collections import defaultdict 

9from collections.abc import Iterable, Mapping, Sequence, Set 

10from typing import TYPE_CHECKING, Any, ClassVar 

11 

12import astropy.time 

13import pydantic 

14import sqlalchemy 

15 

16from lsst.utils.iteration import chunk_iterable 

17 

18from .... import ddl 

19from ...._collection_type import CollectionType 

20from ...._dataset_ref import DatasetId, DatasetIdFactory, DatasetIdGenEnum, DatasetRef 

21from ...._dataset_type import DatasetType, get_dataset_type_name, validate_dataset_type_name 

22from ...._exceptions import CollectionTypeError, DatasetTypeExpressionError, MissingDatasetTypeError 

23from ...._exceptions_legacy import DatasetTypeError 

24from ...._timespan import Timespan 

25from ....dimensions import DataCoordinate, DimensionGroup, DimensionUniverse 

26from ....direct_query_driver import SqlJoinsBuilder, SqlSelectBuilder # new query system, server+direct only 

27from ....queries import QueryFactoryFunction 

28from ....queries import tree as qt # new query system, both clients + server 

29from ..._caching_context import CachingContext 

30from ..._collection_summary import CollectionSummary 

31from ..._exceptions import ConflictingDefinitionError, OrphanedRecordError 

32from ...interfaces import DatasetRecordStorageManager, RunRecord, VersionTuple 

33from ...wildcards import DatasetTypeWildcard 

34from ._dataset_type_cache import DatasetTypeCache 

35from .summaries import CollectionSummaryManager 

36from .tables import ( 

37 DynamicTables, 

38 StaticDatasetTableSpecTuple, 

39 StaticDatasetTablesTuple, 

40 addDatasetForeignKey, 

41 makeStaticTableSpecs, 

42 makeTagTableSpec, 

43) 

44 

45if TYPE_CHECKING: 

46 from ...interfaces import ( 

47 CollectionManager, 

48 CollectionRecord, 

49 Database, 

50 DimensionRecordStorageManager, 

51 StaticTablesContext, 

52 ) 

53 

54 

55# This has to be updated on every schema change 

56# TODO: 1.0.0 can be removed once all repos were migrated to 2.0.0. 

57_VERSION_UUID = VersionTuple(1, 0, 0) 

58# Starting with 2.0.0 the `ingest_date` column type uses nanoseconds instead 

59# of TIMESTAMP. The code supports both 1.0.0 and 2.0.0 for the duration of 

60# client migration period. 

61_VERSION_UUID_NS = VersionTuple(2, 0, 0) 

62 

63_LOG = logging.getLogger(__name__) 

64 

65 

66@dataclasses.dataclass 

67class _DatasetTypeRecord: 

68 """Contents of a single dataset type record.""" 

69 

70 dataset_type: DatasetType 

71 dataset_type_id: int 

72 dimensions_key: int 

73 tag_table_name: str 

74 calib_table_name: str | None 

75 

76 def make_dynamic_tables(self) -> DynamicTables: 

77 return DynamicTables( 

78 self.dataset_type.dimensions, self.dimensions_key, self.tag_table_name, self.calib_table_name 

79 ) 

80 

81 def update_dynamic_tables(self, current: DynamicTables) -> DynamicTables: 

82 assert self.dimensions_key == current.dimensions_key 

83 assert self.tag_table_name == current.tags_name 

84 if self.calib_table_name is not None: 

85 if current.calibs_name is not None: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true

86 assert self.calib_table_name == current.calibs_name 

87 else: 

88 # Some previously-cached dataset type had the same dimensions 

89 # but was not a calibration. 

90 current = current.copy(calibs_name=self.calib_table_name) 

91 # If some previously-cached dataset type was a calibration but this 

92 # one isn't, we don't want to forget the calibs table. 

93 return current 

94 

95 

96@dataclasses.dataclass 

97class _DatasetRecordStorage: 

98 """Information cached about a dataset type. 

99 

100 This combines information cached with different keys - the dataset type 

101 and its ID are cached by name, while the tables are cached by the dataset 

102 types dimensions (and hence shared with other dataset types that have the 

103 same dimensions). 

104 """ 

105 

106 dataset_type: DatasetType 

107 dataset_type_id: int 

108 dynamic_tables: DynamicTables 

109 

110 

111class _DatasetTypeOverride(pydantic.BaseModel): 

112 """A configuration struct that holds storage class overrides and/or a new 

113 name for a dataset type. 

114 """ 

115 

116 storageClass: str | None = None 

117 rename: str | None = None 

118 

119 

120class ByDimensionsDatasetRecordStorageManagerUUID(DatasetRecordStorageManager): 

121 """A manager class for datasets that uses one dataset-collection table for 

122 each group of dataset types that share the same dimensions. 

123 

124 In addition to the table organization, this class makes a number of 

125 other design choices that would have been cumbersome (to say the least) to 

126 try to pack into its name: 

127 

128 - It uses a private surrogate integer autoincrement field to identify 

129 dataset types, instead of using the name as the primary and foreign key 

130 directly. 

131 

132 - It aggressively loads all DatasetTypes into memory instead of fetching 

133 them from the database only when needed or attempting more clever forms 

134 of caching. 

135 

136 Alternative implementations that make different choices for these while 

137 keeping the same general table organization might be reasonable as well. 

138 

139 Parameters 

140 ---------- 

141 db : `Database` 

142 Interface to the underlying database engine and namespace. 

143 collections : `CollectionManager` 

144 Manager object for the collections in this `Registry`. 

145 dimensions : `DimensionRecordStorageManager` 

146 Manager object for the dimensions in this `Registry`. 

147 static : `StaticDatasetTablesTuple` 

148 Named tuple of `sqlalchemy.schema.Table` instances for all static 

149 tables used by this class. 

150 summaries : `CollectionSummaryManager` 

151 Structure containing tables that summarize the contents of collections. 

152 renames 

153 Mapping from a dataset type name in the database to an client-level 

154 override name. 

155 reversed_renames 

156 Mapping from the override name for a dataset type to its true (DB) 

157 name. 

158 storage_class_overrides 

159 Mapping from the true (DB) name of a dataset type to an override 

160 storage class this client should use instead of the one in the 

161 database. 

162 registry_schema_version : `VersionTuple` or `None`, optional 

163 Version of registry schema. 

164 _cache : `None`, optional 

165 For internal use only. 

166 """ 

167 

168 def __init__( 

169 self, 

170 *, 

171 db: Database, 

172 collections: CollectionManager, 

173 dimensions: DimensionRecordStorageManager, 

174 static: StaticDatasetTablesTuple, 

175 summaries: CollectionSummaryManager, 

176 renames: Mapping[str, str], 

177 reversed_renames: Mapping[str, str], 

178 storage_class_overrides: Mapping[str, str], 

179 registry_schema_version: VersionTuple | None = None, 

180 _cache: DatasetTypeCache | None = None, 

181 ): 

182 super().__init__(registry_schema_version=registry_schema_version) 

183 self._db = db 

184 self._collections = collections 

185 self._dimensions = dimensions 

186 self._static = static 

187 self._summaries = summaries 

188 self._cache = _cache if _cache is not None else DatasetTypeCache() 

189 self._use_astropy_ingest_date = self.ingest_date_dtype() is ddl.AstropyTimeNsecTai 

190 self._run_key_column = collections.getRunForeignKeyName() 

191 self._renames = renames 

192 self._reversed_renames = reversed_renames 

193 self._storage_class_overrides = storage_class_overrides 

194 

195 _versions: ClassVar[list[VersionTuple]] = [_VERSION_UUID, _VERSION_UUID_NS] 

196 

197 _id_maker: ClassVar[DatasetIdFactory] = DatasetIdFactory() 

198 """Factory for dataset IDs. In the future this factory may be shared with 

199 other classes (e.g. Registry). 

200 """ 

201 

202 @classmethod 

203 def initialize( 

204 cls, 

205 db: Database, 

206 context: StaticTablesContext, 

207 *, 

208 collections: CollectionManager, 

209 dimensions: DimensionRecordStorageManager, 

210 caching_context: CachingContext, 

211 config: Mapping, 

212 registry_schema_version: VersionTuple | None = None, 

213 ) -> DatasetRecordStorageManager: 

214 # Docstring inherited from DatasetRecordStorageManager. 

215 specs = cls.makeStaticTableSpecs( 

216 type(collections), universe=dimensions.universe, schema_version=registry_schema_version 

217 ) 

218 static: StaticDatasetTablesTuple = context.addTableTuple(specs) # type: ignore 

219 dataset_type_overrides = { 

220 dataset_type_name: _DatasetTypeOverride.model_validate(override_config) 

221 for dataset_type_name, override_config in config.get("overrides", {}).items() 

222 } 

223 renames = { 

224 db_name: override.rename 

225 for db_name, override in dataset_type_overrides.items() 

226 if override.rename is not None 

227 } 

228 reversed_renames = {v: k for k, v in renames.items()} 

229 storage_class_overrides = { 

230 db_name: override.storageClass 

231 for db_name, override in dataset_type_overrides.items() 

232 if override.storageClass is not None 

233 } 

234 summaries = CollectionSummaryManager.initialize( 

235 db, 

236 context, 

237 collections=collections, 

238 dimensions=dimensions, 

239 dataset_type_table=static.dataset_type, 

240 caching_context=caching_context, 

241 reversed_renames=reversed_renames, 

242 ) 

243 return cls( 

244 db=db, 

245 collections=collections, 

246 dimensions=dimensions, 

247 static=static, 

248 summaries=summaries, 

249 registry_schema_version=registry_schema_version, 

250 renames=renames, 

251 reversed_renames=reversed_renames, 

252 storage_class_overrides=storage_class_overrides, 

253 ) 

254 

255 @classmethod 

256 def currentVersions(cls) -> list[VersionTuple]: 

257 # Docstring inherited from VersionedExtension. 

258 return cls._versions 

259 

260 @classmethod 

261 def makeStaticTableSpecs( 

262 cls, 

263 collections: type[CollectionManager], 

264 universe: DimensionUniverse, 

265 schema_version: VersionTuple | None, 

266 ) -> StaticDatasetTableSpecTuple: 

267 """Construct all static tables used by the classes in this package. 

268 

269 Static tables are those that are present in all Registries and do not 

270 depend on what DatasetTypes have been registered. 

271 

272 Parameters 

273 ---------- 

274 collections : `CollectionManager` 

275 Manager object for the collections in this `Registry`. 

276 universe : `DimensionUniverse` 

277 Universe graph containing all dimensions known to this `Registry`. 

278 schema_version : `VersionTuple` or `None` 

279 Version of the schema that should be created, if `None` then 

280 default schema should be used. 

281 

282 Returns 

283 ------- 

284 specs : `StaticDatasetTablesTuple` 

285 A named tuple containing `ddl.TableSpec` instances. 

286 """ 

287 schema_version = cls.clsNewSchemaVersion(schema_version) 

288 assert schema_version is not None, "New schema version cannot be None" 

289 return makeStaticTableSpecs( 

290 collections, 

291 universe=universe, 

292 schema_version=schema_version, 

293 ) 

294 

295 @classmethod 

296 def addDatasetForeignKey( 

297 cls, 

298 tableSpec: ddl.TableSpec, 

299 *, 

300 name: str = "dataset", 

301 constraint: bool = True, 

302 onDelete: str | None = None, 

303 **kwargs: Any, 

304 ) -> ddl.FieldSpec: 

305 # Docstring inherited from DatasetRecordStorageManager. 

306 return addDatasetForeignKey(tableSpec, name=name, onDelete=onDelete, constraint=constraint, **kwargs) 

307 

308 @classmethod 

309 def _newDefaultSchemaVersion(cls) -> VersionTuple: 

310 # Docstring inherited from VersionedExtension. 

311 return _VERSION_UUID_NS 

312 

313 def clone( 

314 self, 

315 *, 

316 db: Database, 

317 collections: CollectionManager, 

318 dimensions: DimensionRecordStorageManager, 

319 caching_context: CachingContext, 

320 ) -> ByDimensionsDatasetRecordStorageManagerUUID: 

321 return ByDimensionsDatasetRecordStorageManagerUUID( 

322 db=db, 

323 collections=collections, 

324 dimensions=dimensions, 

325 static=self._static, 

326 summaries=self._summaries.clone(db=db, collections=collections, caching_context=caching_context), 

327 renames=self._renames, 

328 reversed_renames=self._reversed_renames, 

329 storage_class_overrides=self._storage_class_overrides, 

330 registry_schema_version=self._registry_schema_version, 

331 # See notes on DatasetTypeCache.clone() about cache behavior after 

332 # cloning. 

333 _cache=self._cache.clone(), 

334 ) 

335 

336 def refresh(self) -> None: 

337 # Docstring inherited from DatasetRecordStorageManager. 

338 self._cache.clear() 

339 

340 def remove_dataset_type(self, name: str) -> None: 

341 # Docstring inherited from DatasetRecordStorageManager. 

342 db_name = self._reversed_renames.get(name, name) 

343 compositeName, componentName = DatasetType.splitDatasetTypeName(name) 

344 if componentName is not None: 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true

345 raise ValueError(f"Cannot delete a dataset type of a component of a composite (given {name})") 

346 

347 # Delete the row 

348 try: 

349 self._db.delete(self._static.dataset_type, ["name"], {"name": db_name}) 

350 except sqlalchemy.exc.IntegrityError as e: 

351 msg = name if db_name == name else f"{db_name} (renamed to {name})" 

352 raise OrphanedRecordError( 

353 f"Dataset type {msg} can not be removed." 

354 " It is associated with datasets that must be removed first." 

355 ) from e 

356 

357 # Now refresh everything -- removal is rare enough that this does 

358 # not need to be fast. 

359 self.refresh() 

360 

361 def get_dataset_type(self, name: str) -> DatasetType: 

362 # Docstring inherited from DatasetRecordStorageManager. 

363 return self._find_storage(name).dataset_type 

364 

365 def register_dataset_type(self, dataset_type: DatasetType) -> bool: 

366 # Docstring inherited from DatasetRecordStorageManager. 

367 # 

368 # This is one of the places where we populate the dataset type cache. 

369 # See the comment in _fetch_dataset_types for how these are related and 

370 # invariants they must maintain. 

371 # 

372 if dataset_type.isComponent(): 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true

373 raise ValueError( 

374 f"Component dataset types can not be stored in registry. Rejecting {dataset_type.name}" 

375 ) 

376 

377 # If database universe and dimension group universe are different it 

378 # can cause unexpected effects. 

379 if dataset_type.dimensions.universe is not self._dimensions.universe: 

380 raise ValueError( 

381 "Incompatible dimension universe versions - " 

382 f"database universe: {self._dimensions.universe}, " 

383 f"dataset type universe: {dataset_type.dimensions.universe}." 

384 ) 

385 

386 record = self._fetch_dataset_type_record(dataset_type.name) 

387 if record is None: 

388 if (dynamic_tables := self._cache.get_by_dimensions(dataset_type.dimensions)) is None: 

389 dimensions_key = self._dimensions.save_dimension_group(dataset_type.dimensions) 

390 dynamic_tables = DynamicTables.from_dimensions_key( 

391 dataset_type.dimensions, dimensions_key, dataset_type.isCalibration() 

392 ) 

393 dynamic_tables.create(self._db, type(self._collections), self._cache.tables) 

394 elif dataset_type.isCalibration() and dynamic_tables.calibs_name is None: 

395 dynamic_tables = dynamic_tables.add_calibs( 

396 self._db, type(self._collections), self._cache.tables 

397 ) 

398 db_name = self._reversed_renames.get(dataset_type.name, dataset_type.name) 

399 if db_name == dataset_type.name and db_name in self._renames: 

400 # We were asked to fetch a dataset type that has been renamed 

401 # a config override, with no other existing dataset type 

402 # to replace it. It should appear missing, but we still can't 

403 # register it. 

404 raise ConflictingDefinitionError( 

405 f"A dataset type with the name {db_name!r} has been renamed to , " 

406 f"{self._renames[db_name]!r} in this client; it can only be registered " 

407 "from a client without any rename configuration." 

408 ) 

409 if db_name in self._storage_class_overrides: 

410 raise ConflictingDefinitionError( 

411 f"Dataset type {db_name!r} has had its storage class overridden to " 

412 f"{dataset_type.storageClass_name!r} via butler configuration, and cannot " 

413 "be registered via this client since the database-level storage class is unknown." 

414 ) 

415 row, inserted = self._db.sync( 

416 self._static.dataset_type, 

417 keys={"name": db_name}, 

418 compared={ 

419 "dimensions_key": dynamic_tables.dimensions_key, 

420 # Force the storage class to be loaded to ensure it 

421 # exists and there is no typo in the name. 

422 "storage_class": dataset_type.storageClass.name, 

423 }, 

424 extra={ 

425 "tag_association_table": dynamic_tables.tags_name, 

426 "calibration_association_table": ( 

427 dynamic_tables.calibs_name if dataset_type.isCalibration() else None 

428 ), 

429 }, 

430 returning=["id", "tag_association_table"], 

431 ) 

432 # Make sure that cache is updated 

433 if row is not None: 433 ↛ 443line 433 didn't jump to line 443 because the condition on line 433 was always true

434 self._cache.add(dataset_type, row["id"]) 

435 self._cache.add_by_dimensions(dataset_type.dimensions, dynamic_tables) 

436 else: 

437 if dataset_type != record.dataset_type: 

438 raise ConflictingDefinitionError( 

439 f"Given dataset type {dataset_type} is inconsistent " 

440 f"with database definition {record.dataset_type}." 

441 ) 

442 inserted = False 

443 return bool(inserted) 

444 

445 def resolve_wildcard( 

446 self, 

447 expression: Any, 

448 missing: list[str] | None = None, 

449 explicit_only: bool = False, 

450 ) -> list[DatasetType]: 

451 wildcard = DatasetTypeWildcard.from_expression(expression) 

452 result: list[DatasetType] = [] 

453 for name, dataset_type in wildcard.values.items(): 

454 parent_name, component_name = DatasetType.splitDatasetTypeName(name) 

455 if component_name is not None: 

456 # Distinguish a real component name from a string that is not 

457 # a valid dataset type name at all, such as "...", which is 

458 # sometimes passed in place of the "..." wildcard. 

459 validate_dataset_type_name(name) 

460 raise DatasetTypeExpressionError( 

461 "Component dataset types are not supported in Registry methods; use DatasetRef or " 

462 "DatasetType methods to obtain components from parents instead." 

463 ) 

464 try: 

465 resolved_dataset_type = self.get_dataset_type(parent_name) 

466 except MissingDatasetTypeError: 

467 if missing is not None: 

468 missing.append(name) 

469 else: 

470 if dataset_type is not None: 

471 if dataset_type.is_compatible_with(resolved_dataset_type): 471 ↛ 476line 471 didn't jump to line 476 because the condition on line 471 was always true

472 # Prefer the given dataset type to enable storage class 

473 # conversions. 

474 resolved_dataset_type = dataset_type 

475 else: 

476 raise DatasetTypeExpressionError( 

477 f"Dataset type definition in query expression {dataset_type} is " 

478 f"not compatible with the registered type {resolved_dataset_type}." 

479 ) 

480 result.append(resolved_dataset_type) 

481 if wildcard.patterns is ...: 

482 if explicit_only: 482 ↛ 483line 482 didn't jump to line 483 because the condition on line 482 was never true

483 raise TypeError( 

484 "Universal wildcard '...' is not permitted for dataset types in this context." 

485 ) 

486 for datasetType in self._fetch_dataset_types(): 

487 result.append(datasetType) 

488 elif wildcard.patterns: 

489 if explicit_only: 489 ↛ 490line 489 didn't jump to line 490 because the condition on line 489 was never true

490 raise DatasetTypeExpressionError( 

491 "Dataset type wildcard expressions are not supported in this context." 

492 ) 

493 dataset_types = self._fetch_dataset_types() 

494 for datasetType in dataset_types: 

495 if any(p.fullmatch(datasetType.name) for p in wildcard.patterns): 

496 result.append(datasetType) 

497 

498 return result 

499 

500 def get_dataset_refs(self, ids: list[DatasetId]) -> list[DatasetRef]: 

501 dataset_type_map: dict[DatasetId, DatasetType] = {} 

502 for batch in chunk_iterable(set(ids), 50000): 

503 # Look up the dataset types corresponding to the given Dataset IDs. 

504 id_col = self._static.dataset.columns["id"] 

505 sql = sqlalchemy.sql.select( 

506 id_col, 

507 self._static.dataset.columns["dataset_type_id"], 

508 ).where(id_col.in_(batch)) 

509 with self._db.query(sql) as sql_result: 

510 dataset_rows = sql_result.mappings().all() 

511 for row in dataset_rows: 

512 dataset_type_map[row["id"]] = self._get_dataset_type_by_id(row["dataset_type_id"]) 

513 

514 # Group the given dataset IDs by the DimensionGroup of their dataset 

515 # types -- there is a separate tags table for each DimensionGroup. 

516 dimension_groups = defaultdict[DimensionGroup, set[DatasetId]](set) 

517 for id, dataset_type in dataset_type_map.items(): 

518 dimension_groups[dataset_type.dimensions].add(id) 

519 

520 output_refs: list[DatasetRef] = [] 

521 for dimension_group, datasets in dimension_groups.items(): 

522 # Query the tags table for each dimension group to look up the 

523 # data IDs corresponding to the UUIDs found from the dataset table. 

524 dynamic_tables = self._get_dynamic_tables(dimension_group) 

525 tags_table = self._get_tags_table(dynamic_tables) 

526 for batch in chunk_iterable(datasets, 50000): 

527 tags_sql = tags_table.select().where(tags_table.columns["dataset_id"].in_(batch)) 

528 # Join in the collection table to fetch the run name. 

529 collection_column = tags_table.columns[self._collections.getCollectionForeignKeyName()] 

530 joined_collections = self._collections.join_collections_sql(collection_column, tags_sql) 

531 tags_sql = joined_collections.joined_sql 

532 run_name_column = joined_collections.name_column 

533 tags_sql = tags_sql.add_columns(run_name_column) 

534 # Tags table includes run collections and tagged 

535 # collections. 

536 # In theory the data ID for a given dataset should be the 

537 # same in both, but nothing actually guarantees this. 

538 # So skip any tagged collections, using the run collection 

539 # as the definitive definition. 

540 tags_sql = tags_sql.where(joined_collections.type_column == int(CollectionType.RUN)) 

541 

542 with self._db.query(tags_sql) as sql_result: 

543 data_id_rows = sql_result.mappings().all() 

544 

545 assert run_name_column.key is not None 

546 for data_id_row in data_id_rows: 

547 id = data_id_row["dataset_id"] 

548 dataset_type = dataset_type_map[id] 

549 run_name = data_id_row[run_name_column.key] 

550 data_id = DataCoordinate.from_required_values( 

551 dimension_group, 

552 tuple(data_id_row[dimension] for dimension in dimension_group.required), 

553 ) 

554 ref = DatasetRef( 

555 datasetType=dataset_type, 

556 dataId=data_id, 

557 id=id, 

558 run=run_name, 

559 ) 

560 output_refs.append(ref) 

561 

562 return output_refs 

563 

564 def _fetch_dataset_type_record(self, name: str) -> _DatasetTypeRecord | None: 

565 """Retrieve all dataset types defined in database. 

566 

567 Returns 

568 ------- 

569 dataset_type 

570 Information from a single database record. 

571 """ 

572 db_name = self._reversed_renames.get(name, name) 

573 if db_name == name and db_name in self._renames: 

574 # We were asked to fetch a dataset type that has been renamed via 

575 # a config override, with no other existing dataset type renamed 

576 # to replace it. It should appear missing. 

577 return None 

578 c = self._static.dataset_type.columns 

579 stmt = self._static.dataset_type.select().where(c.name == db_name) 

580 with self._db.query(stmt) as sql_result: 

581 row = sql_result.mappings().one_or_none() 

582 if row is None: 

583 return None 

584 else: 

585 return self._record_from_row(row) 

586 

587 def _record_from_row(self, row: Mapping) -> _DatasetTypeRecord: 

588 name = self._renames.get(row["name"], row["name"]) 

589 dimensions = self._dimensions.load_dimension_group(row["dimensions_key"]) 

590 calibTableName = row["calibration_association_table"] 

591 storage_class_name = self._storage_class_overrides.get(row["name"], row["storage_class"]) 

592 datasetType = DatasetType( 

593 name, dimensions, storage_class_name, isCalibration=(calibTableName is not None) 

594 ) 

595 return _DatasetTypeRecord( 

596 dataset_type=datasetType, 

597 dataset_type_id=row["id"], 

598 dimensions_key=row["dimensions_key"], 

599 tag_table_name=row["tag_association_table"], 

600 calib_table_name=calibTableName, 

601 ) 

602 

603 def _dataset_type_from_row(self, row: Mapping) -> DatasetType: 

604 return self._record_from_row(row).dataset_type 

605 

606 def preload_cache(self) -> None: 

607 self._fetch_dataset_types() 

608 

609 def _fetch_dataset_types(self, force_refresh: bool = False) -> list[DatasetType]: 

610 """Fetch list of all defined dataset types.""" 

611 # This is one of two places we populate the dataset type cache: 

612 # 

613 # - This method handles almost all requests for dataset types that 

614 # should already exist. It always marks the cache as "full" in both 

615 # dataset type names and dimensions. 

616 # 

617 # - register_dataset_type handles the case where the dataset type might 

618 # not existing yet. Since it can only add a single dataset type, it 

619 # never changes whether the cache is full. 

620 # 

621 # In both cases, we require that the per-dimensions data be cached 

622 # whenever a dataset type is added to the cache by name, to reduce the 

623 # number of possible states the cache can be in and minimize the number 

624 # of queries. 

625 if self._cache.full and not force_refresh: 

626 return [dataset_type for dataset_type, _ in self._cache.items()] 

627 with self._db.query(self._static.dataset_type.select()) as sql_result: 

628 sql_rows = sql_result.mappings().fetchall() 

629 records = [self._record_from_row(row) for row in sql_rows] 

630 # Cache everything and specify that cache is complete. 

631 cache_data: list[tuple[DatasetType, int]] = [] 

632 cache_dimensions_data: dict[DimensionGroup, DynamicTables] = {} 

633 for record in records: 

634 cache_data.append((record.dataset_type, record.dataset_type_id)) 

635 if (dynamic_tables := cache_dimensions_data.get(record.dataset_type.dimensions)) is None: 

636 tables = record.make_dynamic_tables() 

637 else: 

638 tables = record.update_dynamic_tables(dynamic_tables) 

639 cache_dimensions_data[record.dataset_type.dimensions] = tables 

640 self._cache.set( 

641 cache_data, full=True, dimensions_data=cache_dimensions_data.items(), dimensions_full=True 

642 ) 

643 return [record.dataset_type for record in records] 

644 

645 def _get_dataset_type_by_id(self, id: int) -> DatasetType: 

646 dt = self._cache.get_by_id(id) 

647 if dt is None: 

648 # Since the ID is not a concept exposed to the public API, it 

649 # had to have come from a dataset table, and our cache must be 

650 # empty or out of date. 

651 self._fetch_dataset_types(force_refresh=True) 

652 dt = self._cache.get_by_id(id) 

653 if dt is None: 653 ↛ 654line 653 didn't jump to line 654 because the condition on line 653 was never true

654 raise RuntimeError(f"Failed to look up dataset type with ID {id}") 

655 return dt 

656 

657 def _find_storage(self, name: str) -> _DatasetRecordStorage: 

658 """Find a dataset type and the extra information needed to work with 

659 it, utilizing and populating the cache as needed. 

660 """ 

661 dataset_type, dataset_type_id = self._cache.get(name) 

662 if dataset_type is not None: 

663 tables = self._get_dynamic_tables(dataset_type.dimensions) 

664 assert dataset_type_id is not None, "Dataset type cache population is incomplete." 

665 return _DatasetRecordStorage( 

666 dataset_type=dataset_type, dataset_type_id=dataset_type_id, dynamic_tables=tables 

667 ) 

668 else: 

669 # On the first cache miss populate the cache with complete list 

670 # of dataset types (if it was not done yet). 

671 if not self._cache.full: 

672 self._fetch_dataset_types() 

673 # Try again 

674 dataset_type, dataset_type_id = self._cache.get(name) 

675 if dataset_type is not None: 

676 tables = self._get_dynamic_tables(dataset_type.dimensions) 

677 assert dataset_type_id is not None, "Dataset type cache population is incomplete." 

678 return _DatasetRecordStorage( 

679 dataset_type=dataset_type, dataset_type_id=dataset_type_id, dynamic_tables=tables 

680 ) 

681 record = self._fetch_dataset_type_record(name) 

682 if record is not None: 

683 self._cache.add(record.dataset_type, record.dataset_type_id) 

684 tables = record.make_dynamic_tables() 

685 self._cache.add_by_dimensions(record.dataset_type.dimensions, tables) 

686 return _DatasetRecordStorage(record.dataset_type, record.dataset_type_id, tables) 

687 raise MissingDatasetTypeError(f"Dataset type {name!r} does not exist.") 

688 

689 def _get_dynamic_tables(self, dimensions: DimensionGroup) -> DynamicTables: 

690 tables = self._cache.get_by_dimensions(dimensions) 

691 assert tables is not None, ( 

692 "_fetch_dataset_types is supposed to guarantee that the tables cache is populated." 

693 ) 

694 return tables 

695 

696 def getCollectionSummary(self, collection: CollectionRecord) -> CollectionSummary: 

697 # Docstring inherited from DatasetRecordStorageManager. 

698 summaries = self._summaries.fetch_summaries([collection], None, self._dataset_type_from_row) 

699 return summaries[collection.key] 

700 

701 def fetch_summaries( 

702 self, 

703 collections: Iterable[CollectionRecord], 

704 dataset_types: Iterable[DatasetType] | Iterable[str] | None = None, 

705 ) -> Mapping[Any, CollectionSummary]: 

706 # Docstring inherited from DatasetRecordStorageManager. 

707 dataset_type_names: Iterable[str] | None = None 

708 if dataset_types is not None: 

709 dataset_type_names = set(get_dataset_type_name(dt) for dt in dataset_types) 

710 return self._summaries.fetch_summaries(collections, dataset_type_names, self._dataset_type_from_row) 

711 

712 def fetch_run_dataset_ids(self, run: RunRecord) -> list[DatasetId]: 

713 # Docstring inherited. 

714 sql = sqlalchemy.select(self._static.dataset.c.id).where( 

715 self._static.dataset.c[self._run_key_column] == run.key 

716 ) 

717 with self._db.query(sql) as result: 

718 return list(result.scalars()) 

719 

720 def ingest_date_dtype(self) -> type: 

721 """Return type of the ``ingest_date`` column.""" 

722 schema_version = self.newSchemaVersion() 

723 if schema_version is not None and schema_version.major > 1: 723 ↛ 726line 723 didn't jump to line 726 because the condition on line 723 was always true

724 return ddl.AstropyTimeNsecTai 

725 else: 

726 return sqlalchemy.TIMESTAMP 

727 

728 def insert( 

729 self, 

730 dataset_type_name: str, 

731 run: RunRecord, 

732 data_ids: Iterable[DataCoordinate], 

733 id_generation_mode: DatasetIdGenEnum = DatasetIdGenEnum.UNIQUE, 

734 ) -> list[DatasetRef]: 

735 # Docstring inherited from DatasetRecordStorageManager. 

736 if (storage := self._find_storage(dataset_type_name)) is None: 736 ↛ 737line 736 didn't jump to line 737 because the condition on line 736 was never true

737 raise MissingDatasetTypeError(f"Dataset type {dataset_type_name!r} has not been registered.") 

738 # Current timestamp, type depends on schema version. Use microsecond 

739 # precision for astropy time to keep things consistent with 

740 # TIMESTAMP(6) SQL type. 

741 timestamp: datetime.datetime | astropy.time.Time 

742 if self._use_astropy_ingest_date: 742 ↛ 747line 742 didn't jump to line 747 because the condition on line 742 was always true

743 # Astropy `now()` precision should be the same as `now()` which 

744 # should mean microsecond. 

745 timestamp = astropy.time.Time.now() 

746 else: 

747 timestamp = datetime.datetime.now(datetime.UTC) 

748 

749 # Iterate over data IDs, transforming a possibly-single-pass iterable 

750 # into a list. 

751 data_id_list: list[DataCoordinate] = [] 

752 rows = [] 

753 summary = CollectionSummary() 

754 for dataId in summary.add_data_ids_generator(storage.dataset_type, data_ids): 

755 data_id_list.append(dataId) 

756 rows.append( 

757 { 

758 "id": self._id_maker.makeDatasetId( 

759 run.name, storage.dataset_type, dataId, id_generation_mode 

760 ), 

761 "dataset_type_id": storage.dataset_type_id, 

762 self._run_key_column: run.key, 

763 "ingest_date": timestamp, 

764 } 

765 ) 

766 if not rows: 766 ↛ 769line 766 didn't jump to line 769 because the condition on line 766 was never true

767 # Just in case an empty collection is provided we want to avoid 

768 # adding dataset type to summary tables. 

769 return [] 

770 

771 with self._db.transaction(): 

772 # Insert into the static dataset table. 

773 self._db.insert(self._static.dataset, *rows) 

774 # Update the summary tables for this collection in case this is the 

775 # first time this dataset type or these governor values will be 

776 # inserted there. 

777 self._summaries.update(run, [storage.dataset_type_id], summary) 

778 # Combine the generated dataset_id values and data ID fields to 

779 # form rows to be inserted into the tags table. 

780 protoTagsRow = { 

781 "dataset_type_id": storage.dataset_type_id, 

782 self._collections.getCollectionForeignKeyName(): run.key, 

783 } 

784 tagsRows = [ 

785 dict(protoTagsRow, dataset_id=row["id"], **dataId.required) 

786 for dataId, row in zip(data_id_list, rows, strict=True) 

787 ] 

788 # Insert those rows into the tags table. 

789 self._db.insert(self._get_tags_table(storage.dynamic_tables), *tagsRows) 

790 

791 return [ 

792 DatasetRef( 

793 datasetType=storage.dataset_type, 

794 dataId=dataId, 

795 id=row["id"], 

796 run=run.name, 

797 ) 

798 for dataId, row in zip(data_id_list, rows, strict=True) 

799 ] 

800 

801 def import_(self, run: RunRecord, refs: list[DatasetRef], assume_new: bool = False) -> None: 

802 # Docstring inherited from DatasetRecordStorageManager. 

803 if not refs: 803 ↛ 806line 803 didn't jump to line 806 because the condition on line 803 was never true

804 # Just in case an empty mapping is provided we want to avoid 

805 # adding dataset type to summary tables. 

806 return 

807 assert all(ref.run == run.name for ref in refs), ( 

808 "Run names in refs must match the run we are inserting into" 

809 ) 

810 

811 dataset_types = {ref.datasetType for ref in refs} 

812 dimensions = _ensure_dimension_groups_match(dataset_types) 

813 dataset_type_storage: dict[str, _DatasetRecordStorage] = {} 

814 for dt in dataset_types: 

815 if (storage := self._find_storage(dt.name)) is None: 815 ↛ 816line 815 didn't jump to line 816 because the condition on line 815 was never true

816 raise MissingDatasetTypeError(f"Dataset type {dt.name!r} has not been registered.") 

817 dataset_type_storage[dt.name] = storage 

818 

819 dynamic_tables = self._get_dynamic_tables(dimensions) 

820 tags_table = self._get_tags_table(dynamic_tables) 

821 # Current timestamp, type depends on schema version. 

822 if self._use_astropy_ingest_date: 822 ↛ 827line 822 didn't jump to line 827 because the condition on line 822 was always true

823 # Astropy `now()` precision should be the same as `now()` which 

824 # should mean microsecond. 

825 timestamp = sqlalchemy.sql.literal(astropy.time.Time.now(), type_=ddl.AstropyTimeNsecTai) 

826 else: 

827 timestamp = sqlalchemy.sql.literal(datetime.datetime.now(datetime.UTC)) 

828 collection_fkey_name = self._collections.getCollectionForeignKeyName() 

829 tags_rows = [ 

830 { 

831 "dataset_type_id": dataset_type_storage[ref.datasetType.name].dataset_type_id, 

832 collection_fkey_name: run.key, 

833 "dataset_id": ref.id, 

834 **ref.dataId.required, 

835 } 

836 for ref in refs 

837 ] 

838 if assume_new: 

839 self._import_new(run, refs, dataset_type_storage, tags_table, tags_rows, timestamp) 

840 else: 

841 self._import_guarded( 

842 run, refs, dimensions, dataset_type_storage, tags_table, tags_rows, timestamp 

843 ) 

844 

845 def _import_guarded( 

846 self, 

847 run: RunRecord, 

848 refs: list[DatasetRef], 

849 dimensions: DimensionGroup, 

850 dataset_type_storage: dict[str, _DatasetRecordStorage], 

851 tags_table: sqlalchemy.Table, 

852 tags_rows: list[dict[str, object]], 

853 timestamp: sqlalchemy.BindParameter[astropy.time.Time | datetime.datetime], 

854 ) -> None: 

855 # We'll insert all new rows into a temporary table 

856 table_spec = makeTagTableSpec(dimensions, type(self._collections), constraints=False) 

857 collection_fkey_name = self._collections.getCollectionForeignKeyName() 

858 with self._db.transaction(for_temp_tables=True), self._db.temporary_table(table_spec) as tmp_tags: 

859 # store all incoming data in a temporary table 

860 self._db.insert(tmp_tags, *tags_rows) 

861 # There are some checks that we want to make for consistency 

862 # of the new datasets with existing ones. 

863 self._validate_import(dimensions, tags_table, tmp_tags, run) 

864 # Before we merge temporary table into dataset/tags we need to 

865 # drop datasets which are already there (and do not conflict). 

866 self._db.deleteWhere( 

867 tmp_tags, 

868 tmp_tags.columns.dataset_id.in_(sqlalchemy.sql.select(self._static.dataset.columns.id)), 

869 ) 

870 # Copy it into dataset table, need to re-label some columns. 

871 self._db.insert( 

872 self._static.dataset, 

873 select=sqlalchemy.sql.select( 

874 tmp_tags.columns.dataset_id.label("id"), 

875 tmp_tags.columns.dataset_type_id, 

876 tmp_tags.columns[collection_fkey_name].label(self._run_key_column), 

877 timestamp.label("ingest_date"), 

878 ), 

879 ) 

880 self._update_summaries(run, refs, dataset_type_storage) 

881 # Copy from temp table into tags table. 

882 self._db.insert(tags_table, select=tmp_tags.select()) 

883 

884 def _update_summaries( 

885 self, run: RunRecord, refs: list[DatasetRef], dataset_type_storage: dict[str, _DatasetRecordStorage] 

886 ) -> None: 

887 summary = CollectionSummary() 

888 summary.add_datasets(refs) 

889 self._summaries.update( 

890 run, [storage.dataset_type_id for storage in dataset_type_storage.values()], summary 

891 ) 

892 

893 def _validate_import( 

894 self, 

895 dimensions: DimensionGroup, 

896 tags: sqlalchemy.schema.Table, 

897 tmp_tags: sqlalchemy.schema.Table, 

898 run: RunRecord, 

899 ) -> None: 

900 """Validate imported refs against existing datasets. 

901 

902 Parameters 

903 ---------- 

904 dimensions : `DimensionGroup` 

905 Dimensions to validate. 

906 tags : `sqlalchemy.schema.Table` 

907 ??? 

908 tmp_tags : `sqlalchemy.schema.Table` 

909 Temporary table with new datasets and the same schema as tags 

910 table. 

911 run : `RunRecord` 

912 The record object describing the `~CollectionType.RUN` collection. 

913 

914 Raises 

915 ------ 

916 ConflictingDefinitionError 

917 Raise if new datasets conflict with existing ones. 

918 """ 

919 dataset = self._static.dataset 

920 collection_fkey_name = self._collections.getCollectionForeignKeyName() 

921 

922 # Check that existing datasets have the same dataset type and 

923 # run. 

924 query = ( 

925 sqlalchemy.sql.select( 

926 dataset.columns.id.label("dataset_id"), 

927 dataset.columns.dataset_type_id.label("dataset_type_id"), 

928 tmp_tags.columns.dataset_type_id.label("new_dataset_type_id"), 

929 dataset.columns[self._run_key_column].label("run"), 

930 tmp_tags.columns[collection_fkey_name].label("new_run"), 

931 ) 

932 .select_from(dataset.join(tmp_tags, dataset.columns.id == tmp_tags.columns.dataset_id)) 

933 .where( 

934 sqlalchemy.sql.or_( 

935 dataset.columns.dataset_type_id != tmp_tags.columns.dataset_type_id, 

936 dataset.columns[self._run_key_column] != tmp_tags.columns[collection_fkey_name], 

937 ) 

938 ) 

939 .limit(1) 

940 ) 

941 with self._db.query(query) as result: 

942 # Only include the first one in the exception message 

943 if (row := result.first()) is not None: 

944 existing_run = self._collections[row.run].name 

945 new_run = self._collections[row.new_run].name 

946 if row.dataset_type_id == row.new_dataset_type_id: 

947 raise ConflictingDefinitionError( 

948 f"Current run {existing_run!r} and new run {new_run!r} do not agree for " 

949 f"dataset {row.dataset_id}." 

950 ) 

951 else: 

952 raise ConflictingDefinitionError( 

953 f"Dataset {row.dataset_id} was provided with type ID {row.new_dataset_type_id} " 

954 f"in run {new_run!r}, but was already defined with type ID " 

955 f"{row.dataset_type_id} in run {run!r}." 

956 ) 

957 

958 # Check that matching dataset in tags table has the same DataId. 

959 query = ( 

960 sqlalchemy.sql.select( 

961 tags.columns.dataset_id, 

962 tags.columns.dataset_type_id.label("type_id"), 

963 tmp_tags.columns.dataset_type_id.label("new_type_id"), 

964 *[tags.columns[dim] for dim in dimensions.required], 

965 *[tmp_tags.columns[dim].label(f"new_{dim}") for dim in dimensions.required], 

966 ) 

967 .select_from(tags.join(tmp_tags, tags.columns.dataset_id == tmp_tags.columns.dataset_id)) 

968 .where( 

969 sqlalchemy.sql.or_( 

970 tags.columns.dataset_type_id != tmp_tags.columns.dataset_type_id, 

971 *[tags.columns[dim] != tmp_tags.columns[dim] for dim in dimensions.required], 

972 ) 

973 ) 

974 .limit(1) 

975 ) 

976 

977 with self._db.query(query) as result: 

978 if (row := result.first()) is not None: 

979 # Only include the first one in the exception message 

980 raise ConflictingDefinitionError( 

981 f"Existing dataset type or dataId do not match new dataset: {row._asdict()}" 

982 ) 

983 

984 # Check that matching run+dataId have the same dataset ID. 

985 query = ( 

986 sqlalchemy.sql.select( 

987 *[tags.columns[dim] for dim in dimensions.required], 

988 tags.columns.dataset_id, 

989 tmp_tags.columns.dataset_id.label("new_dataset_id"), 

990 tmp_tags.columns.dataset_type_id.label("new_dataset_type_id"), 

991 tags.columns[collection_fkey_name], 

992 tmp_tags.columns[collection_fkey_name].label(f"new_{collection_fkey_name}"), 

993 ) 

994 .select_from( 

995 tags.join( 

996 tmp_tags, 

997 sqlalchemy.sql.and_( 

998 tags.columns.dataset_type_id == tmp_tags.columns.dataset_type_id, 

999 tags.columns[collection_fkey_name] == tmp_tags.columns[collection_fkey_name], 

1000 *[tags.columns[dim] == tmp_tags.columns[dim] for dim in dimensions.required], 

1001 ), 

1002 ) 

1003 ) 

1004 .where(tags.columns.dataset_id != tmp_tags.columns.dataset_id) 

1005 .limit(1) 

1006 ) 

1007 with self._db.query(query) as result: 

1008 # only include the first one in the exception message 

1009 if (row := result.first()) is not None: 

1010 data_id = {dim: getattr(row, dim) for dim in dimensions.required} 

1011 existing_collection = self._collections[getattr(row, collection_fkey_name)].name 

1012 new_collection = self._collections[getattr(row, f"new_{collection_fkey_name}")].name 

1013 raise ConflictingDefinitionError( 

1014 f"Dataset with type ID {row.new_dataset_type_id} and data ID {data_id} " 

1015 f"has ID {row.dataset_id} in existing collection {existing_collection!r} " 

1016 f"but ID {row.new_dataset_id} in new collection {new_collection!r}." 

1017 ) 

1018 

1019 def _import_new( 

1020 self, 

1021 run: RunRecord, 

1022 refs: list[DatasetRef], 

1023 dataset_type_storage: dict[str, _DatasetRecordStorage], 

1024 tags_table: sqlalchemy.Table, 

1025 tags_rows: list[dict[str, object]], 

1026 timestamp: sqlalchemy.BindParameter[astropy.time.Time | datetime.datetime], 

1027 ) -> None: 

1028 static_rows = [ 

1029 { 

1030 "id": ref.id, 

1031 "dataset_type_id": dataset_type_storage[ref.datasetType.name].dataset_type_id, 

1032 self._run_key_column: run.key, 

1033 "ingest_date": timestamp.value, 

1034 } 

1035 for ref in refs 

1036 ] 

1037 with self._db.transaction(): 

1038 self._db.insert(self._static.dataset, *static_rows) 

1039 self._update_summaries(run, refs, dataset_type_storage) 

1040 self._db.insert(tags_table, *tags_rows) 

1041 

1042 def delete(self, datasets: Iterable[DatasetId | DatasetRef]) -> None: 

1043 # Docstring inherited from DatasetRecordStorageManager. 

1044 # Only delete from common dataset table; ON DELETE foreign key clauses 

1045 # will handle the rest. 

1046 self._db.delete( 

1047 self._static.dataset, 

1048 ["id"], 

1049 *[{"id": getattr(dataset, "id", dataset)} for dataset in datasets], 

1050 ) 

1051 

1052 def associate( 

1053 self, dataset_type: DatasetType, collection: CollectionRecord, datasets: Iterable[DatasetRef] 

1054 ) -> None: 

1055 # Docstring inherited from DatasetRecordStorageManager. 

1056 if (storage := self._find_storage(dataset_type.name)) is None: 1056 ↛ 1057line 1056 didn't jump to line 1057 because the condition on line 1056 was never true

1057 raise MissingDatasetTypeError(f"Dataset type {dataset_type.name!r} has not been registered.") 

1058 if collection.type is not CollectionType.TAGGED: 

1059 raise CollectionTypeError( 

1060 f"Cannot associate into collection '{collection.name}' " 

1061 f"of type {collection.type.name}; must be TAGGED." 

1062 ) 

1063 proto_row = { 

1064 self._collections.getCollectionForeignKeyName(): collection.key, 

1065 "dataset_type_id": storage.dataset_type_id, 

1066 } 

1067 rows = [] 

1068 summary = CollectionSummary() 

1069 for dataset in summary.add_datasets_generator(datasets): 

1070 rows.append(dict(proto_row, dataset_id=dataset.id, **dataset.dataId.required)) 

1071 if rows: 

1072 # Update the summary tables for this collection in case this is the 

1073 # first time this dataset type or these governor values will be 

1074 # inserted there. 

1075 self._summaries.update(collection, [storage.dataset_type_id], summary) 

1076 # Update the tag table itself. 

1077 self._db.replace(self._get_tags_table(storage.dynamic_tables), *rows) 

1078 

1079 def disassociate( 

1080 self, dataset_type: DatasetType, collection: CollectionRecord, datasets: Iterable[DatasetRef] 

1081 ) -> None: 

1082 # Docstring inherited from DatasetRecordStorageManager. 

1083 if (storage := self._find_storage(dataset_type.name)) is None: 1083 ↛ 1084line 1083 didn't jump to line 1084 because the condition on line 1083 was never true

1084 raise MissingDatasetTypeError(f"Dataset type {dataset_type.name!r} has not been registered.") 

1085 if collection.type is not CollectionType.TAGGED: 1085 ↛ 1086line 1085 didn't jump to line 1086 because the condition on line 1085 was never true

1086 raise CollectionTypeError( 

1087 f"Cannot disassociate from collection '{collection.name}' " 

1088 f"of type {collection.type.name}; must be TAGGED." 

1089 ) 

1090 rows = [ 

1091 { 

1092 "dataset_id": dataset.id, 

1093 self._collections.getCollectionForeignKeyName(): collection.key, 

1094 } 

1095 for dataset in datasets 

1096 ] 

1097 self._db.delete( 

1098 self._get_tags_table(storage.dynamic_tables), 

1099 ["dataset_id", self._collections.getCollectionForeignKeyName()], 

1100 *rows, 

1101 ) 

1102 

1103 def certify( 

1104 self, 

1105 dataset_type: DatasetType, 

1106 collection: CollectionRecord, 

1107 datasets: Iterable[DatasetRef], 

1108 timespan: Timespan, 

1109 query_func: QueryFactoryFunction, 

1110 ) -> None: 

1111 # Docstring inherited from DatasetRecordStorageManager. 

1112 if (storage := self._find_storage(dataset_type.name)) is None: 1112 ↛ 1113line 1112 didn't jump to line 1113 because the condition on line 1112 was never true

1113 raise MissingDatasetTypeError(f"Dataset type {dataset_type.name!r} has not been registered.") 

1114 if not dataset_type.isCalibration(): 1114 ↛ 1115line 1114 didn't jump to line 1115 because the condition on line 1114 was never true

1115 raise DatasetTypeError( 

1116 f"Cannot certify datasets of type {dataset_type.name!r}, for which " 

1117 "DatasetType.isCalibration() is False." 

1118 ) 

1119 if collection.type is not CollectionType.CALIBRATION: 1119 ↛ 1120line 1119 didn't jump to line 1120 because the condition on line 1119 was never true

1120 raise CollectionTypeError( 

1121 f"Cannot certify into collection '{collection.name}' " 

1122 f"of type {collection.type.name}; must be CALIBRATION." 

1123 ) 

1124 TimespanReprClass = self._db.getTimespanRepresentation() 

1125 proto_row = { 

1126 self._collections.getCollectionForeignKeyName(): collection.key, 

1127 "dataset_type_id": storage.dataset_type_id, 

1128 } 

1129 rows = [] 

1130 data_ids: set[DataCoordinate] | None = ( 

1131 set() if not TimespanReprClass.hasExclusionConstraint() else None 

1132 ) 

1133 summary = CollectionSummary() 

1134 for dataset in summary.add_datasets_generator(datasets): 

1135 row = dict(proto_row, dataset_id=dataset.id, **dataset.dataId.required) 

1136 TimespanReprClass.update(timespan, result=row) 

1137 rows.append(row) 

1138 if data_ids is not None: 1138 ↛ 1134line 1138 didn't jump to line 1134 because the condition on line 1138 was always true

1139 data_ids.add(dataset.dataId) 

1140 if not rows: 1140 ↛ 1143line 1140 didn't jump to line 1143 because the condition on line 1140 was never true

1141 # Just in case an empty dataset collection is provided we want to 

1142 # avoid adding dataset type to summary tables. 

1143 return 

1144 # Update the summary tables for this collection in case this is the 

1145 # first time this dataset type or these governor values will be 

1146 # inserted there. 

1147 self._summaries.update(collection, [storage.dataset_type_id], summary) 

1148 # Update the association table itself. 

1149 calibs_table = self._get_calibs_table(storage.dynamic_tables) 

1150 if TimespanReprClass.hasExclusionConstraint(): 1150 ↛ 1153line 1150 didn't jump to line 1153 because the condition on line 1150 was never true

1151 # Rely on database constraint to enforce invariants; we just 

1152 # reraise the exception for consistency across DB engines. 

1153 try: 

1154 self._db.insert(calibs_table, *rows) 

1155 except sqlalchemy.exc.IntegrityError as err: 

1156 raise ConflictingDefinitionError( 

1157 f"Validity range conflict certifying datasets of type {dataset_type.name!r} " 

1158 f"into {collection.name!r} for range {timespan}." 

1159 ) from err 

1160 else: 

1161 # Have to implement exclusion constraint ourselves. 

1162 # Acquire a table lock to ensure there are no concurrent writes 

1163 # could invalidate our checking before we finish the inserts. We 

1164 # use a SAVEPOINT in case there is an outer transaction that a 

1165 # failure here should not roll back. 

1166 with self._db.transaction( 

1167 lock=[calibs_table], 

1168 savepoint=True, 

1169 # join_data_coordinates sometimes requires a temp table 

1170 for_temp_tables=True, 

1171 ): 

1172 # Query for any rows that would overlap this one. 

1173 with query_func() as query: 

1174 if data_ids is not None: 1174 ↛ 1176line 1174 didn't jump to line 1176 because the condition on line 1174 was always true

1175 query = query.join_data_coordinates(data_ids) 

1176 timespan_column = query.expression_factory[dataset_type.name].timespan 

1177 result = query.datasets(dataset_type, collection.name, find_first=False).where( 

1178 timespan_column.overlaps(timespan) 

1179 ) 

1180 conflicting = result.count() 

1181 if conflicting > 0: 

1182 raise ConflictingDefinitionError( 

1183 f"{conflicting} validity range conflicts certifying datasets of type " 

1184 f"{dataset_type.name} into {collection.name} for range " 

1185 f"[{timespan.begin}, {timespan.end})." 

1186 ) 

1187 # Proceed with the insert. 

1188 self._db.insert(calibs_table, *rows) 

1189 

1190 def decertify( 

1191 self, 

1192 dataset_type: DatasetType, 

1193 collection: CollectionRecord, 

1194 timespan: Timespan, 

1195 *, 

1196 data_ids: Iterable[DataCoordinate] | None = None, 

1197 query_func: QueryFactoryFunction, 

1198 ) -> None: 

1199 # Docstring inherited from DatasetRecordStorageManager. 

1200 if (storage := self._find_storage(dataset_type.name)) is None: 1200 ↛ 1201line 1200 didn't jump to line 1201 because the condition on line 1200 was never true

1201 raise MissingDatasetTypeError(f"Dataset type {dataset_type.name!r} has not been registered.") 

1202 if not dataset_type.isCalibration(): 1202 ↛ 1203line 1202 didn't jump to line 1203 because the condition on line 1202 was never true

1203 raise DatasetTypeError( 

1204 f"Cannot certify datasets of type {dataset_type.name!r}, for which " 

1205 "DatasetType.isCalibration() is False." 

1206 ) 

1207 if collection.type is not CollectionType.CALIBRATION: 1207 ↛ 1208line 1207 didn't jump to line 1208 because the condition on line 1207 was never true

1208 raise CollectionTypeError( 

1209 f"Cannot decertify from collection '{collection.name}' " 

1210 f"of type {collection.type.name}; must be CALIBRATION." 

1211 ) 

1212 TimespanReprClass = self._db.getTimespanRepresentation() 

1213 data_id_set: set[DataCoordinate] | None 

1214 if data_ids is not None: 

1215 data_id_set = set(data_ids) 

1216 else: 

1217 data_id_set = None 

1218 

1219 # Set up collections to populate with the rows we'll want to modify. 

1220 # The insert rows will have the same values for collection and 

1221 # dataset type. 

1222 proto_insert_row = { 

1223 self._collections.getCollectionForeignKeyName(): collection.key, 

1224 "dataset_type_id": storage.dataset_type_id, 

1225 } 

1226 rows_to_delete = [] 

1227 rows_to_insert = [] 

1228 # Acquire a table lock to ensure there are no concurrent writes 

1229 # between the SELECT and the DELETE and INSERT queries based on it. 

1230 calibs_table = self._get_calibs_table(storage.dynamic_tables) 

1231 with self._db.transaction(lock=[calibs_table], savepoint=True): 

1232 # Find rows overlapping our inputs. 

1233 with query_func() as query: 

1234 query = query.join_dataset_search(dataset_type, [collection.name]) 

1235 if data_id_set is not None: 

1236 query = query.join_data_coordinates(data_id_set) 

1237 timespan_column = query.expression_factory[dataset_type.name].timespan 

1238 query = query.where(timespan_column.overlaps(timespan)) 

1239 result = query.general( 

1240 dataset_type.dimensions, 

1241 dataset_fields={dataset_type.name: {"dataset_id", "timespan"}}, 

1242 find_first=False, 

1243 )._with_added_dataset_field(dataset_type.name, "calib_pkey") 

1244 

1245 calib_pkey_key = f"{dataset_type.name}.calib_pkey" 

1246 dataset_id_key = f"{dataset_type.name}.dataset_id" 

1247 timespan_key = f"{dataset_type.name}.timespan" 

1248 for row in result.iter_tuples(): 

1249 rows_to_delete.append({"id": row.raw_row[calib_pkey_key]}) 

1250 # Construct the insert row(s) by copying the prototype row, 

1251 # then adding the dimension column values, then adding 

1252 # what's left of the timespan from that row after we 

1253 # subtract the given timespan. 

1254 new_insert_row = proto_insert_row.copy() 

1255 new_insert_row["dataset_id"] = row.raw_row[dataset_id_key] 

1256 for name, value in row.data_id.required.items(): 

1257 new_insert_row[name] = value 

1258 row_timespan = row.raw_row[timespan_key] 

1259 assert row_timespan is not None, "Field should have a NOT NULL constraint." 

1260 for diff_timespan in row_timespan.difference(timespan): 

1261 rows_to_insert.append( 

1262 TimespanReprClass.update(diff_timespan, result=new_insert_row.copy()) 

1263 ) 

1264 # Run the DELETE and INSERT queries. 

1265 self._db.delete(calibs_table, ["id"], *rows_to_delete) 

1266 self._db.insert(calibs_table, *rows_to_insert) 

1267 

1268 def make_joins_builder( 

1269 self, 

1270 dataset_type: DatasetType, 

1271 collections: Sequence[CollectionRecord], 

1272 fields: Set[qt.AnyDatasetFieldName], 

1273 is_union: bool = False, 

1274 ) -> SqlJoinsBuilder: 

1275 if (storage := self._find_storage(dataset_type.name)) is None: 1275 ↛ 1276line 1275 didn't jump to line 1276 because the condition on line 1275 was never true

1276 raise MissingDatasetTypeError(f"Dataset type {dataset_type.name!r} has not been registered.") 

1277 # This method largely mimics `make_relation`, but it uses the new query 

1278 # system primitives instead of the old one. In terms of the SQL 

1279 # queries it builds, there are two more main differences: 

1280 # 

1281 # - Collection and run columns are now string names rather than IDs. 

1282 # This insulates the query result-processing code from collection 

1283 # caching and the collection manager subclass details. 

1284 # 

1285 # - The subquery always has unique rows, which is achieved by using 

1286 # SELECT DISTINCT when necessary. 

1287 # 

1288 collection_types = {collection.type for collection in collections} 

1289 assert CollectionType.CHAINED not in collection_types, "CHAINED collections must be flattened." 

1290 # 

1291 # There are two kinds of table in play here: 

1292 # 

1293 # - the static dataset table (with the dataset ID, dataset type ID, 

1294 # run ID/name, and ingest date); 

1295 # 

1296 # - the dynamic tags/calibs table (with the dataset ID, dataset type 

1297 # type ID, collection ID/name, data ID, and possibly validity 

1298 # range). 

1299 # 

1300 # That means that we might want to return a query against either table 

1301 # or a JOIN of both, depending on which quantities the caller wants. 

1302 # But the data ID is always included, which means we'll always include 

1303 # the tags/calibs table and join in the static dataset table only if we 

1304 # need things from it that we can't get from the tags/calibs table. 

1305 # 

1306 # Note that it's important that we include a WHERE constraint on both 

1307 # tables for any column (e.g. dataset_type_id) that is in both when 

1308 # it's given explicitly; not doing can prevent the query planner from 

1309 # using very important indexes. At present, we don't include those 

1310 # redundant columns in the JOIN ON expression, however, because the 

1311 # FOREIGN KEY (and its index) are defined only on dataset_id. 

1312 columns = qt.ColumnSet(dataset_type.dimensions) 

1313 columns.drop_implied_dimension_keys() 

1314 fields_key: str | qt.AnyDatasetType = qt.ANY_DATASET if is_union else dataset_type.name 

1315 columns.dataset_fields[fields_key].update(fields) 

1316 tags_builder: SqlSelectBuilder | None = None 

1317 if collection_types != {CollectionType.CALIBRATION}: 

1318 # We'll need a subquery for the tags table if any of the given 

1319 # collections are not a CALIBRATION collection. This intentionally 

1320 # also fires when the list of collections is empty as a way to 

1321 # create a dummy subquery that we know will fail. 

1322 # We give the table an alias because it might appear multiple times 

1323 # in the same query, for different dataset types. 

1324 tags_table = self._get_tags_table(storage.dynamic_tables).alias( 

1325 f"{dataset_type.name}_tags{'_union' if is_union else ''}" 

1326 ) 

1327 tags_builder = self._finish_query_builder( 

1328 storage, 

1329 SqlJoinsBuilder(db=self._db, from_clause=tags_table).to_select_builder(columns), 

1330 [record for record in collections if record.type is not CollectionType.CALIBRATION], 

1331 fields, 

1332 fields_key, 

1333 ) 

1334 if "timespan" in fields: 

1335 tags_builder.joins.timespans[fields_key] = self._db.getTimespanRepresentation().fromLiteral( 

1336 Timespan(None, None) 

1337 ) 

1338 assert "calib_pkey" not in fields, ( 

1339 "Calibration primary key for internal use only on calibration collections." 

1340 ) 

1341 calibs_builder: SqlSelectBuilder | None = None 

1342 if CollectionType.CALIBRATION in collection_types: 

1343 # If at least one collection is a CALIBRATION collection, we'll 

1344 # need a subquery for the calibs table, and could include the 

1345 # timespan as a result or constraint. 

1346 calibs_table = self._get_calibs_table(storage.dynamic_tables).alias( 

1347 f"{dataset_type.name}_calibs{'_union' if is_union else ''}" 

1348 ) 

1349 calibs_builder = self._finish_query_builder( 

1350 storage, 

1351 SqlJoinsBuilder(db=self._db, from_clause=calibs_table).to_select_builder(columns), 

1352 [record for record in collections if record.type is CollectionType.CALIBRATION], 

1353 fields, 

1354 fields_key, 

1355 ) 

1356 if "timespan" in fields: 

1357 calibs_builder.joins.timespans[fields_key] = ( 

1358 self._db.getTimespanRepresentation().from_columns(calibs_table.columns) 

1359 ) 

1360 if "calib_pkey" in fields: 

1361 calibs_builder.joins.fields[fields_key]["calib_pkey"] = calibs_table.columns["id"] 

1362 

1363 # In calibration collections, we need timespan as well as data ID 

1364 # to ensure unique rows. 

1365 calibs_builder.distinct = calibs_builder.distinct and "timespan" not in fields 

1366 if tags_builder is not None: 

1367 if calibs_builder is not None: 

1368 # Need a UNION subquery. 

1369 return tags_builder.union_subquery([calibs_builder]) 

1370 else: 

1371 return tags_builder.into_joins_builder(postprocessing=None) 

1372 elif calibs_builder is not None: 

1373 return calibs_builder.into_joins_builder(postprocessing=None) 

1374 else: 

1375 raise AssertionError("Branch should be unreachable.") 

1376 

1377 def _finish_query_builder( 

1378 self, 

1379 storage: _DatasetRecordStorage, 

1380 sql_projection: SqlSelectBuilder, 

1381 collections: Sequence[CollectionRecord], 

1382 fields: Set[qt.AnyDatasetFieldName], 

1383 fields_key: str | qt.AnyDatasetType, 

1384 ) -> SqlSelectBuilder: 

1385 # This method plays the same role as _finish_single_relation in the new 

1386 # query system. It is called exactly one or two times by 

1387 # make_sql_builder, just as _finish_single_relation is called exactly 

1388 # one or two times by make_relation. See make_sql_builder comments for 

1389 # what's different. 

1390 assert sql_projection.joins.from_clause is not None 

1391 run_collections_only = all(record.type is CollectionType.RUN for record in collections) 

1392 sql_projection.joins.where( 

1393 sql_projection.joins.from_clause.c.dataset_type_id == storage.dataset_type_id 

1394 ) 

1395 dataset_id_col = sql_projection.joins.from_clause.c.dataset_id 

1396 collection_col = sql_projection.joins.from_clause.c[self._collections.getCollectionForeignKeyName()] 

1397 fields_provided = sql_projection.joins.fields[fields_key] 

1398 # We always constrain and optionally retrieve the collection(s) via the 

1399 # tags/calibs table. 

1400 if "collection_key" in fields: 

1401 sql_projection.joins.fields[fields_key]["collection_key"] = collection_col 

1402 if len(collections) == 1: 

1403 only_collection_record = collections[0] 

1404 sql_projection.joins.where(collection_col == only_collection_record.key) 

1405 if "collection" in fields: 

1406 fields_provided["collection"] = sqlalchemy.literal(only_collection_record.name).cast( 

1407 # This cast is necessary to ensure that Postgres knows the 

1408 # type of this column if it is used in an aggregate 

1409 # function. 

1410 sqlalchemy.String 

1411 ) 

1412 

1413 elif not collections: 

1414 sql_projection.joins.where(sqlalchemy.literal(False)) 

1415 if "collection" in fields: 

1416 fields_provided["collection"] = sqlalchemy.literal("NO COLLECTIONS") 

1417 else: 

1418 sql_projection.joins.where(collection_col.in_([collection.key for collection in collections])) 

1419 if "collection" in fields: 

1420 # Avoid a join to the collection table to get the name by using 

1421 # a CASE statement. The SQL will be a bit more verbose but 

1422 # more efficient. 

1423 fields_provided["collection"] = _create_case_expression_for_collections( 

1424 collections, collection_col 

1425 ) 

1426 # Add more column definitions, starting with the data ID. 

1427 sql_projection.joins.extract_dimensions(storage.dataset_type.dimensions.required) 

1428 # We can always get the dataset_id from the tags/calibs table, even if 

1429 # could also get it from the 'static' dataset table. 

1430 if "dataset_id" in fields: 

1431 fields_provided["dataset_id"] = dataset_id_col 

1432 

1433 # It's possible we now have everything we need, from just the 

1434 # tags/calibs table. The things we might need to get from the static 

1435 # dataset table are the run key and the ingest date. 

1436 need_static_table = False 

1437 need_collection_table = False 

1438 # Ingest date can only come from the static table. 

1439 if "ingest_date" in fields: 

1440 fields_provided["ingest_date"] = self._static.dataset.c.ingest_date 

1441 need_static_table = True 

1442 if "run" in fields: 

1443 if len(collections) == 1 and run_collections_only: 

1444 # If we are searching exactly one RUN collection, we 

1445 # know that if we find the dataset in that collection, 

1446 # then that's the datasets's run; we don't need to 

1447 # query for it. 

1448 # 

1449 fields_provided["run"] = sqlalchemy.literal(only_collection_record.name).cast( 

1450 # This cast is necessary to ensure that Postgres knows the 

1451 # type of this column if it is used in an aggregate 

1452 # function. 

1453 sqlalchemy.String 

1454 ) 

1455 elif run_collections_only: 

1456 # Once again we can avoid joining to the collection table by 

1457 # adding a CASE statement. 

1458 fields_provided["run"] = _create_case_expression_for_collections( 

1459 collections, self._static.dataset.c[self._run_key_column] 

1460 ) 

1461 need_static_table = True 

1462 else: 

1463 # Here we can't avoid a join to the collection table, because 

1464 # we might find a dataset via something other than its RUN 

1465 # collection. 

1466 # 

1467 # We have to defer adding the join until after we have joined 

1468 # in the static dataset table, because the ON clause involves 

1469 # the run collection from the static dataset table. Postgres 

1470 # cares about the join ordering (though SQLite does not.) 

1471 need_collection_table = True 

1472 need_static_table = True 

1473 if need_static_table: 

1474 # If we need the static table, join it in via dataset_id. We don't 

1475 # use SqlJoinsBuilder.join because we're joining on dataset ID, not 

1476 # dimensions. 

1477 sql_projection.joins.from_clause = sql_projection.joins.from_clause.join( 

1478 self._static.dataset, onclause=(dataset_id_col == self._static.dataset.c.id) 

1479 ) 

1480 # Also constrain dataset_type_id in static table in case that helps 

1481 # generate a better plan. We could also include this in the JOIN ON 

1482 # clause, but my guess is that that's a good idea IFF it's in the 

1483 # foreign key, and right now it isn't. 

1484 sql_projection.joins.where(self._static.dataset.c.dataset_type_id == storage.dataset_type_id) 

1485 if need_collection_table: 

1486 # Join the collection table to look up the RUN collection name 

1487 # associated with the dataset. 

1488 ( 

1489 fields_provided["run"], 

1490 sql_projection.joins.from_clause, 

1491 ) = self._collections.lookup_name_sql( 

1492 self._static.dataset.c[self._run_key_column], 

1493 sql_projection.joins.from_clause, 

1494 ) 

1495 

1496 sql_projection.distinct = ( 

1497 # If there are multiple collections, this subquery might have 

1498 # non-unique rows. 

1499 len(collections) > 1 and not fields 

1500 ) 

1501 return sql_projection 

1502 

1503 def refresh_collection_summaries(self, dataset_type: DatasetType) -> None: 

1504 # Docstring inherited. 

1505 if (storage := self._find_storage(dataset_type.name)) is None: 1505 ↛ 1506line 1505 didn't jump to line 1506 because the condition on line 1505 was never true

1506 raise MissingDatasetTypeError(f"Dataset type {dataset_type.name!r} has not been registered.") 

1507 with self._db.transaction(): 

1508 # The main issue here is consistency in the presence of concurrent 

1509 # updates (using default READ COMMITTED isolation). Regular clients 

1510 # only add to summary tables, and we want to avoid deleting what 

1511 # other concurrent transactions may add while we are in this 

1512 # transaction. This ordering of operations should guarantee it: 

1513 # - read collections for this dataset type from summary tables, 

1514 # - read collections for this dataset type from dataset tables 

1515 # (both tags and calibs), 

1516 # - whatever is in the first set but not in the second can be 

1517 # dropped from summary tables. 

1518 summary_collection_ids = set(self._summaries.get_collection_ids(storage.dataset_type_id)) 

1519 

1520 # Query datasets tables for associated collections. 

1521 column_name = self._collections.getCollectionForeignKeyName() 

1522 tags_table = self._get_tags_table(storage.dynamic_tables) 

1523 query: sqlalchemy.sql.expression.SelectBase = ( 

1524 sqlalchemy.select(tags_table.columns[column_name]) 

1525 .where(tags_table.columns.dataset_type_id == storage.dataset_type_id) 

1526 .distinct() 

1527 ) 

1528 if dataset_type.isCalibration(): 

1529 calibs_table = self._get_calibs_table(storage.dynamic_tables) 

1530 query2 = ( 

1531 sqlalchemy.select(calibs_table.columns[column_name]) 

1532 .where(calibs_table.columns.dataset_type_id == storage.dataset_type_id) 

1533 .distinct() 

1534 ) 

1535 query = sqlalchemy.sql.expression.union(query, query2) 

1536 

1537 with self._db.query(query) as result: 

1538 collection_ids = set(result.scalars()) 

1539 

1540 collections_to_delete = summary_collection_ids - collection_ids 

1541 self._summaries.delete_collections(storage.dataset_type_id, collections_to_delete) 

1542 

1543 def _get_tags_table(self, table: DynamicTables) -> sqlalchemy.Table: 

1544 return table.tags(self._db, type(self._collections), self._cache.tables) 

1545 

1546 def _get_calibs_table(self, table: DynamicTables) -> sqlalchemy.Table: 

1547 return table.calibs(self._db, type(self._collections), self._cache.tables) 

1548 

1549 

1550def _create_case_expression_for_collections( 

1551 collections: Iterable[CollectionRecord], id_column: sqlalchemy.ColumnElement 

1552) -> sqlalchemy.ColumnElement: 

1553 """Return a SQLAlchemy Case expression that converts collection IDs to 

1554 collection names for the given set of collections. 

1555 

1556 Parameters 

1557 ---------- 

1558 collections : `~collections.abc.Iterable` [ `CollectionRecord` ] 

1559 List of collections to include in conversion table. This should be an 

1560 exhaustive list of collections that could appear in `id_column`. 

1561 id_column : `sqlalchemy.ColumnElement` 

1562 The column containing the collection ID that we want to convert to a 

1563 collection name. 

1564 """ 

1565 mapping = {record.key: record.name for record in collections} 

1566 if not mapping: 

1567 # SQLAlchemy does not correctly handle an empty mapping in case() -- it 

1568 # crashes when trying to compile the expression with an 

1569 # "AttributeError('NoneType' object has no attribute 'dialect_impl')" 

1570 # when trying to access the 'type' property of the Case object. If you 

1571 # explicitly specify a type via type_coerce it instead generates 

1572 # invalid SQL syntax. 

1573 # 

1574 # We can end up with empty mappings here in certain "doomed query" edge 

1575 # cases, e.g. we start with a list of valid collections but they are 

1576 # all filtered out by higher-level code on the basis of collection 

1577 # summaries. 

1578 return sqlalchemy.cast(sqlalchemy.null(), sqlalchemy.String) 

1579 

1580 return sqlalchemy.case(mapping, value=id_column) 

1581 

1582 

1583def _ensure_dimension_groups_match(dataset_types: Iterable[DatasetType]) -> DimensionGroup: 

1584 dimensions = set(dt.dimensions for dt in dataset_types) 

1585 assert len(dimensions) > 0, "At least one dataset type is required" 

1586 if len(dimensions) != 1: 1586 ↛ 1587line 1586 didn't jump to line 1587 because the condition on line 1586 was never true

1587 raise DatasetTypeError( 

1588 "Dataset types have more than one dimension group.\n" 

1589 f"Dataset types: {dataset_types}\n" 

1590 f"Dimension groups: {dimensions}" 

1591 ) 

1592 return dimensions.pop()