Coverage for python/felis/datamodel.py: 98%

732 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-04 01:32 -0800

1"""Define Pydantic data models for Felis.""" 

2 

3# This file is part of felis. 

4# 

5# Developed for the LSST Data Management System. 

6# This product includes software developed by the LSST Project 

7# (https://www.lsst.org). 

8# See the COPYRIGHT file at the top-level directory of this distribution 

9# for details of code ownership. 

10# 

11# This program is free software: you can redistribute it and/or modify 

12# it under the terms of the GNU General Public License as published by 

13# the Free Software Foundation, either version 3 of the License, or 

14# (at your option) any later version. 

15# 

16# This program is distributed in the hope that it will be useful, 

17# but WITHOUT ANY WARRANTY; without even the implied warranty of 

18# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19# GNU General Public License for more details. 

20# 

21# You should have received a copy of the GNU General Public License 

22# along with this program. If not, see <https://www.gnu.org/licenses/>. 

23 

24from __future__ import annotations 

25 

26import json 

27import logging 

28import sys 

29from collections.abc import Sequence 

30from enum import StrEnum, auto 

31from operator import itemgetter 

32from typing import IO, Annotated, Any, Generic, Literal, TypeAlias, TypeVar 

33 

34import yaml 

35from astropy import units as units # type: ignore 

36from astropy.io.votable import ucd # type: ignore 

37from lsst.resources import ResourcePath, ResourcePathExpression 

38from pydantic import ( 

39 BaseModel, 

40 ConfigDict, 

41 Field, 

42 PrivateAttr, 

43 ValidationError, 

44 ValidationInfo, 

45 field_serializer, 

46 field_validator, 

47 model_validator, 

48) 

49from pydantic_core import InitErrorDetails 

50 

51from .db._dialects import get_supported_dialects, string_to_typeengine 

52from .db._sqltypes import get_type_func 

53from .types import Boolean, Byte, Char, Double, FelisType, Float, Int, Long, Short, String, Text, Unicode 

54 

55logger = logging.getLogger(__name__) 

56 

57__all__ = ( 

58 "BaseObject", 

59 "CheckConstraint", 

60 "Column", 

61 "ColumnGroup", 

62 "ColumnOverrides", 

63 "ColumnResourceRef", 

64 "Constraint", 

65 "DataType", 

66 "ForeignKeyConstraint", 

67 "Index", 

68 "Resource", 

69 "Schema", 

70 "SchemaVersion", 

71 "Table", 

72 "UniqueConstraint", 

73) 

74 

75CONFIG = ConfigDict( 

76 populate_by_name=True, # Populate attributes by name. 

77 extra="forbid", # Do not allow extra fields. 

78 str_strip_whitespace=True, # Strip whitespace from string fields. 

79 use_enum_values=False, # Do not use enum values during serialization. 

80) 

81"""Pydantic model configuration as described in: 

82https://docs.pydantic.dev/2.0/api/config/#pydantic.config.ConfigDict 

83""" 

84 

85 

86class BaseObject(BaseModel): 

87 """Base model. 

88 

89 All classes representing objects in the Felis data model should inherit 

90 from this class. 

91 """ 

92 

93 model_config = CONFIG 

94 """Pydantic model configuration.""" 

95 

96 name: str 

97 """Name of the database object.""" 

98 

99 id: str = Field(alias="@id") 

100 """Unique identifier of the database object.""" 

101 

102 description: str | None = None 

103 """Description of the database object.""" 

104 

105 votable_utype: str | None = Field(None, alias="votable:utype") 

106 """VOTable utype (usage-specific or unique type) of the object.""" 

107 

108 @model_validator(mode="after") 

109 def check_description(self, info: ValidationInfo) -> BaseObject: 

110 """Check that the description is present and contains at least one 

111 non-whitespace character, if the validation context indicates that this 

112 check is enabled. By default, descriptions may be omitted or empty. 

113 

114 Parameters 

115 ---------- 

116 info 

117 Validation context used to determine if the check is enabled. 

118 

119 Returns 

120 ------- 

121 `BaseObject` 

122 The object being validated. 

123 """ 

124 context = info.context 

125 if not context or not context.get("check_description", False): 

126 return self 

127 if self.description is None or self.description == "": 

128 raise ValueError("Description is required and must be non-empty") 

129 return self 

130 

131 

132class DataType(StrEnum): 

133 """``Enum`` representing the data types supported by Felis.""" 

134 

135 boolean = auto() 

136 byte = auto() 

137 short = auto() 

138 int = auto() 

139 long = auto() 

140 float = auto() 

141 double = auto() 

142 char = auto() 

143 string = auto() 

144 unicode = auto() 

145 text = auto() 

146 binary = auto() 

147 timestamp = auto() 

148 

149 

150def validate_ivoa_ucd(ivoa_ucd: str) -> str: 

151 """Validate IVOA UCD values. 

152 

153 Parameters 

154 ---------- 

155 ivoa_ucd 

156 IVOA UCD value to check. 

157 

158 Returns 

159 ------- 

160 `str` 

161 The IVOA UCD value if it is valid. 

162 

163 Raises 

164 ------ 

165 ValueError 

166 If the IVOA UCD value is invalid. 

167 """ 

168 if ivoa_ucd is not None: 168 ↛ 173line 168 didn't jump to line 173 because the condition on line 168 was always true

169 try: 

170 ucd.parse_ucd(ivoa_ucd, check_controlled_vocabulary=True, has_colon=";" in ivoa_ucd) 

171 except ValueError as e: 

172 raise ValueError(f"Invalid IVOA UCD: {e}") 

173 return ivoa_ucd 

174 

175 

176class Column(BaseObject): 

177 """Column model.""" 

178 

179 datatype: DataType 

180 """Datatype of the column.""" 

181 

182 length: int | None = Field(None, gt=0) 

183 """Length of the column.""" 

184 

185 precision: int | None = Field(None, ge=0) 

186 """The numerical precision of the column. 

187 

188 For timestamps, this is the number of fractional digits retained in the 

189 seconds field. 

190 """ 

191 

192 nullable: bool = True 

193 """Whether the column can be ``NULL``.""" 

194 

195 value: str | int | float | bool | None = None 

196 """Default value of the column.""" 

197 

198 autoincrement: bool | None = None 

199 """Whether the column is autoincremented.""" 

200 

201 ivoa_ucd: str | None = Field(None, alias="ivoa:ucd") 

202 """IVOA UCD of the column.""" 

203 

204 fits_tunit: str | None = Field(None, alias="fits:tunit") 

205 """FITS TUNIT of the column.""" 

206 

207 ivoa_unit: str | None = Field(None, alias="ivoa:unit") 

208 """IVOA unit of the column.""" 

209 

210 tap_column_index: int | None = Field(None, alias="tap:column_index") 

211 """TAP_SCHEMA column index of the column.""" 

212 

213 tap_principal: int | None = Field(0, alias="tap:principal", ge=0, le=1) 

214 """Whether this is a TAP_SCHEMA principal column.""" 

215 

216 votable_arraysize: int | str | None = Field(None, alias="votable:arraysize") 

217 """VOTable arraysize of the column.""" 

218 

219 tap_std: int | None = Field(0, alias="tap:std", ge=0, le=1) 

220 """TAP_SCHEMA indication that this column is defined by an IVOA standard. 

221 """ 

222 

223 votable_xtype: str | None = Field(None, alias="votable:xtype") 

224 """VOTable xtype (extended type) of the column.""" 

225 

226 votable_datatype: str | None = Field(None, alias="votable:datatype") 

227 """VOTable datatype of the column.""" 

228 

229 mysql_datatype: str | None = Field(None, alias="mysql:datatype") 

230 """MySQL datatype override on the column.""" 

231 

232 postgresql_datatype: str | None = Field(None, alias="postgresql:datatype") 

233 """PostgreSQL datatype override on the column.""" 

234 

235 _is_resource_ref: bool = PrivateAttr(False) 

236 """Whether this column is a resource reference column.""" 

237 

238 @model_validator(mode="after") 

239 def check_value(self) -> Column: 

240 """Check that the default value is valid. 

241 

242 Returns 

243 ------- 

244 `Column` 

245 The column being validated. 

246 """ 

247 if (value := self.value) is not None: 

248 if value is not None and self.autoincrement is True: 

249 raise ValueError("Column cannot have both a default value and be autoincremented") 

250 felis_type = FelisType.felis_type(self.datatype) 

251 if felis_type.is_numeric: 

252 if felis_type in (Byte, Short, Int, Long) and not isinstance(value, int): 

253 raise ValueError("Default value must be an int for integer type columns") 

254 elif felis_type in (Float, Double) and not isinstance(value, float): 

255 raise ValueError("Default value must be a decimal number for float and double columns") 

256 elif felis_type in (String, Char, Unicode, Text): 

257 if not isinstance(value, str): 

258 raise ValueError("Default value must be a string for string columns") 

259 if not len(value): 

260 raise ValueError("Default value must be a non-empty string for string columns") 

261 elif felis_type is Boolean and not isinstance(value, bool): 

262 raise ValueError("Default value must be a boolean for boolean columns") 

263 return self 

264 

265 @field_validator("ivoa_ucd") 

266 @classmethod 

267 def check_ivoa_ucd(cls, ivoa_ucd: str) -> str: 

268 """Check that IVOA UCD values are valid. 

269 

270 Parameters 

271 ---------- 

272 ivoa_ucd 

273 IVOA UCD value to check. 

274 

275 Returns 

276 ------- 

277 `str` 

278 The IVOA UCD value if it is valid. 

279 """ 

280 return validate_ivoa_ucd(ivoa_ucd) 

281 

282 @model_validator(mode="after") 

283 def check_units(self) -> Column: 

284 """Check that the ``fits:tunit`` or ``ivoa:unit`` field has valid 

285 units according to astropy. Only one may be provided. 

286 

287 Returns 

288 ------- 

289 `Column` 

290 The column being validated. 

291 

292 Raises 

293 ------ 

294 ValueError 

295 Raised if both FITS and IVOA units are provided, or if the unit is 

296 invalid. 

297 """ 

298 fits_unit = self.fits_tunit 

299 ivoa_unit = self.ivoa_unit 

300 

301 if fits_unit and ivoa_unit: 

302 raise ValueError("Column cannot have both FITS and IVOA units") 

303 unit = fits_unit or ivoa_unit 

304 

305 if unit is not None: 

306 try: 

307 units.Unit(unit) 

308 except ValueError as e: 

309 raise ValueError(f"Invalid unit: {e}") 

310 

311 return self 

312 

313 @model_validator(mode="before") 

314 @classmethod 

315 def check_length(cls, values: dict[str, Any]) -> dict[str, Any]: 

316 """Check that a valid length is provided for sized types. 

317 

318 Parameters 

319 ---------- 

320 values 

321 Values of the column. 

322 

323 Returns 

324 ------- 

325 `dict` [ `str`, `Any` ] 

326 The values of the column. 

327 

328 Raises 

329 ------ 

330 ValueError 

331 Raised if a length is not provided for a sized type. 

332 """ 

333 datatype = values.get("datatype") 

334 if datatype is None: 

335 # Skip this validation if datatype is not provided 

336 return values 

337 length = values.get("length") 

338 felis_type = FelisType.felis_type(datatype) 

339 if felis_type.is_sized and length is None: 

340 raise ValueError( 

341 f"Length must be provided for type '{datatype}'" 

342 + (f" in column '{values['@id']}'" if "@id" in values else "") 

343 ) 

344 elif not felis_type.is_sized and length is not None: 

345 msg = f"The datatype '{datatype}' does not support a specified length" 

346 if "@id" in values: 

347 msg += f" in column '{values['@id']}'" 

348 logger.warning("%s", msg) 

349 return values 

350 

351 @model_validator(mode="after") 

352 def check_redundant_datatypes(self, info: ValidationInfo) -> Column: 

353 """Check for redundant datatypes on columns. 

354 

355 Parameters 

356 ---------- 

357 info 

358 Validation context used to determine if the check is enabled. 

359 

360 Returns 

361 ------- 

362 `Column` 

363 The column being validated. 

364 

365 Raises 

366 ------ 

367 ValueError 

368 Raised if a datatype override is redundant. 

369 """ 

370 context = info.context 

371 if not context or not context.get("check_redundant_datatypes", False): 

372 return self 

373 if all( 373 ↛ 377line 373 didn't jump to line 377 because the condition on line 373 was never true

374 getattr(self, f"{dialect}:datatype", None) is not None 

375 for dialect in get_supported_dialects().keys() 

376 ): 

377 return self 

378 

379 datatype = self.datatype 

380 length: int | None = self.length or None 

381 

382 datatype_func = get_type_func(datatype) 

383 felis_type = FelisType.felis_type(datatype) 

384 if felis_type.is_sized: 

385 datatype_obj = datatype_func(length) 

386 else: 

387 datatype_obj = datatype_func() 

388 

389 for dialect_name, dialect in get_supported_dialects().items(): 

390 db_annotation = f"{dialect_name}_datatype" 

391 if datatype_string := self.model_dump().get(db_annotation): 

392 db_datatype_obj = string_to_typeengine(datatype_string, dialect, length) 

393 if datatype_obj.compile(dialect) == db_datatype_obj.compile(dialect): 

394 raise ValueError( 

395 "'{}: {}' is a redundant override of 'datatype: {}' in column '{}'{}".format( 

396 db_annotation, 

397 datatype_string, 

398 self.datatype, 

399 self.id, 

400 "" if length is None else f" with length {length}", 

401 ) 

402 ) 

403 else: 

404 logger.debug( 

405 "Type override of 'datatype: %s' with '%s: %s' in column '%s' " 

406 "compiled to '%s' and '%s'", 

407 self.datatype, 

408 db_annotation, 

409 datatype_string, 

410 self.id, 

411 datatype_obj.compile(dialect), 

412 db_datatype_obj.compile(dialect), 

413 ) 

414 return self 

415 

416 @model_validator(mode="after") 

417 def check_precision(self) -> Column: 

418 """Check that precision is only valid for timestamp columns. 

419 

420 Returns 

421 ------- 

422 `Column` 

423 The column being validated. 

424 """ 

425 if self.precision is not None and self.datatype != "timestamp": 

426 raise ValueError("Precision is only valid for timestamp columns") 

427 return self 

428 

429 @model_validator(mode="before") 

430 @classmethod 

431 def check_votable_arraysize(cls, values: dict[str, Any], info: ValidationInfo) -> dict[str, Any]: 

432 """Set the default value for the ``votable_arraysize`` field, which 

433 corresponds to ``arraysize`` in the IVOA VOTable standard. 

434 

435 Parameters 

436 ---------- 

437 values 

438 Values of the column. 

439 info 

440 Validation context used to determine if the check is enabled. 

441 

442 Returns 

443 ------- 

444 `dict` [ `str`, `Any` ] 

445 The values of the column. 

446 

447 Notes 

448 ----- 

449 Following the IVOA VOTable standard, an ``arraysize`` of 1 should not 

450 be used. 

451 """ 

452 if values.get("name", None) is None or values.get("datatype", None) is None: 

453 # Skip bad column data that will not validate 

454 return values 

455 context = info.context if info.context else {} 

456 arraysize = values.get("votable:arraysize", None) 

457 if arraysize is None: 

458 length = values.get("length", None) 

459 datatype = values.get("datatype") 

460 if length is not None and length > 1: 

461 # Following the IVOA standard, arraysize of 1 is disallowed 

462 if datatype == "char": 

463 arraysize = str(length) 

464 elif datatype in ("string", "unicode", "binary"): 

465 if context.get("force_unbounded_arraysize", False): 

466 arraysize = "*" 

467 logger.debug( 

468 "Forced VOTable's 'arraysize' to '*' on column '%s' with datatype " 

469 "'%s' and length '%s'", 

470 values["name"], 

471 values["datatype"], 

472 length, 

473 ) 

474 else: 

475 arraysize = f"{length}*" 

476 elif datatype in ("timestamp", "text"): 

477 arraysize = "*" 

478 if arraysize is not None: 

479 values["votable:arraysize"] = arraysize 

480 logger.debug( 

481 "Set default 'votable:arraysize' to '%s' on column '%s'" 

482 " with datatype '%s' and length '%s'", 

483 arraysize, 

484 values["name"], 

485 values["datatype"], 

486 values.get("length", None), 

487 ) 

488 else: 

489 logger.debug( 

490 "Using existing 'votable:arraysize' of '%s' on column '%s'", arraysize, values["name"] 

491 ) 

492 if isinstance(values["votable:arraysize"], int): 492 ↛ 493line 492 didn't jump to line 493 because the condition on line 492 was never true

493 logger.warning( 

494 "Usage of an integer value for 'votable:arraysize' in column '%s' is deprecated", 

495 values["name"], 

496 ) 

497 values["votable:arraysize"] = str(arraysize) 

498 return values 

499 

500 @field_serializer("datatype") 

501 def serialize_datatype(self, value: DataType) -> str: 

502 """Convert `DataType` to string when serializing to JSON/YAML. 

503 

504 Parameters 

505 ---------- 

506 value 

507 The `DataType` value to serialize. 

508 

509 Returns 

510 ------- 

511 `str` 

512 The serialized `DataType` value. 

513 """ 

514 return str(value) 

515 

516 @field_validator("datatype", mode="before") 

517 @classmethod 

518 def deserialize_datatype(cls, value: str) -> DataType: 

519 """Convert string back into `DataType` when loading from JSON/YAML. 

520 

521 Parameters 

522 ---------- 

523 value 

524 The string value to deserialize. 

525 

526 Returns 

527 ------- 

528 `DataType` 

529 The deserialized `DataType` value. 

530 """ 

531 return DataType(value) 

532 

533 @model_validator(mode="after") 

534 def check_votable_xtype(self) -> Column: 

535 """Set the default value for the ``votable_xtype`` field, which 

536 corresponds to an Extended Datatype or ``xtype`` in the IVOA VOTable 

537 standard. 

538 

539 Returns 

540 ------- 

541 `Column` 

542 The column being validated. 

543 

544 Notes 

545 ----- 

546 This is currently only set automatically for the Felis ``timestamp`` 

547 datatype. 

548 """ 

549 if self.datatype == DataType.timestamp and self.votable_xtype is None: 

550 self.votable_xtype = "timestamp" 

551 return self 

552 

553 def _update_from_overrides(self, overrides: ColumnOverrides) -> None: 

554 """Update the column attributes from the given overrides. 

555 

556 Parameters 

557 ---------- 

558 overrides 

559 The column overrides to apply or `None` to skip applying overrides. 

560 

561 Notes 

562 ----- 

563 Using ``model_fields_set`` allows updating only the fields that are 

564 explicitly set in the `overrides` object. This prevents overwriting 

565 existing column attributes which were not explicitly provided. 

566 """ 

567 if overrides.model_fields_set: 

568 logger.debug("Applying overrides to column '%s': %s", self.id, overrides.model_fields_set) 

569 for field in overrides.model_fields_set: 

570 setattr(self, field, getattr(overrides, field)) 

571 

572 

573class Constraint(BaseObject): 

574 """Table constraint model.""" 

575 

576 deferrable: bool = False 

577 """Whether this constraint will be declared as deferrable.""" 

578 

579 initially: Literal["IMMEDIATE", "DEFERRED"] | None = None 

580 """Value for ``INITIALLY`` clause; only used if `deferrable` is 

581 `True`.""" 

582 

583 @model_validator(mode="after") 

584 def check_deferrable(self) -> Constraint: 

585 """Check that the ``INITIALLY`` clause is only used if `deferrable` is 

586 `True`. 

587 

588 Returns 

589 ------- 

590 `Constraint` 

591 The constraint being validated. 

592 """ 

593 if self.initially is not None and not self.deferrable: 

594 raise ValueError("INITIALLY clause can only be used if deferrable is True") 

595 return self 

596 

597 

598class CheckConstraint(Constraint): 

599 """Table check constraint model.""" 

600 

601 type: Literal["Check"] = Field("Check", alias="@type") 

602 """Type of the constraint.""" 

603 

604 expression: str 

605 """Expression for the check constraint.""" 

606 

607 @field_serializer("type") 

608 def serialize_type(self, value: str) -> str: 

609 """Ensure '@type' is included in serialized output. 

610 

611 Parameters 

612 ---------- 

613 value 

614 The value to serialize. 

615 

616 Returns 

617 ------- 

618 `str` 

619 The serialized value. 

620 """ 

621 return value 

622 

623 

624class UniqueConstraint(Constraint): 

625 """Table unique constraint model.""" 

626 

627 type: Literal["Unique"] = Field("Unique", alias="@type") 

628 """Type of the constraint.""" 

629 

630 columns: list[str] 

631 """Columns in the unique constraint.""" 

632 

633 @field_serializer("type") 

634 def serialize_type(self, value: str) -> str: 

635 """Ensure '@type' is included in serialized output. 

636 

637 Parameters 

638 ---------- 

639 value 

640 The value to serialize. 

641 

642 Returns 

643 ------- 

644 `str` 

645 The serialized value. 

646 """ 

647 return value 

648 

649 

650class ForeignKeyConstraint(Constraint): 

651 """Table foreign key constraint model. 

652 

653 This constraint is used to define a foreign key relationship between two 

654 tables in the schema. There must be at least one column in the 

655 `columns` list, and at least one column in the `referenced_columns` list 

656 or a validation error will be raised. 

657 

658 Notes 

659 ----- 

660 These relationships will be reflected in the TAP_SCHEMA ``keys`` and 

661 ``key_columns`` data. 

662 """ 

663 

664 type: Literal["ForeignKey"] = Field("ForeignKey", alias="@type") 

665 """Type of the constraint.""" 

666 

667 columns: list[str] = Field(min_length=1) 

668 """The columns comprising the foreign key.""" 

669 

670 referenced_columns: list[str] = Field(alias="referencedColumns", min_length=1) 

671 """The columns referenced by the foreign key.""" 

672 

673 on_delete: Literal["CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"] | None = None 

674 """Action to take when the referenced row is deleted.""" 

675 

676 on_update: Literal["CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"] | None = None 

677 """Action to take when the referenced row is updated.""" 

678 

679 @field_serializer("type") 

680 def serialize_type(self, value: str) -> str: 

681 """Ensure '@type' is included in serialized output. 

682 

683 Parameters 

684 ---------- 

685 value 

686 The value to serialize. 

687 

688 Returns 

689 ------- 

690 `str` 

691 The serialized value. 

692 """ 

693 return value 

694 

695 @model_validator(mode="after") 

696 def check_column_lengths(self) -> ForeignKeyConstraint: 

697 """Check that the `columns` and `referenced_columns` lists have the 

698 same length. 

699 

700 Returns 

701 ------- 

702 `ForeignKeyConstraint` 

703 The foreign key constraint being validated. 

704 

705 Raises 

706 ------ 

707 ValueError 

708 Raised if the `columns` and `referenced_columns` lists do not have 

709 the same length. 

710 """ 

711 if len(self.columns) != len(self.referenced_columns): 

712 raise ValueError( 

713 "Columns and referencedColumns must have the same length for a ForeignKey constraint" 

714 ) 

715 return self 

716 

717 

718_ConstraintType = Annotated[ 

719 CheckConstraint | ForeignKeyConstraint | UniqueConstraint, Field(discriminator="type") 

720] 

721"""Type alias for a constraint type.""" 

722 

723 

724class Index(BaseObject): 

725 """Table index model. 

726 

727 An index can be defined on either columns or expressions, but not both. 

728 """ 

729 

730 columns: list[str] | None = None 

731 """Columns in the index.""" 

732 

733 expressions: list[str] | None = None 

734 """Expressions in the index.""" 

735 

736 @model_validator(mode="before") 

737 @classmethod 

738 def check_columns_or_expressions(cls, values: dict[str, Any]) -> dict[str, Any]: 

739 """Check that columns or expressions are specified, but not both. 

740 

741 Parameters 

742 ---------- 

743 values 

744 Values of the index. 

745 

746 Returns 

747 ------- 

748 `dict` [ `str`, `Any` ] 

749 The values of the index. 

750 

751 Raises 

752 ------ 

753 ValueError 

754 Raised if both columns and expressions are specified, or if neither 

755 are specified. 

756 """ 

757 if "columns" in values and "expressions" in values: 

758 raise ValueError("Defining columns and expressions is not valid") 

759 elif "columns" not in values and "expressions" not in values: 

760 raise ValueError("Must define columns or expressions") 

761 return values 

762 

763 

764ColumnRef: TypeAlias = str 

765"""Type alias for a column reference.""" 

766 

767 

768class ColumnGroup(BaseObject): 

769 """Column group model.""" 

770 

771 columns: list[ColumnRef | Column] = Field(..., min_length=1) 

772 """Columns in the group.""" 

773 

774 ivoa_ucd: str | None = Field(None, alias="ivoa:ucd") 

775 """IVOA UCD of the column.""" 

776 

777 table: Table | None = Field(None, exclude=True) 

778 """Reference to the parent table.""" 

779 

780 @field_validator("ivoa_ucd") 

781 @classmethod 

782 def check_ivoa_ucd(cls, ivoa_ucd: str) -> str: 

783 """Check that IVOA UCD values are valid. 

784 

785 Parameters 

786 ---------- 

787 ivoa_ucd 

788 IVOA UCD value to check. 

789 

790 Returns 

791 ------- 

792 `str` 

793 The IVOA UCD value if it is valid. 

794 """ 

795 return validate_ivoa_ucd(ivoa_ucd) 

796 

797 @model_validator(mode="after") 

798 def check_unique_columns(self) -> ColumnGroup: 

799 """Check that the columns list contains unique items. 

800 

801 Returns 

802 ------- 

803 `ColumnGroup` 

804 The column group being validated. 

805 """ 

806 column_ids = [col if isinstance(col, str) else col.id for col in self.columns] 

807 if len(column_ids) != len(set(column_ids)): 

808 raise ValueError("Columns in the group must be unique") 

809 return self 

810 

811 def _dereference_columns(self) -> None: 

812 """Dereference ColumnRef to Column objects.""" 

813 if self.table is None: 

814 raise ValueError("ColumnGroup must have a reference to its parent table") 

815 

816 dereferenced_columns: list[ColumnRef | Column] = [] 

817 for col in self.columns: 

818 if isinstance(col, str): 

819 # Dereference ColumnRef to Column object 

820 try: 

821 col_obj = self.table._find_column_by_id(col) 

822 except KeyError as e: 

823 raise ValueError(f"Column '{col}' not found in table '{self.table.name}'") from e 

824 dereferenced_columns.append(col_obj) 

825 else: 

826 dereferenced_columns.append(col) 

827 

828 self.columns = dereferenced_columns 

829 

830 @field_serializer("columns") 

831 def serialize_columns(self, columns: list[ColumnRef | Column]) -> list[str]: 

832 """Serialize columns as their IDs. 

833 

834 Parameters 

835 ---------- 

836 columns 

837 The columns to serialize. 

838 

839 Returns 

840 ------- 

841 `list` [ `str` ] 

842 The serialized column IDs. 

843 """ 

844 return [col if isinstance(col, str) else col.id for col in columns] 

845 

846 

847class ColumnOverrides(BaseModel): 

848 """Allowed overrides for a referenced column. 

849 

850 Notes 

851 ----- 

852 All of these fields are optional. Values of None may be explicitly set to 

853 override the corresponding attribute in the referenced column but only 

854 for certain fields (see validation in `_check_non_nullable_overrides`). 

855 """ 

856 

857 model_config = CONFIG.copy() 

858 

859 datatype: DataType | None = None 

860 """New datatype for the column.""" 

861 

862 length: int | None = None 

863 """New length for the column.""" 

864 

865 description: str | None = None 

866 """New description for the column.""" 

867 

868 nullable: bool | None = None 

869 """New nullable flag for the column.""" 

870 

871 tap_principal: int | None = Field(default=None, alias="tap:principal") 

872 """Override for the TAP_SCHEMA 'principal' flag.""" 

873 

874 tap_column_index: int | None = Field(default=None, alias="tap:column_index") 

875 """Override for the TAP_SCHEMA column index.""" 

876 

877 @model_validator(mode="before") 

878 @classmethod 

879 def _check_non_nullable_overrides(cls, data: Any) -> Any: 

880 """Check that certain fields are not overridden to null.""" 

881 if not isinstance(data, dict): 

882 return data 

883 non_nullable_fields = ("datatype", "length", "nullable", "tap_principal") 

884 for name in non_nullable_fields: 

885 if name in data and data[name] is None: 

886 raise ValueError(f"The '{name}' field cannot be overridden to null") 

887 return data 

888 

889 @field_serializer("datatype") 

890 def serialize_datatype(self, value: DataType | None) -> str | None: 

891 """Convert `DataType` to string when serializing to JSON/YAML. 

892 

893 Parameters 

894 ---------- 

895 value 

896 The `DataType` value to serialize, or None. 

897 

898 Returns 

899 ------- 

900 `str` | None 

901 The serialized `DataType` value, or None if the input was None. 

902 """ 

903 if value is None: 

904 return None 

905 return str(value) 

906 

907 @field_validator("datatype", mode="before") 

908 @classmethod 

909 def deserialize_datatype(cls, value: str | None) -> DataType | None: 

910 """Convert string back into `DataType` when loading from JSON/YAML. 

911 

912 Parameters 

913 ---------- 

914 value 

915 The string value to deserialize, or None. 

916 

917 Returns 

918 ------- 

919 `DataType` | None 

920 The deserialized `DataType` value, or None if the input was None. 

921 """ 

922 if value is None: 922 ↛ 923line 922 didn't jump to line 923 because the condition on line 922 was never true

923 return None 

924 return DataType(value) 

925 

926 

927class ColumnResourceRef(BaseModel): 

928 """A column which is dervived from an external resource.""" 

929 

930 ref_name: str | None = None 

931 """Name of the referenced column in the resource 

932 (if different from the key).""" 

933 

934 overrides: ColumnOverrides | None = None 

935 """Optional overrides of the referenced column's attributes.""" 

936 

937 

938# Type aliases for the nested mapping structure of referenced columns 

939ResourceColumnMap: TypeAlias = dict[str, ColumnResourceRef | None] 

940ResourceTableMap: TypeAlias = dict[str, ResourceColumnMap] 

941ResourceMap: TypeAlias = dict[str, ResourceTableMap] 

942 

943 

944class Table(BaseObject): 

945 """Table model.""" 

946 

947 primary_key: str | list[str] | None = Field(None, alias="primaryKey") 

948 """Primary key of the table.""" 

949 

950 tap_table_index: int | None = Field(None, alias="tap:table_index") 

951 """IVOA TAP_SCHEMA table index of the table.""" 

952 

953 mysql_engine: str | None = Field("MyISAM", alias="mysql:engine") 

954 """MySQL engine to use for the table.""" 

955 

956 mysql_charset: str | None = Field(None, alias="mysql:charset") 

957 """MySQL charset to use for the table.""" 

958 

959 column_refs: ResourceMap = Field(default_factory=dict, alias="columnRefs") 

960 """Referenced columns from external resources.""" 

961 

962 columns: list[Column] = Field(default_factory=list) 

963 """Columns in the table.""" 

964 

965 column_groups: list[ColumnGroup] = Field(default_factory=list, alias="columnGroups") 

966 """Column groups in the table.""" 

967 

968 constraints: list[_ConstraintType] = Field(default_factory=list) 

969 """Constraints on the table.""" 

970 

971 indexes: list[Index] = Field(default_factory=list) 

972 """Indexes on the table.""" 

973 

974 @field_validator("columns", mode="after") 

975 @classmethod 

976 def check_unique_column_names(cls, columns: list[Column]) -> list[Column]: 

977 """Check that column names are unique. 

978 

979 Parameters 

980 ---------- 

981 columns 

982 The columns to check. 

983 

984 Returns 

985 ------- 

986 `list` [ `Column` ] 

987 The columns if they are unique. 

988 

989 Raises 

990 ------ 

991 ValueError 

992 Raised if column names are not unique. 

993 """ 

994 if len(columns) != len(set(column.name for column in columns)): 

995 raise ValueError("Column names must be unique") 

996 return columns 

997 

998 @model_validator(mode="after") 

999 def check_tap_table_index(self, info: ValidationInfo) -> Table: 

1000 """Check that the table has a TAP table index. 

1001 

1002 Parameters 

1003 ---------- 

1004 info 

1005 Validation context used to determine if the check is enabled. 

1006 

1007 Returns 

1008 ------- 

1009 `Table` 

1010 The table being validated. 

1011 

1012 Raises 

1013 ------ 

1014 ValueError 

1015 Raised If the table is missing a TAP table index. 

1016 """ 

1017 context = info.context 

1018 if not context or not context.get("check_tap_table_indexes", False): 

1019 return self 

1020 if self.tap_table_index is None: 

1021 raise ValueError("Table is missing a TAP table index") 

1022 return self 

1023 

1024 @model_validator(mode="after") 

1025 def check_tap_principal(self, info: ValidationInfo) -> Table: 

1026 """Check that at least one column is flagged as 'principal' for TAP 

1027 purposes. 

1028 

1029 Parameters 

1030 ---------- 

1031 info 

1032 Validation context used to determine if the check is enabled. 

1033 

1034 Returns 

1035 ------- 

1036 `Table` 

1037 The table being validated. 

1038 

1039 Raises 

1040 ------ 

1041 ValueError 

1042 Raised if the table is missing a column flagged as 'principal'. 

1043 """ 

1044 context = info.context 

1045 if not context or not context.get("check_tap_principal", False): 

1046 return self 

1047 for col in self.columns: 

1048 if col.tap_principal == 1: 

1049 return self 

1050 raise ValueError(f"Table '{self.name}' is missing at least one column designated as 'tap:principal'") 

1051 

1052 def _find_column_by_id(self, id: str) -> Column: 

1053 """Find a column by ID. 

1054 

1055 Parameters 

1056 ---------- 

1057 id 

1058 The ID of the column to find. 

1059 

1060 Returns 

1061 ------- 

1062 `Column` 

1063 The column with the given ID. 

1064 

1065 Raises 

1066 ------ 

1067 ValueError 

1068 Raised if the column is not found. 

1069 """ 

1070 for column in self.columns: 

1071 if column.id == id: 

1072 return column 

1073 raise KeyError(f"Column '{id}' not found in table '{self.name}'") 

1074 

1075 def _find_column_by_name(self, name: str) -> Column: 

1076 for column in self.columns: 

1077 if column.name == name: 

1078 return column 

1079 raise KeyError(f"Column '{name}' not found in table '{self.name}'") 

1080 

1081 @model_validator(mode="after") 

1082 def dereference_column_groups(self: Table) -> Table: 

1083 """Dereference columns in column groups. 

1084 

1085 Returns 

1086 ------- 

1087 `Table` 

1088 The table with dereferenced column groups. 

1089 """ 

1090 for group in self.column_groups: 

1091 group.table = self 

1092 group._dereference_columns() 

1093 return self 

1094 

1095 @field_serializer("columns") 

1096 def _serialize_columns(self, columns: list[Column]) -> list[dict[str, Any]]: 

1097 """Serialize only non-resource columns.""" 

1098 return [ 

1099 col.model_dump( 

1100 by_alias=True, 

1101 exclude_none=True, 

1102 exclude_defaults=True, 

1103 ) 

1104 for col in columns 

1105 if not col._is_resource_ref 

1106 ] 

1107 

1108 

1109class SchemaVersion(BaseModel): 

1110 """Schema version model.""" 

1111 

1112 current: str 

1113 """The current version of the schema.""" 

1114 

1115 compatible: list[str] = Field(default_factory=list) 

1116 """The compatible versions of the schema.""" 

1117 

1118 read_compatible: list[str] = Field(default_factory=list) 

1119 """The read compatible versions of the schema.""" 

1120 

1121 

1122class SchemaIdVisitor: 

1123 """Visit a schema and build the map of IDs to objects. 

1124 

1125 Notes 

1126 ----- 

1127 Duplicates are added to a set when they are encountered, which can be 

1128 accessed via the ``duplicates`` attribute. The presence of duplicates will 

1129 not throw an error. Only the first object with a given ID will be added to 

1130 the map, but this should not matter, since a ``ValidationError`` will be 

1131 thrown by the ``model_validator`` method if any duplicates are found in the 

1132 schema. 

1133 """ 

1134 

1135 def __init__(self) -> None: 

1136 """Create a new SchemaVisitor.""" 

1137 self.schema: Schema | None = None 

1138 self.duplicates: set[str] = set() 

1139 

1140 def add(self, obj: BaseObject) -> None: 

1141 """Add an object to the ID map. 

1142 

1143 Parameters 

1144 ---------- 

1145 obj 

1146 The object to add to the ID map. 

1147 """ 

1148 if hasattr(obj, "id"): 1148 ↛ exitline 1148 didn't return from function 'add' because the condition on line 1148 was always true

1149 obj_id = getattr(obj, "id") 

1150 if self.schema is not None: 1150 ↛ exitline 1150 didn't return from function 'add' because the condition on line 1150 was always true

1151 if obj_id in self.schema._id_map: 1151 ↛ 1152line 1151 didn't jump to line 1152 because the condition on line 1151 was never true

1152 self.duplicates.add(obj_id) 

1153 else: 

1154 self.schema._id_map[obj_id] = obj 

1155 

1156 def visit_schema(self, schema: Schema) -> None: 

1157 """Visit the objects in a schema and build the ID map. 

1158 

1159 Parameters 

1160 ---------- 

1161 schema 

1162 The schema object to visit. 

1163 

1164 Notes 

1165 ----- 

1166 This will set an internal variable pointing to the schema object. 

1167 """ 

1168 self.schema = schema 

1169 self.duplicates.clear() 

1170 self.add(self.schema) 

1171 for table in self.schema.tables: 

1172 self.visit_table(table) 

1173 

1174 def visit_table(self, table: Table) -> None: 

1175 """Visit a table object. 

1176 

1177 Parameters 

1178 ---------- 

1179 table 

1180 The table object to visit. 

1181 """ 

1182 self.add(table) 

1183 for column in table.columns: 

1184 self.visit_column(column) 

1185 for constraint in table.constraints: 

1186 self.visit_constraint(constraint) 

1187 

1188 def visit_column(self, column: Column) -> None: 

1189 """Visit a column object. 

1190 

1191 Parameters 

1192 ---------- 

1193 column 

1194 The column object to visit. 

1195 """ 

1196 self.add(column) 

1197 

1198 def visit_constraint(self, constraint: Constraint) -> None: 

1199 """Visit a constraint object. 

1200 

1201 Parameters 

1202 ---------- 

1203 constraint 

1204 The constraint object to visit. 

1205 """ 

1206 self.add(constraint) 

1207 

1208 

1209T = TypeVar("T", bound=BaseObject) 

1210 

1211 

1212def _strip_ids(data: Any) -> Any: 

1213 """Recursively strip '@id' fields from a dictionary or list. 

1214 

1215 Parameters 

1216 ---------- 

1217 data 

1218 The data to strip IDs from, which can be a dictionary, list, or any 

1219 other type. Other types will be returned unchanged. 

1220 """ 

1221 if isinstance(data, dict): 

1222 data.pop("@id", None) 

1223 for k, v in data.items(): 

1224 data[k] = _strip_ids(v) 

1225 return data 

1226 elif isinstance(data, list): 

1227 return [_strip_ids(item) for item in data] 

1228 else: 

1229 return data 

1230 

1231 

1232def _append_error( 

1233 errors: list[InitErrorDetails], 

1234 loc: tuple, 

1235 input_value: Any, 

1236 error_message: str, 

1237 error_type: str = "value_error", 

1238) -> None: 

1239 """Append an error to the errors list. 

1240 

1241 Parameters 

1242 ---------- 

1243 errors : list[InitErrorDetails] 

1244 The list of errors to append to. 

1245 loc : tuple 

1246 The location of the error in the schema. 

1247 input_value : Any 

1248 The input value that caused the error. 

1249 error_message : str 

1250 The error message to include in the context. 

1251 """ 

1252 errors.append( 

1253 { 

1254 "type": error_type, 

1255 "loc": loc, 

1256 "input": input_value, 

1257 "ctx": {"error": error_message}, 

1258 } 

1259 ) 

1260 

1261 

1262class Resource(BaseModel): 

1263 """A resource definition referencing an external schema.""" 

1264 

1265 uri: str = Field(..., description="Resource URI or path") 

1266 """URI of the schema resource which may be a local path, ``resource://``, 

1267 or remote URL.""" 

1268 

1269 

1270class Schema(BaseObject, Generic[T]): 

1271 """Database schema model. 

1272 

1273 This represents a database schema, which contains one or more tables. 

1274 """ 

1275 

1276 version: SchemaVersion | str | None = None 

1277 """The version of the schema.""" 

1278 

1279 resources: dict[str, Resource] = Field(default_factory=dict) 

1280 """External resources referenced by this schema.""" 

1281 

1282 tables: Sequence[Table] 

1283 """The tables in the schema.""" 

1284 

1285 _id_map: dict[str, Any] = PrivateAttr(default_factory=dict) 

1286 """Map of IDs to objects.""" 

1287 

1288 _resource_map: dict[str, Schema] = PrivateAttr(default_factory=dict) 

1289 """Map of resource names to loaded schemas.""" 

1290 

1291 @model_validator(mode="after") 

1292 def _load_resources(self: Schema, info: ValidationInfo) -> Schema: 

1293 """Load external resources referenced by this schema into an internal 

1294 mapping of resource names to their `Schema` objects. 

1295 

1296 Returns 

1297 ------- 

1298 `Schema` 

1299 The schema being validated. 

1300 

1301 Raises 

1302 ------ 

1303 ValueError 

1304 Raised if a resource cannot be loaded. 

1305 """ 

1306 if info.context: 

1307 context = info.context.copy() 

1308 # Ignore this flag for loading the resources themselves 

1309 context.pop("dereference_resources", None) 

1310 else: 

1311 context = {} 

1312 

1313 # Get the base URI for resolving relative resource paths from the 

1314 # validation context, if available. 

1315 resource_path = context.pop("resource_path", None) 

1316 base_uri = None 

1317 if resource_path is not None: 

1318 base_uri = resource_path.parent() 

1319 

1320 for resource_name, resource in self.resources.items(): 

1321 uri = resource.uri 

1322 

1323 # Apply the base URI to the resource URI, if available. 

1324 if base_uri is not None: 

1325 orig_uri = uri 

1326 uri = base_uri.join(uri, forceDirectory=False) 

1327 if uri != orig_uri: 1327 ↛ 1336line 1327 didn't jump to line 1336 because the condition on line 1327 was always true

1328 logger.debug( 

1329 "Resolved relative URI '%s' for resource '%s' to '%s' using base URI '%s'", 

1330 resource.uri, 

1331 resource_name, 

1332 uri, 

1333 base_uri, 

1334 ) 

1335 

1336 try: 

1337 loaded_schema = Schema.from_uri(uri, context=context) 

1338 self._resource_map[resource_name] = loaded_schema 

1339 logger.debug("Loaded resource '%s' from URI '%s'", resource_name, uri) 

1340 except Exception as e: 

1341 raise ValueError(f"Failed to load resource '{resource_name}' from URI '{uri}': {e}") from e 

1342 return self 

1343 

1344 def _find_table_by_name(self, name: str) -> Table: 

1345 """Find a table by name. 

1346 

1347 Parameters 

1348 ---------- 

1349 name 

1350 The name of the table to find. 

1351 

1352 Returns 

1353 ------- 

1354 `Table` 

1355 The table with the given name. 

1356 

1357 Raises 

1358 ------ 

1359 KeyError 

1360 Raised if the table is not found. 

1361 """ 

1362 for table in self.tables: 

1363 if table.name == name: 

1364 return table 

1365 raise KeyError(f"Table '{name}' not found in schema '{self.name}'") 

1366 

1367 @model_validator(mode="after") 

1368 def _dereference_resource_columns(self: Schema, info: ValidationInfo) -> Schema: 

1369 """Dereference columns from external resources and add them to the 

1370 tables in this schema. 

1371 """ 

1372 context = info.context 

1373 column_ref_index_increment: int | None = None 

1374 dereference_resources = False 

1375 if context is not None: 

1376 dereference_resources = context.get("dereference_resources", False) 

1377 column_ref_index_increment = context.get("column_ref_index_increment", None) 

1378 

1379 for table in self.tables: 

1380 if column_refs := table.column_refs: 

1381 for resource_name, tables in column_refs.items(): 

1382 resource_schema = self._resource_map.get(resource_name) 

1383 if resource_schema is None: 

1384 raise ValueError(f"Schema resource '{resource_name}' was not found in resources") 

1385 self._process_column_refs( 

1386 table, 

1387 tables, 

1388 resource_schema, 

1389 dereference_resources, 

1390 column_ref_index_increment, 

1391 ) 

1392 if dereference_resources and len(table.column_refs) > 0: 

1393 # Clear column refs in table if fully dereferencing 

1394 logger.debug( 

1395 "Clearing columnRefs in table '%s' after dereferencing resource columns", 

1396 table.name, 

1397 ) 

1398 table.column_refs = {} 

1399 return self 

1400 

1401 @classmethod 

1402 def _process_column_refs( 

1403 cls, 

1404 table: Table, 

1405 ref_tables: ResourceTableMap, 

1406 resource_schema: Schema, 

1407 dereference_resources: bool = False, 

1408 column_ref_index_increment: int | None = None, 

1409 ) -> None: 

1410 """Process column references from an external resource and add them 

1411 to the given table as columns. 

1412 """ 

1413 current_column_index = column_ref_index_increment if column_ref_index_increment is not None else -1 

1414 

1415 for table_name, columns in ref_tables.items(): 

1416 try: 

1417 resource_table = resource_schema._find_table_by_name(table_name) 

1418 except KeyError as e: 

1419 raise ValueError( 

1420 f"Table '{table_name}' not found in resource '{resource_schema.name}'" 

1421 ) from e 

1422 for local_column_name, column_ref in columns.items(): 

1423 if column_ref is not None and column_ref.ref_name is not None: 

1424 # Use specified ref_name 

1425 ref_column_name = column_ref.ref_name 

1426 else: 

1427 # Use the local column name if no ref_name 

1428 # specified 

1429 ref_column_name = local_column_name 

1430 

1431 # Check if referenced column exists in resource 

1432 try: 

1433 base_column = resource_table._find_column_by_name(ref_column_name) 

1434 except KeyError: 

1435 # The ref_name is specified but column is not 

1436 # found 

1437 if column_ref is not None and column_ref.ref_name is not None: 

1438 raise ValueError( 

1439 f"Column '{ref_column_name}' not found in table '{table_name}' " 

1440 f"from resource '{resource_schema.name}'" 

1441 ) 

1442 # The ref_name is not specified and the local 

1443 # column name is not found 

1444 raise ValueError( 

1445 f"Column '{local_column_name}' not found in table '{table_name}' " 

1446 f"from resource '{resource_schema.name}' and no ref_name provided" 

1447 ) 

1448 

1449 # Create a copy of the base column 

1450 column_copy = base_column.model_copy() 

1451 

1452 # Set the local name (key from the mapping) 

1453 column_copy.name = local_column_name 

1454 

1455 if not dereference_resources: 

1456 # Flag the column as a resource reference so it will not be 

1457 # written out during serialization 

1458 column_copy._is_resource_ref = True 

1459 

1460 # Apply overrides to the referenced column definition 

1461 overrides = column_ref.overrides if column_ref is not None else None 

1462 if overrides is not None: 

1463 column_copy._update_from_overrides(overrides) 

1464 

1465 # Manually set the ID of the copied column as ID generation has 

1466 # already occurred by now 

1467 column_copy.id = f"{table.id}.{local_column_name}" 

1468 

1469 # Apply automatic assignment of 'tap:column_index', if enabled 

1470 if column_ref_index_increment is not None: 

1471 if (not overrides) or (overrides.tap_column_index is None): 

1472 column_copy.tap_column_index = current_column_index 

1473 current_column_index += column_ref_index_increment 

1474 logger.debug( 

1475 "Automatically assigned 'tap:column_index' %s to " 

1476 "column '%s' in table '%s' from resource '%s'", 

1477 column_copy.tap_column_index, 

1478 local_column_name, 

1479 table_name, 

1480 resource_schema.name, 

1481 ) 

1482 else: 

1483 # Skip automatic assignment of 'tap:column_index' if it 

1484 # is already overridden 

1485 logger.debug( 

1486 "Skipping automatic assignment of 'tap:column_index' for column " 

1487 "'%s' in table '%s' from resource '%s' as it is already overridden to %s", 

1488 local_column_name, 

1489 table_name, 

1490 resource_schema.name, 

1491 column_copy.tap_column_index, 

1492 ) 

1493 table.columns.append(column_copy) 

1494 logger.debug( 

1495 "Dereferenced column '%s' from table '%s' in resource '%s' into table '%s'", 

1496 local_column_name, 

1497 table_name, 

1498 resource_schema.name, 

1499 table.name, 

1500 ) 

1501 

1502 @model_validator(mode="before") 

1503 @classmethod 

1504 def generate_ids(cls, values: dict[str, Any], info: ValidationInfo) -> dict[str, Any]: 

1505 """Generate IDs for objects that do not have them. 

1506 

1507 Parameters 

1508 ---------- 

1509 values 

1510 The values of the schema. 

1511 info 

1512 Validation context used to determine if ID generation is enabled. 

1513 

1514 Returns 

1515 ------- 

1516 `dict` [ `str`, `Any` ] 

1517 The values of the schema with generated IDs. 

1518 """ 

1519 context = info.context 

1520 if not context or not context.get("id_generation", False): 

1521 logger.debug("Skipping ID generation") 

1522 return values 

1523 schema_name = values["name"] 

1524 if "@id" not in values: 

1525 values["@id"] = f"#{schema_name}" 

1526 logger.debug("Generated ID '%s' for schema '%s'", values["@id"], schema_name) 

1527 if "tables" in values: 1527 ↛ 1560line 1527 didn't jump to line 1560 because the condition on line 1527 was always true

1528 for table in values["tables"]: 

1529 if "@id" not in table: 

1530 table["@id"] = f"#{table['name']}" 

1531 logger.debug("Generated ID '%s' for table '%s'", table["@id"], table["name"]) 

1532 if "columns" in table: 

1533 for column in table["columns"]: 

1534 if "@id" not in column: 

1535 column["@id"] = f"#{table['name']}.{column['name']}" 

1536 logger.debug("Generated ID '%s' for column '%s'", column["@id"], column["name"]) 

1537 if "columnGroups" in table: 

1538 for column_group in table["columnGroups"]: 

1539 if "@id" not in column_group: 

1540 column_group["@id"] = f"#{table['name']}.{column_group['name']}" 

1541 logger.debug( 

1542 "Generated ID '%s' for column group '%s'", 

1543 column_group["@id"], 

1544 column_group["name"], 

1545 ) 

1546 if "constraints" in table: 

1547 for constraint in table["constraints"]: 

1548 if "@id" not in constraint: 

1549 constraint["@id"] = f"#{constraint['name']}" 

1550 logger.debug( 

1551 "Generated ID '%s' for constraint '%s'", 

1552 constraint["@id"], 

1553 constraint["name"], 

1554 ) 

1555 if "indexes" in table: 

1556 for index in table["indexes"]: 

1557 if "@id" not in index: 

1558 index["@id"] = f"#{index['name']}" 

1559 logger.debug("Generated ID '%s' for index '%s'", index["@id"], index["name"]) 

1560 return values 

1561 

1562 @field_validator("tables", mode="after") 

1563 @classmethod 

1564 def check_unique_table_names(cls, tables: list[Table]) -> list[Table]: 

1565 """Check that table names are unique. 

1566 

1567 Parameters 

1568 ---------- 

1569 tables 

1570 The tables to check. 

1571 

1572 Returns 

1573 ------- 

1574 `list` [ `Table` ] 

1575 The tables if they are unique. 

1576 

1577 Raises 

1578 ------ 

1579 ValueError 

1580 Raised if table names are not unique. 

1581 """ 

1582 if len(tables) != len(set(table.name for table in tables)): 

1583 raise ValueError("Table names must be unique") 

1584 return tables 

1585 

1586 @model_validator(mode="after") 

1587 def check_tap_table_indexes(self, info: ValidationInfo) -> Schema: 

1588 """Check that the TAP table indexes are unique. 

1589 

1590 Parameters 

1591 ---------- 

1592 info 

1593 The validation context used to determine if the check is enabled. 

1594 

1595 Returns 

1596 ------- 

1597 `Schema` 

1598 The schema being validated. 

1599 """ 

1600 context = info.context 

1601 if not context or not context.get("check_tap_table_indexes", False): 

1602 return self 

1603 table_indicies = set() 

1604 for table in self.tables: 

1605 table_index = table.tap_table_index 

1606 if table_index is not None: 1606 ↛ 1604line 1606 didn't jump to line 1604 because the condition on line 1606 was always true

1607 if table_index in table_indicies: 

1608 raise ValueError(f"Duplicate 'tap:table_index' value {table_index} found in schema") 

1609 table_indicies.add(table_index) 

1610 return self 

1611 

1612 @model_validator(mode="after") 

1613 def check_unique_constraint_names(self: Schema) -> Schema: 

1614 """Check for duplicate constraint names in the schema. 

1615 

1616 Returns 

1617 ------- 

1618 `Schema` 

1619 The schema being validated. 

1620 

1621 Raises 

1622 ------ 

1623 ValueError 

1624 Raised if duplicate constraint names are found in the schema. 

1625 """ 

1626 constraint_names = set() 

1627 duplicate_names = [] 

1628 

1629 for table in self.tables: 

1630 for constraint in table.constraints: 

1631 constraint_name = constraint.name 

1632 if constraint_name in constraint_names: 

1633 duplicate_names.append(constraint_name) 

1634 else: 

1635 constraint_names.add(constraint_name) 

1636 

1637 if duplicate_names: 

1638 raise ValueError(f"Duplicate constraint names found in schema: {duplicate_names}") 

1639 

1640 return self 

1641 

1642 @model_validator(mode="after") 

1643 def check_unique_index_names(self: Schema) -> Schema: 

1644 """Check for duplicate index names in the schema. 

1645 

1646 Returns 

1647 ------- 

1648 `Schema` 

1649 The schema being validated. 

1650 

1651 Raises 

1652 ------ 

1653 ValueError 

1654 Raised if duplicate index names are found in the schema. 

1655 """ 

1656 index_names = set() 

1657 duplicate_names = [] 

1658 

1659 for table in self.tables: 

1660 for index in table.indexes: 

1661 index_name = index.name 

1662 if index_name in index_names: 

1663 duplicate_names.append(index_name) 

1664 else: 

1665 index_names.add(index_name) 

1666 

1667 if duplicate_names: 

1668 raise ValueError(f"Duplicate index names found in schema: {duplicate_names}") 

1669 

1670 return self 

1671 

1672 @model_validator(mode="after") 

1673 def create_id_map(self: Schema) -> Schema: 

1674 """Create a map of IDs to objects. 

1675 

1676 Returns 

1677 ------- 

1678 `Schema` 

1679 The schema with the ID map created. 

1680 

1681 Raises 

1682 ------ 

1683 ValueError 

1684 Raised if duplicate identifiers are found in the schema. 

1685 """ 

1686 if self._id_map: 1686 ↛ 1687line 1686 didn't jump to line 1687 because the condition on line 1686 was never true

1687 logger.debug("Ignoring call to create_id_map() - ID map was already populated") 

1688 return self 

1689 visitor: SchemaIdVisitor = SchemaIdVisitor() 

1690 visitor.visit_schema(self) 

1691 if len(visitor.duplicates): 1691 ↛ 1692line 1691 didn't jump to line 1692 because the condition on line 1691 was never true

1692 raise ValueError( 

1693 "Duplicate IDs found in schema:\n " + "\n ".join(visitor.duplicates) + "\n" 

1694 ) 

1695 logger.debug("Created ID map with %d entries", len(self._id_map)) 

1696 return self 

1697 

1698 def _validate_column_id( 

1699 self: Schema, 

1700 column_id: str, 

1701 loc: tuple, 

1702 errors: list[InitErrorDetails], 

1703 ) -> None: 

1704 """Validate a column ID from a constraint and append errors if invalid. 

1705 

1706 Parameters 

1707 ---------- 

1708 schema : Schema 

1709 The schema being validated. 

1710 column_id : str 

1711 The column ID to validate. 

1712 loc : tuple 

1713 The location of the error in the schema. 

1714 errors : list[InitErrorDetails] 

1715 The list of errors to append to. 

1716 """ 

1717 if column_id not in self: 

1718 _append_error( 

1719 errors, 

1720 loc, 

1721 column_id, 

1722 f"Column ID '{column_id}' not found in schema", 

1723 ) 

1724 elif not isinstance(self[column_id], Column): 

1725 _append_error( 

1726 errors, 

1727 loc, 

1728 column_id, 

1729 f"ID '{column_id}' does not refer to a Column object", 

1730 ) 

1731 

1732 def _validate_foreign_key_column( 

1733 self: Schema, 

1734 column_id: str, 

1735 table: Table, 

1736 loc: tuple, 

1737 errors: list[InitErrorDetails], 

1738 ) -> None: 

1739 """Validate a foreign key column ID from a constraint and append errors 

1740 if invalid. 

1741 

1742 Parameters 

1743 ---------- 

1744 schema : Schema 

1745 The schema being validated. 

1746 column_id : str 

1747 The foreign key column ID to validate. 

1748 loc : tuple 

1749 The location of the error in the schema. 

1750 errors : list[InitErrorDetails] 

1751 The list of errors to append to. 

1752 """ 

1753 try: 

1754 table._find_column_by_id(column_id) 

1755 except KeyError: 

1756 _append_error( 

1757 errors, 

1758 loc, 

1759 column_id, 

1760 f"Column '{column_id}' not found in table '{table.name}'", 

1761 ) 

1762 

1763 @model_validator(mode="after") 

1764 def check_constraints(self: Schema) -> Schema: 

1765 """Check constraint objects for validity. This needs to be deferred 

1766 until after the schema is fully loaded and the ID map is created. 

1767 

1768 Raises 

1769 ------ 

1770 pydantic.ValidationError 

1771 Raised if any constraints are invalid. 

1772 

1773 Returns 

1774 ------- 

1775 `Schema` 

1776 The schema being validated. 

1777 """ 

1778 errors: list[InitErrorDetails] = [] 

1779 

1780 for table_index, table in enumerate(self.tables): 

1781 for constraint_index, constraint in enumerate(table.constraints): 

1782 column_ids: list[str] = [] 

1783 referenced_column_ids: list[str] = [] 

1784 

1785 if isinstance(constraint, ForeignKeyConstraint): 

1786 column_ids += constraint.columns 

1787 referenced_column_ids += constraint.referenced_columns 

1788 elif isinstance(constraint, UniqueConstraint): 

1789 column_ids += constraint.columns 

1790 # No extra checks are required on CheckConstraint objects. 

1791 

1792 # Validate the foreign key columns 

1793 for column_id in column_ids: 

1794 self._validate_column_id( 

1795 column_id, 

1796 ( 

1797 "tables", 

1798 table_index, 

1799 "constraints", 

1800 constraint_index, 

1801 "columns", 

1802 column_id, 

1803 ), 

1804 errors, 

1805 ) 

1806 # Check that the foreign key column is within the source 

1807 # table. 

1808 self._validate_foreign_key_column( 

1809 column_id, 

1810 table, 

1811 ( 

1812 "tables", 

1813 table_index, 

1814 "constraints", 

1815 constraint_index, 

1816 "columns", 

1817 column_id, 

1818 ), 

1819 errors, 

1820 ) 

1821 

1822 # Validate the primary key (reference) columns 

1823 for referenced_column_id in referenced_column_ids: 

1824 self._validate_column_id( 

1825 referenced_column_id, 

1826 ( 

1827 "tables", 

1828 table_index, 

1829 "constraints", 

1830 constraint_index, 

1831 "referenced_columns", 

1832 referenced_column_id, 

1833 ), 

1834 errors, 

1835 ) 

1836 

1837 if errors: 

1838 raise ValidationError.from_exception_data("Schema validation failed", errors) 

1839 

1840 return self 

1841 

1842 def __getitem__(self, id: str) -> BaseObject: 

1843 """Get an object by its ID. 

1844 

1845 Parameters 

1846 ---------- 

1847 id 

1848 The ID of the object to get. 

1849 

1850 Raises 

1851 ------ 

1852 KeyError 

1853 Raised if the object with the given ID is not found in the schema. 

1854 """ 

1855 if id not in self: 

1856 raise KeyError(f"Object with ID '{id}' not found in schema") 

1857 return self._id_map[id] 

1858 

1859 def __contains__(self, id: str) -> bool: 

1860 """Check if an object with the given ID is in the schema. 

1861 

1862 Parameters 

1863 ---------- 

1864 id 

1865 The ID of the object to check. 

1866 """ 

1867 return id in self._id_map 

1868 

1869 def find_object_by_id(self, id: str, obj_type: type[T]) -> T: 

1870 """Find an object with the given type by its ID. 

1871 

1872 Parameters 

1873 ---------- 

1874 id 

1875 The ID of the object to find. 

1876 obj_type 

1877 The type of the object to find. 

1878 

1879 Returns 

1880 ------- 

1881 BaseObject 

1882 The object with the given ID and type. 

1883 

1884 Raises 

1885 ------ 

1886 KeyError 

1887 If the object with the given ID is not found in the schema. 

1888 TypeError 

1889 If the object that is found does not have the right type. 

1890 

1891 Notes 

1892 ----- 

1893 The actual return type is the user-specified argument ``T``, which is 

1894 expected to be a subclass of `BaseObject`. 

1895 """ 

1896 obj = self[id] 

1897 if not isinstance(obj, obj_type): 

1898 raise TypeError(f"Object with ID '{id}' is not of type '{obj_type.__name__}'") 

1899 return obj 

1900 

1901 def get_table_by_column(self, column: Column) -> Table: 

1902 """Find the table that contains a column. 

1903 

1904 Parameters 

1905 ---------- 

1906 column 

1907 The column to find. 

1908 

1909 Returns 

1910 ------- 

1911 `Table` 

1912 The table that contains the column. 

1913 

1914 Raises 

1915 ------ 

1916 ValueError 

1917 If the column is not found in any table. 

1918 """ 

1919 for table in self.tables: 

1920 if column in table.columns: 

1921 return table 

1922 raise ValueError(f"Column '{column.name}' not found in any table") 

1923 

1924 @classmethod 

1925 def from_uri(cls, resource_path: ResourcePathExpression, context: dict[str, Any] = {}) -> Schema: 

1926 """Load a `Schema` from a string representing a ``ResourcePath``. 

1927 

1928 Parameters 

1929 ---------- 

1930 resource_path 

1931 The ``ResourcePath`` pointing to a YAML file. 

1932 context 

1933 Pydantic context to be used in validation. 

1934 

1935 Returns 

1936 ------- 

1937 `str` 

1938 The ID of the object. 

1939 

1940 Raises 

1941 ------ 

1942 yaml.YAMLError 

1943 Raised if there is an error loading the YAML data. 

1944 ValueError 

1945 Raised if there is an error reading the resource. 

1946 pydantic.ValidationError 

1947 Raised if the schema fails validation. 

1948 """ 

1949 try: 

1950 rp = ResourcePath(resource_path, forceAbsolute=False, forceDirectory=False) 

1951 rp_data = rp.read() 

1952 except Exception as e: 

1953 raise ValueError(f"Error reading resource from '{resource_path}' : {e}") from e 

1954 yaml_data = yaml.safe_load(rp_data) 

1955 context = dict(context) 

1956 # Append the resource path to the context for resolving resource URLs. 

1957 context["resource_path"] = rp 

1958 return Schema.model_validate(yaml_data, context=context) 

1959 

1960 @classmethod 

1961 def from_stream(cls, source: IO[str], context: dict[str, Any] = {}) -> Schema: 

1962 """Load a `Schema` from a file stream which should contain YAML data. 

1963 

1964 Parameters 

1965 ---------- 

1966 source 

1967 The file stream to read from. 

1968 context 

1969 Pydantic context to be used in validation. 

1970 

1971 Returns 

1972 ------- 

1973 `Schema` 

1974 The Felis schema loaded from the stream. 

1975 

1976 Raises 

1977 ------ 

1978 yaml.YAMLError 

1979 Raised if there is an error loading the YAML file. 

1980 pydantic.ValidationError 

1981 Raised if the schema fails validation. 

1982 """ 

1983 logger.debug("Loading schema from: '%s'", source) 

1984 yaml_data = yaml.safe_load(source) 

1985 return Schema.model_validate(yaml_data, context=context) 

1986 

1987 def _model_dump(self, strip_ids: bool = False, sort_columns: bool = False) -> dict[str, Any]: 

1988 """Dump the schema as a dictionary with some default arguments 

1989 applied. 

1990 

1991 Parameters 

1992 ---------- 

1993 strip_ids 

1994 Whether to strip the IDs from the dumped data. Defaults to `False`. 

1995 sort_columns 

1996 Whether to sort columns alphabetically by name. Defaults to 

1997 `False`. 

1998 

1999 Returns 

2000 ------- 

2001 `dict` [ `str`, `Any` ] 

2002 The dumped schema data as a dictionary. 

2003 """ 

2004 data = self.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True) 

2005 if strip_ids: 

2006 data = _strip_ids(data) 

2007 if sort_columns: 

2008 for table in data.get("tables", []): 

2009 table["columns"] = sorted(table.get("columns", []), key=itemgetter("name")) 

2010 return data 

2011 

2012 def dump_yaml( 

2013 self, stream: IO[str] = sys.stdout, strip_ids: bool = False, sort_columns: bool = False 

2014 ) -> None: 

2015 """Pretty print the schema as YAML. 

2016 

2017 Parameters 

2018 ---------- 

2019 stream 

2020 The stream to write the YAML data to. 

2021 strip_ids 

2022 Whether to strip the IDs from the dumped data. Defaults to `False`. 

2023 sort_columns 

2024 Whether to sort columns alphabetically by name. Defaults to 

2025 `False`. 

2026 """ 

2027 data = self._model_dump(strip_ids=strip_ids, sort_columns=sort_columns) 

2028 yaml.safe_dump( 

2029 data, 

2030 stream, 

2031 default_flow_style=False, 

2032 sort_keys=False, 

2033 ) 

2034 

2035 def dump_json( 

2036 self, stream: IO[str] = sys.stdout, strip_ids: bool = False, sort_columns: bool = False 

2037 ) -> None: 

2038 """Pretty print the schema as JSON. 

2039 

2040 Parameters 

2041 ---------- 

2042 stream 

2043 The stream to write the JSON data to. 

2044 strip_ids 

2045 Whether to strip the IDs from the dumped data. Defaults to `False`. 

2046 sort_columns 

2047 Whether to sort columns alphabetically by name. Defaults to 

2048 `False`. 

2049 """ 

2050 data = self._model_dump(strip_ids=strip_ids, sort_columns=sort_columns) 

2051 json.dump( 

2052 data, 

2053 stream, 

2054 indent=4, 

2055 sort_keys=False, 

2056 )