Coverage for python/felis/tap_schema.py: 23%

263 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-24 01:22 -0800

1"""Provides utilities for creating and populating the TAP_SCHEMA database.""" 

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 

24import csv 

25import io 

26import logging 

27import os 

28import re 

29from typing import Any 

30 

31from lsst.resources import ResourcePath 

32from sqlalchemy import MetaData, Table, select, text 

33from sqlalchemy.engine import Connection, Engine 

34from sqlalchemy.engine.mock import MockConnection 

35from sqlalchemy.exc import SQLAlchemyError 

36from sqlalchemy.schema import CreateSchema 

37from sqlalchemy.sql.dml import Insert 

38 

39from felis import datamodel 

40from felis.datamodel import Constraint, Schema 

41from felis.db.utils import is_valid_engine 

42from felis.metadata import MetaDataBuilder 

43 

44from .types import FelisType 

45 

46__all__ = ["DataLoader", "MetadataInserter", "TableManager"] 

47 

48logger = logging.getLogger(__name__) 

49 

50 

51class TableManager: 

52 """Manage creation of TAP_SCHEMA tables. 

53 

54 Parameters 

55 ---------- 

56 engine 

57 The SQLAlchemy engine for reflecting the TAP_SCHEMA tables from an 

58 existing database. 

59 This can be a mock connection or None, in which case the internal 

60 TAP_SCHEMA schema will be used by loading an internal YAML file. 

61 schema_name 

62 The name of the schema to use for the TAP_SCHEMA tables. 

63 Leave as None to use the standard name of "TAP_SCHEMA". 

64 apply_schema_to_metadata 

65 If True, apply the schema to the metadata as well as the tables. 

66 If False, these will be set to None, e.g., for sqlite. 

67 table_name_postfix 

68 A string to append to all the standard table names. 

69 This needs to be used in a way such that the resultant table names 

70 map to tables within the TAP_SCHEMA database. 

71 extensions_path 

72 The path to additional TAP_SCHEMA table definitions. 

73 

74 Notes 

75 ----- 

76 The TAP_SCHEMA schema must either have been created already, in which case 

77 the ``engine`` should be provided. Or the internal TAP_SCHEMA schema will 

78 be used if ``engine`` is None or a ``MockConnection``. 

79 """ 

80 

81 _TABLE_NAMES_STD = ["schemas", "tables", "columns", "keys", "key_columns"] 

82 """The standard table names for the TAP_SCHEMA tables.""" 

83 

84 _SCHEMA_NAME_STD = "TAP_SCHEMA" 

85 """The standard schema name for the TAP_SCHEMA tables.""" 

86 

87 def __init__( 

88 self, 

89 engine: Engine | MockConnection | None = None, 

90 schema_name: str | None = None, 

91 apply_schema_to_metadata: bool = True, 

92 table_name_postfix: str = "", 

93 extensions_path: str | None = None, 

94 ): 

95 """Initialize the table manager.""" 

96 self.table_name_postfix = table_name_postfix 

97 self.apply_schema_to_metadata = apply_schema_to_metadata 

98 self.schema_name = schema_name or TableManager._SCHEMA_NAME_STD 

99 self.extensions_path = extensions_path 

100 

101 if is_valid_engine(engine): 

102 assert isinstance(engine, Engine) 

103 if table_name_postfix != "": 

104 logger.warning( 

105 "Table name postfix '%s' will be ignored when reflecting TAP_SCHEMA database", 

106 table_name_postfix, 

107 ) 

108 logger.debug( 

109 "Reflecting TAP_SCHEMA database from existing database at %s", 

110 engine.url._replace(password="***"), 

111 ) 

112 self._reflect(engine) 

113 else: 

114 self._load_yaml() 

115 self._apply_extensions() 

116 self._build_metadata() 

117 

118 self._create_table_map() 

119 self._check_tables() 

120 

121 def _apply_extensions(self) -> None: 

122 """Apply extensions from a YAML file to the TAP_SCHEMA schema. 

123 

124 This method loads extension column definitions from a YAML file and 

125 adds them to the appropriate TAP_SCHEMA tables. 

126 """ 

127 if not self.extensions_path: 

128 return 

129 

130 logger.info("Loading TAP_SCHEMA extensions from: %s", self.extensions_path) 

131 extensions_schema = Schema.from_uri(self.extensions_path, context={"id_generation": True}) 

132 

133 if not extensions_schema.tables: 

134 logger.warning("Extensions schema does not contain any tables, no extensions applied") 

135 return 

136 

137 extension_count = 0 

138 extension_tables = {table.name: table.columns for table in extensions_schema.tables} 

139 

140 for table in self.schema.tables: 

141 extension_columns = extension_tables.get(table.name) 

142 if extension_columns: 

143 table.columns = list(table.columns) + list(extension_columns) 

144 extension_count += len(extension_columns) 

145 logger.debug("Added %d extension columns to table '%s'", len(extension_columns), table.name) 

146 

147 logger.info("Applied %d extension columns to TAP_SCHEMA", extension_count) 

148 

149 def _reflect(self, engine: Engine) -> None: 

150 """Reflect the TAP_SCHEMA database tables into the metadata. 

151 

152 Parameters 

153 ---------- 

154 engine 

155 The SQLAlchemy engine to use to reflect the tables. 

156 """ 

157 self._metadata = MetaData(schema=self.schema_name if self.apply_schema_to_metadata else None) 

158 try: 

159 self.metadata.reflect(bind=engine) 

160 except SQLAlchemyError as e: 

161 logger.error("Error reflecting TAP_SCHEMA database: %s", e) 

162 raise 

163 

164 def _build_metadata(self) -> None: 

165 """Build SQLAlchemy metadata from the Felis schema.""" 

166 self._metadata = MetaDataBuilder( 

167 self.schema, 

168 apply_schema_to_metadata=self.apply_schema_to_metadata, 

169 table_name_postfix=self.table_name_postfix, 

170 ).build() 

171 

172 def _load_yaml(self) -> None: 

173 """Load the standard TAP_SCHEMA schema from a Felis package 

174 resource. 

175 """ 

176 self._load_schema() 

177 if self.schema_name != TableManager._SCHEMA_NAME_STD: 

178 self.schema.name = self.schema_name 

179 else: 

180 self.schema_name = self.schema.name 

181 

182 logger.debug("Loaded TAP_SCHEMA '%s' from YAML resource", self.schema_name) 

183 

184 def __getitem__(self, table_name: str) -> Table: 

185 """Get one of the TAP_SCHEMA tables by its standard TAP_SCHEMA name. 

186 

187 Parameters 

188 ---------- 

189 table_name 

190 The name of the table to get. 

191 

192 Returns 

193 ------- 

194 Table 

195 The table with the given name. 

196 

197 Notes 

198 ----- 

199 This implements array semantics for the table manager, allowing 

200 tables to be accessed by their standard TAP_SCHEMA names. 

201 """ 

202 if table_name not in self._table_map: 

203 raise KeyError(f"Table '{table_name}' not found in TAP_SCHEMA") 

204 return self.metadata.tables[self._table_map[table_name]] 

205 

206 @property 

207 def schema(self) -> Schema: 

208 """Get the TAP_SCHEMA schema. 

209 

210 Returns 

211 ------- 

212 Schema 

213 The TAP_SCHEMA schema. 

214 

215 Notes 

216 ----- 

217 This will only be set if the TAP_SCHEMA schema was loaded from a 

218 Felis package resource. In the case where the TAP_SCHEMA schema was 

219 reflected from an existing database, this will be None. 

220 """ 

221 return self._schema 

222 

223 @property 

224 def metadata(self) -> MetaData: 

225 """Get the metadata for the TAP_SCHEMA tables. 

226 

227 Returns 

228 ------- 

229 `~sqlalchemy.sql.schema.MetaData` 

230 The metadata for the TAP_SCHEMA tables. 

231 

232 Notes 

233 ----- 

234 This will either be the metadata that was reflected from an existing 

235 database or the metadata that was loaded from a Felis package resource. 

236 """ 

237 return self._metadata 

238 

239 @classmethod 

240 def get_tap_schema_std_path(cls) -> str: 

241 """Get the path to the standard TAP_SCHEMA schema resource. 

242 

243 Returns 

244 ------- 

245 str 

246 The path to the standard TAP_SCHEMA schema resource. 

247 """ 

248 return os.path.join(os.path.dirname(__file__), "config", "tap_schema", "tap_schema_std.yaml") 

249 

250 @classmethod 

251 def get_tap_schema_std_resource(cls) -> ResourcePath: 

252 """Get the standard TAP_SCHEMA schema resource. 

253 

254 Returns 

255 ------- 

256 `~lsst.resources.ResourcePath` 

257 The standard TAP_SCHEMA schema resource. 

258 """ 

259 return ResourcePath("resource://felis/config/tap_schema/tap_schema_std.yaml") 

260 

261 @classmethod 

262 def get_table_names_std(cls) -> list[str]: 

263 """Get the standard column names for the TAP_SCHEMA tables. 

264 

265 Returns 

266 ------- 

267 list 

268 The standard table names for the TAP_SCHEMA tables. 

269 """ 

270 return cls._TABLE_NAMES_STD 

271 

272 @classmethod 

273 def get_schema_name_std(cls) -> str: 

274 """Get the standard schema name for the TAP_SCHEMA tables. 

275 

276 Returns 

277 ------- 

278 str 

279 The standard schema name for the TAP_SCHEMA tables. 

280 """ 

281 return cls._SCHEMA_NAME_STD 

282 

283 @classmethod 

284 def load_schema_resource(cls) -> Schema: 

285 """Load the standard TAP_SCHEMA schema from a Felis package 

286 resource into a Felis `~felis.datamodel.Schema`. 

287 

288 Returns 

289 ------- 

290 Schema 

291 The TAP_SCHEMA schema. 

292 """ 

293 rp = cls.get_tap_schema_std_resource() 

294 return Schema.from_uri(rp, context={"id_generation": True}) 

295 

296 def _load_schema(self) -> None: 

297 """Load the TAP_SCHEMA schema from a Felis package resource.""" 

298 self._schema = self.load_schema_resource() 

299 

300 def _create_table_map(self) -> None: 

301 """Create a mapping of standard table names to the table names modified 

302 with a postfix, as well as the prepended schema name if it is set. 

303 

304 Returns 

305 ------- 

306 dict 

307 A dictionary mapping the standard table names to the modified 

308 table names. 

309 

310 Notes 

311 ----- 

312 This is a private method that is called during initialization, allowing 

313 us to use table names like ``schemas11`` such as those used by the CADC 

314 TAP library instead of the standard table names. It also maps between 

315 the standard table names and those with the schema name prepended like 

316 SQLAlchemy uses. 

317 """ 

318 self._table_map = { 

319 table_name: ( 

320 f"{self.schema_name + '.' if self.apply_schema_to_metadata else ''}" 

321 f"{table_name}{self.table_name_postfix}" 

322 ) 

323 for table_name in TableManager.get_table_names_std() 

324 } 

325 logger.debug(f"Created TAP_SCHEMA table map: {self._table_map}") 

326 

327 def _check_tables(self) -> None: 

328 """Check that there is a valid mapping to each standard table. 

329 

330 Raises 

331 ------ 

332 KeyError 

333 If a table is missing from the table map. 

334 """ 

335 for table_name in TableManager.get_table_names_std(): 

336 self[table_name] 

337 

338 def _create_schema(self, engine: Engine) -> None: 

339 """Create the database schema for TAP_SCHEMA if it does not already 

340 exist. 

341 

342 Parameters 

343 ---------- 

344 engine 

345 The SQLAlchemy engine to use to create the schema. 

346 

347 Notes 

348 ----- 

349 This method only creates the schema in the database. It does not create 

350 the tables. 

351 """ 

352 create_schema_functions = { 

353 "postgresql": self._create_schema_postgresql, 

354 "mysql": self._create_schema_mysql, 

355 } 

356 

357 dialect_name = engine.dialect.name 

358 if dialect_name == "sqlite": 

359 # SQLite doesn't have schemas. 

360 return 

361 

362 create_function = create_schema_functions.get(dialect_name) 

363 

364 if create_function: 

365 with engine.begin() as connection: 

366 create_function(connection) 

367 else: 

368 # Some other database engine we don't currently know how to handle. 

369 raise NotImplementedError( 

370 f"Database engine '{engine.dialect.name}' is not supported for schema creation" 

371 ) 

372 

373 def _create_schema_postgresql(self, connection: Connection) -> None: 

374 """Create the schema in a PostgreSQL database. 

375 

376 Parameters 

377 ---------- 

378 connection 

379 The SQLAlchemy connection to use to create the schema. 

380 """ 

381 connection.execute(CreateSchema(self.schema_name, if_not_exists=True)) 

382 

383 def _create_schema_mysql(self, connection: Connection) -> None: 

384 """Create the schema in a MySQL database. 

385 

386 Parameters 

387 ---------- 

388 connection 

389 The SQLAlchemy connection to use to create the schema. 

390 """ 

391 connection.execute(text(f"CREATE DATABASE IF NOT EXISTS {self.schema_name}")) 

392 

393 def initialize_database(self, engine: Engine) -> None: 

394 """Initialize a database with the TAP_SCHEMA tables. 

395 

396 Parameters 

397 ---------- 

398 engine 

399 The SQLAlchemy engine to use to create the tables. 

400 """ 

401 logger.info("Creating TAP_SCHEMA database '%s'", self.schema_name) 

402 self._create_schema(engine) 

403 self.metadata.create_all(engine) 

404 

405 def select(self, engine: Engine, table_name: str, filter_condition: str = "") -> list[dict[str, Any]]: 

406 """Select all rows from a TAP_SCHEMA table with an optional filter 

407 condition. 

408 

409 Parameters 

410 ---------- 

411 engine 

412 The SQLAlchemy engine to use to connect to the database. 

413 table_name 

414 The name of the table to select from. 

415 filter_condition 

416 The filter condition as a string. If empty, no filter will be 

417 applied. 

418 

419 Returns 

420 ------- 

421 list 

422 A list of dictionaries containing the rows from the table. 

423 """ 

424 table = self[table_name] 

425 query = select(table) 

426 if filter_condition: 

427 query = query.where(text(filter_condition)) 

428 with engine.connect() as connection: 

429 result = connection.execute(query) 

430 rows = [dict(row._mapping) for row in result] 

431 return rows 

432 

433 

434class DataLoader: 

435 """Load data into the TAP_SCHEMA tables. 

436 

437 Parameters 

438 ---------- 

439 schema 

440 The Felis ``Schema`` to load into the TAP_SCHEMA tables. 

441 mgr 

442 The table manager that contains the TAP_SCHEMA tables. 

443 engine 

444 The SQLAlchemy engine to use to connect to the database. 

445 tap_schema_index 

446 The index of the schema in the TAP_SCHEMA database. 

447 output_path 

448 The file to write the SQL statements to. If None, printing will be 

449 suppressed. 

450 print_sql 

451 If True, print the SQL statements that will be executed. 

452 dry_run 

453 If True, the data will not be loaded into the database. 

454 unique_keys 

455 If True, prepend the schema name to the key name to make it unique 

456 when loading data into the keys and key_columns tables. 

457 """ 

458 

459 def __init__( 

460 self, 

461 schema: Schema, 

462 mgr: TableManager, 

463 engine: Engine | MockConnection, 

464 tap_schema_index: int = 0, 

465 output_path: str | None = None, 

466 print_sql: bool = False, 

467 dry_run: bool = False, 

468 unique_keys: bool = False, 

469 ): 

470 self.schema = schema 

471 self.mgr = mgr 

472 self.engine = engine 

473 self.tap_schema_index = tap_schema_index 

474 self.inserts: list[Insert] = [] 

475 self.output_path = output_path 

476 self.print_sql = print_sql 

477 self.dry_run = dry_run 

478 self.unique_keys = unique_keys 

479 

480 def load(self) -> None: 

481 """Load the schema data into the TAP_SCHEMA tables. 

482 

483 Notes 

484 ----- 

485 This will generate inserts for the data, print the SQL statements if 

486 requested, save the SQL statements to a file if requested, and load the 

487 data into the database if not in dry run mode. These are done as 

488 sequential operations rather than for each insert. The logic is that 

489 the user may still want the complete SQL output to be printed or saved 

490 to a file even if loading into the database causes errors. If there are 

491 errors when inserting into the database, the SQLAlchemy error message 

492 should indicate which SQL statement caused the error. 

493 """ 

494 self._generate_all_inserts() 

495 if self.print_sql: 

496 # Print to stdout. 

497 self._print_sql() 

498 if self.output_path: 

499 # Print to an output file. 

500 self._write_sql_to_file() 

501 if not self.dry_run: 

502 # Execute the inserts if not in dry run mode. 

503 self._execute_inserts() 

504 else: 

505 logger.info("Dry run - not loading data into database") 

506 

507 def _insert_schemas(self) -> None: 

508 """Insert the schema data into the ``schemas`` table.""" 

509 schema_record = { 

510 "schema_name": self.schema.name, 

511 "utype": self.schema.votable_utype, 

512 "description": self.schema.description, 

513 "schema_index": self.tap_schema_index, 

514 } 

515 self._insert("schemas", schema_record) 

516 

517 def _get_table_name(self, table: datamodel.Table) -> str: 

518 """Get the name of the table with the schema name prepended. 

519 

520 Parameters 

521 ---------- 

522 table 

523 The table to get the name for. 

524 

525 Returns 

526 ------- 

527 str 

528 The name of the table with the schema name prepended. 

529 """ 

530 return f"{self.schema.name}.{table.name}" 

531 

532 def _insert_tables(self) -> None: 

533 """Insert the table data into the ``tables`` table.""" 

534 for table in self.schema.tables: 

535 table_record = { 

536 "schema_name": self.schema.name, 

537 "table_name": self._get_table_name(table), 

538 "table_type": "table", 

539 "utype": table.votable_utype, 

540 "description": table.description, 

541 "table_index": 0 if table.tap_table_index is None else table.tap_table_index, 

542 } 

543 self._insert("tables", table_record) 

544 

545 def _insert_columns(self) -> None: 

546 """Insert the column data into the ``columns`` table.""" 

547 for table in self.schema.tables: 

548 for column in table.columns: 

549 felis_type = FelisType.felis_type(column.datatype.value) 

550 arraysize = str(column.votable_arraysize) if column.votable_arraysize else None 

551 size = DataLoader._get_size(column) 

552 indexed = DataLoader._is_indexed(column, table) 

553 tap_column_index = column.tap_column_index 

554 unit = column.ivoa_unit or column.fits_tunit 

555 

556 column_record = { 

557 "table_name": self._get_table_name(table), 

558 "column_name": column.name, 

559 "datatype": felis_type.votable_name, 

560 "arraysize": arraysize, 

561 "size": size, 

562 "xtype": column.votable_xtype, 

563 "description": column.description, 

564 "utype": column.votable_utype, 

565 "unit": unit, 

566 "ucd": column.ivoa_ucd, 

567 "indexed": indexed, 

568 "principal": column.tap_principal, 

569 "std": column.tap_std, 

570 "column_index": tap_column_index, 

571 } 

572 self._insert("columns", column_record) 

573 

574 def _get_key(self, constraint: Constraint) -> str: 

575 """Get the key name for a constraint. 

576 

577 Parameters 

578 ---------- 

579 constraint 

580 The constraint to get the key name for. 

581 

582 Returns 

583 ------- 

584 str 

585 The key name for the constraint. 

586 

587 Notes 

588 ----- 

589 This will prepend the name of the schema to the key name if the 

590 `unique_keys` attribute is set to True. Otherwise, it will just return 

591 the name of the constraint. 

592 """ 

593 if self.unique_keys: 

594 key_id = f"{self.schema.name}_{constraint.name}" 

595 logger.debug("Generated unique key_id: %s -> %s", constraint.name, key_id) 

596 else: 

597 key_id = constraint.name 

598 return key_id 

599 

600 def _insert_keys(self) -> None: 

601 """Insert the foreign keys into the ``keys`` and ``key_columns`` 

602 tables. 

603 """ 

604 for table in self.schema.tables: 

605 for constraint in table.constraints: 

606 if isinstance(constraint, datamodel.ForeignKeyConstraint): 

607 ########################################################### 

608 # Handle keys table 

609 ########################################################### 

610 referenced_column = self.schema.find_object_by_id( 

611 constraint.referenced_columns[0], datamodel.Column 

612 ) 

613 referenced_table = self.schema.get_table_by_column(referenced_column) 

614 key_id = self._get_key(constraint) 

615 key_record = { 

616 "key_id": key_id, 

617 "from_table": self._get_table_name(table), 

618 "target_table": self._get_table_name(referenced_table), 

619 "description": constraint.description, 

620 "utype": constraint.votable_utype, 

621 } 

622 self._insert("keys", key_record) 

623 

624 ########################################################### 

625 # Handle key_columns table 

626 ########################################################### 

627 # Loop over the corresponding columns and referenced 

628 # columns and insert a record for each pair. This is 

629 # necessary for proper handling of composite keys. 

630 for from_column_id, target_column_id in zip( 

631 constraint.columns, constraint.referenced_columns 

632 ): 

633 from_column = self.schema.find_object_by_id(from_column_id, datamodel.Column) 

634 target_column = self.schema.find_object_by_id(target_column_id, datamodel.Column) 

635 key_columns_record = { 

636 "key_id": key_id, 

637 "from_column": from_column.name, 

638 "target_column": target_column.name, 

639 } 

640 self._insert("key_columns", key_columns_record) 

641 

642 def _generate_all_inserts(self) -> None: 

643 """Generate the inserts for all the data.""" 

644 self.inserts.clear() 

645 self._insert_schemas() 

646 self._insert_tables() 

647 self._insert_columns() 

648 self._insert_keys() 

649 logger.debug("Generated %d insert statements", len(self.inserts)) 

650 

651 def _execute_inserts(self) -> None: 

652 """Load the `~felis.datamodel.Schema` data into the TAP_SCHEMA 

653 tables. 

654 """ 

655 if isinstance(self.engine, Engine): 

656 with self.engine.connect() as connection: 

657 transaction = connection.begin() 

658 try: 

659 for insert in self.inserts: 

660 connection.execute(insert) 

661 transaction.commit() 

662 except Exception as e: 

663 logger.error("Error loading data into database: %s", e) 

664 transaction.rollback() 

665 raise 

666 

667 def _compiled_inserts(self) -> list[str]: 

668 """Compile the inserts to SQL. 

669 

670 Returns 

671 ------- 

672 list 

673 A list of the compiled insert statements. 

674 """ 

675 return [ 

676 str(insert.compile(self.engine, compile_kwargs={"literal_binds": True})) 

677 for insert in self.inserts 

678 ] 

679 

680 def _print_sql(self) -> None: 

681 """Print the generated inserts to stdout.""" 

682 for insert_str in self._compiled_inserts(): 

683 print(insert_str + ";") 

684 

685 def _write_sql_to_file(self) -> None: 

686 """Write the generated insert statements to a file.""" 

687 if not self.output_path: 

688 raise ValueError("No output path specified") 

689 with open(self.output_path, "w") as outfile: 

690 for insert_str in self._compiled_inserts(): 

691 outfile.write(insert_str + ";" + "\n") 

692 

693 def _insert(self, table_name: str, record: list[Any] | dict[str, Any]) -> None: 

694 """Generate an insert statement for a record. 

695 

696 Parameters 

697 ---------- 

698 table_name 

699 The name of the table to insert the record into. 

700 record 

701 The record to insert into the table. 

702 """ 

703 table = self.mgr[table_name] 

704 insert_statement = table.insert().values(record) 

705 self.inserts.append(insert_statement) 

706 

707 @staticmethod 

708 def _get_size(column: datamodel.Column) -> int | None: 

709 """Get the size of the column. 

710 

711 Parameters 

712 ---------- 

713 column 

714 The column to get the size for. 

715 

716 Returns 

717 ------- 

718 int or None 

719 The size of the column or None if not applicable. 

720 """ 

721 arraysize = column.votable_arraysize 

722 

723 if not arraysize: 

724 return None 

725 

726 arraysize_str = str(arraysize) 

727 if arraysize_str.isdigit(): 

728 return int(arraysize_str) 

729 

730 match = re.match(r"^([0-9]+)\*$", arraysize_str) 

731 if match and match.group(1) is not None: 

732 return int(match.group(1)) 

733 

734 return None 

735 

736 @staticmethod 

737 def _is_indexed(column: datamodel.Column, table: datamodel.Table) -> int: 

738 """Check if the column is indexed in the table. 

739 

740 Parameters 

741 ---------- 

742 column 

743 The column to check. 

744 table 

745 The table to check. 

746 

747 Returns 

748 ------- 

749 int 

750 1 if the column is indexed, 0 otherwise. 

751 """ 

752 if isinstance(table.primary_key, str) and table.primary_key == column.id: 

753 return 1 

754 for index in table.indexes: 

755 if index.columns and len(index.columns) == 1 and index.columns[0] == column.id: 

756 return 1 

757 return 0 

758 

759 

760class MetadataInserter: 

761 """Insert TAP_SCHEMA self-description rows into the database. 

762 

763 Parameters 

764 ---------- 

765 mgr 

766 The table manager that contains the TAP_SCHEMA tables. 

767 engine 

768 The engine for connecting to the TAP_SCHEMA database. 

769 """ 

770 

771 def __init__(self, mgr: TableManager, engine: Engine): 

772 """Initialize the metadata inserter. 

773 

774 Parameters 

775 ---------- 

776 mgr 

777 The table manager representing the TAP_SCHEMA tables. 

778 engine 

779 The SQLAlchemy engine for connecting to the database. 

780 """ 

781 self._mgr = mgr 

782 self._engine = engine 

783 

784 def insert_metadata(self) -> None: 

785 """Insert the TAP_SCHEMA metadata into the database.""" 

786 for table_name in self._mgr.get_table_names_std(): 

787 table = self._mgr[table_name] 

788 csv_bytes = ResourcePath(f"resource://felis/config/tap_schema/{table_name}.csv").read() 

789 text_stream = io.TextIOWrapper(io.BytesIO(csv_bytes), encoding="utf-8") 

790 reader = csv.reader(text_stream) 

791 headers = next(reader) 

792 rows = [ 

793 {key: None if value == "\\N" else value for key, value in zip(headers, row)} for row in reader 

794 ] 

795 logger.debug( 

796 "Inserting %d rows into table '%s' with headers: %s", 

797 len(rows), 

798 table_name, 

799 headers, 

800 ) 

801 with self._engine.begin() as conn: 

802 conn.execute(table.insert(), rows)