Coverage for tests/test_datamodel.py: 8%

253 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-04-30 02:49 -0700

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/>. 

21 

22import os 

23import unittest 

24from collections import defaultdict 

25 

26import yaml 

27from pydantic import ValidationError 

28 

29from felis.datamodel import ( 

30 CheckConstraint, 

31 Column, 

32 DataType, 

33 ForeignKeyConstraint, 

34 Index, 

35 Schema, 

36 SchemaVersion, 

37 Table, 

38 UniqueConstraint, 

39) 

40 

41TESTDIR = os.path.abspath(os.path.dirname(__file__)) 

42TEST_YAML = os.path.join(TESTDIR, "data", "test.yml") 

43 

44 

45class DataModelTestCase(unittest.TestCase): 

46 """Test validation of a test schema from a YAML file.""" 

47 

48 schema_obj: Schema 

49 

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) 

55 

56 

57class ColumnTestCase(unittest.TestCase): 

58 """Test the `Column` class.""" 

59 

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() 

65 

66 # Setting only name should throw an exception. 

67 with self.assertRaises(ValidationError): 

68 Column(name="testColumn") 

69 

70 # Setting name and id should throw an exception from missing datatype. 

71 with self.assertRaises(ValidationError): 

72 Column(name="testColumn", id="#test_id") 

73 

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") 

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'") 

80 

81 # Creating from data dictionary should work and load data correctly. 

82 data = {"name": "testColumn", "id": "#test_id", "datatype": "string"} 

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'") 

87 

88 # Setting a bad IVOA UCD should throw an error. 

89 with self.assertRaises(ValidationError): 

90 Column(**data, ivoa_ucd="bad") 

91 

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'") 

95 

96 units_data = data.copy() 

97 

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) 

102 

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'") 

107 

108 units_data = data.copy() 

109 

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) 

114 

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'") 

119 

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) 

124 

125 def test_require_description(self) -> None: 

126 """Test the require_description flag for the `Column` class.""" 

127 

128 class MockValidationInfo: 

129 """Mock context object for passing to validation method.""" 

130 

131 def __init__(self): 

132 self.context = {"require_description": True} 

133 

134 info = MockValidationInfo() 

135 

136 def _check_description(col: Column): 

137 Schema.check_description(col, info) 

138 

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 ) 

150 

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 ) 

163 

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 ) 

176 

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 ) 

189 

190 def test_values(self): 

191 """Test the `value` field of the `Column` class.""" 

192 

193 # Define a function to return the default column data 

194 def default_coldata(): 

195 return defaultdict(str, {"name": "testColumn", "@id": "#test_col_id"}) 

196 

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) 

204 

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) 

214 

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) 

224 

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) 

235 

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) 

244 

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) 

253 

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) 

260 

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) 

266 

267 

268class ConstraintTestCase(unittest.TestCase): 

269 """Test the `UniqueConstraint`, `Index`, `CheckConstraint`, and 

270 `ForeignKeyConstraint` classes. 

271 """ 

272 

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() 

278 

279 # Setting only name should throw an exception. 

280 with self.assertRaises(ValidationError): 

281 UniqueConstraint(name="testConstraint") 

282 

283 # Setting name and id should throw an exception from missing columns. 

284 with self.assertRaises(ValidationError): 

285 UniqueConstraint(name="testConstraint", id="#test_id") 

286 

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']") 

293 

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']") 

300 

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() 

306 

307 # Setting only name should throw an exception. 

308 with self.assertRaises(ValidationError): 

309 Index(name="testConstraint") 

310 

311 # Setting name and id should throw an exception from missing columns. 

312 with self.assertRaises(ValidationError): 

313 Index(name="testConstraint", id="#test_id") 

314 

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']") 

321 

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']") 

328 

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"]) 

333 

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() 

339 

340 # Setting only name should throw an exception. 

341 with self.assertRaises(ValidationError): 

342 ForeignKeyConstraint(name="testConstraint") 

343 

344 # Setting name and id should throw an exception from missing columns. 

345 with self.assertRaises(ValidationError): 

346 ForeignKeyConstraint(name="testConstraint", id="#test_id") 

347 

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 ) 

359 

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 ) 

374 

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() 

380 

381 # Setting only name should throw an exception. 

382 with self.assertRaises(ValidationError): 

383 CheckConstraint(name="testConstraint") 

384 

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") 

389 

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'") 

396 

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'") 

407 

408 

409class TableTestCase(unittest.TestCase): 

410 """Test the `Table` class.""" 

411 

412 def test_validation(self) -> None: 

413 # Default initialization should throw an exception. 

414 with self.assertRaises(ValidationError): 

415 Table() 

416 

417 # Setting only name should throw an exception. 

418 with self.assertRaises(ValidationError): 

419 Table(name="testTable") 

420 

421 # Setting name and id should throw an exception from missing columns. 

422 with self.assertRaises(ValidationError): 

423 Index(name="testTable", id="#test_id") 

424 

425 testCol = Column(name="testColumn", id="#test_id", datatype="string") 

426 

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']") 

433 

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]) 

438 

439 

440class SchemaTestCase(unittest.TestCase): 

441 """Test the `Schema` class.""" 

442 

443 def test_validation(self) -> None: 

444 # Default initialization should throw an exception. 

445 with self.assertRaises(ValidationError): 

446 Schema() 

447 

448 # Setting only name should throw an exception. 

449 with self.assertRaises(ValidationError): 

450 Schema(name="testSchema") 

451 

452 # Setting name and id should throw an exception from missing columns. 

453 with self.assertRaises(ValidationError): 

454 Schema(name="testSchema", id="#test_id") 

455 

456 test_col = Column(name="testColumn", id="#test_col_id", datatype="string") 

457 test_tbl = Table(name="testTable", id="#test_tbl_id", columns=[test_col]) 

458 

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']") 

465 

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]) 

470 

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]) 

474 

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 ) 

491 

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") 

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]) 

497 

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}'") 

501 

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") 

506 

507 with self.assertRaises(KeyError): 

508 # Test that an invalid id raises an exception. 

509 sch["#bad_id"] 

510 

511 

512class SchemaVersionTest(unittest.TestCase): 

513 """Test the `SchemaVersion` class.""" 

514 

515 def test_validation(self) -> None: 

516 # Default initialization should throw an exception. 

517 with self.assertRaises(ValidationError): 

518 SchemaVersion() 

519 

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'") 

524 

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") 

535 

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"]) 

550 

551 

552if __name__ == "__main__": 552 ↛ 553line 552 didn't jump to line 553, because the condition on line 552 was never true

553 unittest.main()