Coverage for tests/test_apdbSql.py: 94%
202 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 01:40 -0800
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 01:40 -0800
1# This file is part of dax_apdb.
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 program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
22"""Unit test for Apdb class."""
24import gc
25import os
26import shutil
27import tempfile
28import unittest
29from contextlib import closing
30from typing import Any
31from unittest.mock import patch
33import felis.datamodel
34import sqlalchemy
36import lsst.utils.tests
37from lsst.dax.apdb import (
38 Apdb,
39 ApdbConfig,
40 ApdbReplica,
41 ApdbTables,
42 ApdbUpdateRecord,
43 IncompatibleVersionError,
44 ReplicaChunk,
45)
46from lsst.dax.apdb.pixelization import Pixelization
47from lsst.dax.apdb.sql import ApdbSql, ApdbSqlConfig
48from lsst.dax.apdb.tests import ApdbSchemaUpdateTest, ApdbTest
50try:
51 import testing.postgresql
52except ImportError:
53 testing = None
55TEST_SCHEMA = os.path.join(os.path.abspath(os.path.dirname(__file__)), "config/schema-apdb.yaml")
56TEST_SCHEMA_SSO = os.path.join(os.path.abspath(os.path.dirname(__file__)), "config/schema-sso.yaml")
57# Schema that uses `datetime` for timestamps and combines APDB and SSP.
58TEST_SCHEMA_DT = os.path.join(os.path.abspath(os.path.dirname(__file__)), "config/schema-datetime.yaml")
61class ApdbSQLTest(ApdbTest):
62 """A common base class for SQL APDB tests."""
64 dia_object_index: str
66 def make_instance(self, **kwargs: Any) -> ApdbConfig:
67 """Create database and return its config."""
68 kw = {
69 "schema_file": TEST_SCHEMA,
70 "ss_schema_file": TEST_SCHEMA_SSO,
71 "dia_object_index": self.dia_object_index,
72 "enable_replica": self.enable_replica,
73 }
74 kw.update(kwargs)
75 return ApdbSql.init_database(**kw) # type: ignore[arg-type]
77 def getDiaObjects_table(self) -> ApdbTables:
78 """Return type of table returned from getDiaObjects method."""
79 return ApdbTables.DiaObject
81 def pixelization(self, config: ApdbConfig) -> Pixelization:
82 """Return pixelization used by implementation."""
83 assert isinstance(config, ApdbSqlConfig), "Only expect ApdbSqlConfig here"
84 return Pixelization("htm", config.pixelization.htm_level, config.pixelization.htm_max_ranges)
86 def test_connection_timeout(self) -> None:
87 """Test that setting connection timeout does not break things."""
88 config = self.make_instance()
89 assert isinstance(config, ApdbSqlConfig), "Only expect ApdbSqlConfig here"
90 config.connection_config.connection_timeout = 60.0
91 with closing(Apdb.from_config(config)):
92 pass
94 def store_update_records(self, apdb: Apdb, records: list[ApdbUpdateRecord], chunk: ReplicaChunk) -> None:
95 # Docstring inherited.
96 assert isinstance(apdb, ApdbSql), "Expecting ApdbSql instance"
97 apdb._storeUpdateRecords(records, chunk, store_chunk=True)
99 def _count_after_reset_dedup(self, count_before: int) -> int:
100 return count_before
103class ApdbSQLiteTestCase(ApdbSQLTest, unittest.TestCase):
104 """A test case for ApdbSql class using SQLite backend."""
106 fsrc_requires_id_list = True
107 dia_object_index = "baseline"
108 use_mjd = True
110 def setUp(self) -> None:
111 self.tempdir = tempfile.mkdtemp()
112 self.db_url = f"sqlite:///{self.tempdir}/apdb.sqlite3"
114 def tearDown(self) -> None:
115 shutil.rmtree(self.tempdir, ignore_errors=True)
117 def make_instance(self, **kwargs: Any) -> ApdbConfig:
118 return super().make_instance(db_url=self.db_url, **kwargs)
121class ApdbSQLiteDatetimeTestCase(ApdbSQLTest, unittest.TestCase):
122 """A test case for ApdbSql class using SQLite backend and schema with
123 timestamps in TAI MJD.
124 """
126 fsrc_requires_id_list = True
127 dia_object_index = "baseline"
128 use_mjd = False
130 def setUp(self) -> None:
131 self.tempdir = tempfile.mkdtemp()
132 self.db_url = f"sqlite:///{self.tempdir}/apdb.sqlite3"
134 def tearDown(self) -> None:
135 shutil.rmtree(self.tempdir, ignore_errors=True)
137 def make_instance(self, **kwargs: Any) -> ApdbConfig:
138 if "schema_file" in kwargs:
139 return super().make_instance(db_url=self.db_url, **kwargs)
140 else:
141 return super().make_instance(db_url=self.db_url, schema_file=TEST_SCHEMA_DT, **kwargs)
144class ApdbSQLiteTestCaseLastObject(ApdbSQLiteTestCase):
145 """A test case for ApdbSql class using SQLite backend and DiaObjectLast
146 table.
147 """
149 dia_object_index = "last_object_table"
151 def getDiaObjects_table(self) -> ApdbTables:
152 """Return type of table returned from getDiaObjects method."""
153 return ApdbTables.DiaObjectLast
156class ApdbSQLiteTestCasePixIdIovIndex(ApdbSQLiteTestCase):
157 """A test case for ApdbSql class using SQLite backend with pix_id_iov
158 indexing.
159 """
161 dia_object_index = "pix_id_iov"
164class ApdbSQLiteTestCaseReplica(ApdbSQLiteTestCase):
165 """Test case for ApdbSql class using SQLite backend with replica tables."""
167 enable_replica = True
168 meta_row_count = 4
171@unittest.skipUnless(testing is not None, "testing.postgresql module not found")
172class ApdbPostgresTestCase(ApdbSQLTest, unittest.TestCase):
173 """A test case for ApdbSql class using Postgres backend."""
175 fsrc_requires_id_list = True
176 dia_object_index = "last_object_table"
177 postgresql: Any
178 enable_replica = True
179 meta_row_count = 4
180 timestamp_type_name = "datetime64[ns]"
182 @classmethod
183 def setUpClass(cls) -> None:
184 # Create the postgres test server.
185 cls.postgresql = testing.postgresql.PostgresqlFactory(cache_initialized_db=True)
186 super().setUpClass()
188 @classmethod
189 def tearDownClass(cls) -> None:
190 # Clean up any lingering SQLAlchemy engines/connections
191 # so they're closed before we shut down the server.
192 gc.collect()
193 cls.postgresql.clear_cache()
194 super().tearDownClass()
196 def setUp(self) -> None:
197 self.server = self.postgresql()
199 def tearDown(self) -> None:
200 self.server = self.postgresql()
202 def make_instance(self, **kwargs: Any) -> ApdbConfig:
203 return super().make_instance(db_url=self.server.url(), **kwargs)
205 def getDiaObjects_table(self) -> ApdbTables:
206 """Return type of table returned from getDiaObjects method."""
207 return ApdbTables.DiaObjectLast
210@unittest.skipUnless(testing is not None, "testing.postgresql module not found")
211class ApdbPostgresNamespaceTestCase(ApdbPostgresTestCase):
212 """A test case for ApdbSql class using Postgres backend with schema name"""
214 # use mixed case to trigger quoting
215 namespace = "ApdbSchema"
217 def make_instance(self, **kwargs: Any) -> ApdbConfig:
218 """Make config class instance used in all tests."""
219 return super().make_instance(namespace=self.namespace, **kwargs)
222class ApdbSchemaUpdateSQLiteTestCase(ApdbSchemaUpdateTest, unittest.TestCase):
223 """A test case for schema updates using SQLite backend."""
225 def setUp(self) -> None:
226 self.tempdir = tempfile.mkdtemp()
227 self.db_url = f"sqlite:///{self.tempdir}/apdb.sqlite3"
229 def tearDown(self) -> None:
230 shutil.rmtree(self.tempdir, ignore_errors=True)
232 def make_instance(self, **kwargs: Any) -> ApdbConfig:
233 """Make config class instance used in all tests."""
234 kw = {
235 "db_url": self.db_url,
236 "schema_file": TEST_SCHEMA,
237 "ss_schema_file": TEST_SCHEMA_SSO,
238 }
239 kw.update(kwargs)
240 return ApdbSql.init_database(**kw) # type: ignore[arg-type]
242 def add_column(self, apdb: Apdb, table: ApdbTables, column: felis.datamodel.Column) -> None:
243 # SQLAlchemy does not have API for adding columns, have to do it
244 # manually.
245 assert isinstance(apdb, ApdbSql)
247 # Few selected types that we support.
248 type_names = {
249 felis.datamodel.DataType.int: "INT",
250 felis.datamodel.DataType.float: "FLOAT",
251 felis.datamodel.DataType.double: "DOUBLE",
252 felis.datamodel.DataType.string: "TEXT",
253 }
255 table_name = table.table_name()
256 datatype = type_names[column.datatype]
257 not_null = "" if column.nullable else "NOT NULL"
258 value = "" if column.value is None else column.value
259 if isinstance(value, str): 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true
260 value = f"'{value}'"
261 query = f'ALTER TABLE "{table_name}" ADD COLUMN "{column.name}" {datatype} {not_null}'
262 if value: 262 ↛ 264line 262 didn't jump to line 264 because the condition on line 262 was always true
263 query += f" DEFAULT {value}"
264 with apdb._engine.begin() as conn:
265 conn.execute(sqlalchemy.text(query))
268class ApdbSQLiteFromUriTestCase(unittest.TestCase):
269 """A test case for instantiating ApdbSql via URI."""
271 def setUp(self) -> None:
272 self.tempdir = tempfile.mkdtemp()
273 self.addCleanup(shutil.rmtree, self.tempdir, ignore_errors=True)
274 self.db_url = f"sqlite:///{self.tempdir}/apdb.sqlite3"
275 config = ApdbSql.init_database(
276 db_url=self.db_url, schema_file=TEST_SCHEMA, ss_schema_file=TEST_SCHEMA_SSO
277 )
278 # TODO: This will need update when we switch to pydantic configs.
279 self.config_path = os.path.join(self.tempdir, "apdb-config.yaml")
280 config.save(self.config_path)
281 self.bad_config_path = os.path.join(self.tempdir, "not-config.yaml")
282 self.index_path = os.path.join(self.tempdir, "apdb-index.yaml")
283 with open(self.index_path, "w") as index_file:
284 print(f'label1: "{self.config_path}"', file=index_file)
285 print(f'"label2/pex_config": "{self.config_path}"', file=index_file)
286 print(f'bad-label: "{self.bad_config_path}"', file=index_file)
287 # File with incorrect format.
288 self.bad_index_path = os.path.join(self.tempdir, "apdb-index-bad.yaml")
289 with open(self.bad_index_path, "w") as index_file:
290 print(f'label1: ["{self.config_path}"]', file=index_file)
291 self.missing_index_path = os.path.join(self.tempdir, "no-apdb-index.yaml")
293 def test_make_apdb_from_path(self) -> None:
294 """Check that we can make APDB instance from config URI."""
295 with closing(Apdb.from_uri(self.config_path)):
296 with self.assertRaises(FileNotFoundError):
297 Apdb.from_uri(self.bad_config_path)
299 def test_make_apdb_from_labels(self) -> None:
300 """Check that we can make APDB instance from config URI."""
301 # Replace DAX_APDB_INDEX_URI value
302 new_env = {"DAX_APDB_INDEX_URI": self.index_path}
303 with patch.dict(os.environ, new_env, clear=True):
304 with closing(Apdb.from_uri("label:label1")):
305 pass
306 with closing(Apdb.from_uri("label:label2")):
307 pass
308 # Label does not exist.
309 with self.assertRaises(ValueError):
310 Apdb.from_uri("label:not-a-label")
311 # Label exists but points to a missing config.
312 with self.assertRaises(FileNotFoundError):
313 Apdb.from_uri("label:bad-label")
315 def test_make_apdb_bad_index(self) -> None:
316 """Check what happens when DAX_APDB_INDEX_URI is broken."""
317 # envvar is set but empty.
318 new_env = {"DAX_APDB_INDEX_URI": ""}
319 with patch.dict(os.environ, new_env, clear=True):
320 with self.assertRaises(RuntimeError):
321 Apdb.from_uri("label:label")
323 # envvar is set to something non-existing.
324 new_env = {"DAX_APDB_INDEX_URI": self.missing_index_path}
325 with patch.dict(os.environ, new_env, clear=True):
326 with self.assertRaises(FileNotFoundError):
327 Apdb.from_uri("label:label")
329 # envvar points to an incorrect file.
330 new_env = {"DAX_APDB_INDEX_URI": self.bad_index_path}
331 with patch.dict(os.environ, new_env, clear=True):
332 with self.assertRaises(TypeError):
333 Apdb.from_uri("label:label")
335 def test_remove_database_file(self) -> None:
336 """Check that SQLite does not try to recreate database after it was
337 removed, but config persisted.
338 """
339 os.unlink(f"{self.tempdir}/apdb.sqlite3")
340 with self.assertRaisesRegex(sqlalchemy.exc.OperationalError, "unable to open database file"):
341 Apdb.from_uri(self.config_path)
343 def test_make_apdb_replica(self) -> None:
344 """Check that we can make ApdbReplica instance from config URI."""
345 with closing(ApdbReplica.from_uri(self.config_path)):
346 pass
347 with self.assertRaises(FileNotFoundError):
348 Apdb.from_uri(self.bad_config_path)
351class ApdbSQLiteVersionCheck(unittest.TestCase):
352 """A test case to verify that version check happens before reading
353 frozen configuration.
354 """
356 def setUp(self) -> None:
357 self.tempdir = tempfile.mkdtemp()
358 self.addCleanup(shutil.rmtree, self.tempdir, ignore_errors=True)
359 self.db_url = f"sqlite:///{self.tempdir}/apdb.sqlite3"
360 self.config = ApdbSql.init_database(
361 db_url=self.db_url, schema_file=TEST_SCHEMA, ss_schema_file=TEST_SCHEMA_SSO
362 )
364 def test_version_check(self) -> None:
365 """Test that version check happens before reading config."""
366 with closing(Apdb.from_config(self.config)) as apdb:
367 assert isinstance(apdb, ApdbSql)
369 # Store incompatible version.
370 apdb.metadata.set(ApdbSql.metadataSchemaVersionKey, "99.0.0", force=True)
372 # Overwrite frozen config with something that will break.
373 apdb.metadata.set(ApdbSql.metadataConfigKey, '{"not_a_config_key": 0}', force=True)
375 # Try again.
376 with self.assertRaises(IncompatibleVersionError):
377 Apdb.from_config(self.config)
380class MyMemoryTestCase(lsst.utils.tests.MemoryTestCase):
381 """Run file leak tests."""
384def setup_module(module: Any) -> None:
385 """Configure pytest."""
386 lsst.utils.tests.init()
389if __name__ == "__main__":
390 lsst.utils.tests.init()
391 unittest.main()