From 8a18b1d28266e2655a84ed3237e49cd6313d25ca Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Sun, 27 Jul 2025 16:38:57 +0200 Subject: [PATCH 01/16] Add sqlite to the test suite --- tests/conftest.py | 10 ++++++++++ tests/test_fsm/storage/test_storages.py | 1 + 2 files changed, 11 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index fe5c3d22..7c4ccead 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,7 @@ from aiogram.fsm.storage.memory import ( ) from aiogram.fsm.storage.mongo import MongoStorage from aiogram.fsm.storage.redis import RedisStorage +from aiogram.fsm.storage.sqlite import SqliteStorage from tests.mocked_bot import MockedBot DATA_DIR = Path(__file__).parent / "data" @@ -111,6 +112,15 @@ async def memory_storage(): await storage.close() +@pytest.fixture() +async def squlite_storage(): + storage = SqliteStorage() + try: + yield storage + finally: + await storage.close() + + @pytest.fixture() async def redis_isolation(redis_storage): isolation = redis_storage.create_isolation() diff --git a/tests/test_fsm/storage/test_storages.py b/tests/test_fsm/storage/test_storages.py index 44424ed2..eb7bdf97 100644 --- a/tests/test_fsm/storage/test_storages.py +++ b/tests/test_fsm/storage/test_storages.py @@ -12,6 +12,7 @@ from aiogram.fsm.storage.base import BaseStorage, StorageKey pytest.lazy_fixture("redis_storage"), pytest.lazy_fixture("mongo_storage"), pytest.lazy_fixture("memory_storage"), + pytest.lazy_fixture("sqlite_storage"), ], ) class TestStorages: From 12e04cd2cf0b5007ef05282e1b7550aa0a611083 Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Tue, 29 Jul 2025 22:54:17 +0200 Subject: [PATCH 02/16] Add __init__, connect, close methods to SqliteStorage --- aiogram/fsm/storage/sqlite.py | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 aiogram/fsm/storage/sqlite.py diff --git a/aiogram/fsm/storage/sqlite.py b/aiogram/fsm/storage/sqlite.py new file mode 100644 index 00000000..c5954973 --- /dev/null +++ b/aiogram/fsm/storage/sqlite.py @@ -0,0 +1,71 @@ +from aiosqlite import connect, Connection +from typing import Any, Dict, Mapping, Optional, cast + +from aiogram.fsm.storage.base import ( + BaseEventIsolation, + BaseStorage, + DefaultKeyBuilder, + KeyBuilder, + StateType, + StorageKey, +) + +SQLITE_FILENAME = "aiogram_fsm.sqlite" + + +class SqliteStorage(BaseStorage): + """ + SQLite storage required :code:`aiosqlite` package installed (:code:`pip install aiosqlite`) + """ + + def __init__( + self, + connection: Connection, + key_builder: Optional[KeyBuilder] = None, + ) -> None: + """ + Create an instance of :class:`SqliteStorage` with provided SQLite connection + + :param key_builder: builder that helps to convert contextual key to string + :param db_filename: name of the SQLite database file for FSM + """ + if key_builder is None: + key_builder = DefaultKeyBuilder() + self._key_builder = key_builder + self.connection = connection + + @classmethod + async def connect(cls, db_filename: str = SQLITE_FILENAME) -> Connection: + """ + Create an instance of :class:`SqliteStorage` with specifying the DB filename + + :param db_filename: for example :code:`aiogram_fsm.sqlite` + :param connection_kwargs: see :code:`motor` docs + :param kwargs: arguments to be passed to :class:`MongoStorage` + :return: an instance of :class:`MongoStorage` + """ + connection = await connect(db_filename) + await connection.execute(f'''CREATE TABLE IF NOT EXISTS aiogram_fsm ( + id TEXT PRIMARY KEY, + state BLOB, + data BLOB)''') + await connection.commit() + return cls(connection=connection) + + async def close(self) -> None: + await self.connection.close() + + async def set_state(self, key: StorageKey, state: StateType = None) -> None: + pass + + async def get_state(self, key: StorageKey) -> Optional[str]: + pass + + async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: + pass + + async def get_data(self, key: StorageKey) -> Dict[str, Any]: + pass + + + From 886ec0f1332eb4a74e38ed2c415659209947c5e8 Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Tue, 29 Jul 2025 23:05:12 +0200 Subject: [PATCH 03/16] Fix Sqlite tests --- aiogram/fsm/storage/sqlite.py | 6 +++--- tests/conftest.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/aiogram/fsm/storage/sqlite.py b/aiogram/fsm/storage/sqlite.py index c5954973..c5da9917 100644 --- a/aiogram/fsm/storage/sqlite.py +++ b/aiogram/fsm/storage/sqlite.py @@ -32,10 +32,10 @@ class SqliteStorage(BaseStorage): if key_builder is None: key_builder = DefaultKeyBuilder() self._key_builder = key_builder - self.connection = connection + self._connection = connection @classmethod - async def connect(cls, db_filename: str = SQLITE_FILENAME) -> Connection: + async def connect(cls, db_filename: str = SQLITE_FILENAME) -> "SqliteStorage": """ Create an instance of :class:`SqliteStorage` with specifying the DB filename @@ -53,7 +53,7 @@ class SqliteStorage(BaseStorage): return cls(connection=connection) async def close(self) -> None: - await self.connection.close() + await self._connection.close() async def set_state(self, key: StorageKey, state: StateType = None) -> None: pass diff --git a/tests/conftest.py b/tests/conftest.py index 7c4ccead..db3c7a7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -114,7 +114,7 @@ async def memory_storage(): @pytest.fixture() async def squlite_storage(): - storage = SqliteStorage() + storage = SqliteStorage.connect("aiogram_fsm_test.sqlite") try: yield storage finally: From 69df5f88dc3af4d52cd95d96b375232f2591b62c Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Tue, 29 Jul 2025 23:14:09 +0200 Subject: [PATCH 04/16] Add aiosqlite dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index b4642f2c..d9532d5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "aiofiles>=23.2.1,<24.2", "certifi>=2023.7.22", "typing-extensions>=4.7.0,<=5.0", + "aiosqlite>=0.21.0", ] dynamic = ["version"] From 5412c1a1b2b31f9c37d0503b18810245803dcacf Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Tue, 29 Jul 2025 23:15:28 +0200 Subject: [PATCH 05/16] Black+isort --- aiogram/fsm/storage/sqlite.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/aiogram/fsm/storage/sqlite.py b/aiogram/fsm/storage/sqlite.py index c5da9917..d8bb0abe 100644 --- a/aiogram/fsm/storage/sqlite.py +++ b/aiogram/fsm/storage/sqlite.py @@ -1,6 +1,7 @@ -from aiosqlite import connect, Connection from typing import Any, Dict, Mapping, Optional, cast +from aiosqlite import Connection, connect + from aiogram.fsm.storage.base import ( BaseEventIsolation, BaseStorage, @@ -45,10 +46,12 @@ class SqliteStorage(BaseStorage): :return: an instance of :class:`MongoStorage` """ connection = await connect(db_filename) - await connection.execute(f'''CREATE TABLE IF NOT EXISTS aiogram_fsm ( + await connection.execute( + f"""CREATE TABLE IF NOT EXISTS aiogram_fsm ( id TEXT PRIMARY KEY, state BLOB, - data BLOB)''') + data BLOB)""" + ) await connection.commit() return cls(connection=connection) @@ -66,6 +69,3 @@ class SqliteStorage(BaseStorage): async def get_data(self, key: StorageKey) -> Dict[str, Any]: pass - - - From 35b615158fd51b05b0e3d5cb219bff16454dd676 Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Wed, 30 Jul 2025 00:04:23 +0200 Subject: [PATCH 06/16] Add set_state --- aiogram/fsm/storage/sqlite.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/aiogram/fsm/storage/sqlite.py b/aiogram/fsm/storage/sqlite.py index d8bb0abe..4b0392b4 100644 --- a/aiogram/fsm/storage/sqlite.py +++ b/aiogram/fsm/storage/sqlite.py @@ -2,6 +2,7 @@ from typing import Any, Dict, Mapping, Optional, cast from aiosqlite import Connection, connect +from aiogram.fsm.state import State from aiogram.fsm.storage.base import ( BaseEventIsolation, BaseStorage, @@ -48,9 +49,9 @@ class SqliteStorage(BaseStorage): connection = await connect(db_filename) await connection.execute( f"""CREATE TABLE IF NOT EXISTS aiogram_fsm ( - id TEXT PRIMARY KEY, - state BLOB, - data BLOB)""" + id TEXT PRIMARY KEY, + state TEXT, + data BLOB)""" ) await connection.commit() return cls(connection=connection) @@ -59,7 +60,18 @@ class SqliteStorage(BaseStorage): await self._connection.close() async def set_state(self, key: StorageKey, state: StateType = None) -> None: - pass + id = self._key_builder.build(key) + state = cast(str, state.state if isinstance(state, State) else state) + + await self._connection.execute( + f"""INSERT INTO aiogram_fsm (id, state) + VALUES (?, ?) + ON CONFLICT (id) + DO UPDATE SET state = ?""", + (id, state, state), + ) + + await self._connection.commit() async def get_state(self, key: StorageKey) -> Optional[str]: pass From 546ffa953628adcd57858229df5d313db30e8c80 Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Wed, 30 Jul 2025 00:20:06 +0200 Subject: [PATCH 07/16] Add get_state --- aiogram/fsm/storage/sqlite.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/aiogram/fsm/storage/sqlite.py b/aiogram/fsm/storage/sqlite.py index 4b0392b4..65d8e367 100644 --- a/aiogram/fsm/storage/sqlite.py +++ b/aiogram/fsm/storage/sqlite.py @@ -74,7 +74,17 @@ class SqliteStorage(BaseStorage): await self._connection.commit() async def get_state(self, key: StorageKey) -> Optional[str]: - pass + id = self._key_builder.build(key) + + cursor = await self._connection.execute( + f"""SELECT state + FROM aiogram_fsm + WHERE id = ?""", + (id), + ) + + row = await cursor.fetchone() + return row[0] if row else None async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: pass From 2fd6616c64eeb37bce94fd3f5babb80f114589cb Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Wed, 30 Jul 2025 12:16:47 +0200 Subject: [PATCH 08/16] Fix typo --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index db3c7a7b..86886a8e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -113,7 +113,7 @@ async def memory_storage(): @pytest.fixture() -async def squlite_storage(): +async def sqlite_storage(): storage = SqliteStorage.connect("aiogram_fsm_test.sqlite") try: yield storage From 42ec3e570693416a626320bb80706846bd267a2d Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Wed, 30 Jul 2025 14:02:28 +0200 Subject: [PATCH 09/16] Fix tests --- aiogram/fsm/storage/sqlite.py | 2 +- tests/conftest.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/aiogram/fsm/storage/sqlite.py b/aiogram/fsm/storage/sqlite.py index 65d8e367..56cca116 100644 --- a/aiogram/fsm/storage/sqlite.py +++ b/aiogram/fsm/storage/sqlite.py @@ -80,7 +80,7 @@ class SqliteStorage(BaseStorage): f"""SELECT state FROM aiogram_fsm WHERE id = ?""", - (id), + (id,), ) row = await cursor.fetchone() diff --git a/tests/conftest.py b/tests/conftest.py index 86886a8e..356e8366 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -114,11 +114,12 @@ async def memory_storage(): @pytest.fixture() async def sqlite_storage(): - storage = SqliteStorage.connect("aiogram_fsm_test.sqlite") + storage = await SqliteStorage.connect("aiogram_fsm_test.sqlite") try: yield storage finally: await storage.close() + Path("aiogram_fsm_test.sqlite").unlink() @pytest.fixture() From 1ca896245948ee2e3f7551175ffd1f2eb88caa79 Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Wed, 30 Jul 2025 14:30:38 +0200 Subject: [PATCH 10/16] Add set_data --- aiogram/fsm/storage/sqlite.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/aiogram/fsm/storage/sqlite.py b/aiogram/fsm/storage/sqlite.py index 56cca116..6d53bd98 100644 --- a/aiogram/fsm/storage/sqlite.py +++ b/aiogram/fsm/storage/sqlite.py @@ -1,7 +1,10 @@ +import json + from typing import Any, Dict, Mapping, Optional, cast from aiosqlite import Connection, connect +from aiogram.exceptions import DataNotDictLikeError from aiogram.fsm.state import State from aiogram.fsm.storage.base import ( BaseEventIsolation, @@ -51,7 +54,7 @@ class SqliteStorage(BaseStorage): f"""CREATE TABLE IF NOT EXISTS aiogram_fsm ( id TEXT PRIMARY KEY, state TEXT, - data BLOB)""" + data TEXT)""" ) await connection.commit() return cls(connection=connection) @@ -87,7 +90,23 @@ class SqliteStorage(BaseStorage): return row[0] if row else None async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: - pass + if not isinstance(data, dict): + raise DataNotDictLikeError( + f"Data must be a dict or dict-like object, got {type(data).__name__}" + ) + + id = self._key_builder.build(key) + data_cell = json.dumps(data) if data else None + + await self._connection.execute( + f"""INSERT INTO aiogram_fsm (id, data) + VALUES (?, ?) + ON CONFLICT (id) + DO UPDATE SET data = ?""", + (id, data_cell, data_cell), + ) + + await self._connection.commit() async def get_data(self, key: StorageKey) -> Dict[str, Any]: pass From cef35e17f1f3a953a020442becca6d759774964e Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Wed, 30 Jul 2025 23:20:20 +0200 Subject: [PATCH 11/16] Add get_data --- aiogram/fsm/storage/sqlite.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/aiogram/fsm/storage/sqlite.py b/aiogram/fsm/storage/sqlite.py index 6d53bd98..cf5d863d 100644 --- a/aiogram/fsm/storage/sqlite.py +++ b/aiogram/fsm/storage/sqlite.py @@ -1,5 +1,4 @@ import json - from typing import Any, Dict, Mapping, Optional, cast from aiosqlite import Connection, connect @@ -109,4 +108,17 @@ class SqliteStorage(BaseStorage): await self._connection.commit() async def get_data(self, key: StorageKey) -> Dict[str, Any]: - pass + id = self._key_builder.build(key) + data_cell = {} + + cursor = await self._connection.execute( + f"""SELECT data + FROM aiogram_fsm + WHERE id = ?""", + (id,), + ) + row = await cursor.fetchone() + + if row and row[0]: + data_cell = cast(Dict[str, Any], json.loads(row[0])) + return data_cell From 0f95ff8df9cf1ac7afecf554b436be070ad98a37 Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Thu, 31 Jul 2025 17:31:32 +0200 Subject: [PATCH 12/16] Add db cleanup and optimization --- aiogram/fsm/storage/sqlite.py | 58 +++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/aiogram/fsm/storage/sqlite.py b/aiogram/fsm/storage/sqlite.py index cf5d863d..d65c4990 100644 --- a/aiogram/fsm/storage/sqlite.py +++ b/aiogram/fsm/storage/sqlite.py @@ -55,6 +55,8 @@ class SqliteStorage(BaseStorage): state TEXT, data TEXT)""" ) + # db optimization on start + await connection.execute(f"""VACUUM""") await connection.commit() return cls(connection=connection) @@ -65,13 +67,29 @@ class SqliteStorage(BaseStorage): id = self._key_builder.build(key) state = cast(str, state.state if isinstance(state, State) else state) - await self._connection.execute( - f"""INSERT INTO aiogram_fsm (id, state) - VALUES (?, ?) - ON CONFLICT (id) - DO UPDATE SET state = ?""", - (id, state, state), + cursor = await self._connection.execute( + f"""SELECT data + FROM aiogram_fsm + WHERE id = ?""", + (id,), ) + row = await cursor.fetchone() + + if not state and (not row or not row[0]): + # db clean up on the go + await self._connection.execute( + f"""DELETE FROM aiogram_fsm + where id = ?""", + (id,), + ) + else: + await self._connection.execute( + f"""INSERT INTO aiogram_fsm (id, state) + VALUES (?, ?) + ON CONFLICT (id) + DO UPDATE SET state = ?""", + (id, state, state), + ) await self._connection.commit() @@ -97,13 +115,29 @@ class SqliteStorage(BaseStorage): id = self._key_builder.build(key) data_cell = json.dumps(data) if data else None - await self._connection.execute( - f"""INSERT INTO aiogram_fsm (id, data) - VALUES (?, ?) - ON CONFLICT (id) - DO UPDATE SET data = ?""", - (id, data_cell, data_cell), + cursor = await self._connection.execute( + f"""SELECT state + FROM aiogram_fsm + WHERE id = ?""", + (id,), ) + row = await cursor.fetchone() + + if not data and not row: + # db clean up on the go + await self._connection.execute( + f"""DELETE FROM aiogram_fsm + where id = ?""", + (id,), + ) + else: + await self._connection.execute( + f"""INSERT INTO aiogram_fsm (id, data) + VALUES (?, ?) + ON CONFLICT (id) + DO UPDATE SET data = ?""", + (id, data_cell, data_cell), + ) await self._connection.commit() From 1668230a81ff09493531a8a04e88f0917b0008bc Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Thu, 31 Jul 2025 17:44:39 +0200 Subject: [PATCH 13/16] Add doc --- docs/dispatcher/finite_state_machine/storages.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/dispatcher/finite_state_machine/storages.rst b/docs/dispatcher/finite_state_machine/storages.rst index 3795d2f1..7a5a53d2 100644 --- a/docs/dispatcher/finite_state_machine/storages.rst +++ b/docs/dispatcher/finite_state_machine/storages.rst @@ -26,6 +26,13 @@ MongoStorage :members: __init__, from_url :member-order: bysource +SqliteStorage +------------ + +.. autoclass:: aiogram.fsm.storage.sqlite.SqliteStorage + :members: __init__, connect + :member-order: bysource + KeyBuilder ------------ From 25a3840cf469580e41d531980f61caf794056ce6 Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Thu, 31 Jul 2025 18:10:19 +0200 Subject: [PATCH 14/16] Add Changes file --- CHANGES/1718.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 CHANGES/1718.feature.rst diff --git a/CHANGES/1718.feature.rst b/CHANGES/1718.feature.rst new file mode 100644 index 00000000..648f832a --- /dev/null +++ b/CHANGES/1718.feature.rst @@ -0,0 +1 @@ +Added SqliteStorage which makes local development and small deployments a bit easier. \ No newline at end of file From 8b08110f262b0f1d254922ba4eb0b620d43eb1e3 Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Thu, 7 Aug 2025 09:35:59 +0200 Subject: [PATCH 15/16] fix: moved sqlite to project extras --- pyproject.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d9532d5e..955f31ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,6 @@ dependencies = [ "aiofiles>=23.2.1,<24.2", "certifi>=2023.7.22", "typing-extensions>=4.7.0,<=5.0", - "aiosqlite>=0.21.0", ] dynamic = ["version"] @@ -66,6 +65,9 @@ redis = [ mongo = [ "motor>=3.3.2,<3.7.0", ] +sqlite = [ + "aiosqlite>=0.21.0", +] proxy = [ "aiohttp-socks~=0.8.3", ] @@ -123,6 +125,7 @@ features = [ "fast", "redis", "mongo", + "sqlite', "proxy", "i18n", "cli", @@ -143,6 +146,7 @@ features = [ "fast", "redis", "mongo", + "sqlite", "proxy", "i18n", "docs", @@ -158,6 +162,7 @@ features = [ "fast", "redis", "mongo", + "sqlite", "proxy", "i18n", "test", @@ -176,6 +181,7 @@ features = [ "fast", "redis", "mongo", + "sqlite", "proxy", "i18n", "test", From 66402e9cc0827b1dd87e3a9d0b106acabbc9d8d6 Mon Sep 17 00:00:00 2001 From: Oleg Matviichuk Date: Thu, 7 Aug 2025 09:38:31 +0200 Subject: [PATCH 16/16] fix: typo --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 955f31ad..9995edc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,7 +125,7 @@ features = [ "fast", "redis", "mongo", - "sqlite', + "sqlite", "proxy", "i18n", "cli",