Coverage for python / lsst / daf / butler / registry / databases / postgresql.py: 24%

252 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-22 01:48 -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 sqlalchemy.sql.expression import ColumnElement as ColumnElement 

30 

31from ... import ddl, time_utils 

32 

33__all__ = ["PostgresqlDatabase"] 

34 

35import re 

36from collections.abc import Callable, Iterable, Iterator, Mapping 

37from contextlib import closing, contextmanager 

38from typing import Any 

39 

40import psycopg2 

41import sqlalchemy 

42import sqlalchemy.dialects.postgresql 

43from sqlalchemy import sql 

44 

45from ..._named import NamedValueAbstractSet 

46from ..._timespan import Timespan 

47from ...timespan_database_representation import TimespanDatabaseRepresentation 

48from ..interfaces import Database, DatabaseMetadata 

49 

50 

51class PostgresqlDatabase(Database): 

52 """An implementation of the `Database` interface for PostgreSQL. 

53 

54 Parameters 

55 ---------- 

56 engine : `sqlalchemy.engine.Engine` 

57 Engine to use for this connection. 

58 origin : `int` 

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

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

61 primary key. 

62 namespace : `str`, optional 

63 The namespace (schema) this database is associated with. If `None`, 

64 the default schema for the connection is used (which may be `None`). 

65 writeable : `bool`, optional 

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

67 ``CREATE TABLE``. 

68 allow_temporary_tables : `bool`, optional 

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

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

71 tables when possible. 

72 

73 Notes 

74 ----- 

75 This currently requires the psycopg2 driver to be used as the backend for 

76 SQLAlchemy. Running the tests for this class requires the 

77 ``testing.postgresql`` be installed, which we assume indicates that a 

78 PostgreSQL server is installed and can be run locally in userspace. 

79 

80 Some functionality provided by this class (and used by `Registry`) requires 

81 the ``btree_gist`` PostgreSQL server extension to be installed an enabled 

82 on the database being connected to; this is checked at connection time. 

83 """ 

84 

85 def __init__( 

86 self, 

87 *, 

88 engine: sqlalchemy.engine.Engine, 

89 origin: int, 

90 namespace: str | None = None, 

91 writeable: bool = True, 

92 allow_temporary_tables: bool = True, 

93 ): 

94 with engine.connect() as connection: 

95 # `Any` to make mypy ignore the line below, can't use type: ignore 

96 dbapi: Any = connection.connection 

97 try: 

98 dsn = dbapi.get_dsn_parameters() 

99 except (AttributeError, KeyError) as err: 

100 raise RuntimeError("Only the psycopg2 driver for PostgreSQL is supported.") from err 

101 if namespace is None: 

102 query = sql.select(sql.func.current_schema()) 

103 namespace = connection.execute(query).scalar() 

104 query_text = "SELECT COUNT(*) FROM pg_extension WHERE extname='btree_gist';" 

105 if not connection.execute(sqlalchemy.text(query_text)).scalar(): 

106 raise RuntimeError( 

107 "The Butler PostgreSQL backend requires the btree_gist extension. " 

108 "As extensions are enabled per-database, this may require an administrator to run " 

109 "`CREATE EXTENSION btree_gist;` in a database before a butler client for it is " 

110 " initialized." 

111 ) 

112 

113 pg_version = get_postgres_server_version(connection) 

114 self._init( 

115 engine=engine, 

116 origin=origin, 

117 namespace=namespace, 

118 writeable=writeable, 

119 dbname=dsn.get("dbname"), 

120 metadata=None, 

121 pg_version=pg_version, 

122 allow_temporary_tables=allow_temporary_tables, 

123 ) 

124 

125 def _init( 

126 self, 

127 *, 

128 engine: sqlalchemy.engine.Engine, 

129 origin: int, 

130 namespace: str | None = None, 

131 writeable: bool = True, 

132 dbname: str, 

133 metadata: DatabaseMetadata | None, 

134 pg_version: tuple[int, int], 

135 allow_temporary_tables: bool = True, 

136 ) -> None: 

137 # Initialization logic shared between ``__init__`` and ``clone``. 

138 super().__init__( 

139 origin=origin, 

140 engine=engine, 

141 namespace=namespace, 

142 metadata=metadata, 

143 allow_temporary_tables=allow_temporary_tables, 

144 ) 

145 self._writeable = writeable 

146 self.dbname = dbname 

147 self._pg_version = pg_version 

148 

149 def clone(self) -> PostgresqlDatabase: 

150 clone = self.__new__(type(self)) 

151 clone._init( 

152 origin=self.origin, 

153 engine=self._engine, 

154 namespace=self.namespace, 

155 writeable=self._writeable, 

156 dbname=self.dbname, 

157 metadata=self._metadata, 

158 pg_version=self._pg_version, 

159 allow_temporary_tables=self._allow_temporary_tables, 

160 ) 

161 return clone 

162 

163 @classmethod 

164 def makeEngine( 

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

166 ) -> sqlalchemy.engine.Engine: 

167 return sqlalchemy.engine.create_engine( 

168 uri, 

169 # Prevent stale database connections from throwing exeptions, at 

170 # the expense of a round trip to the database server each time we 

171 # check out a session. Many services using the Butler operate in 

172 # networks where connections are dropped when idle for some time. 

173 pool_pre_ping=True, 

174 # This engine and database connection pool can be shared between 

175 # multiple Butler instances created via Butler.clone() or 

176 # LabeledButlerFactory, and typically these will be used from 

177 # multiple threads simultaneously. So we need to configure 

178 # SQLAlchemy to pool connections for multi-threaded usage. 

179 # 

180 # This pool size was chosen to work well for services using 

181 # FastAPI. FastAPI uses a thread pool of 40 by default, so this 

182 # gives us a connection for each thread in the pool. Because Butler 

183 # is currently sync-only, we won't ever be executing more queries 

184 # than we have threads. 

185 # 

186 # Connections are only created as they are needed, so in typical 

187 # single-threaded Butler use only one connection will ever be 

188 # created. Services with low peak concurrency may never create this 

189 # many connections. 

190 # 

191 # The main Butler databases at SLAC are run behind pgbouncer, so we 

192 # can support a larger number of simultaneous connections than if 

193 # we were connecting directly to Postgres. 

194 # 

195 # See 

196 # https://docs.sqlalchemy.org/en/20/core/pooling.html#sqlalchemy.pool.QueuePool.__init__ 

197 # for more information on the behavior of this parameter. 

198 pool_size=40, 

199 # If we are experiencing heavy enough load that we overflow the 

200 # connection pool, it will be harmful to start creating extra 

201 # connections that we disconnect immediately after use. 

202 # Connecting from scratch is fairly expensive, which is why we have 

203 # a pool in the first place. 

204 max_overflow=0, 

205 # If the pool is full, this is the maximum number of seconds we 

206 # will wait for a connection to become available before giving up. 

207 pool_timeout=60, 

208 # In combination with pool_pre_ping, prevent SQLAlchemy from 

209 # unnecessarily reviving pooled connections that have gone stale. 

210 # Setting this to true makes it always re-use the most recent 

211 # known-good connection when possible, instead of cycling to other 

212 # connections in the pool that we may no longer need. 

213 pool_use_lifo=True, 

214 ) 

215 

216 @classmethod 

217 def fromEngine( 

218 cls, 

219 engine: sqlalchemy.engine.Engine, 

220 *, 

221 origin: int, 

222 namespace: str | None = None, 

223 writeable: bool = True, 

224 ) -> Database: 

225 return cls(engine=engine, origin=origin, namespace=namespace, writeable=writeable) 

226 

227 @contextmanager 

228 def _transaction( 

229 self, 

230 *, 

231 interrupting: bool = False, 

232 savepoint: bool = False, 

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

234 for_temp_tables: bool = False, 

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

236 with super()._transaction(interrupting=interrupting, savepoint=savepoint, lock=lock) as ( 

237 is_new, 

238 connection, 

239 ): 

240 if is_new: 

241 # pgbouncer with transaction-level pooling (which we aim to 

242 # support) says that SET cannot be used, except for a list of 

243 # "Startup parameters" that includes "timezone" (see 

244 # https://www.pgbouncer.org/features.html#fnref:0). But I 

245 # don't see "timezone" in PostgreSQL's list of parameters 

246 # passed when creating a new connection 

247 # (https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS). 

248 # Given that the pgbouncer docs say, "PgBouncer detects their 

249 # changes and so it can guarantee they remain consistent for 

250 # the client", I assume we can use "SET TIMESPAN" and pgbouncer 

251 # will take care of clients that share connections being set 

252 # consistently. And if that assumption is wrong, we should 

253 # still probably be okay, since all clients should be Butler 

254 # clients, and they'll all be setting the same thing. 

255 # 

256 # The "SET TRANSACTION READ ONLY" should also be safe, because 

257 # it only ever acts on the current transaction; I think it's 

258 # not included in pgbouncer's declaration that SET is 

259 # incompatible with transaction-level pooling because 

260 # PostgreSQL actually considers SET TRANSACTION to be a 

261 # fundamentally different statement from SET (they have their 

262 # own distinct doc pages, at least). 

263 with closing(connection.connection.cursor()) as cursor: 

264 # PostgreSQL permits writing to temporary tables inside 

265 # read-only transactions, but it doesn't permit creating 

266 # them. 

267 if not (self.isWriteable() or for_temp_tables): 

268 cursor.execute("SET TRANSACTION READ ONLY") 

269 # Make timestamps UTC, because we didn't use TIMESTAMPZ 

270 # for the column type. When we can tolerate a schema 

271 # change, we should change that type and remove this 

272 # line. 

273 cursor.execute("SET TIME ZONE 0") 

274 # Using server-side cursors with complex queries frequently 

275 # generates suboptimal query plan, setting 

276 # cursor_tuple_fraction=1 helps for those cases. 

277 cursor.execute("SET cursor_tuple_fraction = 1") 

278 yield is_new, connection 

279 

280 @contextmanager 

281 def temporary_table( 

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

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

284 # Docstring inherited. 

285 with self.transaction(for_temp_tables=True), super().temporary_table(spec, name) as table: 

286 yield table 

287 

288 def _lockTables( 

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

290 ) -> None: 

291 # Docstring inherited. 

292 for table in tables: 

293 connection.execute(sqlalchemy.text(f"LOCK TABLE {table.key} IN EXCLUSIVE MODE")) 

294 

295 def isWriteable(self) -> bool: 

296 return self._writeable 

297 

298 def __str__(self) -> str: 

299 return f"PostgreSQL@{self.dbname}:{self.namespace}" 

300 

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

302 return self.name_shrinker.shrink(original) 

303 

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

305 return self.name_shrinker.expand(shrunk) 

306 

307 def _convertExclusionConstraintSpec( 

308 self, 

309 table: str, 

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

311 metadata: sqlalchemy.MetaData, 

312 ) -> sqlalchemy.schema.Constraint: 

313 # Docstring inherited. 

314 args: list[tuple[sqlalchemy.schema.Column, str]] = [] 

315 names = ["excl"] 

316 for item in spec: 

317 if isinstance(item, str): 

318 args.append((sqlalchemy.schema.Column(item), "=")) 

319 names.append(item) 

320 elif issubclass(item, TimespanDatabaseRepresentation): 

321 assert item is self.getTimespanRepresentation() 

322 args.append((sqlalchemy.schema.Column(TimespanDatabaseRepresentation.NAME), "&&")) 

323 names.append(TimespanDatabaseRepresentation.NAME) 

324 return sqlalchemy.dialects.postgresql.ExcludeConstraint( 

325 *args, 

326 name=self.shrinkDatabaseEntityName("_".join(names)), 

327 ) 

328 

329 def _make_temporary_table( 

330 self, 

331 connection: sqlalchemy.engine.Connection, 

332 spec: ddl.TableSpec, 

333 name: str | None = None, 

334 **kwargs: Any, 

335 ) -> sqlalchemy.schema.Table: 

336 # Docstring inherited 

337 # Adding ON COMMIT DROP here is really quite defensive: we already 

338 # manually drop the table at the end of the temporary_table context 

339 # manager, and that will usually happen first. But this will guarantee 

340 # that we drop the table at the end of the transaction even if the 

341 # connection lasts longer, and that's good citizenship when connections 

342 # may be multiplexed by e.g. pgbouncer. 

343 return super()._make_temporary_table(connection, spec, name, postgresql_on_commit="DROP", **kwargs) 

344 

345 @classmethod 

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

347 # Docstring inherited. 

348 return _RangeTimespanRepresentation 

349 

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

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

352 if not rows: 

353 return 

354 # This uses special support for UPSERT in PostgreSQL backend: 

355 # https://docs.sqlalchemy.org/en/13/dialects/postgresql.html#insert-on-conflict-upsert 

356 query = sqlalchemy.dialects.postgresql.dml.insert(table) 

357 # In the SET clause assign all columns using special `excluded` 

358 # pseudo-table. If some column in the table does not appear in the 

359 # INSERT list this will set it to NULL. 

360 excluded = query.excluded 

361 data = { 

362 column.name: getattr(excluded, column.name) 

363 for column in table.columns 

364 if column.name not in table.primary_key 

365 } 

366 if not data: 

367 self.ensure(table, *rows) 

368 return 

369 query = query.on_conflict_do_update(constraint=table.primary_key, set_=data) 

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

371 connection.execute(query, rows) 

372 

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

374 # Docstring inherited. 

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

376 if not rows: 

377 return 0 

378 # Like `replace`, this uses UPSERT. 

379 base_insert = sqlalchemy.dialects.postgresql.dml.insert(table) 

380 if primary_key_only: 

381 query = base_insert.on_conflict_do_nothing(constraint=table.primary_key) 

382 else: 

383 query = base_insert.on_conflict_do_nothing() 

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

385 return connection.execute(query, rows).rowcount 

386 

387 def constant_rows( 

388 self, 

389 fields: NamedValueAbstractSet[ddl.FieldSpec], 

390 *rows: dict, 

391 name: str | None = None, 

392 ) -> sqlalchemy.sql.FromClause: 

393 # Docstring inherited. 

394 return super().constant_rows(fields, *rows, name=name) 

395 

396 @property 

397 def has_distinct_on(self) -> bool: 

398 # Docstring inherited. 

399 return True 

400 

401 @property 

402 def has_any_aggregate(self) -> bool: 

403 # Docstring inherited. 

404 return self._pg_version >= (16, 0) 

405 

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

407 # Docstring inherited 

408 

409 # The cast is required to prevent sqlalchemy from forgetting the type 

410 # of the initial column. Without the cast, for example, Base64Region 

411 # would become a String column in the output. 

412 return sqlalchemy.cast(sqlalchemy.func.any_value(column), column.type) 

413 

414 def glob_expression( 

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

416 ) -> sqlalchemy.ColumnElement[bool]: 

417 # Docstring inherited. 

418 # With Postgres we can use LIKE as it is case-sensitive, we only need 

419 # to translate our simple glob language to LIKE patterns, escaping few 

420 # things. Also we can try to optimize special cases when we can use 

421 # ``starts_with()`` function or "*" pattern. ``starts_with()`` in 

422 # Postgres14 does not use btree index, and LIKE with a constant prefix 

423 # is using index, so we may not need ``starts_with()`` after all. 

424 if pattern == "*": 

425 # Simple case. 

426 return sqlalchemy.literal(True) 

427 

428 def _escape(pattern: str) -> str: 

429 """Transform glob pattern string into LIKE pattern.""" 

430 tokens = [] 

431 escape = "" 

432 for char in pattern: 

433 if not escape: 

434 if char == "\\": 

435 escape = char 

436 elif char in "_%": 

437 # Escape special SQL characters. 

438 tokens.append(f"\\{char}") 

439 elif char == "*": 

440 tokens.append("%") 

441 elif char == "?": 

442 tokens.append("_") 

443 else: 

444 tokens.append(char) 

445 else: 

446 if char == "\\": 

447 # Two backslashes become one. 

448 tokens.append(char) 

449 elif char in "*?": 

450 # Handle escaped wildcards. 

451 tokens.append(f"{char}") 

452 else: 

453 # Copy both backslash and character. 

454 tokens.append(escape) 

455 tokens.append(char) 

456 escape = "" 

457 # Trailing backslash added to pattern. 

458 tokens.append(escape) 

459 return "".join(tokens) 

460 

461 pattern = _escape(pattern) 

462 return expression.op("LIKE")(sqlalchemy.literal(pattern)) 

463 

464 

465class _RangeTimespanType(sqlalchemy.TypeDecorator): 

466 """A single-column `Timespan` representation usable only with 

467 PostgreSQL. 

468 

469 This type should be able to take advantage of PostgreSQL's built-in 

470 range operators, and the indexing and EXCLUSION table constraints built 

471 off of them. 

472 """ 

473 

474 impl = sqlalchemy.dialects.postgresql.INT8RANGE 

475 

476 cache_ok = True 

477 

478 def process_bind_param( 

479 self, value: Timespan | None, dialect: sqlalchemy.engine.Dialect 

480 ) -> psycopg2.extras.NumericRange | None: 

481 if value is None: 

482 return None 

483 if not isinstance(value, Timespan): 

484 raise TypeError(f"Unsupported type: {type(value)}, expected Timespan.") 

485 if value.isEmpty(): 

486 return psycopg2.extras.NumericRange(empty=True) 

487 else: 

488 converter = time_utils.TimeConverter() 

489 assert value.nsec[0] >= converter.min_nsec, "Guaranteed by Timespan.__init__." 

490 assert value.nsec[1] <= converter.max_nsec, "Guaranteed by Timespan.__init__." 

491 lower = None if value.nsec[0] == converter.min_nsec else value.nsec[0] 

492 upper = None if value.nsec[1] == converter.max_nsec else value.nsec[1] 

493 return psycopg2.extras.NumericRange(lower=lower, upper=upper) 

494 

495 def process_result_value( 

496 self, value: psycopg2.extras.NumericRange | None, dialect: sqlalchemy.engine.Dialect 

497 ) -> Timespan | None: 

498 if value is None: 

499 return None 

500 if value.isempty: 

501 return Timespan.makeEmpty() 

502 converter = time_utils.TimeConverter() 

503 begin_nsec = converter.min_nsec if value.lower is None else value.lower 

504 end_nsec = converter.max_nsec if value.upper is None else value.upper 

505 return Timespan(begin=None, end=None, _nsec=(begin_nsec, end_nsec)) 

506 

507 

508class _RangeTimespanRepresentation(TimespanDatabaseRepresentation): 

509 """An implementation of `TimespanDatabaseRepresentation` that uses 

510 `_RangeTimespanType` to store a timespan in a single 

511 PostgreSQL-specific field. 

512 

513 Parameters 

514 ---------- 

515 column : `sqlalchemy.sql.ColumnElement` 

516 SQLAlchemy object representing the column. 

517 """ 

518 

519 def __init__(self, column: sqlalchemy.sql.ColumnElement, name: str): 

520 self.column = column 

521 self._name = name 

522 

523 __slots__ = ("column", "_name") 

524 

525 @classmethod 

526 def makeFieldSpecs( 

527 cls, nullable: bool, name: str | None = None, **kwargs: Any 

528 ) -> tuple[ddl.FieldSpec, ...]: 

529 # Docstring inherited. 

530 if name is None: 

531 name = cls.NAME 

532 return ( 

533 ddl.FieldSpec( 

534 name, 

535 dtype=_RangeTimespanType, 

536 nullable=nullable, 

537 default=(None if nullable else sqlalchemy.sql.text("'(,)'::int8range")), 

538 **kwargs, 

539 ), 

540 ) 

541 

542 @classmethod 

543 def getFieldNames(cls, name: str | None = None) -> tuple[str, ...]: 

544 # Docstring inherited. 

545 if name is None: 

546 name = cls.NAME 

547 return (name,) 

548 

549 @classmethod 

550 def update( 

551 cls, extent: Timespan | None, name: str | None = None, result: dict[str, Any] | None = None 

552 ) -> dict[str, Any]: 

553 # Docstring inherited. 

554 if name is None: 

555 name = cls.NAME 

556 if result is None: 

557 result = {} 

558 result[name] = extent 

559 return result 

560 

561 @classmethod 

562 def extract(cls, mapping: Mapping[str, Any], name: str | None = None) -> Timespan | None: 

563 # Docstring inherited. 

564 if name is None: 

565 name = cls.NAME 

566 return mapping[name] 

567 

568 @classmethod 

569 def fromLiteral(cls, timespan: Timespan | None) -> _RangeTimespanRepresentation: 

570 # Docstring inherited. 

571 if timespan is None: 

572 # Cast NULL to an expected type, helps Postgres to figure out 

573 # column type when doing UNION. 

574 return cls( 

575 column=sqlalchemy.func.cast(sqlalchemy.sql.null(), type_=_RangeTimespanType), name=cls.NAME 

576 ) 

577 return cls( 

578 column=sqlalchemy.sql.cast( 

579 sqlalchemy.sql.literal(timespan, type_=_RangeTimespanType), type_=_RangeTimespanType 

580 ), 

581 name=cls.NAME, 

582 ) 

583 

584 @classmethod 

585 def from_columns( 

586 cls, columns: sqlalchemy.sql.ColumnCollection, name: str | None = None 

587 ) -> _RangeTimespanRepresentation: 

588 # Docstring inherited. 

589 if name is None: 

590 name = cls.NAME 

591 return cls(columns[name], name) 

592 

593 @property 

594 def name(self) -> str: 

595 # Docstring inherited. 

596 return self._name 

597 

598 def isNull(self) -> sqlalchemy.sql.ColumnElement: 

599 # Docstring inherited. 

600 return self.column.is_(None) 

601 

602 def isEmpty(self) -> sqlalchemy.sql.ColumnElement: 

603 # Docstring inherited 

604 return sqlalchemy.sql.func.isempty(self.column) 

605 

606 def __lt__( 

607 self, other: _RangeTimespanRepresentation | sqlalchemy.sql.ColumnElement 

608 ) -> sqlalchemy.sql.ColumnElement: 

609 # Docstring inherited. 

610 if isinstance(other, sqlalchemy.sql.ColumnElement): 

611 return sqlalchemy.sql.and_( 

612 sqlalchemy.sql.not_(sqlalchemy.sql.func.upper_inf(self.column)), 

613 sqlalchemy.sql.not_(sqlalchemy.sql.func.isempty(self.column)), 

614 sqlalchemy.sql.func.upper(self.column) <= other, 

615 ) 

616 else: 

617 return self.column << other.column 

618 

619 def __gt__( 

620 self, other: _RangeTimespanRepresentation | sqlalchemy.sql.ColumnElement 

621 ) -> sqlalchemy.sql.ColumnElement: 

622 # Docstring inherited. 

623 if isinstance(other, sqlalchemy.sql.ColumnElement): 

624 return sqlalchemy.sql.and_( 

625 sqlalchemy.sql.not_(sqlalchemy.sql.func.lower_inf(self.column)), 

626 sqlalchemy.sql.not_(sqlalchemy.sql.func.isempty(self.column)), 

627 sqlalchemy.sql.func.lower(self.column) > other, 

628 ) 

629 else: 

630 return self.column >> other.column 

631 

632 def overlaps( 

633 self, other: _RangeTimespanRepresentation | sqlalchemy.sql.ColumnElement 

634 ) -> sqlalchemy.sql.ColumnElement: 

635 # Docstring inherited. 

636 if not isinstance(other, _RangeTimespanRepresentation): 

637 return self.contains(other) 

638 return self.column.overlaps(other.column) 

639 

640 def contains( 

641 self, other: _RangeTimespanRepresentation | sqlalchemy.sql.ColumnElement 

642 ) -> sqlalchemy.sql.ColumnElement: 

643 # Docstring inherited 

644 if isinstance(other, _RangeTimespanRepresentation): 

645 return self.column.contains(other.column) 

646 else: 

647 return self.column.contains(other) 

648 

649 def lower(self) -> sqlalchemy.sql.ColumnElement: 

650 # Docstring inherited. 

651 return sqlalchemy.sql.functions.coalesce( 

652 sqlalchemy.sql.func.lower(self.column), sqlalchemy.sql.literal(0) 

653 ) 

654 

655 def upper(self) -> sqlalchemy.sql.ColumnElement: 

656 # Docstring inherited. 

657 return sqlalchemy.sql.functions.coalesce( 

658 sqlalchemy.sql.func.upper(self.column), sqlalchemy.sql.literal(0) 

659 ) 

660 

661 def flatten(self, name: str | None = None) -> tuple[sqlalchemy.sql.ColumnElement]: 

662 # Docstring inherited. 

663 if name is None: 

664 return (self.column,) 

665 else: 

666 return (self.column.label(name),) 

667 

668 def apply_any_aggregate( 

669 self, func: Callable[[ColumnElement[Any]], ColumnElement[Any]] 

670 ) -> TimespanDatabaseRepresentation: 

671 # Docstring inherited. 

672 return _RangeTimespanRepresentation(func(self.column), self.name) 

673 

674 

675def get_postgres_server_version(connection: sqlalchemy.Connection) -> tuple[int, int]: # numpydoc ignore=PR01 

676 """Return the postgres DB server version as a tuple 

677 (major version, minor version). 

678 """ 

679 raw_pg_version = connection.execute(sqlalchemy.text("SHOW server_version")).scalar() 

680 _SERVER_VERSION_REGEX = re.compile(r"(?P<major>\d+)\.(?P<minor>\d+)") 

681 if raw_pg_version is not None and (m := _SERVER_VERSION_REGEX.search(raw_pg_version)): 

682 return (int(m.group("major")), int(m.group("minor"))) 

683 else: 

684 raise RuntimeError("Failed to get PostgreSQL server version.")