Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Added `Dataset.update_metadata` method to update metadata of multiple samples at once.
- Added cloud storage support for Pascal VOC semantic segmentation annotations.
- Exposed Pascal VOC segmentation export from the Python interface.
Comment thread
MalteEbner marked this conversation as resolved.
Outdated
- Show Embedding Plot selection as a filter item in the left panel.
- Added thumbnail quality setting in the Settings dialog. Enable "High Quality Thumbnails" to load compressed JPEG thumbnails in grid views, reducing bandwidth for large datasets.
- Added a floating selection panel to grid pages.
- Tags can be created and assigned directly from the sample and annotation detail view.
- Tags can be created and assigned directly from the side panel in the grid view.
- Tags can be renamed directly from the side panel in the grid view.
- Tags can be deleted from the side panel in the grid view.
- Exposed Pascal VOC segmentation export from the Python interface.
- Added warning if connecting to a database with a different LightlyStudio version.

### Changed

Expand Down
3 changes: 3 additions & 0 deletions lightly_studio/src/lightly_studio/api/db_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
from lightly_studio.models.dataset import (
DatasetTable, # noqa: F401, required for SQLModel to work properly
)
from lightly_studio.models.database_version import (
DatabaseVersionTable, # noqa: F401, required for SQLModel to work properly
)
from lightly_studio.models.embedding_model import (
EmbeddingModelTable, # noqa: F401, required for SQLModel to work properly
)
Expand Down
48 changes: 45 additions & 3 deletions lightly_studio/src/lightly_studio/db_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,19 @@
from collections.abc import Generator
from contextlib import contextmanager
from enum import Enum
from importlib import metadata
from pathlib import Path
from typing import Annotated

import sqlalchemy_utils
from fastapi import Depends
from sqlalchemy import StaticPool, text
from sqlalchemy import StaticPool, inspect, text
from sqlalchemy.engine import Engine
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel import Session, SQLModel, create_engine, select

import lightly_studio.api.db_tables # noqa: F401, required for SQLModel to work properly
from lightly_studio.dataset.env import LIGHTLY_STUDIO_DATABASE_URL
from lightly_studio.models.database_version import DatabaseVersionTable


class DatabaseBackend(str, Enum):
Expand Down Expand Up @@ -111,7 +113,7 @@ def __init__(
SQLModel.metadata.drop_all(bind=self._engine)
logging.info("Dropped all tables in PostgreSQL database.")

SQLModel.metadata.create_all(self._engine)
_validate_or_initialize_database_version(engine=self._engine)

@contextmanager
def session(self) -> Generator[Session, None, None]:
Expand Down Expand Up @@ -345,3 +347,43 @@ def _session_dependency() -> Generator[Session, None, None]:


SessionDep = Annotated[Session, Depends(_session_dependency)]


def _validate_or_initialize_database_version(engine: Engine) -> None:
"""Validate database schema version or initialize it for fresh databases.

The database schema version is tied to the full lightly-studio package version.
Only fresh databases get a version row written; existing databases with a
missing or mismatched version are warned about but never auto-updated, so
the warning persists until the user takes action.
"""
existing_managed_tables = set(inspect(engine).get_table_names()).intersection(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this really need to be handled? What happens if there is an existing database without the DatabaseVersionTable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is needed to distinguish cases 1 and 2 here:

These cases are handled:
    1. Fresh database with no managed tables: Insert the expected version.
    2. Existing database without a `database_version` table: Warn.
    3. Existing database with a `database_version` table:
         a. With 0 rows: Raise an error, this should not happen.
         b. With >1 rows: Raise an error, this should not happen.
         c. With 1 row and a different version: Warn about incompatible version.
         d. With 1 row and the expected version: Do nothing.

SQLModel.metadata.tables
)
SQLModel.metadata.create_all(engine)
expected_version = metadata.version("lightly-studio")
Comment thread
MalteEbner marked this conversation as resolved.
Outdated

with Session(engine) as version_session:
db_versions = version_session.exec(select(DatabaseVersionTable)).all()
if len(db_versions) > 1:
raise RuntimeError(
f"Expected at most one row in 'database_version', got {len(db_versions)}."
)

db_version = db_versions[0] if db_versions else None
if db_version is None:
if existing_managed_tables:
logging.warning(
f"Incompatible database schema version. Expected version "
f"'{expected_version}', but found missing version metadata."
)
return
version_session.add(DatabaseVersionTable(version=expected_version))
version_session.commit()
Comment thread
MalteEbner marked this conversation as resolved.
Outdated
return

if db_version.version != expected_version:
logging.warning(
f"Incompatible database schema version. Expected version "
f"'{expected_version}', got '{db_version.version}'."
)
13 changes: 13 additions & 0 deletions lightly_studio/src/lightly_studio/models/database_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Model for tracking the LightlyStudio database schema version."""

from __future__ import annotations

from sqlmodel import Field, SQLModel


class DatabaseVersionTable(SQLModel, table=True):
"""Stores the schema version expected by the running LightlyStudio package."""

__tablename__ = "database_version"

version: str = Field(primary_key=True)
Comment thread
MalteEbner marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# Tables not relevant for collection operations:
# - setting (application-level, not collection-specific)
# - two_dim_embeddings (cached projections, regenerated as needed)
_EXCLUDED_TABLES_COUNT = 2
_EXCLUDED_TABLES_COUNT = 3
Comment thread
coderabbitai[bot] marked this conversation as resolved.

_TOTAL_TABLES_COUNT = _HANDLED_TABLES_COUNT + _EXCLUDED_TABLES_COUNT

Expand Down
104 changes: 104 additions & 0 deletions lightly_studio/tests/test_db_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@

from __future__ import annotations

from importlib import metadata
from pathlib import Path

import pytest
import sqlmodel
from pytest_mock import MockerFixture
from sqlmodel import select

from lightly_studio import ImageDataset, db_manager
from lightly_studio.core.dataset_query.image_sample_field import ImageSampleField
Expand All @@ -20,12 +22,15 @@
_detect_backend_from_url,
)
from lightly_studio.models.collection import CollectionTable
from lightly_studio.models.database_version import DatabaseVersionTable
from lightly_studio.resolvers import image_resolver
from tests.helpers_resolvers import (
create_collection,
create_image,
)

PACKAGE_VERSION = metadata.version("lightly-studio")


@pytest.fixture
def patch_engine_singleton(mocker: MockerFixture) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to lightly_studio/tests/conftest.py

Expand Down Expand Up @@ -332,3 +337,102 @@ def test_get_backend(

assert db_manager.get_backend() == DatabaseBackend.DUCKDB
db_manager.close()


def _create_database(db_url: str, version: str | None) -> None:
db_manager.connect(db_url=db_url)
with db_manager.session() as session:
db_version = session.exec(select(DatabaseVersionTable)).first()
assert db_version is not None
if version != db_version.version:
session.delete(db_version)
if version is not None:
session.add(DatabaseVersionTable(version=version))
db_manager.close()


def test_connect__initializes_database_version_for_new_database(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
patch_engine_singleton: None, # noqa: ARG001
) -> None:
db_url = f"duckdb:///{tmp_path / 'no_db.db'}"
caplog.clear()
with caplog.at_level("WARNING"):
db_manager.connect(db_url=db_url, must_exist=False)
assert caplog.messages == []
with db_manager.session() as session:
assert session.exec(select(DatabaseVersionTable.version)).first() == PACKAGE_VERSION
db_manager.close()


def test_connect__warns_for_database_without_version_metadata(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
patch_engine_singleton: None, # noqa: ARG001
) -> None:
db_url = f"duckdb:///{tmp_path / 'db_without_version.db'}"
_create_database(db_url=db_url, version=None)
expected_warning = (
f"Incompatible database schema version. Expected version "
f"'{PACKAGE_VERSION}', but found missing version metadata."
)
caplog.clear()
with caplog.at_level("WARNING"):
db_manager.connect(db_url=db_url, must_exist=True)
assert caplog.messages == [expected_warning]
with db_manager.session() as session:
assert session.exec(select(DatabaseVersionTable.version)).first() is None
db_manager.close()


def test_connect__does_not_warn_for_same_database_version(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
patch_engine_singleton: None, # noqa: ARG001
) -> None:
db_url = f"duckdb:///{tmp_path / 'db_with_same_version.db'}"
_create_database(db_url=db_url, version=PACKAGE_VERSION)
caplog.clear()
with caplog.at_level("WARNING"):
db_manager.connect(db_url=db_url, must_exist=True)
assert caplog.messages == []
with db_manager.session() as session:
assert session.exec(select(DatabaseVersionTable.version)).first() == PACKAGE_VERSION
db_manager.close()


def test_connect__warns_for_other_database_version(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
patch_engine_singleton: None, # noqa: ARG001
) -> None:
db_url = f"duckdb:///{tmp_path / 'db_with_other_version.db'}"
_create_database(db_url=db_url, version="0.0.0")
expected_warning = (
f"Incompatible database schema version. Expected version '{PACKAGE_VERSION}', got '0.0.0'."
)
caplog.clear()
with caplog.at_level("WARNING"):
db_manager.connect(db_url=db_url, must_exist=True)
assert caplog.messages == [expected_warning]
with db_manager.session() as session:
assert session.exec(select(DatabaseVersionTable.version)).first() == "0.0.0"
db_manager.close()


def test_connect__raises_for_multiple_database_versions(
tmp_path: Path,
patch_engine_singleton: None, # noqa: ARG001
) -> None:
db_url = f"duckdb:///{tmp_path / 'db_with_multiple_versions.db'}"
_create_database(db_url=db_url, version=None)

db_manager.connect(db_url=db_url)
with db_manager.session() as session:
session.add(DatabaseVersionTable(version="0.0.0"))
session.add(DatabaseVersionTable(version="0.0.1"))
db_manager.close()

with pytest.raises(RuntimeError, match=r"Expected at most one row in 'database_version'"):
db_manager.connect(db_url=db_url, must_exist=True)
2 changes: 2 additions & 0 deletions lightly_studio_view/src/lib/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4120,6 +4120,8 @@ export interface operations {
query?: never;
header?: never;
path: {
/** @description The ID of the collection */
collection_id: string;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated

tag_id: string;
};
cookie?: never;
Expand Down
Loading