Coverage for python / lsst / daf / butler / registry / interfaces / _database.py: 21%

462 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-20 01:25 -0800

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <http://www.gnu.org/licenses/>. 

27from __future__ import annotations 

28 

29from ... import ddl, time_utils 

30 

31__all__ = [ 

32 "Database", 

33 "DatabaseConflictError", 

34 "DatabaseInsertMode", 

35 "DatabaseMetadata", 

36 "ReadOnlyDatabaseError", 

37 "SchemaAlreadyDefinedError", 

38 "StaticTablesContext", 

39] 

40 

41import enum 

42import os 

43import sys 

44import uuid 

45import warnings 

46from abc import ABC, abstractmethod 

47from collections import defaultdict 

48from collections.abc import Callable, Iterable, Iterator, Sequence 

49from contextlib import contextmanager 

50from threading import Lock 

51from typing import Any, cast, final 

52 

53import astropy.time 

54import sqlalchemy 

55 

56from ..._named import NamedValueAbstractSet 

57from ...name_shrinker import NameShrinker 

58from ...timespan_database_representation import TimespanDatabaseRepresentation 

59from .._exceptions import ConflictingDefinitionError 

60from ._database_explain import get_query_plan 

61 

62 

63class DatabaseInsertMode(enum.Enum): 

64 """Mode options available for inserting database records.""" 

65 

66 INSERT = enum.auto() 

67 """Insert records, failing if they already exist.""" 

68 

69 REPLACE = enum.auto() 

70 """Replace records, overwriting existing.""" 

71 

72 ENSURE = enum.auto() 

73 """Insert records, skipping any that already exist.""" 

74 

75 

76# TODO: method is called with list[ReflectedColumn] in SA 2, and 

77# ReflectedColumn does not exist in 1.4. 

78def _checkExistingTableDefinition(name: str, spec: ddl.TableSpec, inspection: list) -> None: 

79 """Test that the definition of a table in a `ddl.TableSpec` and from 

80 database introspection are consistent. 

81 

82 Parameters 

83 ---------- 

84 name : `str` 

85 Name of the table (only used in error messages). 

86 spec : `ddl.TableSpec` 

87 Specification of the table. 

88 inspection : `dict` 

89 Dictionary returned by 

90 `sqlalchemy.engine.reflection.Inspector.get_columns`. 

91 

92 Raises 

93 ------ 

94 DatabaseConflictError 

95 Raised if the definitions are inconsistent. 

96 """ 

97 columnNames = [c["name"] for c in inspection] 

98 if spec.fields.names != set(columnNames): 

99 raise DatabaseConflictError( 

100 f"Table '{name}' exists but is defined differently in the database; " 

101 f"specification has columns {list(spec.fields.names)}, while the " 

102 f"table in the database has {columnNames}." 

103 ) 

104 

105 

106class ReadOnlyDatabaseError(RuntimeError): 

107 """Exception raised when a write operation is called on a read-only 

108 `Database`. 

109 """ 

110 

111 

112class DatabaseConflictError(ConflictingDefinitionError): 

113 """Exception raised when database content (row values or schema entities) 

114 are inconsistent with what this client expects. 

115 """ 

116 

117 

118class SchemaAlreadyDefinedError(RuntimeError): 

119 """Exception raised when trying to initialize database schema when some 

120 tables already exist. 

121 """ 

122 

123 

124class StaticTablesContext: 

125 """Helper class used to declare the static schema for a registry layer 

126 in a database. 

127 

128 An instance of this class is returned by `Database.declareStaticTables`, 

129 which should be the only way it should be constructed. 

130 

131 Parameters 

132 ---------- 

133 db : `Database` 

134 The database. 

135 connection : `sqlalchemy.engine.Connection` 

136 The connection object. 

137 """ 

138 

139 def __init__(self, db: Database, connection: sqlalchemy.engine.Connection): 

140 self._db = db 

141 self._inspector = sqlalchemy.inspect(connection) 

142 self._tableNames = frozenset(self._inspector.get_table_names(schema=self._db.namespace)) 

143 self._initializers: list[Callable[[Database], None]] = [] 

144 

145 def addTable(self, name: str, spec: ddl.TableSpec) -> sqlalchemy.schema.Table: 

146 """Add a new table to the schema, returning its sqlalchemy 

147 representation. 

148 

149 Parameters 

150 ---------- 

151 name : `str` 

152 The name of the table. 

153 spec : `ddl.TableSpec` 

154 The specification of the table. 

155 

156 Returns 

157 ------- 

158 table : `sqlalchemy.schema.Table` 

159 The created table. 

160 

161 Notes 

162 ----- 

163 The new table may not actually be created until the end of the 

164 context created by `Database.declareStaticTables`, allowing tables 

165 to be declared in any order even in the presence of foreign key 

166 relationships. 

167 """ 

168 metadata = self._db._metadata 

169 assert metadata is not None, "Guaranteed by context manager that returns this object." 

170 return metadata.add_table(self._db, name, spec) 

171 

172 def addTableTuple(self, specs: tuple[ddl.TableSpec, ...]) -> tuple[sqlalchemy.schema.Table, ...]: 

173 """Add a named tuple of tables to the schema, returning their 

174 SQLAlchemy representations in a named tuple of the same type. 

175 

176 The new tables may not actually be created until the end of the 

177 context created by `Database.declareStaticTables`, allowing tables 

178 to be declared in any order even in the presence of foreign key 

179 relationships. 

180 

181 Parameters 

182 ---------- 

183 specs : `tuple` of `ddl.TableSpec` 

184 Specifications of multiple tables. 

185 

186 Returns 

187 ------- 

188 tables : `tuple` of `sqlalchemy.schema.Table` 

189 All the tables created. 

190 

191 Notes 

192 ----- 

193 ``specs`` *must* be an instance of a type created by 

194 `collections.namedtuple`, not just regular tuple, and the returned 

195 object is guaranteed to be the same. Because `~collections.namedtuple` 

196 is just a factory for `type` objects, not an actual type itself, 

197 we cannot represent this with type annotations. 

198 """ 

199 return specs._make( # type: ignore 

200 self.addTable(name, spec) 

201 for name, spec in zip(specs._fields, specs, strict=True) # type: ignore 

202 ) 

203 

204 def addInitializer(self, initializer: Callable[[Database], None]) -> None: 

205 """Add a method that does one-time initialization of a database. 

206 

207 Initialization can mean anything that changes state of a database 

208 and needs to be done exactly once after database schema was created. 

209 An example for that could be population of schema attributes. 

210 

211 Parameters 

212 ---------- 

213 initializer : `~collections.abc.Callable` 

214 Method of a single argument which is a `Database` instance. 

215 """ 

216 self._initializers.append(initializer) 

217 

218 

219class Database(ABC): 

220 """An abstract interface that represents a particular database engine's 

221 representation of a single schema/namespace/database. 

222 

223 Parameters 

224 ---------- 

225 origin : `int` 

226 An integer ID that should be used as the default for any datasets, 

227 quanta, or other entities that use a (autoincrement, origin) compound 

228 primary key. 

229 engine : `sqlalchemy.engine.Engine` 

230 The SQLAlchemy engine for this `Database`. 

231 namespace : `str`, optional 

232 Name of the schema or namespace this instance is associated with. 

233 This is passed as the ``schema`` argument when constructing a 

234 `sqlalchemy.schema.MetaData` instance. We use ``namespace`` instead to 

235 avoid confusion between "schema means namespace" and "schema means 

236 table definitions". 

237 metadata : `sqlalchemy.schema.MetaData`, optional 

238 Object representing the tables and other schema entities. If not 

239 provided, will be generated during the next call to 

240 ``declareStaticTables``. 

241 allow_temporary_tables : `bool`, optional 

242 If `True`, database operations will be allowed to use temporary tables. 

243 If `False`, other SQL constructs will be used instead of temporary 

244 tables when possible. 

245 

246 Notes 

247 ----- 

248 `Database` requires all write operations to go through its special named 

249 methods. Our write patterns are sufficiently simple that we don't really 

250 need the full flexibility of SQL insert/update/delete syntax, and we need 

251 non-standard (but common) functionality in these operations sufficiently 

252 often that it seems worthwhile to provide our own generic API. 

253 

254 In contrast, `Database.query` allows arbitrary ``SELECT`` queries (via 

255 their SQLAlchemy representation) to be run, as we expect these to require 

256 significantly more sophistication while still being limited to standard 

257 SQL. 

258 

259 `Database` itself has several underscore-prefixed attributes: 

260 

261 - ``_engine``: SQLAlchemy object representing its engine. 

262 - ``_connection``: method returning a context manager for 

263 `sqlalchemy.engine.Connection` object. 

264 - ``_metadata``: the `sqlalchemy.schema.MetaData` object representing 

265 the tables and other schema entities. 

266 

267 These are considered protected (derived classes may access them, but other 

268 code should not), and read-only, aside from executing SQL via 

269 ``_connection``. 

270 """ 

271 

272 def __init__( 

273 self, 

274 *, 

275 origin: int, 

276 engine: sqlalchemy.engine.Engine, 

277 namespace: str | None = None, 

278 metadata: DatabaseMetadata | None = None, 

279 allow_temporary_tables: bool = True, 

280 ): 

281 self.origin = origin 

282 self.name_shrinker = NameShrinker(engine.dialect.max_identifier_length) 

283 self.namespace = namespace 

284 self._engine = engine 

285 self._session_connection: sqlalchemy.engine.Connection | None = None 

286 self._temp_tables: set[str] = set() 

287 self._metadata = metadata 

288 self._allow_temporary_tables = allow_temporary_tables 

289 

290 def dispose(self) -> None: 

291 """Close all open database connections held by this `Database` 

292 instance. 

293 """ 

294 self._engine.dispose() 

295 

296 def __repr__(self) -> str: 

297 # Rather than try to reproduce all the parameters used to create 

298 # the object, instead report the more useful information of the 

299 # connection URL. 

300 if self._engine.url.password is not None: 

301 uri = str(self._engine.url.set(password="***")) 

302 else: 

303 uri = str(self._engine.url) 

304 if self.namespace: 

305 uri += f"#{self.namespace}" 

306 return f'{type(self).__name__}("{uri}")' 

307 

308 @classmethod 

309 def makeDefaultUri(cls, root: str) -> str | None: 

310 """Create a default connection URI appropriate for the given root 

311 directory, or `None` if there can be no such default. 

312 

313 Parameters 

314 ---------- 

315 root : `str` 

316 Root string to use to build connection URI. 

317 

318 Returns 

319 ------- 

320 uri : `str` or `None` 

321 The URI string or `None`. 

322 """ 

323 return None 

324 

325 @classmethod 

326 def fromUri( 

327 cls, 

328 uri: str | sqlalchemy.engine.URL, 

329 *, 

330 origin: int, 

331 namespace: str | None = None, 

332 writeable: bool = True, 

333 allow_temporary_tables: bool = True, 

334 ) -> Database: 

335 """Construct a database from a SQLAlchemy URI. 

336 

337 Parameters 

338 ---------- 

339 uri : `str` or `sqlalchemy.engine.URL` 

340 A SQLAlchemy URI connection string. 

341 origin : `int` 

342 An integer ID that should be used as the default for any datasets, 

343 quanta, or other entities that use a (autoincrement, origin) 

344 compound primary key. 

345 namespace : `str`, optional 

346 A database namespace (i.e. schema) the new instance should be 

347 associated with. If `None` (default), the namespace (if any) is 

348 inferred from the URI. 

349 writeable : `bool`, optional 

350 If `True`, allow write operations on the database, including 

351 ``CREATE TABLE``. 

352 allow_temporary_tables : `bool`, optional 

353 If `True`, database operations will be allowed to use temporary 

354 tables. 

355 If `False`, other SQL constructs will be used instead of temporary 

356 tables when possible. 

357 

358 Returns 

359 ------- 

360 db : `Database` 

361 A new `Database` instance. 

362 """ 

363 db = cls.fromEngine( 

364 cls.makeEngine(uri, writeable=writeable), origin=origin, namespace=namespace, writeable=writeable 

365 ) 

366 db._allow_temporary_tables = allow_temporary_tables 

367 return db 

368 

369 @abstractmethod 

370 def clone(self) -> Database: 

371 """Make an independent copy of this `Database` object. 

372 

373 Returns 

374 ------- 

375 db : `Database` 

376 A new `Database` instance with the same configuration as this 

377 instance. 

378 """ 

379 raise NotImplementedError() 

380 

381 @classmethod 

382 @abstractmethod 

383 def makeEngine( 

384 cls, uri: str | sqlalchemy.engine.URL, *, writeable: bool = True 

385 ) -> sqlalchemy.engine.Engine: 

386 """Create a `sqlalchemy.engine.Engine` from a SQLAlchemy URI. 

387 

388 Parameters 

389 ---------- 

390 uri : `str` or `sqlalchemy.engine.URL` 

391 A SQLAlchemy URI connection string. 

392 writeable : `bool`, optional 

393 If `True`, allow write operations on the database, including 

394 ``CREATE TABLE``. 

395 

396 Returns 

397 ------- 

398 engine : `sqlalchemy.engine.Engine` 

399 A database engine. 

400 

401 Notes 

402 ----- 

403 Subclasses that support other ways to connect to a database are 

404 encouraged to add optional arguments to their implementation of this 

405 method, as long as they maintain compatibility with the base class 

406 call signature. 

407 """ 

408 raise NotImplementedError() 

409 

410 @classmethod 

411 @abstractmethod 

412 def fromEngine( 

413 cls, 

414 engine: sqlalchemy.engine.Engine, 

415 *, 

416 origin: int, 

417 namespace: str | None = None, 

418 writeable: bool = True, 

419 ) -> Database: 

420 """Create a new `Database` from an existing `sqlalchemy.engine.Engine`. 

421 

422 Parameters 

423 ---------- 

424 engine : `sqlalchemy.engine.Engine` 

425 The engine for the database. May be shared between `Database` 

426 instances. 

427 origin : `int` 

428 An integer ID that should be used as the default for any datasets, 

429 quanta, or other entities that use a (autoincrement, origin) 

430 compound primary key. 

431 namespace : `str`, optional 

432 A different database namespace (i.e. schema) the new instance 

433 should be associated with. If `None` (default), the namespace 

434 (if any) is inferred from the connection. 

435 writeable : `bool`, optional 

436 If `True`, allow write operations on the database, including 

437 ``CREATE TABLE``. 

438 

439 Returns 

440 ------- 

441 db : `Database` 

442 A new `Database` instance. 

443 

444 Notes 

445 ----- 

446 This method allows different `Database` instances to share the same 

447 engine, which is desirable when they represent different namespaces 

448 can be queried together. 

449 """ 

450 raise NotImplementedError() 

451 

452 @final 

453 @contextmanager 

454 def session(self) -> Iterator[None]: 

455 """Return a context manager that represents a session (persistent 

456 connection to a database). 

457 

458 Returns 

459 ------- 

460 context : `AbstractContextManager` [ `None` ] 

461 A context manager that does not return a value when entered. 

462 

463 Notes 

464 ----- 

465 This method should be used when a sequence of read-only SQL operations 

466 will be performed in rapid succession *without* a requirement that they 

467 yield consistent results in the presence of concurrent writes (or, more 

468 rarely, when conflicting concurrent writes are rare/impossible and the 

469 session will be open long enough that a transaction is inadvisable). 

470 """ 

471 with self._session(): 

472 yield 

473 

474 @final 

475 @contextmanager 

476 def transaction( 

477 self, 

478 *, 

479 interrupting: bool = False, 

480 savepoint: bool = False, 

481 lock: Iterable[sqlalchemy.schema.Table] = (), 

482 for_temp_tables: bool = False, 

483 ) -> Iterator[None]: 

484 """Return a context manager that represents a transaction. 

485 

486 Parameters 

487 ---------- 

488 interrupting : `bool`, optional 

489 If `True` (`False` is default), this transaction block may not be 

490 nested within an outer one, and attempting to do so is a logic 

491 (i.e. assertion) error. 

492 savepoint : `bool`, optional 

493 If `True` (`False` is default), create a `SAVEPOINT`, allowing 

494 exceptions raised by the database (e.g. due to constraint 

495 violations) during this transaction's context to be caught outside 

496 it without also rolling back all operations in an outer transaction 

497 block. If `False`, transactions may still be nested, but a 

498 rollback may be generated at any level and affects all levels, and 

499 commits are deferred until the outermost block completes. If any 

500 outer transaction block was created with ``savepoint=True``, all 

501 inner blocks will be as well (regardless of the actual value 

502 passed). This has no effect if this is the outermost transaction. 

503 lock : `~collections.abc.Iterable` [ `sqlalchemy.schema.Table` ], \ 

504 optional 

505 A list of tables to lock for the duration of this transaction. 

506 These locks are guaranteed to prevent concurrent writes and allow 

507 this transaction (only) to acquire the same locks (others should 

508 block), but only prevent concurrent reads if the database engine 

509 requires that in order to block concurrent writes. 

510 for_temp_tables : `bool`, optional 

511 If `True`, this transaction may involve creating temporary tables. 

512 

513 Returns 

514 ------- 

515 context : `AbstractContextManager` [ `None` ] 

516 A context manager that commits the transaction when it is exited 

517 without error and rolls back the transactoin when it is exited via 

518 an exception. 

519 

520 Notes 

521 ----- 

522 All transactions on a connection managed by one or more `Database` 

523 instances _must_ go through this method, or transaction state will not 

524 be correctly managed. 

525 """ 

526 with self._transaction( 

527 interrupting=interrupting, savepoint=savepoint, lock=lock, for_temp_tables=for_temp_tables 

528 ): 

529 yield 

530 

531 @contextmanager 

532 def temporary_table( 

533 self, spec: ddl.TableSpec, name: str | None = None 

534 ) -> Iterator[sqlalchemy.schema.Table]: 

535 """Return a context manager that creates and then drops a temporary 

536 table. 

537 

538 Parameters 

539 ---------- 

540 spec : `ddl.TableSpec` 

541 Specification for the columns. Unique and foreign key constraints 

542 may be ignored. 

543 name : `str`, optional 

544 If provided, the name of the SQL construct. If not provided, an 

545 opaque but unique identifier is generated. 

546 

547 Returns 

548 ------- 

549 context : `AbstractContextManager` [ `sqlalchemy.schema.Table` ] 

550 A context manager that returns a SQLAlchemy representation of the 

551 temporary table when entered. 

552 

553 Notes 

554 ----- 

555 Temporary tables may be created, dropped, and written to even in 

556 read-only databases - at least according to the Python-level 

557 protections in the `Database` classes. Server permissions may say 

558 otherwise, but in that case they probably need to be modified to 

559 support the full range of expected read-only butler behavior. 

560 """ 

561 assert self._metadata is not None, "Static tables must be created before temporary tables" 

562 if not self.supports_temporary_tables: 

563 raise ReadOnlyDatabaseError("Creation of temporary tables is not supported by this database.") 

564 with self._session() as connection: 

565 table = self._make_temporary_table(connection, spec=spec, name=name) 

566 self._temp_tables.add(table.key) 

567 try: 

568 yield table 

569 finally: 

570 self._temp_tables.remove(table.key) 

571 self._metadata.remove_table(table.name) 

572 

573 @contextmanager 

574 def _session(self) -> Iterator[sqlalchemy.engine.Connection]: 

575 """Protected implementation for `session` that actually returns the 

576 connection. 

577 

578 This method is for internal `Database` calls that need the actual 

579 SQLAlchemy connection object. It should be overridden by subclasses 

580 instead of `session` itself. 

581 

582 Returns 

583 ------- 

584 context : `AbstractContextManager` [ `sqlalchemy.engine.Connection` ] 

585 A context manager that returns a SQLALchemy connection when 

586 entered. 

587 

588 """ 

589 if self._session_connection is not None: 

590 # session already started, just reuse that 

591 yield self._session_connection 

592 else: 

593 try: 

594 # open new connection and close it when done 

595 self._session_connection = self._engine.connect() 

596 yield self._session_connection 

597 finally: 

598 if self._session_connection is not None: 

599 self._session_connection.close() 

600 self._session_connection = None 

601 # Temporary tables only live within session 

602 self._temp_tables = set() 

603 

604 @contextmanager 

605 def _transaction( 

606 self, 

607 *, 

608 interrupting: bool = False, 

609 savepoint: bool = False, 

610 lock: Iterable[sqlalchemy.schema.Table] = (), 

611 for_temp_tables: bool = False, 

612 ) -> Iterator[tuple[bool, sqlalchemy.engine.Connection]]: 

613 """Protected implementation for `transaction` that actually returns the 

614 connection and whether this is a new outermost transaction. 

615 

616 This method is for internal `Database` calls that need the actual 

617 SQLAlchemy connection object. It should be overridden by subclasses 

618 instead of `transaction` itself. 

619 

620 Parameters 

621 ---------- 

622 interrupting : `bool`, optional 

623 If `True` (`False` is default), this transaction block may not be 

624 nested without an outer one, and attempting to do so is a logic 

625 (i.e. assertion) error. 

626 savepoint : `bool`, optional 

627 If `True` (`False` is default), create a `SAVEPOINT`, allowing 

628 exceptions raised by the database (e.g. due to constraint 

629 violations) during this transaction's context to be caught outside 

630 it without also rolling back all operations in an outer transaction 

631 block. If `False`, transactions may still be nested, but a 

632 rollback may be generated at any level and affects all levels, and 

633 commits are deferred until the outermost block completes. If any 

634 outer transaction block was created with ``savepoint=True``, all 

635 inner blocks will be as well (regardless of the actual value 

636 passed). This has no effect if this is the outermost transaction. 

637 lock : `~collections.abc.Iterable` [ `sqlalchemy.schema.Table` ], \ 

638 optional 

639 A list of tables to lock for the duration of this transaction. 

640 These locks are guaranteed to prevent concurrent writes and allow 

641 this transaction (only) to acquire the same locks (others should 

642 block), but only prevent concurrent reads if the database engine 

643 requires that in order to block concurrent writes. 

644 for_temp_tables : `bool`, optional 

645 If `True`, this transaction may involve creating temporary tables. 

646 

647 Returns 

648 ------- 

649 context : `AbstractContextManager` [ `tuple` [ `bool`, 

650 `sqlalchemy.engine.Connection` ] ] 

651 A context manager that commits the transaction when it is exited 

652 without error and rolls back the transactoin when it is exited via 

653 an exception. When entered, it returns a tuple of: 

654 

655 - ``is_new`` (`bool`): whether this is a new (outermost) 

656 transaction; 

657 - ``connection`` (`sqlalchemy.engine.Connection`): the connection. 

658 """ 

659 with self._session() as connection: 

660 already_in_transaction = self.isInTransaction() 

661 assert not (interrupting and already_in_transaction), ( 

662 "Logic error in transaction nesting: an operation that would " 

663 "interrupt the active transaction context has been requested." 

664 ) 

665 savepoint = savepoint or connection.in_nested_transaction() 

666 trans: sqlalchemy.engine.Transaction | None 

667 if already_in_transaction: 

668 if savepoint: 

669 trans = connection.begin_nested() 

670 else: 

671 # Nested non-savepoint transactions don't do anything. 

672 trans = None 

673 else: 

674 # Use a regular (non-savepoint) transaction always for the 

675 # outermost context. 

676 trans = connection.begin() 

677 self._lockTables(connection, lock) 

678 try: 

679 yield not already_in_transaction, connection 

680 if trans is not None: 

681 trans.commit() 

682 except BaseException: 

683 if trans is not None: 

684 trans.rollback() 

685 raise 

686 

687 @abstractmethod 

688 def _lockTables( 

689 self, connection: sqlalchemy.engine.Connection, tables: Iterable[sqlalchemy.schema.Table] = () 

690 ) -> None: 

691 """Acquire locks on the given tables. 

692 

693 This is an implementation hook for subclasses, called by `transaction`. 

694 It should not be called directly by other code. 

695 

696 Parameters 

697 ---------- 

698 connection : `sqlalchemy.engine.Connection` 

699 Database connection object. It is guaranteed that transaction is 

700 already in a progress for this connection. 

701 tables : `~collections.abc.Iterable` [ `sqlalchemy.schema.Table` ], \ 

702 optional 

703 A list of tables to lock for the duration of this transaction. 

704 These locks are guaranteed to prevent concurrent writes and allow 

705 this transaction (only) to acquire the same locks (others should 

706 block), but only prevent concurrent reads if the database engine 

707 requires that in order to block concurrent writes. 

708 """ 

709 raise NotImplementedError() 

710 

711 def isTableWriteable(self, table: sqlalchemy.schema.Table) -> bool: 

712 """Check whether a table is writeable, either because the database 

713 connection is read-write or the table is a temporary table. 

714 

715 Parameters 

716 ---------- 

717 table : `sqlalchemy.schema.Table` 

718 SQLAlchemy table object to check. 

719 

720 Returns 

721 ------- 

722 writeable : `bool` 

723 Whether this table is writeable. 

724 """ 

725 return self.isWriteable() or table.key in self._temp_tables 

726 

727 def assertTableWriteable(self, table: sqlalchemy.schema.Table, msg: str) -> None: 

728 """Raise if the given table is not writeable, either because the 

729 database connection is read-write or the table is a temporary table. 

730 

731 Parameters 

732 ---------- 

733 table : `sqlalchemy.schema.Table` 

734 SQLAlchemy table object to check. 

735 msg : `str`, optional 

736 If provided, raise `ReadOnlyDatabaseError` instead of returning 

737 `False`, with this message. 

738 """ 

739 if not self.isTableWriteable(table): 

740 raise ReadOnlyDatabaseError(msg) 

741 

742 @contextmanager 

743 def declareStaticTables(self, *, create: bool) -> Iterator[StaticTablesContext]: 

744 """Return a context manager in which the database's static DDL schema 

745 can be declared. 

746 

747 Parameters 

748 ---------- 

749 create : `bool` 

750 If `True`, attempt to create all tables at the end of the context. 

751 If `False`, they will be assumed to already exist. 

752 

753 Returns 

754 ------- 

755 schema : `StaticTablesContext` 

756 A helper object that is used to add new tables. 

757 

758 Raises 

759 ------ 

760 ReadOnlyDatabaseError 

761 Raised if ``create`` is `True`, `Database.isWriteable` is `False`, 

762 and one or more declared tables do not already exist. 

763 

764 Examples 

765 -------- 

766 Given a `Database` instance ``db``:: 

767 

768 with db.declareStaticTables(create=True) as schema: 

769 schema.addTable("table1", TableSpec(...)) 

770 schema.addTable("table2", TableSpec(...)) 

771 

772 Notes 

773 ----- 

774 A database's static DDL schema must be declared before any dynamic 

775 tables are managed via calls to `ensureTableExists` or 

776 `getExistingTable`. The order in which static schema tables are added 

777 inside the context block is unimportant; they will automatically be 

778 sorted and added in an order consistent with their foreign key 

779 relationships. 

780 """ 

781 if create and not self.isWriteable(): 

782 raise ReadOnlyDatabaseError(f"Cannot create tables in read-only database {self}.") 

783 

784 self._metadata = DatabaseMetadata(self.namespace) 

785 try: 

786 with self._transaction() as (_, connection): 

787 context = StaticTablesContext(self, connection) 

788 if create and context._tableNames: 

789 # Looks like database is already initalized, to avoid 

790 # danger of modifying/destroying valid schema we refuse to 

791 # do anything in this case 

792 raise SchemaAlreadyDefinedError(f"Cannot create tables in non-empty database {self}.") 

793 yield context 

794 if create: 

795 if ( 

796 self.namespace is not None 

797 and self.namespace not in context._inspector.get_schema_names() 

798 ): 

799 connection.execute(sqlalchemy.schema.CreateSchema(self.namespace)) 

800 # In our tables we have columns that make use of sqlalchemy 

801 # Sequence objects. There is currently a bug in sqlalchemy 

802 # that causes a deprecation warning to be thrown on a 

803 # property of the Sequence object when the repr for the 

804 # sequence is created. Here a filter is used to catch these 

805 # deprecation warnings when tables are created. 

806 with warnings.catch_warnings(): 

807 warnings.simplefilter("ignore", category=sqlalchemy.exc.SADeprecationWarning) 

808 self._metadata.create_all(connection) 

809 # call all initializer methods sequentially 

810 for init in context._initializers: 

811 init(self) 

812 except BaseException: 

813 self._metadata = None 

814 raise 

815 

816 @abstractmethod 

817 def isWriteable(self) -> bool: 

818 """Return `True` if this database can be modified by this client.""" 

819 raise NotImplementedError() 

820 

821 def isInTransaction(self) -> bool: 

822 """Return `True` if there is currently a database connection open with 

823 an active transaction; `False` otherwise. 

824 """ 

825 session = self._session_connection 

826 return session is not None and session.in_transaction() 

827 

828 @abstractmethod 

829 def __str__(self) -> str: 

830 """Return a human-readable identifier for this `Database`, including 

831 any namespace or schema that identifies its names within a `Registry`. 

832 """ 

833 raise NotImplementedError() 

834 

835 @property 

836 def dialect(self) -> sqlalchemy.engine.Dialect: 

837 """The SQLAlchemy dialect for this database engine 

838 (`sqlalchemy.engine.Dialect`). 

839 """ 

840 return self._engine.dialect 

841 

842 def shrinkDatabaseEntityName(self, original: str) -> str: 

843 """Return a version of the given name that fits within this database 

844 engine's length limits for table, constraint, indexes, and sequence 

845 names. 

846 

847 Implementations should not assume that simple truncation is safe, 

848 because multiple long names often begin with the same prefix. 

849 

850 The default implementation simply returns the given name. 

851 

852 Parameters 

853 ---------- 

854 original : `str` 

855 The original name. 

856 

857 Returns 

858 ------- 

859 shrunk : `str` 

860 The new, possibly shortened name. 

861 """ 

862 return original 

863 

864 def expandDatabaseEntityName(self, shrunk: str) -> str: 

865 """Retrieve the original name for a database entity that was too long 

866 to fit within the database engine's limits. 

867 

868 Parameters 

869 ---------- 

870 shrunk : `str` 

871 The original name. 

872 

873 Returns 

874 ------- 

875 shrunk : `str` 

876 The new, possibly shortened name. 

877 """ 

878 return shrunk 

879 

880 def _makeColumnConstraints(self, table: str, spec: ddl.FieldSpec) -> list[sqlalchemy.CheckConstraint]: 

881 """Create constraints based on this spec. 

882 

883 Parameters 

884 ---------- 

885 table : `str` 

886 Name of the table this column is being added to. 

887 spec : `FieldSpec` 

888 Specification for the field to be added. 

889 

890 Returns 

891 ------- 

892 constraint : `list` of `sqlalchemy.CheckConstraint` 

893 Constraint added for this column. 

894 """ 

895 # By default we return no additional constraints 

896 return [] 

897 

898 def _convertFieldSpec( 

899 self, table: str, spec: ddl.FieldSpec, metadata: sqlalchemy.MetaData, **kwargs: Any 

900 ) -> sqlalchemy.schema.Column: 

901 """Convert a `FieldSpec` to a `sqlalchemy.schema.Column`. 

902 

903 Parameters 

904 ---------- 

905 table : `str` 

906 Name of the table this column is being added to. 

907 spec : `FieldSpec` 

908 Specification for the field to be added. 

909 metadata : `sqlalchemy.MetaData` 

910 SQLAlchemy representation of the DDL schema this field's table is 

911 being added to. 

912 **kwargs 

913 Additional keyword arguments to forward to the 

914 `sqlalchemy.schema.Column` constructor. This is provided to make 

915 it easier for derived classes to delegate to ``super()`` while 

916 making only minor changes. 

917 

918 Returns 

919 ------- 

920 column : `sqlalchemy.schema.Column` 

921 SQLAlchemy representation of the field. 

922 """ 

923 args = [] 

924 if spec.autoincrement: 

925 # Generate a sequence to use for auto incrementing for databases 

926 # that do not support it natively. This will be ignored by 

927 # sqlalchemy for databases that do support it. 

928 args.append( 

929 sqlalchemy.Sequence( 

930 self.shrinkDatabaseEntityName(f"{table}_seq_{spec.name}"), metadata=metadata 

931 ) 

932 ) 

933 assert spec.doc is None or isinstance(spec.doc, str), f"Bad doc for {table}.{spec.name}." 

934 return sqlalchemy.schema.Column( 

935 spec.name, 

936 spec.getSizedColumnType(), 

937 *args, 

938 nullable=spec.nullable, 

939 primary_key=spec.primaryKey, 

940 comment=spec.doc, 

941 server_default=spec.default, 

942 **kwargs, 

943 ) 

944 

945 def _convertForeignKeySpec( 

946 self, table: str, spec: ddl.ForeignKeySpec, metadata: sqlalchemy.MetaData, **kwargs: Any 

947 ) -> sqlalchemy.schema.ForeignKeyConstraint: 

948 """Convert a `ForeignKeySpec` to a 

949 `sqlalchemy.schema.ForeignKeyConstraint`. 

950 

951 Parameters 

952 ---------- 

953 table : `str` 

954 Name of the table this foreign key is being added to. 

955 spec : `ForeignKeySpec` 

956 Specification for the foreign key to be added. 

957 metadata : `sqlalchemy.MetaData` 

958 SQLAlchemy representation of the DDL schema this constraint is 

959 being added to. 

960 **kwargs 

961 Additional keyword arguments to forward to the 

962 `sqlalchemy.schema.ForeignKeyConstraint` constructor. This is 

963 provided to make it easier for derived classes to delegate to 

964 ``super()`` while making only minor changes. 

965 

966 Returns 

967 ------- 

968 constraint : `sqlalchemy.schema.ForeignKeyConstraint` 

969 SQLAlchemy representation of the constraint. 

970 """ 

971 name = self.shrinkDatabaseEntityName( 

972 "_".join(["fkey", table, spec.table] + list(spec.target) + list(spec.source)) 

973 ) 

974 return sqlalchemy.schema.ForeignKeyConstraint( 

975 spec.source, 

976 [f"{spec.table}.{col}" for col in spec.target], 

977 name=name, 

978 ondelete=spec.onDelete, 

979 ) 

980 

981 def _convertExclusionConstraintSpec( 

982 self, 

983 table: str, 

984 spec: tuple[str | type[TimespanDatabaseRepresentation], ...], 

985 metadata: sqlalchemy.MetaData, 

986 ) -> sqlalchemy.schema.Constraint: 

987 """Convert a `tuple` from `ddl.TableSpec.exclusion` into a SQLAlchemy 

988 constraint representation. 

989 

990 Parameters 

991 ---------- 

992 table : `str` 

993 Name of the table this constraint is being added to. 

994 spec : `tuple` [ `str` or `type` ] 

995 A tuple of `str` column names and the `type` object returned by 

996 `getTimespanRepresentation` (which must appear exactly once), 

997 indicating the order of the columns in the index used to back the 

998 constraint. 

999 metadata : `sqlalchemy.MetaData` 

1000 SQLAlchemy representation of the DDL schema this constraint is 

1001 being added to. 

1002 

1003 Returns 

1004 ------- 

1005 constraint : `sqlalchemy.schema.Constraint` 

1006 SQLAlchemy representation of the constraint. 

1007 

1008 Raises 

1009 ------ 

1010 NotImplementedError 

1011 Raised if this database does not support exclusion constraints. 

1012 """ 

1013 raise NotImplementedError(f"Database {self} does not support exclusion constraints.") 

1014 

1015 def _convertTableSpec( 

1016 self, name: str, spec: ddl.TableSpec, metadata: sqlalchemy.MetaData, **kwargs: Any 

1017 ) -> sqlalchemy.schema.Table: 

1018 """Convert a `TableSpec` to a `sqlalchemy.schema.Table`. 

1019 

1020 Parameters 

1021 ---------- 

1022 name : `str` 

1023 The name of the table. 

1024 spec : `TableSpec` 

1025 Specification for the foreign key to be added. 

1026 metadata : `sqlalchemy.MetaData` 

1027 SQLAlchemy representation of the DDL schema this table is being 

1028 added to. 

1029 **kwargs 

1030 Additional keyword arguments to forward to the 

1031 `sqlalchemy.schema.Table` constructor. This is provided to make it 

1032 easier for derived classes to delegate to ``super()`` while making 

1033 only minor changes. 

1034 

1035 Returns 

1036 ------- 

1037 table : `sqlalchemy.schema.Table` 

1038 SQLAlchemy representation of the table. 

1039 

1040 Notes 

1041 ----- 

1042 This method does not handle ``spec.foreignKeys`` at all, in order to 

1043 avoid circular dependencies. These are added by higher-level logic in 

1044 `ensureTableExists`, `getExistingTable`, and `declareStaticTables`. 

1045 """ 

1046 args: list[sqlalchemy.schema.SchemaItem] = [ 

1047 self._convertFieldSpec(name, fieldSpec, metadata) for fieldSpec in spec.fields 

1048 ] 

1049 

1050 # Add any column constraints 

1051 for fieldSpec in spec.fields: 

1052 args.extend(self._makeColumnConstraints(name, fieldSpec)) 

1053 

1054 # Track indexes added for primary key and unique constraints, to make 

1055 # sure we don't add duplicate explicit or foreign key indexes for 

1056 # those. 

1057 allIndexes = {tuple(fieldSpec.name for fieldSpec in spec.fields if fieldSpec.primaryKey)} 

1058 args.extend( 

1059 sqlalchemy.schema.UniqueConstraint( 

1060 *columns, name=self.shrinkDatabaseEntityName("_".join([name, "unq"] + list(columns))) 

1061 ) 

1062 for columns in spec.unique 

1063 ) 

1064 allIndexes.update(spec.unique) 

1065 args.extend( 

1066 sqlalchemy.schema.Index( 

1067 self.shrinkDatabaseEntityName("_".join([name, "idx"] + list(index.columns))), 

1068 *index.columns, 

1069 unique=(index.columns in spec.unique), 

1070 **index.kwargs, 

1071 ) 

1072 for index in spec.indexes 

1073 if index.columns not in allIndexes 

1074 ) 

1075 allIndexes.update(index.columns for index in spec.indexes) 

1076 args.extend( 

1077 sqlalchemy.schema.Index( 

1078 self.shrinkDatabaseEntityName("_".join((name, "fkidx") + fk.source)), 

1079 *fk.source, 

1080 ) 

1081 for fk in spec.foreignKeys 

1082 if fk.addIndex and fk.source not in allIndexes 

1083 ) 

1084 

1085 args.extend(self._convertExclusionConstraintSpec(name, excl, metadata) for excl in spec.exclusion) 

1086 

1087 assert spec.doc is None or isinstance(spec.doc, str), f"Bad doc for {name}." 

1088 return sqlalchemy.schema.Table(name, metadata, *args, comment=spec.doc, info={"spec": spec}, **kwargs) 

1089 

1090 def ensureTableExists(self, name: str, spec: ddl.TableSpec) -> sqlalchemy.schema.Table: 

1091 """Ensure that a table with the given name and specification exists, 

1092 creating it if necessary. 

1093 

1094 Parameters 

1095 ---------- 

1096 name : `str` 

1097 Name of the table (not including namespace qualifiers). 

1098 spec : `TableSpec` 

1099 Specification for the table. This will be used when creating the 

1100 table, and *may* be used when obtaining an existing table to check 

1101 for consistency, but no such check is guaranteed. 

1102 

1103 Returns 

1104 ------- 

1105 table : `sqlalchemy.schema.Table` 

1106 SQLAlchemy representation of the table. 

1107 

1108 Raises 

1109 ------ 

1110 ReadOnlyDatabaseError 

1111 Raised if `isWriteable` returns `False`, and the table does not 

1112 already exist. 

1113 DatabaseConflictError 

1114 Raised if the table exists but ``spec`` is inconsistent with its 

1115 definition. 

1116 

1117 Notes 

1118 ----- 

1119 This method may not be called within transactions. It may be called on 

1120 read-only databases if and only if the table does in fact already 

1121 exist. 

1122 

1123 Subclasses may override this method, but usually should not need to. 

1124 """ 

1125 # TODO: if _engine is used to make a table then it uses separate 

1126 # connection and should not interfere with current transaction 

1127 assert not self.isInTransaction(), "Table creation interrupts transactions." 

1128 assert self._metadata is not None, "Static tables must be declared before dynamic tables." 

1129 table = self.getExistingTable(name, spec) 

1130 if table is not None: 

1131 return table 

1132 if not self.isWriteable(): 

1133 raise ReadOnlyDatabaseError( 

1134 f"Table {name} does not exist, and cannot be created because database {self} is read-only." 

1135 ) 

1136 

1137 table = self._metadata.add_table(self, name, spec) 

1138 try: 

1139 with self._transaction() as (_, connection): 

1140 table.create(connection) 

1141 except sqlalchemy.exc.DatabaseError: 

1142 # Some other process could have created the table meanwhile, which 

1143 # usually causes OperationalError or ProgrammingError. We cannot 

1144 # use IF NOT EXISTS clause in this case due to PostgreSQL race 

1145 # condition on server side which causes IntegrityError. Instead we 

1146 # catch these exceptions (they all inherit DatabaseError) and 

1147 # re-check whether table is now there. 

1148 table = self.getExistingTable(name, spec) 

1149 if table is None: 

1150 raise 

1151 return table 

1152 

1153 def getExistingTable(self, name: str, spec: ddl.TableSpec) -> sqlalchemy.schema.Table | None: 

1154 """Obtain an existing table with the given name and specification. 

1155 

1156 Parameters 

1157 ---------- 

1158 name : `str` 

1159 Name of the table (not including namespace qualifiers). 

1160 spec : `TableSpec` 

1161 Specification for the table. This will be used when creating the 

1162 SQLAlchemy representation of the table, and it is used to 

1163 check that the actual table in the database is consistent. 

1164 

1165 Returns 

1166 ------- 

1167 table : `sqlalchemy.schema.Table` or `None` 

1168 SQLAlchemy representation of the table, or `None` if it does not 

1169 exist. 

1170 

1171 Raises 

1172 ------ 

1173 DatabaseConflictError 

1174 Raised if the table exists but ``spec`` is inconsistent with its 

1175 definition. 

1176 

1177 Notes 

1178 ----- 

1179 This method can be called within transactions and never modifies the 

1180 database. 

1181 

1182 Subclasses may override this method, but usually should not need to. 

1183 """ 

1184 assert self._metadata is not None, "Static tables must be declared before dynamic tables." 

1185 table = self._metadata.get_table(name) 

1186 if table is not None: 

1187 if spec.fields.names != set(table.columns.keys()): 

1188 raise DatabaseConflictError( 

1189 f"Table '{name}' has already been defined differently; the new " 

1190 f"specification has columns {list(spec.fields.names)}, while " 

1191 f"the previous definition has {list(table.columns.keys())}." 

1192 ) 

1193 else: 

1194 inspector = sqlalchemy.inspect( 

1195 self._engine if self._session_connection is None else self._session_connection, raiseerr=True 

1196 ) 

1197 if name in inspector.get_table_names(schema=self.namespace): 

1198 _checkExistingTableDefinition(name, spec, inspector.get_columns(name, schema=self.namespace)) 

1199 return self._metadata.add_table(self, name, spec) 

1200 return table 

1201 

1202 def _make_temporary_table( 

1203 self, 

1204 connection: sqlalchemy.engine.Connection, 

1205 spec: ddl.TableSpec, 

1206 name: str | None = None, 

1207 **kwargs: Any, 

1208 ) -> sqlalchemy.schema.Table: 

1209 """Create a temporary table. 

1210 

1211 Parameters 

1212 ---------- 

1213 connection : `sqlalchemy.engine.Connection` 

1214 Connection to use when creating the table. 

1215 spec : `TableSpec` 

1216 Specification for the table. 

1217 name : `str`, optional 

1218 A unique (within this session/connetion) name for the table. 

1219 Subclasses may override to modify the actual name used. If not 

1220 provided, a unique name will be generated. 

1221 **kwargs 

1222 Additional keyword arguments to forward to the 

1223 `sqlalchemy.schema.Table` constructor. This is provided to make it 

1224 easier for derived classes to delegate to ``super()`` while making 

1225 only minor changes. 

1226 

1227 Returns 

1228 ------- 

1229 table : `sqlalchemy.schema.Table` 

1230 SQLAlchemy representation of the table. 

1231 """ 

1232 if name is None: 

1233 name = f"tmp_{uuid.uuid4().hex}" 

1234 if self._metadata is None: 

1235 raise RuntimeError("Cannot create temporary table before static schema is defined.") 

1236 table = self._metadata.add_table( 

1237 self, name, spec, prefixes=["TEMPORARY"], schema=sqlalchemy.schema.BLANK_SCHEMA, **kwargs 

1238 ) 

1239 if table.key in self._temp_tables and table.key != name: 

1240 raise ValueError( 

1241 f"A temporary table with name {name} (transformed to {table.key} by Database) already exists." 

1242 ) 

1243 with self._transaction(): 

1244 table.create(connection) 

1245 return table 

1246 

1247 @classmethod 

1248 def getTimespanRepresentation(cls) -> type[TimespanDatabaseRepresentation]: 

1249 """Return a `type` that encapsulates the way `Timespan` objects are 

1250 stored in this database. 

1251 

1252 `Database` does not automatically use the return type of this method 

1253 anywhere else; calling code is responsible for making sure that DDL 

1254 and queries are consistent with it. 

1255 

1256 Returns 

1257 ------- 

1258 TimespanReprClass : `type` (`TimespanDatabaseRepresention` subclass) 

1259 A type that encapsulates the way `Timespan` objects should be 

1260 stored in this database. 

1261 

1262 Notes 

1263 ----- 

1264 There are two big reasons we've decided to keep timespan-mangling logic 

1265 outside the `Database` implementations, even though the choice of 

1266 representation is ultimately up to a `Database` implementation: 

1267 

1268 - Timespans appear in relatively few tables and queries in our 

1269 typical usage, and the code that operates on them is already aware 

1270 that it is working with timespans. In contrast, a 

1271 timespan-representation-aware implementation of, say, `insert`, 

1272 would need to have extra logic to identify when timespan-mangling 

1273 needed to occur, which would usually be useless overhead. 

1274 

1275 - SQLAlchemy's rich SELECT query expression system has no way to wrap 

1276 multiple columns in a single expression object (the ORM does, but 

1277 we are not using the ORM). So we would have to wrap _much_ more of 

1278 that code in our own interfaces to encapsulate timespan 

1279 representations there. 

1280 """ 

1281 return TimespanDatabaseRepresentation.Compound 

1282 

1283 def sync( 

1284 self, 

1285 table: sqlalchemy.schema.Table, 

1286 *, 

1287 keys: dict[str, Any], 

1288 compared: dict[str, Any] | None = None, 

1289 extra: dict[str, Any] | None = None, 

1290 returning: Sequence[str] | None = None, 

1291 update: bool = False, 

1292 ) -> tuple[dict[str, Any] | None, bool | dict[str, Any]]: 

1293 """Insert into a table as necessary to ensure database contains 

1294 values equivalent to the given ones. 

1295 

1296 Parameters 

1297 ---------- 

1298 table : `sqlalchemy.schema.Table` 

1299 Table to be queried and possibly inserted into. 

1300 keys : `dict` 

1301 Column name-value pairs used to search for an existing row; must 

1302 be a combination that can be used to select a single row if one 

1303 exists. If such a row does not exist, these values are used in 

1304 the insert. 

1305 compared : `dict`, optional 

1306 Column name-value pairs that are compared to those in any existing 

1307 row. If such a row does not exist, these rows are used in the 

1308 insert. 

1309 extra : `dict`, optional 

1310 Column name-value pairs that are ignored if a matching row exists, 

1311 but used in an insert if one is necessary. 

1312 returning : `~collections.abc.Sequence` of `str`, optional 

1313 The names of columns whose values should be returned. 

1314 update : `bool`, optional 

1315 If `True` (`False` is default), update the existing row with the 

1316 values in ``compared`` instead of raising `DatabaseConflictError`. 

1317 

1318 Returns 

1319 ------- 

1320 row : `dict`, optional 

1321 The value of the fields indicated by ``returning``, or `None` if 

1322 ``returning`` is `None`. 

1323 inserted_or_updated : `bool` or `dict` 

1324 If `True`, a new row was inserted; if `False`, a matching row 

1325 already existed. If a `dict` (only possible if ``update=True``), 

1326 then an existing row was updated, and the dict maps the names of 

1327 the updated columns to their *old* values (new values can be 

1328 obtained from ``compared``). 

1329 

1330 Raises 

1331 ------ 

1332 DatabaseConflictError 

1333 Raised if the values in ``compared`` do not match the values in the 

1334 database. 

1335 ReadOnlyDatabaseError 

1336 Raised if `isWriteable` returns `False`, and no matching record 

1337 already exists. 

1338 

1339 Notes 

1340 ----- 

1341 May be used inside transaction contexts, so implementations may not 

1342 perform operations that interrupt transactions. 

1343 

1344 It may be called on read-only databases if and only if the matching row 

1345 does in fact already exist. 

1346 """ 

1347 

1348 def check() -> tuple[int, dict[str, Any] | None, list | None]: 

1349 """Query for a row that matches the ``key`` argument, and compare 

1350 to what was given by the caller. 

1351 

1352 Returns 

1353 ------- 

1354 n : `int` 

1355 Number of matching rows. ``n != 1`` is always an error, but 

1356 it's a different kind of error depending on where `check` is 

1357 being called. 

1358 bad : `dict` or `None` 

1359 The subset of the keys of ``compared`` for which the existing 

1360 values did not match the given one, mapped to the existing 

1361 values in the database. Once again, ``not bad`` is always an 

1362 error, but a different kind on context. `None` if ``n != 1``. 

1363 result : `list` or `None` 

1364 Results in the database that correspond to the columns given 

1365 in ``returning``, or `None` if ``returning is None``. 

1366 """ 

1367 toSelect: set[str] = set() 

1368 if compared is not None: 

1369 toSelect.update(compared.keys()) 

1370 if returning is not None: 

1371 toSelect.update(returning) 

1372 if not toSelect: 

1373 # Need to select some column, even if we just want to see 

1374 # how many rows we get back. 

1375 toSelect.add(next(iter(keys.keys()))) 

1376 selectSql = ( 

1377 sqlalchemy.sql.select(*[table.columns[k].label(k) for k in toSelect]) 

1378 .select_from(table) 

1379 .where(sqlalchemy.sql.and_(*[table.columns[k] == v for k, v in keys.items()])) 

1380 ) 

1381 with self._transaction() as (_, connection): 

1382 fetched = list(connection.execute(selectSql).mappings()) 

1383 if len(fetched) != 1: 

1384 return len(fetched), None, None 

1385 existing = fetched[0] 

1386 if compared is not None: 

1387 

1388 def safeNotEqual(a: Any, b: Any) -> bool: 

1389 if isinstance(a, astropy.time.Time): 

1390 return not time_utils.TimeConverter().times_equal(a, b) 

1391 return a != b 

1392 

1393 inconsistencies = { 

1394 k: existing[k] for k, v in compared.items() if safeNotEqual(existing[k], v) 

1395 } 

1396 else: 

1397 inconsistencies = {} 

1398 if returning is not None: 

1399 toReturn: list | None = [existing[k] for k in returning] 

1400 else: 

1401 toReturn = None 

1402 return 1, inconsistencies, toReturn 

1403 

1404 def _format_bad(inconsistencies: dict[str, Any]) -> str: 

1405 """Format the 'bad' dictionary of existing values returned by 

1406 ``check`` into a string suitable for an error message. 

1407 """ 

1408 assert compared is not None, "Should not be able to get inconsistencies without comparing." 

1409 return ", ".join(f"{k}: {v!r} != {compared[k]!r}" for k, v in inconsistencies.items()) 

1410 

1411 if self.isTableWriteable(table): 

1412 # Try an insert first, but allow it to fail (in only specific 

1413 # ways). 

1414 row = keys.copy() 

1415 if compared is not None: 

1416 row.update(compared) 

1417 if extra is not None: 

1418 row.update(extra) 

1419 with self.transaction(): 

1420 inserted = bool(self.ensure(table, row)) 

1421 inserted_or_updated: bool | dict[str, Any] 

1422 # Need to perform check() for this branch inside the 

1423 # transaction, so we roll back an insert that didn't do 

1424 # what we expected. That limits the extent to which we 

1425 # can reduce duplication between this block and the other 

1426 # ones that perform similar logic. 

1427 n, bad, result = check() 

1428 if n < 1: 

1429 raise ConflictingDefinitionError( 

1430 f"Attempted to ensure {row} exists by inserting it with ON CONFLICT IGNORE, " 

1431 f"but a post-insert query on {keys} returned no results. " 

1432 f"Insert was {'' if inserted else 'not '}reported as successful. " 

1433 "This can occur if the insert violated a database constraint other than the " 

1434 "unique constraint or primary key used to identify the row in this call." 

1435 ) 

1436 elif n > 1: 

1437 raise RuntimeError( 

1438 f"Keys passed to sync {keys.keys()} do not comprise a " 

1439 f"unique constraint for table {table.name}." 

1440 ) 

1441 elif bad: 

1442 assert compared is not None, ( 

1443 "Should not be able to get inconsistencies without comparing." 

1444 ) 

1445 if inserted: 

1446 raise RuntimeError( 

1447 f"Conflict ({bad}) in sync after successful insert; this is " 

1448 "possible if the same table is being updated by a concurrent " 

1449 "process that isn't using sync, but it may also be a bug in " 

1450 "daf_butler." 

1451 ) 

1452 elif update: 

1453 with self._transaction() as (_, connection): 

1454 connection.execute( 

1455 table.update() 

1456 .where(sqlalchemy.sql.and_(*[table.columns[k] == v for k, v in keys.items()])) 

1457 .values(**{k: compared[k] for k in bad}) 

1458 ) 

1459 inserted_or_updated = bad 

1460 else: 

1461 raise DatabaseConflictError( 

1462 f"Conflict in sync for table {table.name} on column(s) {_format_bad(bad)}." 

1463 ) 

1464 else: 

1465 inserted_or_updated = inserted 

1466 else: 

1467 # Database is not writeable; just see if the row exists. 

1468 n, bad, result = check() 

1469 if n < 1: 

1470 raise ReadOnlyDatabaseError("sync needs to insert, but database is read-only.") 

1471 elif n > 1: 

1472 raise RuntimeError("Keys passed to sync do not comprise a unique constraint.") 

1473 elif bad: 

1474 if update: 

1475 raise ReadOnlyDatabaseError("sync needs to update, but database is read-only.") 

1476 else: 

1477 raise DatabaseConflictError( 

1478 f"Conflict in sync for table {table.name} on column(s) {_format_bad(bad)}." 

1479 ) 

1480 inserted_or_updated = False 

1481 if returning is None: 

1482 return None, inserted_or_updated 

1483 else: 

1484 assert result is not None 

1485 return dict(zip(returning, result, strict=True)), inserted_or_updated 

1486 

1487 def insert( 

1488 self, 

1489 table: sqlalchemy.schema.Table, 

1490 *rows: dict, 

1491 returnIds: bool = False, 

1492 select: sqlalchemy.sql.expression.SelectBase | None = None, 

1493 names: Iterable[str] | None = None, 

1494 ) -> list[int] | None: 

1495 """Insert one or more rows into a table, optionally returning 

1496 autoincrement primary key values. 

1497 

1498 Parameters 

1499 ---------- 

1500 table : `sqlalchemy.schema.Table` 

1501 Table rows should be inserted into. 

1502 *rows : `dict` 

1503 Positional arguments are the rows to be inserted, as dictionaries 

1504 mapping column name to value. The keys in all dictionaries must 

1505 be the same. 

1506 returnIds : `bool`, optional 

1507 If `True` (`False` is default), return the values of the table's 

1508 autoincrement primary key field (which much exist). 

1509 select : `sqlalchemy.sql.SelectBase`, optional 

1510 A SELECT query expression to insert rows from. Cannot be provided 

1511 with either ``rows`` or ``returnIds=True``. 

1512 names : `~collections.abc.Iterable` [ `str` ], optional 

1513 Names of columns in ``table`` to be populated, ordered to match the 

1514 columns returned by ``select``. Ignored if ``select`` is `None`. 

1515 If not provided, the columns returned by ``select`` must be named 

1516 to match the desired columns of ``table``. 

1517 

1518 Returns 

1519 ------- 

1520 ids : `None`, or `list` of `int` 

1521 If ``returnIds`` is `True`, a `list` containing the inserted 

1522 values for the table's autoincrement primary key. 

1523 

1524 Raises 

1525 ------ 

1526 ReadOnlyDatabaseError 

1527 Raised if `isWriteable` returns `False` when this method is called. 

1528 

1529 Notes 

1530 ----- 

1531 The default implementation uses bulk insert syntax when ``returnIds`` 

1532 is `False`, and a loop over single-row insert operations when it is 

1533 `True`. 

1534 

1535 Derived classes should reimplement when they can provide a more 

1536 efficient implementation (especially for the latter case). 

1537 

1538 May be used inside transaction contexts, so implementations may not 

1539 perform operations that interrupt transactions. 

1540 """ 

1541 self.assertTableWriteable(table, f"Cannot insert into read-only table {table}.") 

1542 if select is not None and (rows or returnIds): 

1543 raise TypeError("'select' is incompatible with passing value rows or returnIds=True.") 

1544 if not rows and select is None: 

1545 if returnIds: 

1546 return [] 

1547 else: 

1548 return None 

1549 with self._transaction() as (_, connection): 

1550 if not returnIds: 

1551 if select is not None: 

1552 if names is None: 

1553 # columns() is deprecated since 1.4, but 

1554 # selected_columns() method did not exist in 1.3. 

1555 if hasattr(select, "selected_columns"): 

1556 names = select.selected_columns.keys() 

1557 else: 

1558 names = select.columns.keys() 

1559 connection.execute(table.insert().from_select(list(names), select)) 

1560 else: 

1561 connection.execute(table.insert(), rows) 

1562 return None 

1563 else: 

1564 sql = table.insert() 

1565 ids = [] 

1566 for row in rows: 

1567 key = connection.execute(sql, row).inserted_primary_key 

1568 assert key is not None 

1569 ids.append(key[0]) 

1570 return ids 

1571 

1572 @abstractmethod 

1573 def replace(self, table: sqlalchemy.schema.Table, *rows: dict) -> None: 

1574 """Insert one or more rows into a table, replacing any existing rows 

1575 for which insertion of a new row would violate the primary key 

1576 constraint. 

1577 

1578 Parameters 

1579 ---------- 

1580 table : `sqlalchemy.schema.Table` 

1581 Table rows should be inserted into. 

1582 *rows 

1583 Positional arguments are the rows to be inserted, as dictionaries 

1584 mapping column name to value. The keys in all dictionaries must 

1585 be the same. 

1586 

1587 Raises 

1588 ------ 

1589 ReadOnlyDatabaseError 

1590 Raised if `isWriteable` returns `False` when this method is called. 

1591 

1592 Notes 

1593 ----- 

1594 May be used inside transaction contexts, so implementations may not 

1595 perform operations that interrupt transactions. 

1596 

1597 Implementations should raise a `sqlalchemy.exc.IntegrityError` 

1598 exception when a constraint other than the primary key would be 

1599 violated. 

1600 

1601 Implementations are not required to support `replace` on tables 

1602 with autoincrement keys. 

1603 """ 

1604 raise NotImplementedError() 

1605 

1606 @abstractmethod 

1607 def ensure(self, table: sqlalchemy.schema.Table, *rows: dict, primary_key_only: bool = False) -> int: 

1608 """Insert one or more rows into a table, skipping any rows for which 

1609 insertion would violate a unique constraint. 

1610 

1611 Parameters 

1612 ---------- 

1613 table : `sqlalchemy.schema.Table` 

1614 Table rows should be inserted into. 

1615 *rows 

1616 Positional arguments are the rows to be inserted, as dictionaries 

1617 mapping column name to value. The keys in all dictionaries must 

1618 be the same. 

1619 primary_key_only : `bool`, optional 

1620 If `True` (`False` is default), only skip rows that violate the 

1621 primary key constraint, and raise an exception (and rollback 

1622 transactions) for other constraint violations. 

1623 

1624 Returns 

1625 ------- 

1626 count : `int` 

1627 The number of rows actually inserted. 

1628 

1629 Raises 

1630 ------ 

1631 ReadOnlyDatabaseError 

1632 Raised if `isWriteable` returns `False` when this method is called. 

1633 This is raised even if the operation would do nothing even on a 

1634 writeable database. 

1635 

1636 Notes 

1637 ----- 

1638 May be used inside transaction contexts, so implementations may not 

1639 perform operations that interrupt transactions. 

1640 

1641 Implementations are not required to support `ensure` on tables 

1642 with autoincrement keys. 

1643 """ 

1644 raise NotImplementedError() 

1645 

1646 def delete(self, table: sqlalchemy.schema.Table, columns: Iterable[str], *rows: dict) -> int: 

1647 """Delete one or more rows from a table. 

1648 

1649 Parameters 

1650 ---------- 

1651 table : `sqlalchemy.schema.Table` 

1652 Table that rows should be deleted from. 

1653 columns : `~collections.abc.Iterable` of `str` 

1654 The names of columns that will be used to constrain the rows to 

1655 be deleted; these will be combined via ``AND`` to form the 

1656 ``WHERE`` clause of the delete query. 

1657 *rows 

1658 Positional arguments are the keys of rows to be deleted, as 

1659 dictionaries mapping column name to value. The keys in all 

1660 dictionaries must be exactly the names in ``columns``. 

1661 

1662 Returns 

1663 ------- 

1664 count : `int` 

1665 Number of rows deleted. 

1666 

1667 Raises 

1668 ------ 

1669 ReadOnlyDatabaseError 

1670 Raised if `isWriteable` returns `False` when this method is called. 

1671 

1672 Notes 

1673 ----- 

1674 May be used inside transaction contexts, so implementations may not 

1675 perform operations that interrupt transactions. 

1676 

1677 The default implementation should be sufficient for most derived 

1678 classes. 

1679 """ 

1680 self.assertTableWriteable(table, f"Cannot delete from read-only table {table}.") 

1681 if columns and not rows: 

1682 # If there are no columns, this operation is supposed to delete 

1683 # everything (so we proceed as usual). But if there are columns, 

1684 # but no rows, it was a constrained bulk operation where the 

1685 # constraint is that no rows match, and we should short-circuit 

1686 # while reporting that no rows were affected. 

1687 return 0 

1688 sql = table.delete() 

1689 columns = list(columns) # Force iterators to list 

1690 

1691 # More efficient to use IN operator if there is only one 

1692 # variable changing across all rows. 

1693 content: dict[str, set] = defaultdict(set) 

1694 if len(columns) == 1: 

1695 # Nothing to calculate since we can always use IN 

1696 column = columns[0] 

1697 changing_columns = [column] 

1698 content[column] = {row[column] for row in rows} 

1699 else: 

1700 for row in rows: 

1701 for k, v in row.items(): 

1702 content[k].add(v) 

1703 changing_columns = [col for col, values in content.items() if len(values) > 1] 

1704 

1705 if len(changing_columns) != 1: 

1706 # More than one column changes each time so do explicit bind 

1707 # parameters and have each row processed separately. 

1708 whereTerms = [table.columns[name] == sqlalchemy.sql.bindparam(name) for name in columns] 

1709 if whereTerms: 

1710 sql = sql.where(sqlalchemy.sql.and_(*whereTerms)) 

1711 with self._transaction() as (_, connection): 

1712 return connection.execute(sql, rows).rowcount 

1713 else: 

1714 # One of the columns has changing values but any others are 

1715 # fixed. In this case we can use an IN operator and be more 

1716 # efficient. 

1717 name = changing_columns.pop() 

1718 

1719 # Simple where clause for the unchanging columns 

1720 clauses = [] 

1721 for k, v in content.items(): 

1722 if k == name: 

1723 continue 

1724 # The set only has one element 

1725 clauses.append(table.columns[k] == v.pop()) 

1726 

1727 # The IN operator will not work for "infinite" numbers of 

1728 # rows so must batch it up into distinct calls. 

1729 in_content = list(content[name]) 

1730 n_elements = len(in_content) 

1731 

1732 rowcount = 0 

1733 iposn = 0 

1734 n_per_loop = 1_000 # Controls how many items to put in IN clause 

1735 with self._transaction() as (_, connection): 

1736 for iposn in range(0, n_elements, n_per_loop): 

1737 endpos = iposn + n_per_loop 

1738 in_clause = table.columns[name].in_(in_content[iposn:endpos]) 

1739 

1740 newsql = sql.where(sqlalchemy.sql.and_(*clauses, in_clause)) 

1741 rowcount += connection.execute(newsql).rowcount 

1742 return rowcount 

1743 

1744 def deleteWhere(self, table: sqlalchemy.schema.Table, where: sqlalchemy.sql.ColumnElement) -> int: 

1745 """Delete rows from a table with pre-constructed WHERE clause. 

1746 

1747 Parameters 

1748 ---------- 

1749 table : `sqlalchemy.schema.Table` 

1750 Table that rows should be deleted from. 

1751 where : `sqlalchemy.sql.ClauseElement` 

1752 The names of columns that will be used to constrain the rows to 

1753 be deleted; these will be combined via ``AND`` to form the 

1754 ``WHERE`` clause of the delete query. 

1755 

1756 Returns 

1757 ------- 

1758 count : `int` 

1759 Number of rows deleted. 

1760 

1761 Raises 

1762 ------ 

1763 ReadOnlyDatabaseError 

1764 Raised if `isWriteable` returns `False` when this method is called. 

1765 

1766 Notes 

1767 ----- 

1768 May be used inside transaction contexts, so implementations may not 

1769 perform operations that interrupt transactions. 

1770 

1771 The default implementation should be sufficient for most derived 

1772 classes. 

1773 """ 

1774 self.assertTableWriteable(table, f"Cannot delete from read-only table {table}.") 

1775 

1776 sql = table.delete().where(where) 

1777 with self._transaction() as (_, connection): 

1778 return connection.execute(sql).rowcount 

1779 

1780 def update(self, table: sqlalchemy.schema.Table, where: dict[str, str], *rows: dict) -> int: 

1781 """Update one or more rows in a table. 

1782 

1783 Parameters 

1784 ---------- 

1785 table : `sqlalchemy.schema.Table` 

1786 Table containing the rows to be updated. 

1787 where : `dict` [`str`, `str`] 

1788 A mapping from the names of columns that will be used to search for 

1789 existing rows to the keys that will hold these values in the 

1790 ``rows`` dictionaries. Note that these may not be the same due to 

1791 SQLAlchemy limitations. 

1792 *rows 

1793 Positional arguments are the rows to be updated. The keys in all 

1794 dictionaries must be the same, and may correspond to either a 

1795 value in the ``where`` dictionary or the name of a column to be 

1796 updated. 

1797 

1798 Returns 

1799 ------- 

1800 count : `int` 

1801 Number of rows matched (regardless of whether the update actually 

1802 modified them). 

1803 

1804 Raises 

1805 ------ 

1806 ReadOnlyDatabaseError 

1807 Raised if `isWriteable` returns `False` when this method is called. 

1808 

1809 Notes 

1810 ----- 

1811 May be used inside transaction contexts, so implementations may not 

1812 perform operations that interrupt transactions. 

1813 

1814 The default implementation should be sufficient for most derived 

1815 classes. 

1816 """ 

1817 self.assertTableWriteable(table, f"Cannot update read-only table {table}.") 

1818 if not rows: 

1819 return 0 

1820 sql = table.update().where( 

1821 sqlalchemy.sql.and_(*[table.columns[k] == sqlalchemy.sql.bindparam(v) for k, v in where.items()]) 

1822 ) 

1823 with self._transaction() as (_, connection): 

1824 return connection.execute(sql, rows).rowcount 

1825 

1826 @contextmanager 

1827 def query( 

1828 self, 

1829 sql: sqlalchemy.sql.expression.Executable | sqlalchemy.sql.expression.SelectBase, 

1830 *args: Any, 

1831 **kwargs: Any, 

1832 ) -> Iterator[sqlalchemy.engine.CursorResult]: 

1833 """Run a SELECT query against the database. 

1834 

1835 Parameters 

1836 ---------- 

1837 sql : `sqlalchemy.sql.expression.SelectBase` 

1838 A SQLAlchemy representation of a ``SELECT`` query. 

1839 *args 

1840 Additional positional arguments are forwarded to 

1841 `sqlalchemy.engine.Connection.execute`. 

1842 **kwargs 

1843 Additional keyword arguments are forwarded to 

1844 `sqlalchemy.engine.Connection.execute`. 

1845 

1846 Returns 

1847 ------- 

1848 result_context : `sqlalchemy.engine.CursorResults` 

1849 Context manager that returns the query result object when entered. 

1850 These results are invalidated when the context is exited. 

1851 """ 

1852 if self._session_connection is None: 

1853 connection = self._engine.connect() 

1854 else: 

1855 connection = self._session_connection 

1856 try: 

1857 self._log_query_and_plan(connection, sql) 

1858 # TODO: SelectBase is not good for execute(), but it used 

1859 # everywhere, e.g. in daf_relation. We should switch to Executable 

1860 # at some point. 

1861 with connection.execute( 

1862 cast(sqlalchemy.sql.expression.Executable, sql), *args, **kwargs 

1863 ) as result: 

1864 yield result 

1865 finally: 

1866 if connection is not self._session_connection: 

1867 connection.close() 

1868 

1869 def _log_query_and_plan( 

1870 self, 

1871 connection: sqlalchemy.Connection, 

1872 sql: sqlalchemy.sql.expression.Executable | sqlalchemy.sql.expression.SelectBase, 

1873 ) -> None: 

1874 """Log the given SQL statement and the DB's plan for executing it if 

1875 the environment variable DAF_BUTLER_DEBUG_QUERIES is set to a truthy 

1876 value. 

1877 """ 

1878 if os.environ.get("DAF_BUTLER_DEBUG_QUERIES", False): 

1879 assert isinstance(sql, sqlalchemy.SelectBase) 

1880 compiled = sql.compile(connection) 

1881 print( 

1882 f"Executing SQL statement:\n{compiled}\nBind parameters: {compiled.params}", file=sys.stderr 

1883 ) 

1884 

1885 query_plan = get_query_plan(connection, sql) 

1886 print(f"Query plan:\n{query_plan}", file=sys.stderr) 

1887 

1888 @abstractmethod 

1889 def constant_rows( 

1890 self, 

1891 fields: NamedValueAbstractSet[ddl.FieldSpec], 

1892 *rows: dict, 

1893 name: str | None = None, 

1894 ) -> sqlalchemy.sql.FromClause: 

1895 """Return a SQLAlchemy object that represents a small number of 

1896 constant-valued rows. 

1897 

1898 Parameters 

1899 ---------- 

1900 fields : `NamedValueAbstractSet` [ `ddl.FieldSpec` ] 

1901 The columns of the rows. Unique and foreign key constraints are 

1902 ignored. 

1903 *rows : `dict` 

1904 Values for the rows. 

1905 name : `str`, optional 

1906 If provided, the name of the SQL construct. If not provided, an 

1907 opaque but unique identifier is generated. 

1908 

1909 Returns 

1910 ------- 

1911 from_clause : `sqlalchemy.sql.FromClause` 

1912 SQLAlchemy object representing the given rows. This is guaranteed 

1913 to be something that can be directly joined into a ``SELECT`` 

1914 query's ``FROM`` clause, and will not involve a temporary table 

1915 that needs to be cleaned up later. 

1916 

1917 Notes 

1918 ----- 

1919 The default implementation uses the SQL-standard ``VALUES`` construct, 

1920 but support for that construct is varied enough across popular RDBMSs 

1921 that the method is still marked abstract to force explicit opt-in via 

1922 delegation to `super`. 

1923 """ 

1924 if name is None: 

1925 name = f"tmp_{uuid.uuid4().hex}" 

1926 return sqlalchemy.sql.values( 

1927 *[sqlalchemy.Column(field.name, field.getSizedColumnType()) for field in fields], 

1928 name=name, 

1929 ).data([tuple(row[name] for name in fields.names) for row in rows]) 

1930 

1931 def get_constant_rows_max(self) -> int: 

1932 """Return the maximum number of rows that should be passed to 

1933 `constant_rows` for this backend. 

1934 

1935 Returns 

1936 ------- 

1937 max : `int` 

1938 Maximum number of rows. 

1939 

1940 Notes 

1941 ----- 

1942 This should reflect typical performance profiles (or a guess at these), 

1943 not just hard database engine limits. 

1944 """ 

1945 return 1000 

1946 

1947 @abstractmethod 

1948 def glob_expression( 

1949 self, expression: sqlalchemy.ColumnElement[Any], pattern: str 

1950 ) -> sqlalchemy.ColumnElement[bool]: 

1951 """Produce boolean expression for expression match against glob-like 

1952 pattern. 

1953 

1954 Parameters 

1955 ---------- 

1956 expression : `sqlalchemy.ColumnElement` 

1957 Column expression that evaluates to string. 

1958 pattern : `str` 

1959 GLob pattern string. 

1960 

1961 Returns 

1962 ------- 

1963 glob : `sqlalchemy.ColumnElement` 

1964 Boolean expression that matches ``expression`` against ``pattern``. 

1965 """ 

1966 raise NotImplementedError() 

1967 

1968 @property 

1969 @abstractmethod 

1970 def has_distinct_on(self) -> bool: 

1971 """Whether this database supports the ``DISTINCT ON`` SQL construct.""" 

1972 raise NotImplementedError() 

1973 

1974 @property 

1975 @abstractmethod 

1976 def has_any_aggregate(self) -> bool: 

1977 """Whether this database supports the ``ANY_VALUE`` aggregate function 

1978 or something equivalent. 

1979 """ 

1980 raise NotImplementedError() 

1981 

1982 @property 

1983 def supports_temporary_tables(self) -> bool: 

1984 return self._allow_temporary_tables 

1985 

1986 @abstractmethod 

1987 def apply_any_aggregate(self, column: sqlalchemy.ColumnElement[Any]) -> sqlalchemy.ColumnElement[Any]: 

1988 """Wrap the given SQLAlchemy column in the ``ANY_VALUE`` aggregate 

1989 function or its equivalent. 

1990 

1991 Parameters 

1992 ---------- 

1993 column : `sqlalchemy.ColumnElement` 

1994 Original column to wrap. 

1995 

1996 Returns 

1997 ------- 

1998 wrapped : `sqlalchemy.ColumnElement` 

1999 A column element of the same SQL type that can appear in the 

2000 ``SELECT`` clause even when this column does not appear in the 

2001 ``GROUP BY`` clause. 

2002 

2003 Notes 

2004 ----- 

2005 This method's behavior is unspecified when `has_any_aggregate` is 

2006 `False`; the caller is responsible for checking that property first. 

2007 """ 

2008 raise NotImplementedError() 

2009 

2010 origin: int 

2011 """An integer ID that should be used as the default for any datasets, 

2012 quanta, or other entities that use a (autoincrement, origin) compound 

2013 primary key (`int`). 

2014 """ 

2015 

2016 namespace: str | None 

2017 """The schema or namespace this database instance is associated with 

2018 (`str` or `None`). 

2019 """ 

2020 

2021 name_shrinker: NameShrinker 

2022 """An object that can be used to shrink field names to fit within the 

2023 identifier limit of the database engine (`NameShrinker`). 

2024 """ 

2025 

2026 

2027class DatabaseMetadata: 

2028 """Wrapper around SqlAlchemy MetaData object to ensure threadsafety. 

2029 

2030 Parameters 

2031 ---------- 

2032 namespace : `str` or `None` 

2033 Name of the schema or namespace this instance is associated with. 

2034 

2035 Notes 

2036 ----- 

2037 `sqlalchemy.MetaData` is documented to be threadsafe for reads, but not 

2038 with concurrent modifications. We add tables dynamically at runtime, 

2039 and the MetaData object is shared by all Database instances sharing 

2040 the same connection pool. 

2041 """ 

2042 

2043 def __init__(self, namespace: str | None) -> None: 

2044 self._lock = Lock() 

2045 self._metadata = sqlalchemy.MetaData(schema=namespace) 

2046 self._tables: dict[str, sqlalchemy.Table] = {} 

2047 

2048 def add_table( 

2049 self, db: Database, name: str, spec: ddl.TableSpec, **kwargs: Any 

2050 ) -> sqlalchemy.schema.Table: 

2051 """Add a new table to the MetaData object, returning its sqlalchemy 

2052 representation. This does not physically create the table in the 

2053 database -- it only sets up its definition. 

2054 

2055 Parameters 

2056 ---------- 

2057 db : `Database` 

2058 Database connection associated with the table definition. 

2059 name : `str` 

2060 The name of the table. 

2061 spec : `ddl.TableSpec` 

2062 The specification of the table. 

2063 **kwargs 

2064 Additional keyword arguments to forward to the 

2065 `sqlalchemy.schema.Table` constructor. 

2066 

2067 Returns 

2068 ------- 

2069 table : `sqlalchemy.schema.Table` 

2070 The created table. 

2071 """ 

2072 with self._lock: 

2073 if (table := self._tables.get(name)) is not None: 

2074 return table 

2075 

2076 table = db._convertTableSpec(name, spec, self._metadata, **kwargs) 

2077 for foreignKeySpec in spec.foreignKeys: 

2078 table.append_constraint(db._convertForeignKeySpec(name, foreignKeySpec, self._metadata)) 

2079 

2080 self._tables[name] = table 

2081 return table 

2082 

2083 def get_table(self, name: str) -> sqlalchemy.schema.Table | None: 

2084 """Return the definition of a table that was previously added to this 

2085 MetaData object. 

2086 

2087 Parameters 

2088 ---------- 

2089 name : `str` 

2090 Name of the table. 

2091 

2092 Returns 

2093 ------- 

2094 table : `sqlalchemy.schema.Table` or `None` 

2095 The table definition, or `None` if the table is not known to this 

2096 MetaData instance. 

2097 """ 

2098 with self._lock: 

2099 return self._tables.get(name) 

2100 

2101 def remove_table(self, name: str) -> None: 

2102 """Remove a table that was previously added to this MetaData object. 

2103 

2104 Parameters 

2105 ---------- 

2106 name : `str` 

2107 Name of the table. 

2108 """ 

2109 with self._lock: 

2110 table = self._tables.pop(name, None) 

2111 if table is not None: 

2112 self._metadata.remove(table) 

2113 

2114 def create_all(self, connection: sqlalchemy.engine.Connection) -> None: 

2115 """Create all tables known to this MetaData object in the database. 

2116 Same as `sqlalchemy.MetaData.create_all`. 

2117 

2118 Parameters 

2119 ---------- 

2120 connection : `sqlalchemy.engine.connection` 

2121 Database connection that will be used to create tables. 

2122 """ 

2123 with self._lock: 

2124 self._metadata.create_all(connection)