Skip to content

Commit a10aa58

Browse files
authored
Merge pull request #10 from manti-by/mnt-107-replace-redis-with-postgres
MNT-107: Replace Redis with Postgres
2 parents 0cec9f1 + a7cff6e commit a10aa58

15 files changed

Lines changed: 499 additions & 119 deletions

File tree

Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ install:
33
sudo apt install -y python3-gi python3-gi-cairo gir1.2-gtk-3.0 libgirepository-2.0-dev gcc libcairo2-dev
44

55
flush-db:
6-
redis-cli -n 5 FLUSHALL
6+
psql -h 127.0.0.1 -U manti -d mgallery -c "TRUNCATE TABLE images;"
7+
8+
migrate:
9+
uv run alembic upgrade head
710

811
clean-dirs:
912
rm -rf /var/mgallery/thumbnails/*
@@ -56,4 +59,4 @@ update:
5659
ci: install check test
5760

5861
test:
59-
uv run pytest tests/
62+
uv run pytest tests/

alembic.ini

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
[alembic]
2+
script_location = alembic
3+
prepend_sys_path = .
4+
version_path_separator = os
5+
6+
sqlalchemy.url = driver://user:pass@localhost/db
7+
8+
[post_write_hooks]
9+
10+
[loggers]
11+
keys = root,sqlalchemy,alembic
12+
13+
[handlers]
14+
keys = console
15+
16+
[formatters]
17+
keys = generic
18+
19+
[logger_root]
20+
level = WARN
21+
handlers = console
22+
qualname =
23+
24+
[logger_sqlalchemy]
25+
level = WARN
26+
handlers =
27+
qualname = sqlalchemy.engine
28+
29+
[logger_alembic]
30+
level = INFO
31+
handlers =
32+
qualname = alembic
33+
34+
[handler_console]
35+
class = StreamHandler
36+
args = (sys.stderr,)
37+
level = NOTSET
38+
formatter = generic
39+
40+
[formatter_generic]
41+
format = %(levelname)-5.5s [%(name)s] %(message)s
42+
datefmt = %H:%M:%S

alembic/__init__.py

Whitespace-only changes.

alembic/env.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from logging.config import fileConfig
2+
3+
from sqlalchemy import engine_from_config, pool # noqa: E402
4+
5+
from alembic import context # noqa: E402
6+
7+
from mgallery.library.tables import Base # noqa: E402
8+
9+
from mgallery.utils.settings import DATABASE_URL # noqa: E402
10+
11+
12+
config = context.config
13+
14+
if config.config_file_name is not None:
15+
fileConfig(config.config_file_name)
16+
17+
config.set_main_option("sqlalchemy.url", DATABASE_URL)
18+
19+
target_metadata = Base.metadata
20+
21+
22+
def run_migrations_offline() -> None:
23+
url = config.get_main_option("sqlalchemy.url")
24+
context.configure(
25+
url=url,
26+
target_metadata=target_metadata,
27+
literal_binds=True,
28+
dialect_opts={"paramstyle": "named"},
29+
)
30+
31+
with context.begin_transaction():
32+
context.run_migrations()
33+
34+
35+
def run_migrations_online() -> None:
36+
connectable = engine_from_config(
37+
config.get_section(config.config_ini_section, {}),
38+
prefix="sqlalchemy.",
39+
poolclass=pool.NullPool,
40+
)
41+
42+
with connectable.connect() as connection:
43+
context.configure(connection=connection, target_metadata=target_metadata)
44+
45+
with context.begin_transaction():
46+
context.run_migrations()
47+
48+
49+
if __name__ == "__main__":
50+
run_migrations_online()

alembic/script.py.mako

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
${imports if imports else ""}
13+
14+
revision: str = ${repr(up_revision)}
15+
down_revision: Union[str, None] = ${repr(down_revision)}
16+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
17+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
18+
19+
20+
def upgrade() -> None:
21+
${upgrades if upgrades else "pass"}
22+
23+
24+
def downgrade() -> None:
25+
${downgrades if downgrades else "pass"}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""initial migration
2+
3+
Revision ID: 001
4+
Revises:
5+
Create Date: 2026-06-04 00:00:00.000000
6+
7+
"""
8+
from collections.abc import Sequence
9+
10+
import sqlalchemy as sa
11+
12+
from alembic import op
13+
14+
15+
revision: str = "001"
16+
down_revision: str | None = None
17+
branch_labels: str | Sequence[str] | None = None
18+
depends_on: str | Sequence[str] | None = None
19+
20+
21+
def upgrade() -> None:
22+
op.create_table(
23+
"images",
24+
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
25+
sa.Column("path", sa.Text, nullable=False),
26+
sa.Column("name", sa.Text, nullable=False),
27+
sa.Column("phash", sa.Text, nullable=True),
28+
sa.Column("width", sa.Integer, nullable=True),
29+
sa.Column("height", sa.Integer, nullable=True),
30+
sa.Column("size", sa.BigInteger, nullable=True),
31+
sa.UniqueConstraint("path", "name", name="uq_images_path_name"),
32+
)
33+
op.create_index("ix_images_phash", "images", ["phash"])
34+
35+
36+
def downgrade() -> None:
37+
op.drop_index("ix_images_phash", table_name="images")
38+
op.drop_table("images")

alembic/versions/__init__.py

Whitespace-only changes.

mgallery/library/database.py

Lines changed: 127 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,84 @@
1-
import json
21
from collections import defaultdict
2+
from typing import Any
33

4-
import redis
4+
from sqlalchemy import create_engine, text
5+
from sqlalchemy.engine import Engine
6+
from sqlalchemy.orm import Session, sessionmaker
57

6-
from mgallery.utils.settings import REDIS_URL
8+
from mgallery.utils.settings import DATABASE_URL
79

810

911
class Database:
10-
def __init__(self):
11-
self.client = redis.from_url(REDIS_URL)
12+
def __init__(self, engine: Engine | None = None):
13+
self._engine = engine or create_engine(
14+
DATABASE_URL,
15+
pool_size=5,
16+
max_overflow=5,
17+
pool_pre_ping=True,
18+
)
19+
self._session_factory = sessionmaker(bind=self._engine)
20+
21+
def _session(self) -> Session:
22+
return self._session_factory()
1223

13-
def get(self, key: str | bytes) -> dict:
14-
if data := self.client.get(key):
15-
return json.loads(data)
24+
def get(self, key: str | bytes) -> dict[str, Any]:
25+
key_str = key.decode() if isinstance(key, bytes) else key
26+
parts = key_str.split("-", 1)
27+
if len(parts) != 2:
28+
return {}
29+
phash, rest = parts
30+
if "/" not in rest:
31+
return {}
32+
path, name = rest.rsplit("/", 1)
33+
if phash == "None":
34+
with self._session() as session:
35+
row = session.execute(
36+
text("SELECT path, name, phash, width, height, size FROM images WHERE phash IS NULL AND path = :path AND name = :name"),
37+
{"path": path, "name": name},
38+
).mappings().first()
39+
if row:
40+
return dict(row)
41+
else:
42+
with self._session() as session:
43+
row = session.execute(
44+
text("SELECT path, name, phash, width, height, size FROM images WHERE phash = :phash AND path = :path AND name = :name"),
45+
{"phash": phash, "path": path, "name": name},
46+
).mappings().first()
47+
if row:
48+
return dict(row)
1649
return {}
1750

18-
def all(self, pattern: str = "*") -> list:
19-
return [self.get(key) for key in self.client.keys(pattern)]
51+
def all(self, pattern: str = "*") -> list[dict[str, Any]]:
52+
if pattern == "*":
53+
with self._session() as session:
54+
rows = session.execute(text("SELECT path, name, phash, width, height, size FROM images")).mappings().all()
55+
else:
56+
with self._session() as session:
57+
rows = session.execute(
58+
text("SELECT path, name, phash, width, height, size FROM images WHERE path LIKE :pattern"),
59+
{"pattern": f"%{pattern}%"},
60+
).mappings().all()
61+
return [dict(row) for row in rows]
2062

21-
def duplicates(self) -> dict[str, list]:
22-
duplicates = defaultdict(list)
23-
for item in self.all():
63+
def duplicates(self) -> dict[str, list[dict[str, Any]]]:
64+
with self._session() as session:
65+
rows = session.execute(
66+
text("""
67+
SELECT path, name, phash, width, height, size
68+
FROM images
69+
WHERE phash IN (
70+
SELECT phash FROM images WHERE phash IS NOT NULL
71+
GROUP BY phash
72+
HAVING COUNT(*) > 1
73+
)
74+
"""),
75+
).mappings().all()
76+
result: dict[str, list[dict[str, Any]]] = defaultdict(list)
77+
for row in rows:
78+
item = dict(row)
2479
if item["phash"]:
25-
duplicates[item["phash"]].append(item)
26-
return {k: v for k, v in duplicates.items() if len(v) > 1}
80+
result[item["phash"]].append(item)
81+
return result
2782

2883
def create(
2984
self,
@@ -33,20 +88,63 @@ def create(
3388
width: int | None = None,
3489
height: int | None = None,
3590
size: int | None = None,
91+
session: Session | None = None,
3692
):
37-
key = f"{phash}-{path}/{name}"
38-
value = json.dumps(
39-
{
40-
"path": path,
41-
"name": name,
42-
"phash": phash,
43-
"width": width,
44-
"height": height,
45-
"size": size,
46-
}
47-
)
48-
self.client.set(key, value)
93+
external_session = session is not None
94+
session = session or self._session()
95+
try:
96+
session.execute(
97+
text("""
98+
INSERT INTO images (path, name, phash, width, height, size)
99+
VALUES (:path, :name, :phash, :width, :height, :size)
100+
ON CONFLICT (path, name) DO UPDATE SET
101+
phash = COALESCE(EXCLUDED.phash, images.phash),
102+
width = COALESCE(EXCLUDED.width, images.width),
103+
height = COALESCE(EXCLUDED.height, images.height),
104+
size = COALESCE(EXCLUDED.size, images.size)
105+
"""),
106+
{"path": path, "name": name, "phash": phash, "width": width, "height": height, "size": size},
107+
)
108+
if not external_session:
109+
session.commit()
110+
finally:
111+
if not external_session:
112+
session.close()
113+
114+
def create_batch(self, items: list[dict[str, Any]], session: Session | None = None) -> None:
115+
external_session = session is not None
116+
session = session or self._session()
117+
try:
118+
for item in items:
119+
session.execute(
120+
text("""
121+
INSERT INTO images (path, name, phash, width, height, size)
122+
VALUES (:path, :name, :phash, :width, :height, :size)
123+
ON CONFLICT (path, name) DO UPDATE SET
124+
phash = COALESCE(EXCLUDED.phash, images.phash),
125+
width = COALESCE(EXCLUDED.width, images.width),
126+
height = COALESCE(EXCLUDED.height, images.height),
127+
size = COALESCE(EXCLUDED.size, images.size)
128+
"""),
129+
{
130+
"path": item["path"],
131+
"name": item["name"],
132+
"phash": item.get("phash"),
133+
"width": item.get("width"),
134+
"height": item.get("height"),
135+
"size": item.get("size"),
136+
},
137+
)
138+
if not external_session:
139+
session.commit()
140+
finally:
141+
if not external_session:
142+
session.close()
49143

50144
def delete(self, path: str, name: str):
51-
for key in self.client.keys(f"*{path}/{name}"):
52-
self.client.delete(key)
145+
with self._session() as session:
146+
session.execute(
147+
text("DELETE FROM images WHERE path = :path AND name = :name"),
148+
{"path": path, "name": name},
149+
)
150+
session.commit()

mgallery/library/tables.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from sqlalchemy import BigInteger, Integer, MetaData, Text, UniqueConstraint
2+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
3+
4+
5+
class Base(DeclarativeBase):
6+
metadata = MetaData()
7+
8+
9+
class Image(Base):
10+
__tablename__ = "images"
11+
__table_args__ = (UniqueConstraint("path", "name", name="uq_images_path_name"),)
12+
13+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
14+
path: Mapped[str] = mapped_column(Text, nullable=False)
15+
name: Mapped[str] = mapped_column(Text, nullable=False)
16+
phash: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
17+
width: Mapped[int | None] = mapped_column(Integer, nullable=True)
18+
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
19+
size: Mapped[int | None] = mapped_column(BigInteger, nullable=True)

mgallery/utils/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
GALLERY_PATH = os.getenv("GALLERY_PATH", "/home/manti/www/mgallery/data")
55
THUMBNAILS_PATH = os.getenv("THUMBNAILS_PATH", "/var/mgallery/thumbnails")
66

7-
REDIS_URL = os.getenv("REDIS_URL", "redis://127.0.0.1:6379/5")
7+
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://manti@127.0.0.1:5432/mgallery")
88

99
FILE_TYPES = ("arw", "dng", "jpg", "jpeg", "png", "webp", "gif")
1010

0 commit comments

Comments
 (0)