Coverage for python/lsst/daf/butler/registry/tests/_database.py: 87%

527 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-13 08:57 +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 

30 

31__all__ = ["DatabaseTests"] 

32 

33import asyncio 

34import itertools 

35import warnings 

36from abc import ABC, abstractmethod 

37from collections import namedtuple 

38from collections.abc import Iterable 

39from concurrent.futures import ThreadPoolExecutor 

40from contextlib import AbstractContextManager, contextmanager 

41from typing import Any 

42 

43import astropy.time 

44import sqlalchemy 

45import sqlalchemy.dialects.postgresql 

46 

47from lsst.sphgeom import Circle, ConvexPolygon, Mq3cPixelization, UnionRegion, UnitVector3d 

48 

49from ..._timespan import Timespan 

50from ...tests.utils import TestCaseMixin 

51from ..interfaces import Database, DatabaseConflictError, ReadOnlyDatabaseError, SchemaAlreadyDefinedError 

52 

53StaticTablesTuple = namedtuple("StaticTablesTuple", ["a", "b", "c"]) 

54 

55STATIC_TABLE_SPECS = StaticTablesTuple( 

56 a=ddl.TableSpec( 

57 fields=[ 

58 ddl.FieldSpec("name", dtype=sqlalchemy.String, length=16, primaryKey=True), 

59 ddl.FieldSpec("region", dtype=ddl.Base64Region, nbytes=128), 

60 ] 

61 ), 

62 b=ddl.TableSpec( 

63 fields=[ 

64 ddl.FieldSpec("id", dtype=sqlalchemy.BigInteger, autoincrement=True, primaryKey=True), 

65 ddl.FieldSpec("name", dtype=sqlalchemy.String, length=16, nullable=False), 

66 ddl.FieldSpec("value", dtype=sqlalchemy.SmallInteger, nullable=True), 

67 ], 

68 unique=[("name",)], 

69 ), 

70 c=ddl.TableSpec( 

71 fields=[ 

72 ddl.FieldSpec("id", dtype=sqlalchemy.BigInteger, autoincrement=True, primaryKey=True), 

73 ddl.FieldSpec("b_id", dtype=sqlalchemy.BigInteger, nullable=True), 

74 ], 

75 foreignKeys=[ 

76 ddl.ForeignKeySpec("b", source=("b_id",), target=("id",), onDelete="SET NULL"), 

77 ], 

78 ), 

79) 

80 

81DYNAMIC_TABLE_SPEC = ddl.TableSpec( 

82 fields=[ 

83 ddl.FieldSpec("c_id", dtype=sqlalchemy.BigInteger, primaryKey=True), 

84 ddl.FieldSpec("a_name", dtype=sqlalchemy.String, length=16, nullable=False), 

85 ], 

86 foreignKeys=[ 

87 ddl.ForeignKeySpec("c", source=("c_id",), target=("id",), onDelete="CASCADE"), 

88 ddl.ForeignKeySpec("a", source=("a_name",), target=("name",), onDelete="CASCADE"), 

89 ], 

90) 

91 

92TEMPORARY_TABLE_SPEC = ddl.TableSpec( 

93 fields=[ 

94 ddl.FieldSpec("a_name", dtype=sqlalchemy.String, length=16, primaryKey=True), 

95 ddl.FieldSpec("b_id", dtype=sqlalchemy.BigInteger, primaryKey=True), 

96 ], 

97) 

98 

99 

100@contextmanager 

101def _patch_getExistingTable(db: Database) -> Database: 

102 """Patch getExistingTable method in a database instance to test concurrent 

103 creation of tables. This patch obviously depends on knowning internals of 

104 ``ensureTableExists()`` implementation. 

105 """ 

106 original_method = db.getExistingTable 

107 

108 def _getExistingTable(name: str, spec: ddl.TableSpec) -> sqlalchemy.schema.Table | None: 

109 # Return None on first call, but forward to original method after that 

110 db.getExistingTable = original_method 

111 return None 

112 

113 db.getExistingTable = _getExistingTable 

114 yield db 

115 db.getExistingTable = original_method 

116 

117 

118class DatabaseTests(ABC, TestCaseMixin): 

119 """Generic tests for the `Database` interface that can be subclassed to 

120 generate tests for concrete implementations. 

121 """ 

122 

123 @abstractmethod 

124 def makeEmptyDatabase(self, origin: int = 0) -> Database: 

125 """Return an empty `Database` with the given origin, or an 

126 automatically-generated one if ``origin`` is `None`. 

127 

128 Parameters 

129 ---------- 

130 origin : `int` or `None` 

131 Origin to use for the database. 

132 

133 Returns 

134 ------- 

135 db : `Database` 

136 Empty database with given origin or auto-generated origin. 

137 """ 

138 raise NotImplementedError() 

139 

140 @abstractmethod 

141 def asReadOnly(self, database: Database) -> AbstractContextManager[Database]: 

142 """Return a context manager for a read-only connection into the given 

143 database. 

144 

145 Parameters 

146 ---------- 

147 database : `Database` 

148 The database to use. 

149 

150 Yields 

151 ------ 

152 `Database` 

153 The new database connection. 

154 

155 Notes 

156 ----- 

157 The original database should be considered unusable within the context 

158 but safe to use again afterwards (this allows the context manager to 

159 block write access by temporarily changing user permissions to really 

160 guarantee that write operations are not performed). 

161 """ 

162 raise NotImplementedError() 

163 

164 @abstractmethod 

165 def getNewConnection(self, database: Database, *, writeable: bool) -> Database: 

166 """Return a new `Database` instance that points to the same underlying 

167 storage as the given one. 

168 

169 Parameters 

170 ---------- 

171 database : `Database` 

172 The current database. 

173 writeable : `bool` 

174 Whether the connection should be writeable or not. 

175 

176 Returns 

177 ------- 

178 db : `Database` 

179 The new database connection. 

180 """ 

181 raise NotImplementedError() 

182 

183 def query_list( 

184 self, database: Database, executable: sqlalchemy.sql.expression.SelectBase 

185 ) -> list[sqlalchemy.engine.Row]: 

186 """Run a SELECT or other read-only query against the database and 

187 return the results as a list. 

188 

189 Parameters 

190 ---------- 

191 database : `Database` 

192 The database to use. 

193 executable : `sqlalchemy.sql.expression.SelectBase` 

194 Expression to execute. 

195 

196 Returns 

197 ------- 

198 results : `list` of `sqlalchemy.engine.Row` 

199 The results. 

200 

201 Notes 

202 ----- 

203 This is a thin wrapper around database.query() that just avoids 

204 context-manager boilerplate that is usefully verbose in production code 

205 but just noise in tests. 

206 """ 

207 with database.transaction(), database.query(executable) as result: 

208 return result.fetchall() 

209 

210 def query_scalar(self, database: Database, executable: sqlalchemy.sql.expression.SelectBase) -> Any: 

211 """Run a SELECT query that yields a single column and row against the 

212 database and return its value. 

213 

214 Parameters 

215 ---------- 

216 database : `Database` 

217 The database to use. 

218 executable : `sqlalchemy.sql.expression.SelectBase` 

219 Expression to execute. 

220 

221 Returns 

222 ------- 

223 results : `~typing.Any` 

224 The results. 

225 

226 Notes 

227 ----- 

228 This is a thin wrapper around database.query() that just avoids 

229 context-manager boilerplate that is usefully verbose in production code 

230 but just noise in tests. 

231 """ 

232 with database.query(executable) as result: 

233 return result.scalar() 

234 

235 def checkTable(self, spec: ddl.TableSpec, table: sqlalchemy.schema.Table): 

236 self.assertCountEqual(spec.fields.names, table.columns.keys()) 

237 # Checking more than this currently seems fragile, as it might restrict 

238 # what Database implementations do; we don't care if the spec is 

239 # actually preserved in terms of types and constraints as long as we 

240 # can use the returned table as if it was. 

241 

242 def checkStaticSchema(self, tables: StaticTablesTuple): 

243 self.checkTable(STATIC_TABLE_SPECS.a, tables.a) 

244 self.checkTable(STATIC_TABLE_SPECS.b, tables.b) 

245 self.checkTable(STATIC_TABLE_SPECS.c, tables.c) 

246 

247 def testDeclareStaticTables(self): 

248 """Tests for `Database.declareStaticSchema` and the methods it 

249 delegates to. 

250 """ 

251 # Create the static schema in a new, empty database. 

252 newDatabase = self.makeEmptyDatabase() 

253 with newDatabase.declareStaticTables(create=True) as context: 

254 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

255 self.checkStaticSchema(tables) 

256 # Check that we can load that schema even from a read-only connection. 

257 with self.asReadOnly(newDatabase) as existingReadOnlyDatabase: 

258 with existingReadOnlyDatabase.declareStaticTables(create=False) as context: 

259 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

260 self.checkStaticSchema(tables) 

261 

262 def testDeclareStaticTablesTwice(self): 

263 """Tests for `Database.declareStaticSchema` being called twice.""" 

264 # Create the static schema in a new, empty database. 

265 newDatabase = self.makeEmptyDatabase() 

266 with newDatabase.declareStaticTables(create=True) as context: 

267 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

268 self.checkStaticSchema(tables) 

269 # Second time it should raise 

270 with self.assertRaises(SchemaAlreadyDefinedError): 

271 with newDatabase.declareStaticTables(create=True) as context: 

272 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

273 # Check schema, it should still contain all tables, and maybe some 

274 # extra. 

275 with newDatabase.declareStaticTables(create=False) as context: 

276 self.assertLessEqual(frozenset(STATIC_TABLE_SPECS._fields), context._tableNames) 

277 

278 def testRepr(self): 

279 """Test that repr does not return a generic thing.""" 

280 newDatabase = self.makeEmptyDatabase() 

281 rep = repr(newDatabase) 

282 # Check that stringification works and gives us something different 

283 self.assertNotEqual(rep, str(newDatabase)) 

284 self.assertNotIn("object at 0x", rep, "Check default repr was not used") 

285 self.assertIn("://", rep) 

286 

287 def testDynamicTables(self): 

288 """Tests for `Database.ensureTableExists` and 

289 `Database.getExistingTable`. 

290 """ 

291 # Need to start with the static schema. 

292 newDatabase = self.makeEmptyDatabase() 

293 with newDatabase.declareStaticTables(create=True) as context: 

294 context.addTableTuple(STATIC_TABLE_SPECS) 

295 # Try to ensure the dynamic table exists in a read-only version of that 

296 # database, which should fail because we can't create it. 

297 with self.asReadOnly(newDatabase) as existingReadOnlyDatabase: 

298 with existingReadOnlyDatabase.declareStaticTables(create=False) as context: 

299 context.addTableTuple(STATIC_TABLE_SPECS) 

300 with self.assertRaises(ReadOnlyDatabaseError): 

301 existingReadOnlyDatabase.ensureTableExists("d", DYNAMIC_TABLE_SPEC) 

302 # Just getting the dynamic table before it exists should return None. 

303 self.assertIsNone(newDatabase.getExistingTable("d", DYNAMIC_TABLE_SPEC)) 

304 # Ensure the new table exists back in the original database, which 

305 # should create it. 

306 table = newDatabase.ensureTableExists("d", DYNAMIC_TABLE_SPEC) 

307 self.checkTable(DYNAMIC_TABLE_SPEC, table) 

308 # Ensuring that it exists should just return the exact same table 

309 # instance again. 

310 self.assertIs(newDatabase.ensureTableExists("d", DYNAMIC_TABLE_SPEC), table) 

311 # Try again from the read-only database. 

312 with self.asReadOnly(newDatabase) as existingReadOnlyDatabase: 

313 with existingReadOnlyDatabase.declareStaticTables(create=False) as context: 

314 context.addTableTuple(STATIC_TABLE_SPECS) 

315 # Just getting the dynamic table should now work... 

316 self.assertIsNotNone(existingReadOnlyDatabase.getExistingTable("d", DYNAMIC_TABLE_SPEC)) 

317 # ...as should ensuring that it exists, since it now does. 

318 existingReadOnlyDatabase.ensureTableExists("d", DYNAMIC_TABLE_SPEC) 

319 self.checkTable(DYNAMIC_TABLE_SPEC, table) 

320 # Trying to get the table with a different specification (at least 

321 # in terms of what columns are present) should raise. 

322 with self.assertRaises(DatabaseConflictError): 

323 newDatabase.ensureTableExists( 

324 "d", 

325 ddl.TableSpec( 

326 fields=[ddl.FieldSpec("name", dtype=sqlalchemy.String, length=4, primaryKey=True)] 

327 ), 

328 ) 

329 # Calling ensureTableExists inside a transaction block is an error, 

330 # even if it would do nothing. 

331 with newDatabase.transaction(): 

332 with self.assertRaises(AssertionError): 

333 newDatabase.ensureTableExists("d", DYNAMIC_TABLE_SPEC) 

334 

335 def testDynamicTablesConcurrency(self): 

336 """Tests for `Database.ensureTableExists` concurrent use.""" 

337 # We cannot really run things concurrently in a deterministic way, here 

338 # we just simulate a situation when the table is created by other 

339 # process between the call to getExistingTable() and actual table 

340 # creation. 

341 db1 = self.makeEmptyDatabase() 

342 with db1.declareStaticTables(create=True) as context: 

343 context.addTableTuple(STATIC_TABLE_SPECS) 

344 self.assertIsNone(db1.getExistingTable("d", DYNAMIC_TABLE_SPEC)) 

345 

346 # Make a dynamic table using separate connection 

347 db2 = self.getNewConnection(db1, writeable=True) 

348 with db2.declareStaticTables(create=False) as context: 

349 context.addTableTuple(STATIC_TABLE_SPECS) 

350 table = db2.ensureTableExists("d", DYNAMIC_TABLE_SPEC) 

351 self.checkTable(DYNAMIC_TABLE_SPEC, table) 

352 

353 # Call it again but trick it into thinking that table is not there. 

354 # This test depends on knowing implementation of ensureTableExists() 

355 # which initially calls getExistingTable() to check that table may 

356 # exist, the patch intercepts that call and returns None. 

357 with _patch_getExistingTable(db1): 

358 table = db1.ensureTableExists("d", DYNAMIC_TABLE_SPEC) 

359 

360 def testTemporaryTables(self): 

361 """Tests for `Database.temporary_table`, and `Database.insert` with the 

362 ``select`` argument. 

363 """ 

364 # Need to start with the static schema; also insert some test data. 

365 newDatabase = self.makeEmptyDatabase() 

366 with newDatabase.declareStaticTables(create=True) as context: 

367 static = context.addTableTuple(STATIC_TABLE_SPECS) 

368 newDatabase.insert(static.a, {"name": "a1", "region": None}, {"name": "a2", "region": None}) 

369 bIds = newDatabase.insert( 

370 static.b, 

371 {"name": "b1", "value": 11}, 

372 {"name": "b2", "value": 12}, 

373 {"name": "b3", "value": 13}, 

374 returnIds=True, 

375 ) 

376 # Create the table. 

377 with newDatabase.session(): 

378 with newDatabase.temporary_table(TEMPORARY_TABLE_SPEC, "e1") as table1: 

379 self.checkTable(TEMPORARY_TABLE_SPEC, table1) 

380 # Insert via a INSERT INTO ... SELECT query. 

381 newDatabase.insert( 

382 table1, 

383 select=sqlalchemy.sql.select( 

384 static.a.columns.name.label("a_name"), static.b.columns.id.label("b_id") 

385 ) 

386 .select_from(static.a.join(static.b, onclause=sqlalchemy.sql.literal(True))) 

387 .where( 

388 sqlalchemy.sql.and_( 

389 static.a.columns.name == "a1", 

390 static.b.columns.value <= 12, 

391 ) 

392 ), 

393 ) 

394 # Check that the inserted rows are present. 

395 self.assertCountEqual( 

396 [{"a_name": "a1", "b_id": bId} for bId in bIds[:2]], 

397 [row._asdict() for row in self.query_list(newDatabase, table1.select())], 

398 ) 

399 # Create another one via a read-only connection to the 

400 # database. We _do_ allow temporary table modifications in 

401 # read-only databases. 

402 with self.asReadOnly(newDatabase) as existingReadOnlyDatabase: 

403 with existingReadOnlyDatabase.declareStaticTables(create=False) as context: 

404 context.addTableTuple(STATIC_TABLE_SPECS) 

405 with existingReadOnlyDatabase.temporary_table(TEMPORARY_TABLE_SPEC) as table2: 

406 self.checkTable(TEMPORARY_TABLE_SPEC, table2) 

407 # Those tables should not be the same, despite having 

408 # the same ddl. 

409 self.assertIsNot(table1, table2) 

410 # Do a slightly different insert into this table, to 

411 # check that it works in a read-only database. This 

412 # time we pass column names as a kwarg to insert 

413 # instead of by labeling the columns in the select. 

414 existingReadOnlyDatabase.insert( 

415 table2, 

416 select=sqlalchemy.sql.select(static.a.columns.name, static.b.columns.id) 

417 .select_from(static.a.join(static.b, onclause=sqlalchemy.sql.literal(True))) 

418 .where( 

419 sqlalchemy.sql.and_( 

420 static.a.columns.name == "a2", 

421 static.b.columns.value >= 12, 

422 ) 

423 ), 

424 names=["a_name", "b_id"], 

425 ) 

426 # Check that the inserted rows are present. 

427 self.assertCountEqual( 

428 [{"a_name": "a2", "b_id": bId} for bId in bIds[1:]], 

429 [row._asdict() for row in self.query_list(existingReadOnlyDatabase, table2.select())], 

430 ) 

431 # Exiting the context managers will drop the temporary tables from 

432 # the read-only DB. It's unspecified whether attempting to use it 

433 # after this point is an error or just never returns any results, 

434 # so we can't test what it does, only that it's not an error. 

435 

436 def testSchemaSeparation(self): 

437 """Test that creating two different `Database` instances allows us 

438 to create different tables with the same name in each. 

439 """ 

440 db1 = self.makeEmptyDatabase(origin=1) 

441 with db1.declareStaticTables(create=True) as context: 

442 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

443 self.checkStaticSchema(tables) 

444 

445 db2 = self.makeEmptyDatabase(origin=2) 

446 # Make the DDL here intentionally different so we'll definitely 

447 # notice if db1 and db2 are pointing at the same schema. 

448 spec = ddl.TableSpec(fields=[ddl.FieldSpec("id", dtype=sqlalchemy.Integer, primaryKey=True)]) 

449 with db2.declareStaticTables(create=True) as context: 

450 # Make the DDL here intentionally different so we'll definitely 

451 # notice if db1 and db2 are pointing at the same schema. 

452 table = context.addTable("a", spec) 

453 self.checkTable(spec, table) 

454 

455 def testInsertQueryDelete(self): 

456 """Test the `Database.insert`, `Database.query`, and `Database.delete` 

457 methods, as well as the `Base64Region` type and the ``onDelete`` 

458 argument to `ddl.ForeignKeySpec`. 

459 """ 

460 db = self.makeEmptyDatabase(origin=1) 

461 with db.declareStaticTables(create=True) as context: 

462 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

463 # Insert a single, non-autoincrement row that contains a region and 

464 # query to get it back. 

465 region = ConvexPolygon((UnitVector3d(1, 0, 0), UnitVector3d(0, 1, 0), UnitVector3d(0, 0, 1))) 

466 row = {"name": "a1", "region": region} 

467 db.insert(tables.a, row) 

468 self.assertEqual([r._asdict() for r in self.query_list(db, tables.a.select())], [row]) 

469 # Insert multiple autoincrement rows but do not try to get the IDs 

470 # back immediately. 

471 db.insert(tables.b, {"name": "b1", "value": 10}, {"name": "b2", "value": 20}) 

472 results = [r._asdict() for r in self.query_list(db, tables.b.select().order_by("id"))] 

473 self.assertEqual(len(results), 2) 

474 for row in results: 

475 self.assertIn(row["name"], ("b1", "b2")) 

476 self.assertIsInstance(row["id"], int) 

477 self.assertGreater(results[1]["id"], results[0]["id"]) 

478 # Insert multiple autoincrement rows and get the IDs back from insert. 

479 rows = [{"name": "b3", "value": 30}, {"name": "b4", "value": 40}] 

480 ids = db.insert(tables.b, *rows, returnIds=True) 

481 results = [ 

482 r._asdict() 

483 for r in self.query_list(db, tables.b.select().where(tables.b.columns.id > results[1]["id"])) 

484 ] 

485 expected = [dict(row, id=id) for row, id in zip(rows, ids, strict=True)] 

486 self.assertCountEqual(results, expected) 

487 self.assertTrue(all(result["id"] is not None for result in results)) 

488 # Insert multiple rows into a table with an autoincrement primary key, 

489 # then use the returned IDs to insert into a dynamic table. 

490 rows = [{"b_id": results[0]["id"]}, {"b_id": None}] 

491 ids = db.insert(tables.c, *rows, returnIds=True) 

492 results = [r._asdict() for r in self.query_list(db, tables.c.select())] 

493 expected = [dict(row, id=id) for row, id in zip(rows, ids, strict=True)] 

494 self.assertCountEqual(results, expected) 

495 self.assertTrue(all(result["id"] is not None for result in results)) 

496 # Add the dynamic table. 

497 d = db.ensureTableExists("d", DYNAMIC_TABLE_SPEC) 

498 # Insert into it. 

499 rows = [{"c_id": id, "a_name": "a1"} for id in ids] 

500 db.insert(d, *rows) 

501 results = [r._asdict() for r in self.query_list(db, d.select())] 

502 self.assertCountEqual(rows, results) 

503 # Insert multiple rows into a table with an autoincrement primary key, 

504 # but pass in a value for the autoincrement key. 

505 rows2 = [ 

506 {"id": 700, "b_id": None}, 

507 {"id": 701, "b_id": None}, 

508 ] 

509 db.insert(tables.c, *rows2) 

510 results = [r._asdict() for r in self.query_list(db, tables.c.select())] 

511 self.assertCountEqual(results, expected + rows2) 

512 self.assertTrue(all(result["id"] is not None for result in results)) 

513 

514 # Define 'SELECT COUNT(*)' query for later use. 

515 count = sqlalchemy.sql.select(sqlalchemy.sql.func.count()) 

516 # Get the values we inserted into table b. 

517 bValues = [r._asdict() for r in self.query_list(db, tables.b.select())] 

518 # Remove two row from table b by ID. 

519 n = db.delete(tables.b, ["id"], {"id": bValues[0]["id"]}, {"id": bValues[1]["id"]}) 

520 self.assertEqual(n, 2) 

521 # Remove the other two rows from table b by name. 

522 n = db.delete(tables.b, ["name"], {"name": bValues[2]["name"]}, {"name": bValues[3]["name"]}) 

523 self.assertEqual(n, 2) 

524 # There should now be no rows in table b. 

525 self.assertEqual(self.query_scalar(db, count.select_from(tables.b)), 0) 

526 # All b_id values in table c should now be NULL, because there's an 

527 # onDelete='SET NULL' foreign key. 

528 self.assertEqual( 

529 self.query_scalar( 

530 db, 

531 count.select_from(tables.c).where(tables.c.columns.b_id != None), # noqa:E711 

532 ), 

533 0, 

534 ) 

535 # Remove all rows in table a (there's only one); this should remove all 

536 # rows in d due to onDelete='CASCADE'. 

537 n = db.delete(tables.a, []) 

538 self.assertEqual(n, 1) 

539 self.assertEqual(self.query_scalar(db, count.select_from(tables.a)), 0) 

540 self.assertEqual(self.query_scalar(db, count.select_from(d)), 0) 

541 

542 def testDeleteWhere(self): 

543 """Tests for `Database.deleteWhere`.""" 

544 db = self.makeEmptyDatabase(origin=1) 

545 with db.declareStaticTables(create=True) as context: 

546 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

547 db.insert(tables.b, *[{"id": i, "name": f"b{i}"} for i in range(10)]) 

548 count = sqlalchemy.sql.select(sqlalchemy.sql.func.count()) 

549 

550 n = db.deleteWhere(tables.b, tables.b.columns.id.in_([0, 1, 2])) 

551 self.assertEqual(n, 3) 

552 self.assertEqual(self.query_scalar(db, count.select_from(tables.b)), 7) 

553 

554 n = db.deleteWhere( 

555 tables.b, 

556 tables.b.columns.id.in_( 

557 sqlalchemy.sql.select(tables.b.columns.id).where(tables.b.columns.id > 5) 

558 ), 

559 ) 

560 self.assertEqual(n, 4) 

561 self.assertEqual(self.query_scalar(db, count.select_from(tables.b)), 3) 

562 

563 n = db.deleteWhere(tables.b, tables.b.columns.name == "b5") 

564 self.assertEqual(n, 1) 

565 self.assertEqual(self.query_scalar(db, count.select_from(tables.b)), 2) 

566 

567 n = db.deleteWhere(tables.b, sqlalchemy.sql.literal(True)) 

568 self.assertEqual(n, 2) 

569 self.assertEqual(self.query_scalar(db, count.select_from(tables.b)), 0) 

570 

571 def testUpdate(self): 

572 """Tests for `Database.update`.""" 

573 db = self.makeEmptyDatabase(origin=1) 

574 with db.declareStaticTables(create=True) as context: 

575 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

576 # Insert two rows into table a, both without regions. 

577 db.insert(tables.a, {"name": "a1"}, {"name": "a2"}) 

578 # Update one of the rows with a region. 

579 region = ConvexPolygon((UnitVector3d(1, 0, 0), UnitVector3d(0, 1, 0), UnitVector3d(0, 0, 1))) 

580 n = db.update(tables.a, {"name": "k"}, {"k": "a2", "region": region}) 

581 self.assertEqual(n, 1) 

582 sql = sqlalchemy.sql.select(tables.a.columns.name, tables.a.columns.region).select_from(tables.a) 

583 self.assertCountEqual( 

584 [r._asdict() for r in self.query_list(db, sql)], 

585 [{"name": "a1", "region": None}, {"name": "a2", "region": region}], 

586 ) 

587 

588 def testSync(self): 

589 """Tests for `Database.sync`.""" 

590 db = self.makeEmptyDatabase(origin=1) 

591 with db.declareStaticTables(create=True) as context: 

592 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

593 # Insert a row with sync, because it doesn't exist yet. 

594 values, inserted = db.sync(tables.b, keys={"name": "b1"}, extra={"value": 10}, returning=["id"]) 

595 self.assertTrue(inserted) 

596 self.assertEqual( 

597 [{"id": values["id"], "name": "b1", "value": 10}], 

598 [r._asdict() for r in self.query_list(db, tables.b.select())], 

599 ) 

600 # Repeat that operation, which should do nothing but return the 

601 # requested values. 

602 values, inserted = db.sync(tables.b, keys={"name": "b1"}, extra={"value": 10}, returning=["id"]) 

603 self.assertFalse(inserted) 

604 self.assertEqual( 

605 [{"id": values["id"], "name": "b1", "value": 10}], 

606 [r._asdict() for r in self.query_list(db, tables.b.select())], 

607 ) 

608 # Repeat the operation without the 'extra' arg, which should also just 

609 # return the existing row. 

610 values, inserted = db.sync(tables.b, keys={"name": "b1"}, returning=["id"]) 

611 self.assertFalse(inserted) 

612 self.assertEqual( 

613 [{"id": values["id"], "name": "b1", "value": 10}], 

614 [r._asdict() for r in self.query_list(db, tables.b.select())], 

615 ) 

616 # Repeat the operation with a different value in 'extra'. That still 

617 # shouldn't be an error, because 'extra' is only used if we really do 

618 # insert. Also drop the 'returning' argument. 

619 _, inserted = db.sync(tables.b, keys={"name": "b1"}, extra={"value": 20}) 

620 self.assertFalse(inserted) 

621 self.assertEqual( 

622 [{"id": values["id"], "name": "b1", "value": 10}], 

623 [r._asdict() for r in self.query_list(db, tables.b.select())], 

624 ) 

625 # Repeat the operation with the correct value in 'compared' instead of 

626 # 'extra'. 

627 _, inserted = db.sync(tables.b, keys={"name": "b1"}, compared={"value": 10}) 

628 self.assertFalse(inserted) 

629 self.assertEqual( 

630 [{"id": values["id"], "name": "b1", "value": 10}], 

631 [r._asdict() for r in self.query_list(db, tables.b.select())], 

632 ) 

633 # Repeat the operation with an incorrect value in 'compared'; this 

634 # should raise. 

635 with self.assertRaises(DatabaseConflictError): 

636 db.sync(tables.b, keys={"name": "b1"}, compared={"value": 20}) 

637 # Try to sync in a read-only database. This should work if and only 

638 # if the matching row already exists. 

639 with self.asReadOnly(db) as rodb: 

640 with rodb.declareStaticTables(create=False) as context: 

641 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

642 _, inserted = rodb.sync(tables.b, keys={"name": "b1"}) 

643 self.assertFalse(inserted) 

644 self.assertEqual( 

645 [{"id": values["id"], "name": "b1", "value": 10}], 

646 [r._asdict() for r in self.query_list(rodb, tables.b.select())], 

647 ) 

648 with self.assertRaises(ReadOnlyDatabaseError): 

649 rodb.sync(tables.b, keys={"name": "b2"}, extra={"value": 20}) 

650 # Repeat the operation with a different value in 'compared' and ask to 

651 # update. 

652 _, updated = db.sync(tables.b, keys={"name": "b1"}, compared={"value": 20}, update=True) 

653 self.assertEqual(updated, {"value": 10}) 

654 self.assertEqual( 

655 [{"id": values["id"], "name": "b1", "value": 20}], 

656 [r._asdict() for r in self.query_list(db, tables.b.select())], 

657 ) 

658 

659 def testReplace(self): 

660 """Tests for `Database.replace`.""" 

661 db = self.makeEmptyDatabase(origin=1) 

662 with db.declareStaticTables(create=True) as context: 

663 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

664 # Use 'replace' to insert a single row that contains a region and 

665 # query to get it back. 

666 region = ConvexPolygon((UnitVector3d(1, 0, 0), UnitVector3d(0, 1, 0), UnitVector3d(0, 0, 1))) 

667 row1 = {"name": "a1", "region": region} 

668 db.replace(tables.a, row1) 

669 self.assertEqual([r._asdict() for r in self.query_list(db, tables.a.select())], [row1]) 

670 # Insert another row without a region. 

671 row2 = {"name": "a2", "region": None} 

672 db.replace(tables.a, row2) 

673 self.assertCountEqual([r._asdict() for r in self.query_list(db, tables.a.select())], [row1, row2]) 

674 # Use replace to re-insert both of those rows again, which should do 

675 # nothing. 

676 db.replace(tables.a, row1, row2) 

677 self.assertCountEqual([r._asdict() for r in self.query_list(db, tables.a.select())], [row1, row2]) 

678 # Replace row1 with a row with no region, while reinserting row2. 

679 row1a = {"name": "a1", "region": None} 

680 db.replace(tables.a, row1a, row2) 

681 self.assertCountEqual([r._asdict() for r in self.query_list(db, tables.a.select())], [row1a, row2]) 

682 # Replace both rows, returning row1 to its original state, while adding 

683 # a new one. Pass them in in a different order. 

684 row2a = {"name": "a2", "region": region} 

685 row3 = {"name": "a3", "region": None} 

686 db.replace(tables.a, row3, row2a, row1) 

687 self.assertCountEqual( 

688 [r._asdict() for r in self.query_list(db, tables.a.select())], [row1, row2a, row3] 

689 ) 

690 

691 def test_replace_pkey_only(self): 

692 """Test `Database.replace` on a table that only has primary key.""" 

693 spec = ddl.TableSpec( 

694 [ 

695 ddl.FieldSpec("a1", dtype=sqlalchemy.BigInteger, primaryKey=True), 

696 ddl.FieldSpec("a2", dtype=sqlalchemy.BigInteger, primaryKey=True), 

697 ] 

698 ) 

699 db = self.makeEmptyDatabase(origin=1) 

700 with db.declareStaticTables(create=True) as context: 

701 table = context.addTable("a", spec) 

702 row1 = {"a1": 1, "a2": 2} 

703 row2 = {"a1": 1, "a2": 3} 

704 db.replace(table, row1) 

705 db.replace(table, row2) 

706 db.replace(table, row1) 

707 self.assertCountEqual([r._asdict() for r in self.query_list(db, table.select())], [row1, row2]) 

708 

709 def testEnsure(self): 

710 """Tests for `Database.ensure`.""" 

711 db = self.makeEmptyDatabase(origin=1) 

712 with db.declareStaticTables(create=True) as context: 

713 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

714 # Use 'ensure' to insert a single row that contains a region and 

715 # query to get it back. 

716 region = ConvexPolygon((UnitVector3d(1, 0, 0), UnitVector3d(0, 1, 0), UnitVector3d(0, 0, 1))) 

717 row1 = {"name": "a1", "region": region} 

718 self.assertEqual(db.ensure(tables.a, row1), 1) 

719 self.assertEqual([r._asdict() for r in self.query_list(db, tables.a.select())], [row1]) 

720 # Insert another row without a region. 

721 row2 = {"name": "a2", "region": None} 

722 self.assertEqual(db.ensure(tables.a, row2), 1) 

723 self.assertCountEqual([r._asdict() for r in self.query_list(db, tables.a.select())], [row1, row2]) 

724 # Use ensure to re-insert both of those rows again, which should do 

725 # nothing. 

726 self.assertEqual(db.ensure(tables.a, row1, row2), 0) 

727 self.assertCountEqual([r._asdict() for r in self.query_list(db, tables.a.select())], [row1, row2]) 

728 # Attempt to insert row1's key with no region, while 

729 # reinserting row2. This should also do nothing. 

730 row1a = {"name": "a1", "region": None} 

731 self.assertEqual(db.ensure(tables.a, row1a, row2), 0) 

732 self.assertCountEqual([r._asdict() for r in self.query_list(db, tables.a.select())], [row1, row2]) 

733 # Attempt to insert new rows for both existing keys, this time also 

734 # adding a new row. Pass them in in a different order. Only the new 

735 # row should be added. 

736 row2a = {"name": "a2", "region": region} 

737 row3 = {"name": "a3", "region": None} 

738 self.assertEqual(db.ensure(tables.a, row3, row2a, row1a), 1) 

739 self.assertCountEqual( 

740 [r._asdict() for r in self.query_list(db, tables.a.select())], [row1, row2, row3] 

741 ) 

742 # Add some data to a table with both a primary key and a different 

743 # unique constraint. 

744 row_b = {"id": 5, "name": "five", "value": 50} 

745 db.insert(tables.b, row_b) 

746 # Attempt ensure with primary_key_only=False and a conflict for the 

747 # non-PK constraint. This should do nothing. 

748 db.ensure(tables.b, {"id": 10, "name": "five", "value": 200}) 

749 self.assertEqual([r._asdict() for r in self.query_list(db, tables.b.select())], [row_b]) 

750 # Now use primary_key_only=True with conflict in only the non-PK field. 

751 # This should be an integrity error and nothing should change. 

752 with self.assertRaises(sqlalchemy.exc.IntegrityError): 

753 db.ensure(tables.b, {"id": 10, "name": "five", "value": 200}, primary_key_only=True) 

754 self.assertEqual([r._asdict() for r in self.query_list(db, tables.b.select())], [row_b]) 

755 # With primary_key_only=True a conflict in the primary key is ignored 

756 # regardless of whether there is a conflict elsewhere. 

757 db.ensure(tables.b, {"id": 5, "name": "ten", "value": 100}, primary_key_only=True) 

758 self.assertEqual([r._asdict() for r in self.query_list(db, tables.b.select())], [row_b]) 

759 db.ensure(tables.b, {"id": 5, "name": "five", "value": 100}, primary_key_only=True) 

760 self.assertEqual([r._asdict() for r in self.query_list(db, tables.b.select())], [row_b]) 

761 

762 def testTransactionNesting(self): 

763 """Test that transactions can be nested with the behavior in the 

764 presence of exceptions working as documented. 

765 """ 

766 db = self.makeEmptyDatabase(origin=1) 

767 with db.declareStaticTables(create=True) as context: 

768 tables = context.addTableTuple(STATIC_TABLE_SPECS) 

769 # Insert one row so we can trigger integrity errors by trying to insert 

770 # a duplicate of it below. 

771 db.insert(tables.a, {"name": "a1"}) 

772 # First test: error recovery via explicit savepoint=True in the inner 

773 # transaction. 

774 with db.transaction(): 

775 # This insert should succeed, and should not be rolled back because 

776 # the assertRaises context should catch any exception before it 

777 # propagates up to the outer transaction. 

778 db.insert(tables.a, {"name": "a2"}) 

779 with self.assertRaises(sqlalchemy.exc.IntegrityError): 

780 with db.transaction(savepoint=True): 

781 # This insert should succeed, but should be rolled back. 

782 db.insert(tables.a, {"name": "a4"}) 

783 # This insert should fail (duplicate primary key), raising 

784 # an exception. 

785 db.insert(tables.a, {"name": "a1"}) 

786 self.assertCountEqual( 

787 [r._asdict() for r in self.query_list(db, tables.a.select())], 

788 [{"name": "a1", "region": None}, {"name": "a2", "region": None}], 

789 ) 

790 # Second test: error recovery via implicit savepoint=True, when the 

791 # innermost transaction is inside a savepoint=True transaction. 

792 with db.transaction(): 

793 # This insert should succeed, and should not be rolled back 

794 # because the assertRaises context should catch any 

795 # exception before it propagates up to the outer 

796 # transaction. 

797 db.insert(tables.a, {"name": "a3"}) 

798 with self.assertRaises(sqlalchemy.exc.IntegrityError): 

799 with db.transaction(savepoint=True): 

800 # This insert should succeed, but should be rolled back. 

801 db.insert(tables.a, {"name": "a4"}) 

802 with db.transaction(): 

803 # This insert should succeed, but should be rolled 

804 # back. 

805 db.insert(tables.a, {"name": "a5"}) 

806 # This insert should fail (duplicate primary key), 

807 # raising an exception. 

808 db.insert(tables.a, {"name": "a1"}) 

809 self.assertCountEqual( 

810 [r._asdict() for r in self.query_list(db, tables.a.select())], 

811 [{"name": "a1", "region": None}, {"name": "a2", "region": None}, {"name": "a3", "region": None}], 

812 ) 

813 

814 def testTransactionLocking(self): 

815 """Test that `Database.transaction` can be used to acquire a lock 

816 that prohibits concurrent writes. 

817 """ 

818 db1 = self.makeEmptyDatabase(origin=1) 

819 with db1.declareStaticTables(create=True) as context: 

820 tables1 = context.addTableTuple(STATIC_TABLE_SPECS) 

821 

822 async def _side1(lock: Iterable[str] = ()) -> tuple[set[str], set[str]]: 

823 """One side of the concurrent locking test. 

824 

825 Parameters 

826 ---------- 

827 lock : `~collections.abc.Iterable` of `str` 

828 Locks. 

829 

830 Notes 

831 ----- 

832 This optionally locks the table (and maybe the whole database), 

833 does a select for its contents, inserts a new row, and then selects 

834 again, with some waiting in between to make sure the other side has 

835 a chance to _attempt_ to insert in between. If the locking is 

836 enabled and works, the difference between the selects should just 

837 be the insert done on this thread. 

838 """ 

839 # Give Side2 a chance to create a connection 

840 await asyncio.sleep(1.0) 

841 with db1.transaction(lock=lock): 

842 names1 = {row.name for row in self.query_list(db1, tables1.a.select())} 

843 # Give Side2 a chance to insert (which will be blocked if 

844 # we've acquired a lock). 

845 await asyncio.sleep(2.0) 

846 db1.insert(tables1.a, {"name": "a1"}) 

847 names2 = {row.name for row in self.query_list(db1, tables1.a.select())} 

848 return names1, names2 

849 

850 async def _side2() -> None: 

851 """Other side of the concurrent locking test. 

852 

853 Notes 

854 ----- 

855 This side just waits a bit and then tries to insert a row into the 

856 table that the other side is trying to lock. Hopefully that 

857 waiting is enough to give the other side a chance to acquire the 

858 lock and thus make this side block until the lock is released. If 

859 this side manages to do the insert before side1 acquires the lock, 

860 we'll just warn about not succeeding at testing the locking, 

861 because we can only make that unlikely, not impossible. 

862 """ 

863 

864 def _toRunInThread(): 

865 """Create new SQLite connection for use in thread. 

866 

867 SQLite locking isn't asyncio-friendly unless we actually 

868 run it in another thread. And SQLite gets very unhappy if 

869 we try to use a connection from multiple threads, so we have 

870 to create the new connection here instead of out in the main 

871 body of the test function. 

872 """ 

873 db2 = self.getNewConnection(db1, writeable=True) 

874 with db2.declareStaticTables(create=False) as context: 

875 tables2 = context.addTableTuple(STATIC_TABLE_SPECS) 

876 with db2.transaction(): 

877 db2.insert(tables2.a, {"name": "a2"}) 

878 

879 await asyncio.sleep(2.0) 

880 loop = asyncio.get_running_loop() 

881 with ThreadPoolExecutor() as pool: 

882 await loop.run_in_executor(pool, _toRunInThread) 

883 

884 async def _testProblemsWithNoLocking() -> None: 

885 """Run side1 and side2 with no locking, attempting to demonstrate 

886 the problem that locking is supposed to solve. If we get unlucky 

887 with scheduling, side2 will just happen to insert after side1 is 

888 done, and we won't have anything definitive. We just warn in that 

889 case because we really don't want spurious test failures. 

890 """ 

891 task1 = asyncio.create_task(_side1()) 

892 task2 = asyncio.create_task(_side2()) 

893 

894 names1, names2 = await task1 

895 await task2 

896 if "a2" in names1: 

897 warnings.warn( 

898 "Unlucky scheduling in no-locking test: concurrent INSERT happened before first SELECT.", 

899 stacklevel=1, 

900 ) 

901 self.assertEqual(names1, {"a2"}) 

902 self.assertEqual(names2, {"a1", "a2"}) 

903 elif "a2" not in names2: 

904 warnings.warn( 

905 "Unlucky scheduling in no-locking test: concurrent INSERT " 

906 "happened after second SELECT even without locking.", 

907 stacklevel=1, 

908 ) 

909 self.assertEqual(names1, set()) 

910 self.assertEqual(names2, {"a1"}) 

911 else: 

912 # This is the expected case: both INSERTS happen between the 

913 # two SELECTS. If we don't get this almost all of the time we 

914 # should adjust the sleep amounts. 

915 self.assertEqual(names1, set()) 

916 self.assertEqual(names2, {"a1", "a2"}) 

917 

918 asyncio.run(_testProblemsWithNoLocking()) 

919 

920 # Clean up after first test. 

921 db1.delete(tables1.a, ["name"], {"name": "a1"}, {"name": "a2"}) 

922 

923 async def _testSolutionWithLocking() -> None: 

924 """Run side1 and side2 with locking, which should make side2 block 

925 its insert until side2 releases its lock. 

926 """ 

927 task1 = asyncio.create_task(_side1(lock=[tables1.a])) 

928 task2 = asyncio.create_task(_side2()) 

929 

930 names1, names2 = await task1 

931 await task2 

932 if "a2" in names1: 

933 warnings.warn( 

934 "Unlucky scheduling in locking test: concurrent INSERT happened before first SELECT.", 

935 stacklevel=1, 

936 ) 

937 self.assertEqual(names1, {"a2"}) 

938 self.assertEqual(names2, {"a1", "a2"}) 

939 else: 

940 # This is the expected case: the side2 INSERT happens after the 

941 # last SELECT on side1. This can also happen due to unlucky 

942 # scheduling, and we have no way to detect that here, but the 

943 # similar "no-locking" test has at least some chance of being 

944 # affected by the same problem and warning about it. 

945 self.assertEqual(names1, set()) 

946 self.assertEqual(names2, {"a1"}) 

947 

948 asyncio.run(_testSolutionWithLocking()) 

949 

950 def testTimespanDatabaseRepresentation(self): 

951 """Tests for `TimespanDatabaseRepresentation` and the `Database` 

952 methods that interact with it. 

953 """ 

954 # Make some test timespans to play with, with the full suite of 

955 # topological relationships. 

956 start = astropy.time.Time("2020-01-01T00:00:00", format="isot", scale="tai") 

957 offset = astropy.time.TimeDelta(60, format="sec") 

958 timestamps = [start + offset * n for n in range(3)] 

959 aTimespans = [Timespan(begin=None, end=None)] 

960 aTimespans.extend(Timespan(begin=None, end=t) for t in timestamps) 

961 aTimespans.extend(Timespan(begin=t, end=None) for t in timestamps) 

962 aTimespans.extend(Timespan.fromInstant(t) for t in timestamps) 

963 aTimespans.append(Timespan.makeEmpty()) 

964 aTimespans.extend(Timespan(begin=t1, end=t2) for t1, t2 in itertools.combinations(timestamps, 2)) 

965 # Make another list of timespans that span the full range but don't 

966 # overlap. This is a subset of the previous list. 

967 bTimespans = [Timespan(begin=None, end=timestamps[0])] 

968 bTimespans.extend( 

969 Timespan(begin=t1, end=t2) for t1, t2 in zip(timestamps[:-1], timestamps[1:], strict=True) 

970 ) 

971 bTimespans.append(Timespan(begin=timestamps[-1], end=None)) 

972 # Make a database and create a table with that database's timespan 

973 # representation. This one will have no exclusion constraint and 

974 # a nullable timespan. 

975 db = self.makeEmptyDatabase(origin=1) 

976 TimespanReprClass = db.getTimespanRepresentation() 

977 aSpec = ddl.TableSpec( 

978 fields=[ 

979 ddl.FieldSpec(name="id", dtype=sqlalchemy.Integer, primaryKey=True), 

980 ], 

981 ) 

982 for fieldSpec in TimespanReprClass.makeFieldSpecs(nullable=True): 

983 aSpec.fields.add(fieldSpec) 

984 with db.declareStaticTables(create=True) as context: 

985 aTable = context.addTable("a", aSpec) 

986 self.maxDiff = None 

987 

988 def _convertRowForInsert(row: dict) -> dict: 

989 """Convert a row containing a Timespan instance into one suitable 

990 for insertion into the database. 

991 """ 

992 result = row.copy() 

993 ts = result.pop(TimespanReprClass.NAME) 

994 return TimespanReprClass.update(ts, result=result) 

995 

996 def _convertRowFromSelect(row: dict) -> dict: 

997 """Convert a row from the database into one containing a Timespan. 

998 

999 Parameters 

1000 ---------- 

1001 row : `dict` 

1002 Original row. 

1003 

1004 Returns 

1005 ------- 

1006 row : `dict` 

1007 The updated row. 

1008 """ 

1009 result = row.copy() 

1010 timespan = TimespanReprClass.extract(result) 

1011 for name in TimespanReprClass.getFieldNames(): 

1012 del result[name] 

1013 result[TimespanReprClass.NAME] = timespan 

1014 return result 

1015 

1016 # Insert rows into table A, in chunks just to make things interesting. 

1017 # Include one with a NULL timespan. 

1018 aRows = [{"id": n, TimespanReprClass.NAME: t} for n, t in enumerate(aTimespans)] 

1019 aRows.append({"id": len(aRows), TimespanReprClass.NAME: None}) 

1020 db.insert(aTable, _convertRowForInsert(aRows[0])) 

1021 db.insert(aTable, *[_convertRowForInsert(r) for r in aRows[1:3]]) 

1022 db.insert(aTable, *[_convertRowForInsert(r) for r in aRows[3:]]) 

1023 # Add another one with a NULL timespan, but this time by invoking 

1024 # the server-side default. 

1025 aRows.append({"id": len(aRows)}) 

1026 db.insert(aTable, aRows[-1]) 

1027 aRows[-1][TimespanReprClass.NAME] = None 

1028 # Test basic round-trip through database. 

1029 self.assertEqual( 

1030 aRows, 

1031 [ 

1032 _convertRowFromSelect(row._asdict()) 

1033 for row in self.query_list(db, aTable.select().order_by(aTable.columns.id)) 

1034 ], 

1035 ) 

1036 # Create another table B with a not-null timespan and (if the database 

1037 # supports it), an exclusion constraint. Use ensureTableExists this 

1038 # time to check that mode of table creation vs. timespans. 

1039 bSpec = ddl.TableSpec( 

1040 fields=[ 

1041 ddl.FieldSpec(name="id", dtype=sqlalchemy.Integer, primaryKey=True), 

1042 ddl.FieldSpec(name="key", dtype=sqlalchemy.Integer, nullable=False), 

1043 ], 

1044 ) 

1045 for fieldSpec in TimespanReprClass.makeFieldSpecs(nullable=False): 

1046 bSpec.fields.add(fieldSpec) 

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

1048 bSpec.exclusion.add(("key", TimespanReprClass)) 

1049 bTable = db.ensureTableExists("b", bSpec) 

1050 # Insert rows into table B, again in chunks. Each Timespan appears 

1051 # twice, but with different values for the 'key' field (which should 

1052 # still be okay for any exclusion constraint we may have defined). 

1053 bRows = [{"id": n, "key": 1, TimespanReprClass.NAME: t} for n, t in enumerate(bTimespans)] 

1054 offset = len(bRows) 

1055 bRows.extend( 

1056 {"id": n + offset, "key": 2, TimespanReprClass.NAME: t} for n, t in enumerate(bTimespans) 

1057 ) 

1058 db.insert(bTable, *[_convertRowForInsert(r) for r in bRows[:2]]) 

1059 db.insert(bTable, _convertRowForInsert(bRows[2])) 

1060 db.insert(bTable, *[_convertRowForInsert(r) for r in bRows[3:]]) 

1061 # Insert a row with no timespan into table B. This should invoke the 

1062 # server-side default, which is a timespan over (-∞, ∞). We set 

1063 # key=3 to avoid upsetting an exclusion constraint that might exist. 

1064 bRows.append({"id": len(bRows), "key": 3}) 

1065 db.insert(bTable, bRows[-1]) 

1066 bRows[-1][TimespanReprClass.NAME] = Timespan(None, None) 

1067 # Test basic round-trip through database. 

1068 self.assertEqual( 

1069 bRows, 

1070 [ 

1071 _convertRowFromSelect(row._asdict()) 

1072 for row in self.query_list(db, bTable.select().order_by(bTable.columns.id)) 

1073 ], 

1074 ) 

1075 # Test that we can't insert timespan=None into this table. 

1076 with self.assertRaises(sqlalchemy.exc.IntegrityError): 

1077 db.insert( 

1078 bTable, _convertRowForInsert({"id": len(bRows), "key": 4, TimespanReprClass.NAME: None}) 

1079 ) 

1080 # IFF this database supports exclusion constraints, test that they 

1081 # also prevent inserts. 

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

1083 with self.assertRaises(sqlalchemy.exc.IntegrityError): 

1084 db.insert( 

1085 bTable, 

1086 _convertRowForInsert( 

1087 {"id": len(bRows), "key": 1, TimespanReprClass.NAME: Timespan(None, timestamps[1])} 

1088 ), 

1089 ) 

1090 with self.assertRaises(sqlalchemy.exc.IntegrityError): 

1091 db.insert( 

1092 bTable, 

1093 _convertRowForInsert( 

1094 { 

1095 "id": len(bRows), 

1096 "key": 1, 

1097 TimespanReprClass.NAME: Timespan(timestamps[0], timestamps[2]), 

1098 } 

1099 ), 

1100 ) 

1101 with self.assertRaises(sqlalchemy.exc.IntegrityError): 

1102 db.insert( 

1103 bTable, 

1104 _convertRowForInsert( 

1105 {"id": len(bRows), "key": 1, TimespanReprClass.NAME: Timespan(timestamps[2], None)} 

1106 ), 

1107 ) 

1108 # Test NULL checks in SELECT queries, on both tables. 

1109 aRepr = TimespanReprClass.from_columns(aTable.columns) 

1110 self.assertEqual( 

1111 [row[TimespanReprClass.NAME] is None for row in aRows], 

1112 [ 

1113 row.f 

1114 for row in self.query_list( 

1115 db, sqlalchemy.sql.select(aRepr.isNull().label("f")).order_by(aTable.columns.id) 

1116 ) 

1117 ], 

1118 ) 

1119 bRepr = TimespanReprClass.from_columns(bTable.columns) 

1120 self.assertEqual( 

1121 [False for row in bRows], 

1122 [ 

1123 row.f 

1124 for row in self.query_list( 

1125 db, sqlalchemy.sql.select(bRepr.isNull().label("f")).order_by(bTable.columns.id) 

1126 ) 

1127 ], 

1128 ) 

1129 # Test relationships expressions that relate in-database timespans to 

1130 # Python-literal timespans, all from the more complete 'a' set; check 

1131 # that this is consistent with Python-only relationship tests. 

1132 for rhsRow in aRows: 

1133 if rhsRow[TimespanReprClass.NAME] is None: 

1134 continue 

1135 with self.subTest(rhsRow=repr(rhsRow)): 

1136 expected = {} 

1137 for lhsRow in aRows: 

1138 if lhsRow[TimespanReprClass.NAME] is None: 

1139 expected[lhsRow["id"]] = (None, None, None, None) 

1140 else: 

1141 expected[lhsRow["id"]] = ( 

1142 lhsRow[TimespanReprClass.NAME].overlaps(rhsRow[TimespanReprClass.NAME]), 

1143 lhsRow[TimespanReprClass.NAME].contains(rhsRow[TimespanReprClass.NAME]), 

1144 lhsRow[TimespanReprClass.NAME] < rhsRow[TimespanReprClass.NAME], 

1145 lhsRow[TimespanReprClass.NAME] > rhsRow[TimespanReprClass.NAME], 

1146 ) 

1147 rhsRepr = TimespanReprClass.fromLiteral(rhsRow[TimespanReprClass.NAME]) 

1148 sql = sqlalchemy.sql.select( 

1149 aTable.columns.id.label("lhs"), 

1150 aRepr.overlaps(rhsRepr).label("overlaps"), 

1151 aRepr.contains(rhsRepr).label("contains"), 

1152 (aRepr < rhsRepr).label("less_than"), 

1153 (aRepr > rhsRepr).label("greater_than"), 

1154 ).select_from(aTable) 

1155 queried = { 

1156 row.lhs: (row.overlaps, row.contains, row.less_than, row.greater_than) 

1157 for row in self.query_list(db, sql) 

1158 } 

1159 self.assertEqual(expected, queried) 

1160 # Test relationship expressions that relate in-database timespans to 

1161 # each other, all from the more complete 'a' set; check that this is 

1162 # consistent with Python-only relationship tests. 

1163 expected = {} 

1164 for lhs, rhs in itertools.product(aRows, aRows): 

1165 lhsT = lhs[TimespanReprClass.NAME] 

1166 rhsT = rhs[TimespanReprClass.NAME] 

1167 if lhsT is not None and rhsT is not None: 

1168 expected[lhs["id"], rhs["id"]] = ( 

1169 lhsT.overlaps(rhsT), 

1170 lhsT.contains(rhsT), 

1171 lhsT < rhsT, 

1172 lhsT > rhsT, 

1173 ) 

1174 else: 

1175 expected[lhs["id"], rhs["id"]] = (None, None, None, None) 

1176 lhsSubquery = aTable.alias("lhs") 

1177 rhsSubquery = aTable.alias("rhs") 

1178 lhsRepr = TimespanReprClass.from_columns(lhsSubquery.columns) 

1179 rhsRepr = TimespanReprClass.from_columns(rhsSubquery.columns) 

1180 sql = sqlalchemy.sql.select( 

1181 lhsSubquery.columns.id.label("lhs"), 

1182 rhsSubquery.columns.id.label("rhs"), 

1183 lhsRepr.overlaps(rhsRepr).label("overlaps"), 

1184 lhsRepr.contains(rhsRepr).label("contains"), 

1185 (lhsRepr < rhsRepr).label("less_than"), 

1186 (lhsRepr > rhsRepr).label("greater_than"), 

1187 ).select_from(lhsSubquery.join(rhsSubquery, onclause=sqlalchemy.sql.literal(True))) 

1188 queried = { 

1189 (row.lhs, row.rhs): (row.overlaps, row.contains, row.less_than, row.greater_than) 

1190 for row in self.query_list(db, sql) 

1191 } 

1192 self.assertEqual(expected, queried) 

1193 # Test relationship expressions between in-database timespans and 

1194 # Python-literal instantaneous times. 

1195 for t in timestamps: 

1196 with self.subTest(t=repr(t)): 

1197 expected = {} 

1198 for lhsRow in aRows: 

1199 if lhsRow[TimespanReprClass.NAME] is None: 

1200 expected[lhsRow["id"]] = (None, None, None, None) 

1201 else: 

1202 expected[lhsRow["id"]] = ( 

1203 lhsRow[TimespanReprClass.NAME].contains(t), 

1204 lhsRow[TimespanReprClass.NAME].overlaps(t), 

1205 lhsRow[TimespanReprClass.NAME] < t, 

1206 lhsRow[TimespanReprClass.NAME] > t, 

1207 ) 

1208 rhs = sqlalchemy.sql.literal(t, type_=ddl.AstropyTimeNsecTai) 

1209 sql = sqlalchemy.sql.select( 

1210 aTable.columns.id.label("lhs"), 

1211 aRepr.contains(rhs).label("contains"), 

1212 aRepr.overlaps(rhs).label("overlaps_point"), 

1213 (aRepr < rhs).label("less_than"), 

1214 (aRepr > rhs).label("greater_than"), 

1215 ).select_from(aTable) 

1216 queried = { 

1217 row.lhs: (row.contains, row.overlaps_point, row.less_than, row.greater_than) 

1218 for row in self.query_list(db, sql) 

1219 } 

1220 self.assertEqual(expected, queried) 

1221 

1222 def testConstantRows(self): 

1223 """Test Database.constant_rows.""" 

1224 new_db = self.makeEmptyDatabase() 

1225 with new_db.declareStaticTables(create=True) as context: 

1226 static = context.addTableTuple(STATIC_TABLE_SPECS) 

1227 b_ids = new_db.insert( 

1228 static.b, 

1229 {"name": "b1", "value": 11}, 

1230 {"name": "b2", "value": 12}, 

1231 {"name": "b3", "value": 13}, 

1232 returnIds=True, 

1233 ) 

1234 values_spec = ddl.TableSpec( 

1235 [ 

1236 ddl.FieldSpec(name="b", dtype=sqlalchemy.BigInteger), 

1237 ddl.FieldSpec(name="s", dtype=sqlalchemy.String(8)), 

1238 ddl.FieldSpec(name="r", dtype=ddl.Base64Region()), 

1239 ], 

1240 ) 

1241 values_data = [ 

1242 {"b": b_ids[0], "s": "b1", "r": None}, 

1243 {"b": b_ids[2], "s": "b3", "r": Circle.empty()}, 

1244 ] 

1245 values = new_db.constant_rows(values_spec.fields, *values_data) 

1246 select_values_alone = sqlalchemy.sql.select( 

1247 values.columns["b"], values.columns["s"], values.columns["r"] 

1248 ) 

1249 self.assertCountEqual( 

1250 [row._mapping for row in self.query_list(new_db, select_values_alone)], 

1251 values_data, 

1252 ) 

1253 select_values_joined = sqlalchemy.sql.select( 

1254 values.columns["s"].label("name"), 

1255 static.b.columns["value"].label("value"), 

1256 values.columns["r"].label("region"), 

1257 ).select_from(values.join(static.b, onclause=static.b.columns["id"] == values.columns["b"])) 

1258 self.assertCountEqual( 

1259 [row._mapping for row in self.query_list(new_db, select_values_joined)], 

1260 [ 

1261 {"value": 11, "name": "b1", "region": None}, 

1262 {"value": 13, "name": "b3", "region": Circle.empty()}, 

1263 ], 

1264 ) 

1265 

1266 def test_aggregate(self) -> None: 

1267 """Test Database.apply_any_aggregate, ddl.Base64Region.union_aggregate, 

1268 and TimespanDatabaseRepresetnation.apply_any_aggregate. 

1269 """ 

1270 db = self.makeEmptyDatabase() 

1271 with db.declareStaticTables(create=True) as context: 

1272 t = context.addTable( 

1273 "t", 

1274 ddl.TableSpec( 

1275 [ 

1276 ddl.FieldSpec("id", sqlalchemy.BigInteger(), nullable=False), 

1277 ddl.FieldSpec("name", sqlalchemy.String(16), nullable=False), 

1278 ddl.FieldSpec.for_region(), 

1279 ] 

1280 + list(db.getTimespanRepresentation().makeFieldSpecs(nullable=True)), 

1281 ), 

1282 ) 

1283 pixelization = Mq3cPixelization(10) 

1284 start = astropy.time.Time("2020-01-01T00:00:00", format="isot", scale="tai") 

1285 offset = astropy.time.TimeDelta(60, format="sec") 

1286 timespans = [Timespan(begin=start + offset * n, end=start + offset * (n + 1)) for n in range(3)] 

1287 ts_cls = db.getTimespanRepresentation() 

1288 ts_col = ts_cls.from_columns(t.columns) 

1289 db.insert( 

1290 t, 

1291 ts_cls.update(timespans[0], result={"id": 1, "name": "a", "region": pixelization.quad(12058870)}), 

1292 ts_cls.update(timespans[1], result={"id": 2, "name": "a", "region": pixelization.quad(12058871)}), 

1293 ts_cls.update(timespans[2], result={"id": 3, "name": "b", "region": pixelization.quad(12058872)}), 

1294 ts_cls.update(timespans[2], result={"id": 3, "name": "b", "region": pixelization.quad(12058873)}), 

1295 ) 

1296 # This should use DISTINCT ON in PostgreSQL and GROUP BY in SQLite. 

1297 if db.has_distinct_on: 1297 ↛ 1298line 1297 didn't jump to line 1298 because the condition on line 1297 was never true

1298 sql = sqlalchemy.select( 

1299 t.c.id.label("i"), 

1300 t.c.name.label("n"), 

1301 *ts_col.flatten("t"), 

1302 ).select_from(t) 

1303 # SQLAlchemy 2.1 deprecates Select.distinct(*columns) in favor of 

1304 # Select.ext(dialects.postgresql.distinct_on(*columns)), but that 

1305 # does not exist in 2.0. 

1306 if (distinct_on := getattr(sqlalchemy.dialects.postgresql, "distinct_on", None)) and hasattr( 

1307 sql, "ext" 

1308 ): 

1309 sql = sql.ext(distinct_on(t.c.id)) 

1310 else: 

1311 sql = sql.distinct(t.c.id) 

1312 elif db.has_any_aggregate: 

1313 sql = ( 

1314 sqlalchemy.select( 

1315 t.c.id.label("i"), 

1316 db.apply_any_aggregate(t.c.name).label("n"), 

1317 *ts_col.apply_any_aggregate(db.apply_any_aggregate).flatten("t"), 

1318 ) 

1319 .select_from(t) 

1320 .group_by(t.c.id) 

1321 ) 

1322 else: 

1323 raise AssertionError( 

1324 "PostgreSQL should support DISTINCT ON and SQLite should support no-op any aggregates." 

1325 ) 

1326 self.assertCountEqual( 

1327 [(row.i, row.n, ts_cls.extract(row._mapping, "t")) for row in self.query_list(db, sql)], 

1328 [(1, "a", timespans[0]), (2, "a", timespans[1]), (3, "b", timespans[2])], 

1329 ) 

1330 # Test union_aggregate in all versions of both database, with a GROUP 

1331 # BY that does not need apply_any_aggregate. 

1332 self.assertCountEqual( 

1333 [ 

1334 (row.i, row.r.encode()) 

1335 for row in self.query_list( 

1336 db, 

1337 sqlalchemy.select( 

1338 t.c.id.label("i"), ddl.Base64Region.union_aggregate(t.c.region).label("r") 

1339 ) 

1340 .select_from(t) 

1341 .group_by(t.c.id), 

1342 ) 

1343 ], 

1344 [ 

1345 (1, pixelization.quad(12058870).encode()), 

1346 (2, pixelization.quad(12058871).encode()), 

1347 (3, UnionRegion(pixelization.quad(12058872), pixelization.quad(12058873)).encode()), 

1348 ], 

1349 ) 

1350 if db.has_any_aggregate: 1350 ↛ exitline 1350 didn't return from function 'test_aggregate' because the condition on line 1350 was always true

1351 # This should use run in SQLite and PostgreSQL 16+. 

1352 self.assertCountEqual( 

1353 [ 

1354 (row.i, row.n, row.r.encode()) 

1355 for row in self.query_list( 

1356 db, 

1357 sqlalchemy.select( 

1358 t.c.id.label("i"), 

1359 db.apply_any_aggregate(t.c.name).label("n"), 

1360 ddl.Base64Region.union_aggregate(t.c.region).label("r"), 

1361 ) 

1362 .select_from(t) 

1363 .group_by(t.c.id), 

1364 ) 

1365 ], 

1366 [ 

1367 (1, "a", pixelization.quad(12058870).encode()), 

1368 (2, "a", pixelization.quad(12058871).encode()), 

1369 (3, "b", UnionRegion(pixelization.quad(12058872), pixelization.quad(12058873)).encode()), 

1370 ], 

1371 )