Coverage for pepdbagent/db_utils.py: 91%
234 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-01 22:07 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-01 22:07 +0000
1import datetime
2import enum
3import logging
4import os
6from alembic import command
7from alembic.config import Config
8from sqlalchemy import (
9 TIMESTAMP,
10 BigInteger,
11 Enum,
12 FetchedValue,
13 ForeignKey,
14 Result,
15 Select,
16 String,
17 UniqueConstraint,
18 event,
19 select,
20)
21from sqlalchemy.dialects.postgresql import JSON
22from sqlalchemy.engine import URL, Engine, create_engine
23from sqlalchemy.exc import ProgrammingError
24from sqlalchemy.ext.compiler import compiles
25from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
27from pepdbagent.const import PKG_NAME, POSTGRES_DIALECT
28from pepdbagent.exceptions import SchemaError
30_LOGGER = logging.getLogger(PKG_NAME)
33class BIGSERIAL(BigInteger):
34 pass
37@compiles(BIGSERIAL, POSTGRES_DIALECT)
38def compile_bigserial_pg(type_, compiler, **kw) -> str:
39 return "BIGSERIAL"
42@compiles(JSON, POSTGRES_DIALECT)
43def compile_jsonb_pg(type_, compiler, **kw) -> str:
44 return "JSON"
47class Base(DeclarativeBase):
48 type_annotation_map = {datetime.datetime: TIMESTAMP(timezone=True)}
51@event.listens_for(Base.metadata, "after_create")
52def receive_after_create(target, connection, tables, **kw) -> None:
53 """Listen for the 'after_create' event."""
54 if tables:
55 _LOGGER.info("A table was created")
56 else:
57 _LOGGER.info("A table was not created")
60# def deliver_description(context):
61# return context.get_current_parameters()["config"]["description"]
64def deliver_update_date(context) -> datetime.datetime:
65 return datetime.datetime.now(datetime.timezone.utc)
68class Projects(Base):
69 """
70 Projects table representation in the database
71 """
73 __tablename__ = "projects"
75 id: Mapped[int] = mapped_column(primary_key=True)
76 namespace: Mapped[str] = mapped_column(
77 ForeignKey("users.namespace", ondelete="CASCADE")
78 )
79 name: Mapped[str] = mapped_column()
80 tag: Mapped[str] = mapped_column()
81 digest: Mapped[str] = mapped_column(String(32))
82 description: Mapped[str | None]
83 config: Mapped[dict] = mapped_column(JSON, server_default=FetchedValue())
84 private: Mapped[bool]
85 number_of_samples: Mapped[int]
86 number_of_stars: Mapped[int] = mapped_column(default=0)
87 submission_date: Mapped[datetime.datetime]
88 last_update_date: Mapped[datetime.datetime | None] = mapped_column(
89 default=deliver_update_date, # onupdate=deliver_update_date, # This field should not be updated, while we are adding project to favorites
90 )
92 schema_id: Mapped[int | None] = mapped_column(
93 ForeignKey("schema_versions.id", ondelete="SET NULL"), nullable=True
94 )
95 schema_mapping: Mapped["SchemaVersions"] = relationship(
96 "SchemaVersions", lazy="joined"
97 )
99 pop: Mapped[bool | None] = mapped_column(default=False)
100 samples_mapping: Mapped[list["Samples"]] = relationship(
101 back_populates="project_mapping", cascade="all, delete-orphan"
102 )
103 subsamples_mapping: Mapped[list["Subsamples"]] = relationship(
104 back_populates="subsample_mapping", cascade="all, delete-orphan"
105 )
106 stars_mapping: Mapped[list["Stars"]] = relationship(
107 back_populates="project_mapping", cascade="all, delete-orphan"
108 )
109 views_mapping: Mapped[list["Views"]] = relationship(
110 back_populates="project_mapping", cascade="all, delete-orphan"
111 )
113 # Self-referential relationship. The parent project is the one that was forked to create this one.
114 forked_from_id: Mapped[int | None] = mapped_column(
115 ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
116 )
117 forked_from_mapping = relationship(
118 "Projects",
119 back_populates="forked_to_mapping",
120 remote_side=[id],
121 single_parent=True,
122 cascade="save-update, merge, refresh-expire",
123 )
125 forked_to_mapping = relationship(
126 "Projects",
127 back_populates="forked_from_mapping",
128 cascade="save-update, merge, refresh-expire",
129 )
131 namespace_mapping: Mapped["User"] = relationship(
132 "User", back_populates="projects_mapping"
133 )
135 history_mapping: Mapped[list["HistoryProjects"]] = relationship(
136 back_populates="project_mapping", cascade="all, delete-orphan"
137 )
139 __table_args__ = (UniqueConstraint("namespace", "name", "tag"),)
142class Samples(Base):
143 """
144 Samples table representation in the database
145 """
147 __tablename__ = "samples"
149 id: Mapped[int] = mapped_column(primary_key=True)
150 sample: Mapped[dict] = mapped_column(JSON, server_default=FetchedValue())
151 project_id = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
152 project_mapping: Mapped["Projects"] = relationship(back_populates="samples_mapping")
153 sample_name: Mapped[str | None] = mapped_column()
154 guid: Mapped[str | None] = mapped_column(nullable=False, unique=True)
156 submission_date: Mapped[datetime.datetime] = mapped_column(
157 default=deliver_update_date
158 )
159 last_update_date: Mapped[datetime.datetime | None] = mapped_column(
160 default=deliver_update_date,
161 onupdate=deliver_update_date,
162 )
164 parent_guid: Mapped[str | None] = mapped_column(
165 ForeignKey("samples.guid", ondelete="CASCADE"),
166 nullable=True,
167 doc="Parent sample id. Used to create a hierarchy of samples.",
168 )
170 parent_mapping: Mapped["Samples"] = relationship(
171 "Samples", remote_side=guid, back_populates="child_mapping"
172 )
173 child_mapping: Mapped["Samples"] = relationship(
174 "Samples", back_populates="parent_mapping"
175 )
177 views: Mapped[list["ViewSampleAssociation"] | None] = relationship(
178 back_populates="sample", cascade="all, delete-orphan"
179 )
182class Subsamples(Base):
183 """
184 Subsamples table representation in the database
185 """
187 __tablename__ = "subsamples"
189 id: Mapped[int] = mapped_column(primary_key=True)
190 subsample: Mapped[dict] = mapped_column(JSON, server_default=FetchedValue())
191 subsample_number: Mapped[int]
192 row_number: Mapped[int]
193 project_id = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
194 subsample_mapping: Mapped["Projects"] = relationship(
195 back_populates="subsamples_mapping"
196 )
199class User(Base):
200 """
201 User table representation in the database
202 """
204 __tablename__ = "users"
206 id: Mapped[int] = mapped_column(primary_key=True)
207 namespace: Mapped[str] = mapped_column(nullable=False, unique=True)
208 stars_mapping: Mapped[list["Stars"]] = relationship(
209 back_populates="user_mapping",
210 cascade="all, delete-orphan",
211 order_by="Stars.star_date.desc()",
212 )
213 number_of_projects: Mapped[int] = mapped_column(default=0)
214 number_of_schemas: Mapped[int] = mapped_column(default=0)
216 projects_mapping: Mapped[list["Projects"]] = relationship(
217 "Projects", back_populates="namespace_mapping"
218 )
219 schemas_mapping: Mapped[list["SchemaRecords"]] = relationship(
220 "SchemaRecords", back_populates="user_mapping"
221 )
224class Stars(Base):
225 """
226 FavoriteProjects table representation in the database
227 """
229 __tablename__ = "stars"
231 user_id = mapped_column(
232 ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
233 )
234 project_id = mapped_column(
235 ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True
236 )
237 user_mapping: Mapped[list["User"]] = relationship(back_populates="stars_mapping")
238 project_mapping: Mapped["Projects"] = relationship(back_populates="stars_mapping")
239 star_date: Mapped[datetime.datetime] = mapped_column(
240 onupdate=deliver_update_date, default=deliver_update_date
241 )
244class Views(Base):
245 """
246 Views table representation in the database
247 """
249 __tablename__ = "views"
251 id: Mapped[int] = mapped_column(primary_key=True)
252 name: Mapped[str] = mapped_column()
253 description: Mapped[str | None]
255 project_id = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
256 project_mapping = relationship("Projects", back_populates="views_mapping")
258 samples: Mapped[list["ViewSampleAssociation"]] = relationship(
259 back_populates="view", cascade="all, delete-orphan"
260 )
262 _table_args__ = (UniqueConstraint("name", "project_id"),)
265class ViewSampleAssociation(Base):
266 """
267 Association table between views and samples
268 """
270 __tablename__ = "views_samples"
272 sample_id = mapped_column(
273 ForeignKey("samples.id", ondelete="CASCADE"), primary_key=True
274 )
275 view_id = mapped_column(
276 ForeignKey("views.id", ondelete="CASCADE"), primary_key=True
277 )
278 sample: Mapped["Samples"] = relationship(back_populates="views")
279 view: Mapped["Views"] = relationship(back_populates="samples")
282class HistoryProjects(Base):
283 __tablename__ = "project_history"
285 id: Mapped[int] = mapped_column(primary_key=True)
286 project_id: Mapped[int] = mapped_column(
287 ForeignKey("projects.id", ondelete="CASCADE")
288 )
289 user: Mapped[str] = mapped_column(
290 ForeignKey("users.namespace", ondelete="SET NULL")
291 )
292 update_time: Mapped[datetime.datetime] = mapped_column(
293 TIMESTAMP(timezone=True), default=deliver_update_date
294 )
295 project_yaml: Mapped[dict] = mapped_column(JSON, server_default=FetchedValue())
297 project_mapping: Mapped["Projects"] = relationship(
298 "Projects", back_populates="history_mapping"
299 )
300 sample_changes_mapping: Mapped[list["HistorySamples"]] = relationship(
301 back_populates="history_project_mapping", cascade="all, delete-orphan"
302 )
305class UpdateTypes(enum.Enum):
306 """
307 Enum for the type of update
308 """
310 UPDATE = "update"
311 INSERT = "insert"
312 DELETE = "delete"
315class HistorySamples(Base):
316 __tablename__ = "sample_history"
318 id: Mapped[int] = mapped_column(primary_key=True)
319 history_id: Mapped[int] = mapped_column(
320 ForeignKey("project_history.id", ondelete="CASCADE")
321 )
322 guid: Mapped[str] = mapped_column(nullable=False)
323 parent_guid: Mapped[str | None] = mapped_column(nullable=True)
324 sample_json: Mapped[dict] = mapped_column(JSON, server_default=FetchedValue())
325 change_type: Mapped[UpdateTypes] = mapped_column(Enum(UpdateTypes), nullable=False)
327 history_project_mapping: Mapped["HistoryProjects"] = relationship(
328 "HistoryProjects", back_populates="sample_changes_mapping"
329 )
332class SchemaRecords(Base):
333 __tablename__ = "schema_records"
335 id: Mapped[int] = mapped_column(primary_key=True)
336 namespace: Mapped[str] = mapped_column(
337 ForeignKey("users.namespace", ondelete="CASCADE")
338 )
339 name: Mapped[str] = mapped_column(nullable=False)
340 maintainers: Mapped[str] = mapped_column(nullable=True)
341 lifecycle_stage: Mapped[str] = mapped_column(nullable=True)
342 description: Mapped[str | None] = mapped_column(nullable=True)
343 private: Mapped[bool] = mapped_column(default=False)
344 last_update_date: Mapped[datetime.datetime | None] = mapped_column(
345 default=deliver_update_date, onupdate=deliver_update_date
346 )
348 __table_args__ = (UniqueConstraint("namespace", "name"),)
350 versions_mapping: Mapped[list["SchemaVersions"]] = relationship(
351 "SchemaVersions",
352 back_populates="schema_mapping",
353 cascade="all, delete-orphan",
354 order_by="SchemaVersions.version.desc()",
355 )
356 user_mapping: Mapped["User"] = relationship(
357 "User", back_populates="schemas_mapping"
358 )
361class SchemaVersions(Base):
362 __tablename__ = "schema_versions"
364 id: Mapped[int] = mapped_column(primary_key=True)
365 schema_id: Mapped[int] = mapped_column(
366 ForeignKey("schema_records.id", ondelete="CASCADE")
367 )
368 version: Mapped[str] = mapped_column(nullable=False)
369 schema_value: Mapped[dict] = mapped_column(JSON, server_default=FetchedValue())
370 release_date: Mapped[datetime.datetime] = mapped_column(default=deliver_update_date)
371 last_update_date: Mapped[datetime.datetime | None] = mapped_column(
372 default=deliver_update_date, onupdate=deliver_update_date
373 )
374 contributors: Mapped[str | None] = mapped_column(nullable=True)
375 release_notes: Mapped[str | None] = mapped_column(nullable=True)
377 __table_args__ = (UniqueConstraint("schema_id", "version"),)
379 schema_mapping: Mapped["SchemaRecords"] = relationship(
380 "SchemaRecords", back_populates="versions_mapping"
381 )
383 tags_mapping: Mapped[list["SchemaTags"]] = relationship(
384 "SchemaTags",
385 back_populates="schema_mapping",
386 lazy="joined",
387 cascade="all, delete-orphan",
388 )
391class SchemaTags(Base):
392 __tablename__ = "schema_tags"
394 id: Mapped[int] = mapped_column(primary_key=True)
395 tag_name: Mapped[str] = mapped_column(nullable=False)
396 tag_value: Mapped[str] = mapped_column(nullable=True)
397 schema_version_id: Mapped[int] = mapped_column(
398 ForeignKey("schema_versions.id", ondelete="CASCADE")
399 )
401 schema_mapping: Mapped["SchemaVersions"] = relationship(
402 "SchemaVersions", back_populates="tags_mapping"
403 )
406class TarNamespace(Base):
407 __tablename__ = "namespace_archives"
409 id: Mapped[int] = mapped_column(primary_key=True)
410 namespace: Mapped[str] = mapped_column(
411 ForeignKey("users.namespace", ondelete="CASCADE")
412 )
413 file_path: Mapped[str] = mapped_column(nullable=False)
414 creation_date: Mapped[datetime.datetime] = mapped_column(
415 default=deliver_update_date
416 )
417 number_of_projects: Mapped[int] = mapped_column(default=0)
418 file_size: Mapped[int] = mapped_column(nullable=False)
421class BedBaseStats(Base):
422 __tablename__ = "bedbase_stats"
424 id: Mapped[int] = mapped_column(primary_key=True)
425 gse: Mapped[str] = mapped_column()
426 gsm: Mapped[str] = mapped_column()
427 sample_name: Mapped[str] = mapped_column(nullable=True)
428 genome: Mapped[str | None] = mapped_column(nullable=True, default="")
429 last_update_date: Mapped[str | None] = mapped_column()
430 submission_date: Mapped[str | None] = mapped_column()
433class BaseEngine:
434 """
435 A class with base methods, that are used in several classes. e.g. fetch_one or fetch_all
436 """
438 def __init__(
439 self,
440 *,
441 host: str = "localhost",
442 port: int = 5432,
443 database: str = "pep-db",
444 user: str | None = None,
445 password: str | None = None,
446 drivername: str = POSTGRES_DIALECT,
447 dsn: str | None = None,
448 echo: bool = False,
449 run_migrations: bool = False,
450 ) -> None:
451 """Initialize connection to the pep_db database.
453 Args:
454 host: Database server address.
455 port: Port number (default: 5432).
456 database: Database name.
457 user: Username for authentication.
458 password: Password for authentication.
459 drivername: Database driver.
460 dsn: libpq connection string, e.g., "postgresql://user:pass@host:port/db".
461 echo: Log all SQL statements if True.
462 run_migrations: Run database migrations if True.
463 """
464 if not dsn:
465 dsn = URL.create(
466 host=host,
467 port=port,
468 database=database,
469 username=user,
470 password=password,
471 drivername=drivername,
472 )
473 if run_migrations:
474 dsn_with_password = (
475 f"{drivername}://{user}:{password}@{host}:{port}/{database}"
476 )
477 self.run_db_migration(dsn_with_password)
479 self._engine = create_engine(dsn, echo=echo)
480 self.create_schema(self._engine)
481 self.check_db_connection()
483 def create_schema(self, engine=None) -> None:
484 """Create SQL schema in the database.
486 Args:
487 engine: SQLAlchemy engine (default: uses internal engine).
488 """
489 if not engine:
490 engine = self._engine
491 Base.metadata.create_all(engine)
492 return None
494 def session_execute(self, statement: Select) -> Result:
495 """Execute a SQLAlchemy statement.
497 Args:
498 statement: SQL query or expression to execute.
500 Returns:
501 Query result.
502 """
503 _LOGGER.debug(f"Executing statement: {statement}")
504 with Session(self._engine) as session:
505 query_result = session.execute(statement)
507 return query_result
509 @property
510 def session(self) -> Session:
511 """Return a started SQLAlchemy session."""
512 return self._start_session()
514 @property
515 def engine(self) -> Engine:
516 return self._engine
518 def _start_session(self) -> Session:
519 session = Session(self.engine)
520 try:
521 session.execute(select(Projects).limit(1))
522 except ProgrammingError:
523 raise SchemaError()
525 return session
527 def check_db_connection(self) -> None:
528 try:
529 self.session_execute(select(Projects).limit(1))
530 except ProgrammingError:
531 raise SchemaError()
533 def delete_schema(self, engine=None) -> None:
534 """Delete SQL schema from the database.
536 Args:
537 engine: SQLAlchemy engine (default: uses internal engine).
538 """
539 if not engine:
540 engine = self._engine
541 Base.metadata.drop_all(engine)
542 return None
544 def run_db_migration(self, database_url: str) -> None:
545 """Migrate the database to the latest version."""
546 script_directory = os.path.dirname(os.path.abspath(__file__))
547 script_location = os.path.join(script_directory, "alembic")
549 alembic_cfg = Config()
551 alembic_cfg.set_main_option("script_location", script_location)
552 alembic_cfg.set_main_option("sqlalchemy.url", database_url)
554 command.upgrade(alembic_cfg, "head")