Coverage for tests/test_datamodel.py: 8%
253 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-14 09:10 +0000
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-14 09:10 +0000
1# This file is part of felis.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://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 <https://www.gnu.org/licenses/>.
22import os
23import unittest
24from collections import defaultdict
26import yaml
27from pydantic import ValidationError
29from felis.datamodel import (
30 CheckConstraint,
31 Column,
32 DataType,
33 ForeignKeyConstraint,
34 Index,
35 Schema,
36 SchemaVersion,
37 Table,
38 UniqueConstraint,
39)
41TESTDIR = os.path.abspath(os.path.dirname(__file__))
42TEST_YAML = os.path.join(TESTDIR, "data", "test.yml")
45class DataModelTestCase(unittest.TestCase):
46 """Test validation of a test schema from a YAML file."""
48 schema_obj: Schema
50 def test_validation(self) -> None:
51 """Load test file and validate it using the data model."""
52 with open(TEST_YAML) as test_yaml:
53 data = yaml.safe_load(test_yaml)
54 self.schema_obj = Schema.model_validate(data)
57class ColumnTestCase(unittest.TestCase):
58 """Test the `Column` class."""
60 def test_validation(self) -> None:
61 """Test validation of the `Column` class."""
62 # Default initialization should throw an exception.
63 with self.assertRaises(ValidationError):
64 Column()
66 # Setting only name should throw an exception.
67 with self.assertRaises(ValidationError):
68 Column(name="testColumn")
70 # Setting name and id should throw an exception from missing datatype.
71 with self.assertRaises(ValidationError):
72 Column(name="testColumn", id="#test_id")
74 # Setting name, id, and datatype should not throw an exception and
75 # should load data correctly.
76 col = Column(name="testColumn", id="#test_id", datatype="string", length=256)
77 self.assertEqual(col.name, "testColumn", "name should be 'testColumn'")
78 self.assertEqual(col.id, "#test_id", "id should be '#test_id'")
79 self.assertEqual(col.datatype, DataType.string, "datatype should be 'DataType.string'")
81 # Creating from data dictionary should work and load data correctly.
82 data = {"name": "testColumn", "id": "#test_id", "datatype": "string", "length": 256}
83 col = Column(**data)
84 self.assertEqual(col.name, "testColumn", "name should be 'testColumn'")
85 self.assertEqual(col.id, "#test_id", "id should be '#test_id'")
86 self.assertEqual(col.datatype, DataType.string, "datatype should be 'DataType.string'")
88 # Setting a bad IVOA UCD should throw an error.
89 with self.assertRaises(ValidationError):
90 Column(**data, ivoa_ucd="bad")
92 # Setting a valid IVOA UCD should not throw an error.
93 col = Column(**data, ivoa_ucd="meta.id")
94 self.assertEqual(col.ivoa_ucd, "meta.id", "ivoa_ucd should be 'meta.id'")
96 units_data = data.copy()
98 # Setting a bad IVOA unit should throw an error.
99 units_data["ivoa:unit"] = "bad"
100 with self.assertRaises(ValidationError):
101 Column(**units_data)
103 # Setting a valid IVOA unit should not throw an error.
104 units_data["ivoa:unit"] = "m"
105 col = Column(**units_data)
106 self.assertEqual(col.ivoa_unit, "m", "ivoa_unit should be 'm'")
108 units_data = data.copy()
110 # Setting a bad FITS TUNIT should throw an error.
111 units_data["fits:tunit"] = "bad"
112 with self.assertRaises(ValidationError):
113 Column(**units_data)
115 # Setting a valid FITS TUNIT should not throw an error.
116 units_data["fits:tunit"] = "m"
117 col = Column(**units_data)
118 self.assertEqual(col.fits_tunit, "m", "fits_tunit should be 'm'")
120 # Setting both IVOA unit and FITS TUNIT should throw an error.
121 units_data["ivoa:unit"] = "m"
122 with self.assertRaises(ValidationError):
123 Column(**units_data)
125 def test_require_description(self) -> None:
126 """Test the require_description flag for the `Column` class."""
128 class MockValidationInfo:
129 """Mock context object for passing to validation method."""
131 def __init__(self):
132 self.context = {"require_description": True}
134 info = MockValidationInfo()
136 def _check_description(col: Column):
137 Schema.check_description(col, info)
139 # Creating a column without a description should throw.
140 with self.assertRaises(ValueError):
141 _check_description(
142 Column(
143 **{
144 "name": "testColumn",
145 "@id": "#test_col_id",
146 "datatype": "string",
147 }
148 )
149 )
151 # Creating a column with a description of 'None' should throw.
152 with self.assertRaises(ValueError):
153 _check_description(
154 Column(
155 **{
156 "name": "testColumn",
157 "@id": "#test_col_id",
158 "datatype": "string",
159 "description": None,
160 }
161 )
162 )
164 # Creating a column with an empty description should throw.
165 with self.assertRaises(ValueError):
166 _check_description(
167 Column(
168 **{
169 "name": "testColumn",
170 "@id": "#test_col_id",
171 "datatype": "string",
172 "description": "",
173 }
174 )
175 )
177 # Creating a column with a description that is too short should throw.
178 with self.assertRaises(ValidationError):
179 _check_description(
180 Column(
181 **{
182 "name": "testColumn",
183 "@id": "#test_col_id",
184 "datatype": "string",
185 "description": "xy",
186 }
187 )
188 )
190 def test_values(self):
191 """Test the `value` field of the `Column` class."""
193 # Define a function to return the default column data
194 def default_coldata():
195 return defaultdict(str, {"name": "testColumn", "@id": "#test_col_id"})
197 # Setting both value and autoincrement should throw.
198 autoincr_coldata = default_coldata()
199 autoincr_coldata["datatype"] = "int"
200 autoincr_coldata["autoincrement"] = True
201 autoincr_coldata["value"] = 1
202 with self.assertRaises(ValueError):
203 Column(**autoincr_coldata)
205 # Setting an invalid default on a column with an integer type should
206 # throw.
207 bad_numeric_coldata = default_coldata()
208 for datatype in ["int", "long", "short", "byte"]:
209 for value in ["bad", "1.0", "1", 1.1]:
210 bad_numeric_coldata["datatype"] = datatype
211 bad_numeric_coldata["value"] = value
212 with self.assertRaises(ValueError):
213 Column(**bad_numeric_coldata)
215 # Setting an invalid default on a column with a decimal type should
216 # throw.
217 bad_numeric_coldata = default_coldata()
218 for datatype in ["double", "float"]:
219 for value in ["bad", "1.0", "1", 1]:
220 bad_numeric_coldata["datatype"] = datatype
221 bad_numeric_coldata["value"] = value
222 with self.assertRaises(ValueError):
223 Column(**bad_numeric_coldata)
225 # Setting a bad default on a string column should throw.
226 bad_str_coldata = default_coldata()
227 bad_str_coldata["value"] = 1
228 bad_str_coldata["length"] = 256
229 for datatype in ["string", "char", "unicode", "text"]:
230 for value in [1, 1.1, True, "", " ", " ", "\n", "\t"]:
231 bad_str_coldata["datatype"] = datatype
232 bad_str_coldata["value"] = value
233 with self.assertRaises(ValueError):
234 Column(**bad_str_coldata)
236 # Setting a non-boolean value on a boolean column should throw.
237 bool_coldata = default_coldata()
238 bool_coldata["datatype"] = "boolean"
239 bool_coldata["value"] = "bad"
240 with self.assertRaises(ValueError):
241 for value in ["bad", 1, 1.1]:
242 bool_coldata["value"] = value
243 Column(**bool_coldata)
245 # Setting a valid value on a string column should be okay.
246 str_coldata = default_coldata()
247 str_coldata["value"] = 1
248 str_coldata["length"] = 256
249 str_coldata["value"] = "okay"
250 for datatype in ["string", "char", "unicode", "text"]:
251 str_coldata["datatype"] = datatype
252 Column(**str_coldata)
254 # Setting an integer value on a column with an int type should be okay.
255 int_coldata = default_coldata()
256 int_coldata["value"] = 1
257 for datatype in ["int", "long", "short", "byte"]:
258 int_coldata["datatype"] = datatype
259 Column(**int_coldata)
261 # Setting a decimal value on a column with a float type should be okay.
262 bool_coldata = default_coldata()
263 bool_coldata["datatype"] = "boolean"
264 bool_coldata["value"] = True
265 Column(**bool_coldata)
268class ConstraintTestCase(unittest.TestCase):
269 """Test the `UniqueConstraint`, `Index`, `CheckConstraint`, and
270 `ForeignKeyConstraint` classes.
271 """
273 def test_unique_constraint_validation(self) -> None:
274 """Test validation of the `UniqueConstraint` class."""
275 # Default initialization should throw an exception.
276 with self.assertRaises(ValidationError):
277 UniqueConstraint()
279 # Setting only name should throw an exception.
280 with self.assertRaises(ValidationError):
281 UniqueConstraint(name="testConstraint")
283 # Setting name and id should throw an exception from missing columns.
284 with self.assertRaises(ValidationError):
285 UniqueConstraint(name="testConstraint", id="#test_id")
287 # Setting name, id, and columns should not throw an exception and
288 # should load data correctly.
289 col = UniqueConstraint(name="testConstraint", id="#test_id", columns=["testColumn"])
290 self.assertEqual(col.name, "testConstraint", "name should be 'testConstraint'")
291 self.assertEqual(col.id, "#test_id", "id should be '#test_id'")
292 self.assertEqual(col.columns, ["testColumn"], "columns should be ['testColumn']")
294 # Creating from data dictionary should work and load data correctly.
295 data = {"name": "testConstraint", "id": "#test_id", "columns": ["testColumn"]}
296 col = UniqueConstraint(**data)
297 self.assertEqual(col.name, "testConstraint", "name should be 'testConstraint'")
298 self.assertEqual(col.id, "#test_id", "id should be '#test_id'")
299 self.assertEqual(col.columns, ["testColumn"], "columns should be ['testColumn']")
301 def test_index_validation(self) -> None:
302 """Test validation of the `Index` class."""
303 # Default initialization should throw an exception.
304 with self.assertRaises(ValidationError):
305 Index()
307 # Setting only name should throw an exception.
308 with self.assertRaises(ValidationError):
309 Index(name="testConstraint")
311 # Setting name and id should throw an exception from missing columns.
312 with self.assertRaises(ValidationError):
313 Index(name="testConstraint", id="#test_id")
315 # Setting name, id, and columns should not throw an exception and
316 # should load data correctly.
317 col = Index(name="testConstraint", id="#test_id", columns=["testColumn"])
318 self.assertEqual(col.name, "testConstraint", "name should be 'testConstraint'")
319 self.assertEqual(col.id, "#test_id", "id should be '#test_id'")
320 self.assertEqual(col.columns, ["testColumn"], "columns should be ['testColumn']")
322 # Creating from data dictionary should work and load data correctly.
323 data = {"name": "testConstraint", "id": "#test_id", "columns": ["testColumn"]}
324 col = Index(**data)
325 self.assertEqual(col.name, "testConstraint", "name should be 'testConstraint'")
326 self.assertEqual(col.id, "#test_id", "id should be '#test_id'")
327 self.assertEqual(col.columns, ["testColumn"], "columns should be ['testColumn']")
329 # Setting both columns and expressions on an index should throw an
330 # exception.
331 with self.assertRaises(ValidationError):
332 Index(name="testConstraint", id="#test_id", columns=["testColumn"], expressions=["1+2"])
334 def test_foreign_key_validation(self) -> None:
335 """Test validation of the `ForeignKeyConstraint` class."""
336 # Default initialization should throw an exception.
337 with self.assertRaises(ValidationError):
338 ForeignKeyConstraint()
340 # Setting only name should throw an exception.
341 with self.assertRaises(ValidationError):
342 ForeignKeyConstraint(name="testConstraint")
344 # Setting name and id should throw an exception from missing columns.
345 with self.assertRaises(ValidationError):
346 ForeignKeyConstraint(name="testConstraint", id="#test_id")
348 # Setting name, id, and columns should not throw an exception and
349 # should load data correctly.
350 col = ForeignKeyConstraint(
351 name="testConstraint", id="#test_id", columns=["testColumn"], referenced_columns=["testColumn"]
352 )
353 self.assertEqual(col.name, "testConstraint", "name should be 'testConstraint'")
354 self.assertEqual(col.id, "#test_id", "id should be '#test_id'")
355 self.assertEqual(col.columns, ["testColumn"], "columns should be ['testColumn']")
356 self.assertEqual(
357 col.referenced_columns, ["testColumn"], "referenced_columns should be ['testColumn']"
358 )
360 # Creating from data dictionary should work and load data correctly.
361 data = {
362 "name": "testConstraint",
363 "id": "#test_id",
364 "columns": ["testColumn"],
365 "referenced_columns": ["testColumn"],
366 }
367 col = ForeignKeyConstraint(**data)
368 self.assertEqual(col.name, "testConstraint", "name should be 'testConstraint'")
369 self.assertEqual(col.id, "#test_id", "id should be '#test_id'")
370 self.assertEqual(col.columns, ["testColumn"], "columns should be ['testColumn']")
371 self.assertEqual(
372 col.referenced_columns, ["testColumn"], "referenced_columns should be ['testColumn']"
373 )
375 def test_check_constraint_validation(self) -> None:
376 """Check validation of the `CheckConstraint` class."""
377 # Default initialization should throw an exception.
378 with self.assertRaises(ValidationError):
379 CheckConstraint()
381 # Setting only name should throw an exception.
382 with self.assertRaises(ValidationError):
383 CheckConstraint(name="testConstraint")
385 # Setting name and id should throw an exception from missing
386 # expression.
387 with self.assertRaises(ValidationError):
388 CheckConstraint(name="testConstraint", id="#test_id")
390 # Setting name, id, and expression should not throw an exception and
391 # should load data correctly.
392 col = CheckConstraint(name="testConstraint", id="#test_id", expression="1+2")
393 self.assertEqual(col.name, "testConstraint", "name should be 'testConstraint'")
394 self.assertEqual(col.id, "#test_id", "id should be '#test_id'")
395 self.assertEqual(col.expression, "1+2", "expression should be '1+2'")
397 # Creating from data dictionary should work and load data correctly.
398 data = {
399 "name": "testConstraint",
400 "id": "#test_id",
401 "expression": "1+2",
402 }
403 col = CheckConstraint(**data)
404 self.assertEqual(col.name, "testConstraint", "name should be 'testConstraint'")
405 self.assertEqual(col.id, "#test_id", "id should be '#test_id'")
406 self.assertEqual(col.expression, "1+2", "expression should be '1+2'")
409class TableTestCase(unittest.TestCase):
410 """Test the `Table` class."""
412 def test_validation(self) -> None:
413 # Default initialization should throw an exception.
414 with self.assertRaises(ValidationError):
415 Table()
417 # Setting only name should throw an exception.
418 with self.assertRaises(ValidationError):
419 Table(name="testTable")
421 # Setting name and id should throw an exception from missing columns.
422 with self.assertRaises(ValidationError):
423 Index(name="testTable", id="#test_id")
425 testCol = Column(name="testColumn", id="#test_id", datatype="string", length=256)
427 # Setting name, id, and columns should not throw an exception and
428 # should load data correctly.
429 tbl = Table(name="testTable", id="#test_id", columns=[testCol])
430 self.assertEqual(tbl.name, "testTable", "name should be 'testTable'")
431 self.assertEqual(tbl.id, "#test_id", "id should be '#test_id'")
432 self.assertEqual(tbl.columns, [testCol], "columns should be ['testColumn']")
434 # Creating a table with duplicate column names should raise an
435 # exception.
436 with self.assertRaises(ValidationError):
437 Table(name="testTable", id="#test_id", columns=[testCol, testCol])
440class SchemaTestCase(unittest.TestCase):
441 """Test the `Schema` class."""
443 def test_validation(self) -> None:
444 # Default initialization should throw an exception.
445 with self.assertRaises(ValidationError):
446 Schema()
448 # Setting only name should throw an exception.
449 with self.assertRaises(ValidationError):
450 Schema(name="testSchema")
452 # Setting name and id should throw an exception from missing columns.
453 with self.assertRaises(ValidationError):
454 Schema(name="testSchema", id="#test_id")
456 test_col = Column(name="testColumn", id="#test_col_id", datatype="string", length=256)
457 test_tbl = Table(name="testTable", id="#test_tbl_id", columns=[test_col])
459 # Setting name, id, and columns should not throw an exception and
460 # should load data correctly.
461 sch = Schema(name="testSchema", id="#test_sch_id", tables=[test_tbl])
462 self.assertEqual(sch.name, "testSchema", "name should be 'testSchema'")
463 self.assertEqual(sch.id, "#test_sch_id", "id should be '#test_sch_id'")
464 self.assertEqual(sch.tables, [test_tbl], "tables should be ['testTable']")
466 # Creating a schema with duplicate table names should raise an
467 # exception.
468 with self.assertRaises(ValidationError):
469 Schema(name="testSchema", id="#test_id", tables=[test_tbl, test_tbl])
471 # Using an undefined YAML field should raise an exception.
472 with self.assertRaises(ValidationError):
473 Schema(**{"name": "testSchema", "id": "#test_sch_id", "bad_field": "1234"}, tables=[test_tbl])
475 # Creating a schema containing duplicate IDs should raise an error.
476 with self.assertRaises(ValidationError):
477 Schema(
478 name="testSchema",
479 id="#test_sch_id",
480 tables=[
481 Table(
482 name="testTable",
483 id="#test_tbl_id",
484 columns=[
485 Column(name="testColumn", id="#test_col_id", datatype="string"),
486 Column(name="testColumn2", id="#test_col_id", datatype="string"),
487 ],
488 )
489 ],
490 )
492 def test_schema_object_ids(self) -> None:
493 """Test that the id_map is properly populated."""
494 test_col = Column(name="testColumn", id="#test_col_id", datatype="string", length=256)
495 test_tbl = Table(name="testTable", id="#test_table_id", columns=[test_col])
496 sch = Schema(name="testSchema", id="#test_schema_id", tables=[test_tbl])
498 for id in ["#test_col_id", "#test_table_id", "#test_schema_id"]:
499 # Test that the schema contains the expected id.
500 self.assertTrue(id in sch, f"schema should contain '{id}'")
502 # Check that types of returned objects are correct.
503 self.assertIsInstance(sch["#test_col_id"], Column, "schema[id] should return a Column")
504 self.assertIsInstance(sch["#test_table_id"], Table, "schema[id] should return a Table")
505 self.assertIsInstance(sch["#test_schema_id"], Schema, "schema[id] should return a Schema")
507 with self.assertRaises(KeyError):
508 # Test that an invalid id raises an exception.
509 sch["#bad_id"]
512class SchemaVersionTest(unittest.TestCase):
513 """Test the `SchemaVersion` class."""
515 def test_validation(self) -> None:
516 # Default initialization should throw an exception.
517 with self.assertRaises(ValidationError):
518 SchemaVersion()
520 # Setting current should not throw an exception and should load data
521 # correctly.
522 sv = SchemaVersion(current="1.0.0")
523 self.assertEqual(sv.current, "1.0.0", "current should be '1.0.0'")
525 # Check that schema version can be specified as a single string or
526 # an object.
527 data = {
528 "name": "schema",
529 "@id": "#schema",
530 "tables": [],
531 "version": "1.2.3",
532 }
533 schema = Schema.model_validate(data)
534 self.assertEqual(schema.version, "1.2.3")
536 data = {
537 "name": "schema",
538 "@id": "#schema",
539 "tables": [],
540 "version": {
541 "current": "1.2.3",
542 "compatible": ["1.2.0", "1.2.1", "1.2.2"],
543 "read_compatible": ["1.1.0", "1.1.1"],
544 },
545 }
546 schema = Schema.model_validate(data)
547 self.assertEqual(schema.version.current, "1.2.3")
548 self.assertEqual(schema.version.compatible, ["1.2.0", "1.2.1", "1.2.2"])
549 self.assertEqual(schema.version.read_compatible, ["1.1.0", "1.1.1"])
552if __name__ == "__main__": 552 ↛ 553line 552 didn't jump to line 553, because the condition on line 552 was never true
553 unittest.main()