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

466 statements  

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

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, TypeVar, 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_T = TypeVar("_T") 

63 

64 

65class DatabaseInsertMode(enum.Enum): 

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

67 

68 INSERT = enum.auto() 

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

70 

71 REPLACE = enum.auto() 

72 """Replace records, overwriting existing.""" 

73 

74 ENSURE = enum.auto() 

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

76 

77 

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

79# ReflectedColumn does not exist in 1.4. 

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

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

82 database introspection are consistent. 

83 

84 Parameters 

85 ---------- 

86 name : `str` 

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

88 spec : `ddl.TableSpec` 

89 Specification of the table. 

90 inspection : `dict` 

91 Dictionary returned by 

92 `sqlalchemy.engine.reflection.Inspector.get_columns`. 

93 

94 Raises 

95 ------ 

96 DatabaseConflictError 

97 Raised if the definitions are inconsistent. 

98 """ 

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

100 if spec.fields.names != set(columnNames): 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true

101 raise DatabaseConflictError( 

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

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

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

105 ) 

106 

107 

108class ReadOnlyDatabaseError(RuntimeError): 

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

110 `Database`. 

111 """ 

112 

113 

114class DatabaseConflictError(ConflictingDefinitionError): 

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

116 are inconsistent with what this client expects. 

117 """ 

118 

119 

120class SchemaAlreadyDefinedError(RuntimeError): 

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

122 tables already exist. 

123 """ 

124 

125 

126class StaticTablesContext: 

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

128 in a database. 

129 

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

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

132 

133 Parameters 

134 ---------- 

135 db : `Database` 

136 The database. 

137 connection : `sqlalchemy.engine.Connection` 

138 The connection object. 

139 """ 

140 

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

142 self._db = db 

143 self._inspector = sqlalchemy.inspect(connection) 

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

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

146 

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

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

149 representation. 

150 

151 Parameters 

152 ---------- 

153 name : `str` 

154 The name of the table. 

155 spec : `ddl.TableSpec` 

156 The specification of the table. 

157 

158 Returns 

159 ------- 

160 table : `sqlalchemy.schema.Table` 

161 The created table. 

162 

163 Notes 

164 ----- 

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

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

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

168 relationships. 

169 """ 

170 metadata = self._db._metadata 

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

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

173 

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

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

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

177 

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

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

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

181 relationships. 

182 

183 Parameters 

184 ---------- 

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

186 Specifications of multiple tables. 

187 

188 Returns 

189 ------- 

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

191 All the tables created. 

192 

193 Notes 

194 ----- 

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

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

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

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

199 we cannot represent this with type annotations. 

200 """ 

201 return specs._make( # type: ignore 

202 self.addTable(name, spec) 

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

204 ) 

205 

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

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

208 

209 Initialization can mean anything that changes state of a database 

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

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

212 

213 Parameters 

214 ---------- 

215 initializer : `~collections.abc.Callable` 

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

217 """ 

218 self._initializers.append(initializer) 

219 

220 

221class Database(ABC): 

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

223 representation of a single schema/namespace/database. 

224 

225 Parameters 

226 ---------- 

227 origin : `int` 

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

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

230 primary key. 

231 engine : `sqlalchemy.engine.Engine` 

232 The SQLAlchemy engine for this `Database`. 

233 namespace : `str`, optional 

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

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

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

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

238 table definitions". 

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

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

241 provided, will be generated during the next call to 

242 ``declareStaticTables``. 

243 allow_temporary_tables : `bool`, optional 

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

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

246 tables when possible. 

247 

248 Notes 

249 ----- 

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

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

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

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

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

255 

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

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

258 significantly more sophistication while still being limited to standard 

259 SQL. 

260 

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

262 

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

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

265 `sqlalchemy.engine.Connection` object. 

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

267 the tables and other schema entities. 

268 

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

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

271 ``_connection``. 

272 """ 

273 

274 def __init__( 

275 self, 

276 *, 

277 origin: int, 

278 engine: sqlalchemy.engine.Engine, 

279 namespace: str | None = None, 

280 metadata: DatabaseMetadata | None = None, 

281 allow_temporary_tables: bool = True, 

282 ): 

283 self.origin = origin 

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

285 self.namespace = namespace 

286 self._engine = engine 

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

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

289 self._metadata = metadata 

290 self._allow_temporary_tables = allow_temporary_tables 

291 

292 def dispose(self) -> None: 

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

294 instance. 

295 """ 

296 self._engine.dispose() 

297 

298 def __repr__(self) -> str: 

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

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

301 # connection URL. 

302 if self._engine.url.password is not None: 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true

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

304 else: 

305 uri = str(self._engine.url) 

306 if self.namespace: 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true

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

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

309 

310 @classmethod 

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

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

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

314 

315 Parameters 

316 ---------- 

317 root : `str` 

318 Root string to use to build connection URI. 

319 

320 Returns 

321 ------- 

322 uri : `str` or `None` 

323 The URI string or `None`. 

324 """ 

325 return None 

326 

327 @classmethod 

328 def fromUri( 

329 cls, 

330 uri: str | sqlalchemy.engine.URL, 

331 *, 

332 origin: int, 

333 namespace: str | None = None, 

334 writeable: bool = True, 

335 allow_temporary_tables: bool = True, 

336 ) -> Database: 

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

338 

339 Parameters 

340 ---------- 

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

342 A SQLAlchemy URI connection string. 

343 origin : `int` 

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

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

346 compound primary key. 

347 namespace : `str`, optional 

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

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

350 inferred from the URI. 

351 writeable : `bool`, optional 

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

353 ``CREATE TABLE``. 

354 allow_temporary_tables : `bool`, optional 

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

356 tables. 

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

358 tables when possible. 

359 

360 Returns 

361 ------- 

362 db : `Database` 

363 A new `Database` instance. 

364 """ 

365 db = cls.fromEngine( 

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

367 ) 

368 db._allow_temporary_tables = allow_temporary_tables 

369 return db 

370 

371 @abstractmethod 

372 def clone(self) -> Database: 

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

374 

375 Returns 

376 ------- 

377 db : `Database` 

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

379 instance. 

380 """ 

381 raise NotImplementedError() 

382 

383 @classmethod 

384 @abstractmethod 

385 def makeEngine( 

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

387 ) -> sqlalchemy.engine.Engine: 

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

389 

390 Parameters 

391 ---------- 

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

393 A SQLAlchemy URI connection string. 

394 writeable : `bool`, optional 

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

396 ``CREATE TABLE``. 

397 

398 Returns 

399 ------- 

400 engine : `sqlalchemy.engine.Engine` 

401 A database engine. 

402 

403 Notes 

404 ----- 

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

406 encouraged to add optional arguments to their implementation of this 

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

408 call signature. 

409 """ 

410 raise NotImplementedError() 

411 

412 @classmethod 

413 @abstractmethod 

414 def fromEngine( 

415 cls, 

416 engine: sqlalchemy.engine.Engine, 

417 *, 

418 origin: int, 

419 namespace: str | None = None, 

420 writeable: bool = True, 

421 ) -> Database: 

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

423 

424 Parameters 

425 ---------- 

426 engine : `sqlalchemy.engine.Engine` 

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

428 instances. 

429 origin : `int` 

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

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

432 compound primary key. 

433 namespace : `str`, optional 

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

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

436 (if any) is inferred from the connection. 

437 writeable : `bool`, optional 

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

439 ``CREATE TABLE``. 

440 

441 Returns 

442 ------- 

443 db : `Database` 

444 A new `Database` instance. 

445 

446 Notes 

447 ----- 

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

449 engine, which is desirable when they represent different namespaces 

450 can be queried together. 

451 """ 

452 raise NotImplementedError() 

453 

454 @final 

455 @contextmanager 

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

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

458 connection to a database). 

459 

460 Returns 

461 ------- 

462 context : `AbstractContextManager` [ `None` ] 

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

464 

465 Notes 

466 ----- 

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

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

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

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

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

472 """ 

473 with self._session(): 

474 yield 

475 

476 @final 

477 @contextmanager 

478 def transaction( 

479 self, 

480 *, 

481 interrupting: bool = False, 

482 savepoint: bool = False, 

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

484 for_temp_tables: bool = False, 

485 ) -> Iterator[None]: 

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

487 

488 Parameters 

489 ---------- 

490 interrupting : `bool`, optional 

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

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

493 (i.e. assertion) error. 

494 savepoint : `bool`, optional 

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

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

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

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

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

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

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

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

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

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

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

506 optional 

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

508 These locks are guaranteed to prevent concurrent writes and allow 

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

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

511 requires that in order to block concurrent writes. 

512 for_temp_tables : `bool`, optional 

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

514 

515 Returns 

516 ------- 

517 context : `AbstractContextManager` [ `None` ] 

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

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

520 an exception. 

521 

522 Notes 

523 ----- 

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

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

526 be correctly managed. 

527 """ 

528 with self._transaction( 

529 interrupting=interrupting, savepoint=savepoint, lock=lock, for_temp_tables=for_temp_tables 

530 ): 

531 yield 

532 

533 @contextmanager 

534 def temporary_table( 

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

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

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

538 table. 

539 

540 Parameters 

541 ---------- 

542 spec : `ddl.TableSpec` 

543 Specification for the columns. Unique and foreign key constraints 

544 may be ignored. 

545 name : `str`, optional 

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

547 opaque but unique identifier is generated. 

548 

549 Returns 

550 ------- 

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

552 A context manager that returns a SQLAlchemy representation of the 

553 temporary table when entered. 

554 

555 Notes 

556 ----- 

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

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

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

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

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

562 """ 

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

564 if not self.supports_temporary_tables: 

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

566 with self._session() as connection: 

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

568 self._temp_tables.add(table.key) 

569 try: 

570 yield table 

571 finally: 

572 self._temp_tables.remove(table.key) 

573 self._metadata.remove_table(table.name) 

574 

575 @contextmanager 

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

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

578 connection. 

579 

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

581 SQLAlchemy connection object. It should be overridden by subclasses 

582 instead of `session` itself. 

583 

584 Returns 

585 ------- 

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

587 A context manager that returns a SQLALchemy connection when 

588 entered. 

589 

590 """ 

591 if self._session_connection is not None: 

592 # session already started, just reuse that 

593 yield self._session_connection 

594 else: 

595 try: 

596 # open new connection and close it when done 

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

598 yield self._session_connection 

599 finally: 

600 if self._session_connection is not None: 600 ↛ 604line 600 didn't jump to line 604 because the condition on line 600 was always true

601 self._session_connection.close() 

602 self._session_connection = None 

603 # Temporary tables only live within session 

604 self._temp_tables = set() 

605 

606 @contextmanager 

607 def _transaction( 

608 self, 

609 *, 

610 interrupting: bool = False, 

611 savepoint: bool = False, 

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

613 for_temp_tables: bool = False, 

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

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

616 connection and whether this is a new outermost transaction. 

617 

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

619 SQLAlchemy connection object. It should be overridden by subclasses 

620 instead of `transaction` itself. 

621 

622 Parameters 

623 ---------- 

624 interrupting : `bool`, optional 

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

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

627 (i.e. assertion) error. 

628 savepoint : `bool`, optional 

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

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

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

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

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

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

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

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

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

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

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

640 optional 

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

642 These locks are guaranteed to prevent concurrent writes and allow 

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

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

645 requires that in order to block concurrent writes. 

646 for_temp_tables : `bool`, optional 

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

648 

649 Returns 

650 ------- 

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

652 `sqlalchemy.engine.Connection` ] ] 

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

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

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

656 

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

658 transaction; 

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

660 """ 

661 with self._session() as connection: 

662 already_in_transaction = self.isInTransaction() 

663 assert not (interrupting and already_in_transaction), ( 

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

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

666 ) 

667 savepoint = savepoint or connection.in_nested_transaction() 

668 trans: sqlalchemy.engine.Transaction | None 

669 if already_in_transaction: 

670 if savepoint: 

671 trans = connection.begin_nested() 

672 else: 

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

674 trans = None 

675 else: 

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

677 # outermost context. 

678 trans = connection.begin() 

679 self._lockTables(connection, lock) 

680 try: 

681 yield not already_in_transaction, connection 

682 if trans is not None: 

683 trans.commit() 

684 except BaseException: 

685 if trans is not None: 

686 trans.rollback() 

687 raise 

688 

689 @abstractmethod 

690 def _lockTables( 

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

692 ) -> None: 

693 """Acquire locks on the given tables. 

694 

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

696 It should not be called directly by other code. 

697 

698 Parameters 

699 ---------- 

700 connection : `sqlalchemy.engine.Connection` 

701 Database connection object. It is guaranteed that transaction is 

702 already in a progress for this connection. 

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

704 optional 

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

706 These locks are guaranteed to prevent concurrent writes and allow 

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

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

709 requires that in order to block concurrent writes. 

710 """ 

711 raise NotImplementedError() 

712 

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

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

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

716 

717 Parameters 

718 ---------- 

719 table : `sqlalchemy.schema.Table` 

720 SQLAlchemy table object to check. 

721 

722 Returns 

723 ------- 

724 writeable : `bool` 

725 Whether this table is writeable. 

726 """ 

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

728 

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

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

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

732 

733 Parameters 

734 ---------- 

735 table : `sqlalchemy.schema.Table` 

736 SQLAlchemy table object to check. 

737 msg : `str`, optional 

738 If provided, raise `ReadOnlyDatabaseError` instead of returning 

739 `False`, with this message. 

740 """ 

741 if not self.isTableWriteable(table): 741 ↛ 742line 741 didn't jump to line 742 because the condition on line 741 was never true

742 raise ReadOnlyDatabaseError(msg) 

743 

744 @contextmanager 

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

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

747 can be declared. 

748 

749 Parameters 

750 ---------- 

751 create : `bool` 

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

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

754 

755 Returns 

756 ------- 

757 schema : `StaticTablesContext` 

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

759 

760 Raises 

761 ------ 

762 ReadOnlyDatabaseError 

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

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

765 

766 Examples 

767 -------- 

768 Given a `Database` instance ``db``:: 

769 

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

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

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

773 

774 Notes 

775 ----- 

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

777 tables are managed via calls to `ensureTableExists` or 

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

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

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

781 relationships. 

782 """ 

783 if create and not self.isWriteable(): 

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

785 

786 self._metadata = DatabaseMetadata(self.namespace) 

787 try: 

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

789 context = StaticTablesContext(self, connection) 

790 if create and context._tableNames: 

791 # Looks like database is already initalized, to avoid 

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

793 # do anything in this case 

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

795 yield context 

796 if create: 

797 if ( 797 ↛ 801line 797 didn't jump to line 801 because the condition on line 797 was never true

798 self.namespace is not None 

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

800 ): 

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

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

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

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

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

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

807 # deprecation warnings when tables are created. 

808 with warnings.catch_warnings(): 

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

810 self._metadata.create_all(connection) 

811 # call all initializer methods sequentially 

812 for init in context._initializers: 812 ↛ 813line 812 didn't jump to line 813 because the loop on line 812 never started

813 init(self) 

814 except BaseException: 

815 self._metadata = None 

816 raise 

817 

818 @abstractmethod 

819 def isWriteable(self) -> bool: 

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

821 raise NotImplementedError() 

822 

823 def isInTransaction(self) -> bool: 

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

825 an active transaction; `False` otherwise. 

826 """ 

827 session = self._session_connection 

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

829 

830 @abstractmethod 

831 def __str__(self) -> str: 

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

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

834 """ 

835 raise NotImplementedError() 

836 

837 @property 

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

839 """The SQLAlchemy dialect for this database engine 

840 (`sqlalchemy.engine.Dialect`). 

841 """ 

842 return self._engine.dialect 

843 

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

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

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

847 names. 

848 

849 Implementations should not assume that simple truncation is safe, 

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

851 

852 The default implementation simply returns the given name. 

853 

854 Parameters 

855 ---------- 

856 original : `str` 

857 The original name. 

858 

859 Returns 

860 ------- 

861 shrunk : `str` 

862 The new, possibly shortened name. 

863 """ 

864 return original 

865 

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

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

868 to fit within the database engine's limits. 

869 

870 Parameters 

871 ---------- 

872 shrunk : `str` 

873 The original name. 

874 

875 Returns 

876 ------- 

877 shrunk : `str` 

878 The new, possibly shortened name. 

879 """ 

880 return shrunk 

881 

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

883 """Create constraints based on this spec. 

884 

885 Parameters 

886 ---------- 

887 table : `str` 

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

889 spec : `FieldSpec` 

890 Specification for the field to be added. 

891 

892 Returns 

893 ------- 

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

895 Constraint added for this column. 

896 """ 

897 # By default we return no additional constraints 

898 return [] 

899 

900 def _convertFieldSpec( 

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

902 ) -> sqlalchemy.schema.Column: 

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

904 

905 Parameters 

906 ---------- 

907 table : `str` 

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

909 spec : `FieldSpec` 

910 Specification for the field to be added. 

911 metadata : `sqlalchemy.MetaData` 

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

913 being added to. 

914 **kwargs 

915 Additional keyword arguments to forward to the 

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

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

918 making only minor changes. 

919 

920 Returns 

921 ------- 

922 column : `sqlalchemy.schema.Column` 

923 SQLAlchemy representation of the field. 

924 """ 

925 args = [] 

926 if spec.autoincrement: 

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

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

929 # sqlalchemy for databases that do support it. 

930 args.append( 

931 sqlalchemy.Sequence( 

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

933 ) 

934 ) 

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

936 return sqlalchemy.schema.Column( 

937 spec.name, 

938 spec.getSizedColumnType(), 

939 *args, 

940 nullable=spec.nullable, 

941 primary_key=spec.primaryKey, 

942 comment=spec.doc, 

943 server_default=spec.default, 

944 **kwargs, 

945 ) 

946 

947 def _convertForeignKeySpec( 

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

949 ) -> sqlalchemy.schema.ForeignKeyConstraint: 

950 """Convert a `ForeignKeySpec` to a 

951 `sqlalchemy.schema.ForeignKeyConstraint`. 

952 

953 Parameters 

954 ---------- 

955 table : `str` 

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

957 spec : `ForeignKeySpec` 

958 Specification for the foreign key to be added. 

959 metadata : `sqlalchemy.MetaData` 

960 SQLAlchemy representation of the DDL schema this constraint is 

961 being added to. 

962 **kwargs 

963 Additional keyword arguments to forward to the 

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

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

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

967 

968 Returns 

969 ------- 

970 constraint : `sqlalchemy.schema.ForeignKeyConstraint` 

971 SQLAlchemy representation of the constraint. 

972 """ 

973 name = self.shrinkDatabaseEntityName( 

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

975 ) 

976 return sqlalchemy.schema.ForeignKeyConstraint( 

977 spec.source, 

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

979 name=name, 

980 ondelete=spec.onDelete, 

981 ) 

982 

983 def _convertExclusionConstraintSpec( 

984 self, 

985 table: str, 

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

987 metadata: sqlalchemy.MetaData, 

988 ) -> sqlalchemy.schema.Constraint: 

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

990 constraint representation. 

991 

992 Parameters 

993 ---------- 

994 table : `str` 

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

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

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

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

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

1000 constraint. 

1001 metadata : `sqlalchemy.MetaData` 

1002 SQLAlchemy representation of the DDL schema this constraint is 

1003 being added to. 

1004 

1005 Returns 

1006 ------- 

1007 constraint : `sqlalchemy.schema.Constraint` 

1008 SQLAlchemy representation of the constraint. 

1009 

1010 Raises 

1011 ------ 

1012 NotImplementedError 

1013 Raised if this database does not support exclusion constraints. 

1014 """ 

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

1016 

1017 def _convertTableSpec( 

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

1019 ) -> sqlalchemy.schema.Table: 

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

1021 

1022 Parameters 

1023 ---------- 

1024 name : `str` 

1025 The name of the table. 

1026 spec : `TableSpec` 

1027 Specification for the foreign key to be added. 

1028 metadata : `sqlalchemy.MetaData` 

1029 SQLAlchemy representation of the DDL schema this table is being 

1030 added to. 

1031 **kwargs 

1032 Additional keyword arguments to forward to the 

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

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

1035 only minor changes. 

1036 

1037 Returns 

1038 ------- 

1039 table : `sqlalchemy.schema.Table` 

1040 SQLAlchemy representation of the table. 

1041 

1042 Notes 

1043 ----- 

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

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

1046 `ensureTableExists`, `getExistingTable`, and `declareStaticTables`. 

1047 """ 

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

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

1050 ] 

1051 

1052 # Add any column constraints 

1053 for fieldSpec in spec.fields: 

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

1055 

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

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

1058 # those. 

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

1060 args.extend( 

1061 sqlalchemy.schema.UniqueConstraint( 

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

1063 ) 

1064 for columns in spec.unique 

1065 ) 

1066 allIndexes.update(spec.unique) 

1067 args.extend( 

1068 sqlalchemy.schema.Index( 

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

1070 *index.columns, 

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

1072 **index.kwargs, 

1073 ) 

1074 for index in spec.indexes 

1075 if index.columns not in allIndexes 

1076 ) 

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

1078 args.extend( 

1079 sqlalchemy.schema.Index( 

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

1081 *fk.source, 

1082 ) 

1083 for fk in spec.foreignKeys 

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

1085 ) 

1086 

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

1088 

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

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

1091 

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

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

1094 creating it if necessary. 

1095 

1096 Parameters 

1097 ---------- 

1098 name : `str` 

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

1100 spec : `TableSpec` 

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

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

1103 for consistency, but no such check is guaranteed. 

1104 

1105 Returns 

1106 ------- 

1107 table : `sqlalchemy.schema.Table` 

1108 SQLAlchemy representation of the table. 

1109 

1110 Raises 

1111 ------ 

1112 ReadOnlyDatabaseError 

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

1114 already exist. 

1115 DatabaseConflictError 

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

1117 definition. 

1118 

1119 Notes 

1120 ----- 

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

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

1123 exist. 

1124 

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

1126 """ 

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

1128 # connection and should not interfere with current transaction 

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

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

1131 table = self.getExistingTable(name, spec) 

1132 if table is not None: 

1133 return table 

1134 if not self.isWriteable(): 

1135 raise ReadOnlyDatabaseError( 

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

1137 ) 

1138 

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

1140 try: 

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

1142 table.create(connection) 

1143 except sqlalchemy.exc.DatabaseError: 

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

1145 # usually causes OperationalError or ProgrammingError. We cannot 

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

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

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

1149 # re-check whether table is now there. 

1150 table = self.getExistingTable(name, spec) 

1151 if table is None: 1151 ↛ 1152line 1151 didn't jump to line 1152 because the condition on line 1151 was never true

1152 raise 

1153 return table 

1154 

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

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

1157 

1158 Parameters 

1159 ---------- 

1160 name : `str` 

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

1162 spec : `TableSpec` 

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

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

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

1166 

1167 Returns 

1168 ------- 

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

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

1171 exist. 

1172 

1173 Raises 

1174 ------ 

1175 DatabaseConflictError 

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

1177 definition. 

1178 

1179 Notes 

1180 ----- 

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

1182 database. 

1183 

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

1185 """ 

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

1187 table = self._metadata.get_table(name) 

1188 if table is not None: 

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

1190 raise DatabaseConflictError( 

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

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

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

1194 ) 

1195 else: 

1196 inspector = sqlalchemy.inspect( 

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

1198 ) 

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

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

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

1202 return table 

1203 

1204 def _make_temporary_table( 

1205 self, 

1206 connection: sqlalchemy.engine.Connection, 

1207 spec: ddl.TableSpec, 

1208 name: str | None = None, 

1209 **kwargs: Any, 

1210 ) -> sqlalchemy.schema.Table: 

1211 """Create a temporary table. 

1212 

1213 Parameters 

1214 ---------- 

1215 connection : `sqlalchemy.engine.Connection` 

1216 Connection to use when creating the table. 

1217 spec : `TableSpec` 

1218 Specification for the table. 

1219 name : `str`, optional 

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

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

1222 provided, a unique name will be generated. 

1223 **kwargs 

1224 Additional keyword arguments to forward to the 

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

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

1227 only minor changes. 

1228 

1229 Returns 

1230 ------- 

1231 table : `sqlalchemy.schema.Table` 

1232 SQLAlchemy representation of the table. 

1233 """ 

1234 if name is None: 

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

1236 if self._metadata is None: 1236 ↛ 1237line 1236 didn't jump to line 1237 because the condition on line 1236 was never true

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

1238 table = self._metadata.add_table( 

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

1240 ) 

1241 if table.key in self._temp_tables and table.key != name: 1241 ↛ 1242line 1241 didn't jump to line 1242 because the condition on line 1241 was never true

1242 raise ValueError( 

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

1244 ) 

1245 with self._transaction(): 

1246 table.create(connection) 

1247 return table 

1248 

1249 @classmethod 

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

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

1252 stored in this database. 

1253 

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

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

1256 and queries are consistent with it. 

1257 

1258 Returns 

1259 ------- 

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

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

1262 stored in this database. 

1263 

1264 Notes 

1265 ----- 

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

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

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

1269 

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

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

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

1273 timespan-representation-aware implementation of, say, 

1274 `Database.insert`, would need to have extra logic to identify when 

1275 timespan-mangling needed to occur, which would usually be useless 

1276 overhead. 

1277 

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

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

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

1281 that code in our own interfaces to encapsulate timespan 

1282 representations there. 

1283 """ 

1284 return TimespanDatabaseRepresentation.Compound 

1285 

1286 def sync( 

1287 self, 

1288 table: sqlalchemy.schema.Table, 

1289 *, 

1290 keys: dict[str, Any], 

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

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

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

1294 update: bool = False, 

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

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

1297 values equivalent to the given ones. 

1298 

1299 Parameters 

1300 ---------- 

1301 table : `sqlalchemy.schema.Table` 

1302 Table to be queried and possibly inserted into. 

1303 keys : `dict` 

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

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

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

1307 the insert. 

1308 compared : `dict`, optional 

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

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

1311 insert. 

1312 extra : `dict`, optional 

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

1314 but used in an insert if one is necessary. 

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

1316 The names of columns whose values should be returned. 

1317 update : `bool`, optional 

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

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

1320 

1321 Returns 

1322 ------- 

1323 row : `dict`, optional 

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

1325 ``returning`` is `None`. 

1326 inserted_or_updated : `bool` or `dict` 

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

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

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

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

1331 obtained from ``compared``). 

1332 

1333 Raises 

1334 ------ 

1335 DatabaseConflictError 

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

1337 database. 

1338 ReadOnlyDatabaseError 

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

1340 already exists. 

1341 

1342 Notes 

1343 ----- 

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

1345 perform operations that interrupt transactions. 

1346 

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

1348 does in fact already exist. 

1349 """ 

1350 

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

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

1353 to what was given by the caller. 

1354 

1355 Returns 

1356 ------- 

1357 n : `int` 

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

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

1360 being called. 

1361 bad : `dict` or `None` 

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

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

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

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

1366 result : `list` or `None` 

1367 Results in the database that correspond to the columns given 

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

1369 """ 

1370 toSelect: set[str] = set() 

1371 if compared is not None: 

1372 toSelect.update(compared.keys()) 

1373 if returning is not None: 

1374 toSelect.update(returning) 

1375 if not toSelect: 

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

1377 # how many rows we get back. 

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

1379 selectSql = ( 

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

1381 .select_from(table) 

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

1383 ) 

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

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

1386 if len(fetched) != 1: 

1387 return len(fetched), None, None 

1388 existing = fetched[0] 

1389 if compared is not None: 

1390 

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

1392 if isinstance(a, astropy.time.Time): 1392 ↛ 1393line 1392 didn't jump to line 1393 because the condition on line 1392 was never true

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

1394 return a != b 

1395 

1396 inconsistencies = { 

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

1398 } 

1399 else: 

1400 inconsistencies = {} 

1401 if returning is not None: 

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

1403 else: 

1404 toReturn = None 

1405 return 1, inconsistencies, toReturn 

1406 

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

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

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

1410 """ 

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

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

1413 

1414 if self.isTableWriteable(table): 

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

1416 # ways). 

1417 row = keys.copy() 

1418 if compared is not None: 

1419 row.update(compared) 

1420 if extra is not None: 

1421 row.update(extra) 

1422 with self.transaction(): 

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

1424 inserted_or_updated: bool | dict[str, Any] 

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

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

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

1428 # can reduce duplication between this block and the other 

1429 # ones that perform similar logic. 

1430 n, bad, result = check() 

1431 if n < 1: 1431 ↛ 1432line 1431 didn't jump to line 1432 because the condition on line 1431 was never true

1432 raise ConflictingDefinitionError( 

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

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

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

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

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

1438 ) 

1439 elif n > 1: 1439 ↛ 1440line 1439 didn't jump to line 1440 because the condition on line 1439 was never true

1440 raise RuntimeError( 

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

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

1443 ) 

1444 elif bad: 

1445 assert compared is not None, ( 

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

1447 ) 

1448 if inserted: 1448 ↛ 1449line 1448 didn't jump to line 1449 because the condition on line 1448 was never true

1449 raise RuntimeError( 

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

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

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

1453 "daf_butler." 

1454 ) 

1455 elif update: 

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

1457 connection.execute( 

1458 table.update() 

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

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

1461 ) 

1462 inserted_or_updated = bad 

1463 else: 

1464 raise DatabaseConflictError( 

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

1466 ) 

1467 else: 

1468 inserted_or_updated = inserted 

1469 else: 

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

1471 n, bad, result = check() 

1472 if n < 1: 

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

1474 elif n > 1: 1474 ↛ 1475line 1474 didn't jump to line 1475 because the condition on line 1474 was never true

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

1476 elif bad: 1476 ↛ 1477line 1476 didn't jump to line 1477 because the condition on line 1476 was never true

1477 if update: 

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

1479 else: 

1480 raise DatabaseConflictError( 

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

1482 ) 

1483 inserted_or_updated = False 

1484 if returning is None: 

1485 return None, inserted_or_updated 

1486 else: 

1487 assert result is not None 

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

1489 

1490 def insert( 

1491 self, 

1492 table: sqlalchemy.schema.Table, 

1493 *rows: dict, 

1494 returnIds: bool = False, 

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

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

1497 ) -> list[int] | None: 

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

1499 autoincrement primary key values. 

1500 

1501 Parameters 

1502 ---------- 

1503 table : `sqlalchemy.schema.Table` 

1504 Table rows should be inserted into. 

1505 *rows : `dict` 

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

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

1508 be the same. 

1509 returnIds : `bool`, optional 

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

1511 autoincrement primary key field (which much exist). 

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

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

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

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

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

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

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

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

1520 

1521 Returns 

1522 ------- 

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

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

1525 values for the table's autoincrement primary key. 

1526 

1527 Raises 

1528 ------ 

1529 ReadOnlyDatabaseError 

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

1531 

1532 Notes 

1533 ----- 

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

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

1536 `True`. 

1537 

1538 Derived classes should reimplement when they can provide a more 

1539 efficient implementation (especially for the latter case). 

1540 

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

1542 perform operations that interrupt transactions. 

1543 """ 

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

1545 if select is not None and (rows or returnIds): 1545 ↛ 1546line 1545 didn't jump to line 1546 because the condition on line 1545 was never true

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

1547 if not rows and select is None: 

1548 if returnIds: 1548 ↛ 1549line 1548 didn't jump to line 1549 because the condition on line 1548 was never true

1549 return [] 

1550 else: 

1551 return None 

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

1553 if not returnIds: 

1554 if select is not None: 

1555 if names is None: 

1556 names = select.selected_columns.keys() 

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

1558 else: 

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

1560 return None 

1561 else: 

1562 sql = table.insert() 

1563 ids = [] 

1564 for row in rows: 

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

1566 assert key is not None 

1567 ids.append(key[0]) 

1568 return ids 

1569 

1570 @abstractmethod 

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

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

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

1574 constraint. 

1575 

1576 Parameters 

1577 ---------- 

1578 table : `sqlalchemy.schema.Table` 

1579 Table rows should be inserted into. 

1580 *rows 

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

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

1583 be the same. 

1584 

1585 Raises 

1586 ------ 

1587 ReadOnlyDatabaseError 

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

1589 

1590 Notes 

1591 ----- 

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

1593 perform operations that interrupt transactions. 

1594 

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

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

1597 violated. 

1598 

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

1600 with autoincrement keys. 

1601 """ 

1602 raise NotImplementedError() 

1603 

1604 @abstractmethod 

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

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

1607 insertion would violate a unique constraint. 

1608 

1609 Parameters 

1610 ---------- 

1611 table : `sqlalchemy.schema.Table` 

1612 Table rows should be inserted into. 

1613 *rows 

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

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

1616 be the same. 

1617 primary_key_only : `bool`, optional 

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

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

1620 transactions) for other constraint violations. 

1621 

1622 Returns 

1623 ------- 

1624 count : `int` 

1625 The number of rows actually inserted. 

1626 

1627 Raises 

1628 ------ 

1629 ReadOnlyDatabaseError 

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

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

1632 writeable database. 

1633 

1634 Notes 

1635 ----- 

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

1637 perform operations that interrupt transactions. 

1638 

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

1640 with autoincrement keys. 

1641 """ 

1642 raise NotImplementedError() 

1643 

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

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

1646 

1647 Parameters 

1648 ---------- 

1649 table : `sqlalchemy.schema.Table` 

1650 Table that rows should be deleted from. 

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

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

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

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

1655 *rows 

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

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

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

1659 

1660 Returns 

1661 ------- 

1662 count : `int` 

1663 Number of rows deleted. 

1664 

1665 Raises 

1666 ------ 

1667 ReadOnlyDatabaseError 

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

1669 

1670 Notes 

1671 ----- 

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

1673 perform operations that interrupt transactions. 

1674 

1675 The default implementation should be sufficient for most derived 

1676 classes. 

1677 """ 

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

1679 if columns and not rows: 1679 ↛ 1685line 1679 didn't jump to line 1685 because the condition on line 1679 was never true

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

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

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

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

1684 # while reporting that no rows were affected. 

1685 return 0 

1686 sql = table.delete() 

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

1688 

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

1690 # variable changing across all rows. 

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

1692 if len(columns) == 1: 

1693 # Nothing to calculate since we can always use IN 

1694 column = columns[0] 

1695 changing_columns = [column] 

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

1697 else: 

1698 for row in rows: 

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

1700 content[k].add(v) 

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

1702 

1703 if not columns: 

1704 # No columns means to delete all rows. 

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

1706 return connection.execute(sql).rowcount 

1707 elif len(changing_columns) != 1: 

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

1709 # parameters and have each row processed separately. 

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

1711 if whereTerms: 1711 ↛ 1713line 1711 didn't jump to line 1713 because the condition on line 1711 was always true

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

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

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

1715 else: 

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

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

1718 # efficient. 

1719 name = changing_columns.pop() 

1720 

1721 # Simple where clause for the unchanging columns 

1722 clauses = [] 

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

1724 if k == name: 

1725 continue 

1726 # The set only has one element 

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

1728 

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

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

1731 in_content = list(content[name]) 

1732 n_elements = len(in_content) 

1733 

1734 rowcount = 0 

1735 iposn = 0 

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

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

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

1739 endpos = iposn + n_per_loop 

1740 in_clause = self.make_in_array_constraint(table.columns[name], in_content[iposn:endpos]) 

1741 

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

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

1744 return rowcount 

1745 

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

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

1748 

1749 Parameters 

1750 ---------- 

1751 table : `sqlalchemy.schema.Table` 

1752 Table that rows should be deleted from. 

1753 where : `sqlalchemy.sql.ClauseElement` 

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

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

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

1757 

1758 Returns 

1759 ------- 

1760 count : `int` 

1761 Number of rows deleted. 

1762 

1763 Raises 

1764 ------ 

1765 ReadOnlyDatabaseError 

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

1767 

1768 Notes 

1769 ----- 

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

1771 perform operations that interrupt transactions. 

1772 

1773 The default implementation should be sufficient for most derived 

1774 classes. 

1775 """ 

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

1777 

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

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

1780 return connection.execute(sql).rowcount 

1781 

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

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

1784 

1785 Parameters 

1786 ---------- 

1787 table : `sqlalchemy.schema.Table` 

1788 Table containing the rows to be updated. 

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

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

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

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

1793 SQLAlchemy limitations. 

1794 *rows 

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

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

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

1798 updated. 

1799 

1800 Returns 

1801 ------- 

1802 count : `int` 

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

1804 modified them). 

1805 

1806 Raises 

1807 ------ 

1808 ReadOnlyDatabaseError 

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

1810 

1811 Notes 

1812 ----- 

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

1814 perform operations that interrupt transactions. 

1815 

1816 The default implementation should be sufficient for most derived 

1817 classes. 

1818 """ 

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

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

1821 return 0 

1822 sql = table.update().where( 

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

1824 ) 

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

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

1827 

1828 @contextmanager 

1829 def query( 

1830 self, 

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

1832 *args: Any, 

1833 **kwargs: Any, 

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

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

1836 

1837 Parameters 

1838 ---------- 

1839 sql : `sqlalchemy.sql.expression.SelectBase` 

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

1841 *args 

1842 Additional positional arguments are forwarded to 

1843 `sqlalchemy.engine.Connection.execute`. 

1844 **kwargs 

1845 Additional keyword arguments are forwarded to 

1846 `sqlalchemy.engine.Connection.execute`. 

1847 

1848 Returns 

1849 ------- 

1850 result_context : `sqlalchemy.engine.CursorResults` 

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

1852 These results are invalidated when the context is exited. 

1853 """ 

1854 if self._session_connection is None: 

1855 connection = self._engine.connect() 

1856 else: 

1857 connection = self._session_connection 

1858 try: 

1859 self._log_query_and_plan(connection, sql) 

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

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

1862 # at some point. 

1863 with connection.execute( 

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

1865 ) as result: 

1866 yield result 

1867 finally: 

1868 if connection is not self._session_connection: 

1869 connection.close() 

1870 

1871 def _log_query_and_plan( 

1872 self, 

1873 connection: sqlalchemy.Connection, 

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

1875 ) -> None: 

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

1877 the environment variable DAF_BUTLER_DEBUG_QUERIES is set to a truthy 

1878 value. 

1879 """ 

1880 if os.environ.get("DAF_BUTLER_DEBUG_QUERIES", False): 1880 ↛ 1881line 1880 didn't jump to line 1881 because the condition on line 1880 was never true

1881 assert isinstance(sql, sqlalchemy.SelectBase) 

1882 compiled = sql.compile(connection) 

1883 print( 

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

1885 ) 

1886 

1887 query_plan = get_query_plan(connection, sql) 

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

1889 

1890 @abstractmethod 

1891 def constant_rows( 

1892 self, 

1893 fields: NamedValueAbstractSet[ddl.FieldSpec], 

1894 *rows: dict, 

1895 name: str | None = None, 

1896 ) -> sqlalchemy.sql.FromClause: 

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

1898 constant-valued rows. 

1899 

1900 Parameters 

1901 ---------- 

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

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

1904 ignored. 

1905 *rows : `dict` 

1906 Values for the rows. 

1907 name : `str`, optional 

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

1909 opaque but unique identifier is generated. 

1910 

1911 Returns 

1912 ------- 

1913 from_clause : `sqlalchemy.sql.FromClause` 

1914 SQLAlchemy object representing the given rows. This is guaranteed 

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

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

1917 that needs to be cleaned up later. 

1918 

1919 Notes 

1920 ----- 

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

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

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

1924 delegation to `super`. 

1925 """ 

1926 if name is None: 

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

1928 return sqlalchemy.sql.values( 

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

1930 name=name, 

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

1932 

1933 def get_constant_rows_max(self) -> int: 

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

1935 `constant_rows` for this backend. 

1936 

1937 Returns 

1938 ------- 

1939 max : `int` 

1940 Maximum number of rows. 

1941 

1942 Notes 

1943 ----- 

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

1945 not just hard database engine limits. 

1946 """ 

1947 return 1000 

1948 

1949 @abstractmethod 

1950 def glob_expression( 

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

1952 ) -> sqlalchemy.ColumnElement[bool]: 

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

1954 pattern. 

1955 

1956 Parameters 

1957 ---------- 

1958 expression : `sqlalchemy.ColumnElement` 

1959 Column expression that evaluates to string. 

1960 pattern : `str` 

1961 GLob pattern string. 

1962 

1963 Returns 

1964 ------- 

1965 glob : `sqlalchemy.ColumnElement` 

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

1967 """ 

1968 raise NotImplementedError() 

1969 

1970 @property 

1971 @abstractmethod 

1972 def has_distinct_on(self) -> bool: 

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

1974 raise NotImplementedError() 

1975 

1976 @property 

1977 @abstractmethod 

1978 def has_any_aggregate(self) -> bool: 

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

1980 or something equivalent. 

1981 """ 

1982 raise NotImplementedError() 

1983 

1984 @property 

1985 def supports_temporary_tables(self) -> bool: 

1986 return self._allow_temporary_tables 

1987 

1988 @abstractmethod 

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

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

1991 function or its equivalent. 

1992 

1993 Parameters 

1994 ---------- 

1995 column : `sqlalchemy.ColumnElement` 

1996 Original column to wrap. 

1997 

1998 Returns 

1999 ------- 

2000 wrapped : `sqlalchemy.ColumnElement` 

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

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

2003 ``GROUP BY`` clause. 

2004 

2005 Notes 

2006 ----- 

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

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

2009 """ 

2010 raise NotImplementedError() 

2011 

2012 def make_in_array_constraint( 

2013 self, column: sqlalchemy.ColumnElement[_T], values: Iterable[_T] 

2014 ) -> sqlalchemy.ColumnElement[bool]: 

2015 return column.in_(values) 

2016 

2017 origin: int 

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

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

2020 primary key (`int`). 

2021 """ 

2022 

2023 namespace: str | None 

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

2025 (`str` or `None`). 

2026 """ 

2027 

2028 name_shrinker: NameShrinker 

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

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

2031 """ 

2032 

2033 

2034class DatabaseMetadata: 

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

2036 

2037 Parameters 

2038 ---------- 

2039 namespace : `str` or `None` 

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

2041 

2042 Notes 

2043 ----- 

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

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

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

2047 the same connection pool. 

2048 """ 

2049 

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

2051 self._lock = Lock() 

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

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

2054 

2055 def add_table( 

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

2057 ) -> sqlalchemy.schema.Table: 

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

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

2060 database -- it only sets up its definition. 

2061 

2062 Parameters 

2063 ---------- 

2064 db : `Database` 

2065 Database connection associated with the table definition. 

2066 name : `str` 

2067 The name of the table. 

2068 spec : `ddl.TableSpec` 

2069 The specification of the table. 

2070 **kwargs 

2071 Additional keyword arguments to forward to the 

2072 `sqlalchemy.schema.Table` constructor. 

2073 

2074 Returns 

2075 ------- 

2076 table : `sqlalchemy.schema.Table` 

2077 The created table. 

2078 """ 

2079 with self._lock: 

2080 if (table := self._tables.get(name)) is not None: 2080 ↛ 2081line 2080 didn't jump to line 2081 because the condition on line 2080 was never true

2081 return table 

2082 

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

2084 for foreignKeySpec in spec.foreignKeys: 

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

2086 

2087 self._tables[name] = table 

2088 return table 

2089 

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

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

2092 MetaData object. 

2093 

2094 Parameters 

2095 ---------- 

2096 name : `str` 

2097 Name of the table. 

2098 

2099 Returns 

2100 ------- 

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

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

2103 MetaData instance. 

2104 """ 

2105 with self._lock: 

2106 return self._tables.get(name) 

2107 

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

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

2110 

2111 Parameters 

2112 ---------- 

2113 name : `str` 

2114 Name of the table. 

2115 """ 

2116 with self._lock: 

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

2118 if table is not None: 2118 ↛ exitline 2118 didn't jump to the function exit

2119 self._metadata.remove(table) 

2120 

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

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

2123 Same as `sqlalchemy.MetaData.create_all`. 

2124 

2125 Parameters 

2126 ---------- 

2127 connection : `sqlalchemy.engine.connection` 

2128 Database connection that will be used to create tables. 

2129 """ 

2130 with self._lock: 

2131 self._metadata.create_all(connection)