Storage and ORM
nonebot_plugin_orm backend, cross-dialect types, six-backend upsert, and TOML store.
Storage and ORM
Lingchu Bot stores runtime data through two cooperating layers: nonebot_plugin_orm for relational data and a TOML-backed file store for lightweight runtime configuration. This page covers the default backend, the six supported databases, the cross-dialect type compatibility layer, the dialect-specific upsert implementation, and the TOML store that works alongside nonebot_plugin_localstore.
Default backend
The default database backend is SQLite via aiosqlite, provided by nonebot_plugin_orm. No explicit connection URL is required for local development — the ORM plugin creates a SQLite database file under the localstore data directory.
All relational access goes through nonebot_plugin_orm sessions. The project does not introduce custom engine management; database/orm_crud/ wraps get_session() from the ORM plugin into typed async helpers.
Six supported backends
SQLALCHEMY_DATABASE_URL (consumed by nonebot_plugin_orm) selects the backend. Six engines are supported:
| Backend | Driver (URL scheme) | Notes |
|---|---|---|
| SQLite | sqlite+aiosqlite:// | Default; no URL required |
| PostgreSQL | postgresql+psycopg:// or postgresql+asyncpg:// | Uses on_conflict_do_update |
| MySQL | mysql+aiomysql:// | Uses on_duplicate_key_update |
| MariaDB | mariadb+aiomysql:// | Shares the MySQL path; aiomysql driver |
| Oracle | oracle+oracledb:// | Thin mode by default; MERGE INTO upsert |
| SQL Server | mssql+aioodbc:// | Requires msodbcsql18; MERGE INTO upsert |
When SQLALCHEMY_DATABASE_URL is unset, nonebot_plugin_orm falls back to the default SQLite database. The CI matrix exercises all six engines plus version variants (PostgreSQL 16/18, MySQL 8.4/9.7, MariaDB 11.4/11.8, Oracle 23ai, SQL Server 2022/2025).
Cross-dialect types
ORM models MUST use the compatibility types from database/_dialect_compat.py instead of raw String / Text / Boolean / DateTime(timezone=True). The module exports four helpers:
| Helper | Behavior |
|---|---|
CompatBoolean | Native BOOLEAN on most backends; NUMBER(1) on Oracle pre-23c |
CompatDateTimeTZ | DateTime(timezone=True) on most backends; DATETIME(fsp=6) on MySQL / MariaDB |
CompatText | TEXT on most backends; CLOB on Oracle to avoid VARCHAR2(4000) truncation |
compat_string(length) | VARCHAR(length) by default; NVARCHAR(MAX) on SQL Server when length > 4000; CLOB on Oracle when length > 4000 |
CompatDateTimeTZ on MySQL / MariaDB emits a "timezone only supported in MySQL 5.6+" warning. Writes use datetime.now(UTC) (the utc_now() helper in database/models/message.py) so no drift occurs in practice.
All current String columns in the repository are ≤ 128 characters, so compat_string(length) stays on VARCHAR(N) for every backend — the NVARCHAR(MAX) / CLOB branch is reserved for future long-string columns.
Six-backend upsert
database/orm_crud/_bulk.py::upsert() is the single entry point for atomic upsert across all six backends. It dispatches by session dialect name:
| Dialect(s) | Implementation | RETURNING support |
|---|---|---|
sqlite | sqlite_insert(model).on_conflict_do_update(...) | Yes — uses RETURNING |
postgresql | postgresql_insert(model).on_conflict_do_update(...) | Yes — uses RETURNING |
mysql, mariadb | mysql_insert(model).on_duplicate_key_update(...) | No — follow-up SELECT by conflict_fields |
oracle | Hand-written MERGE INTO ... USING (SELECT ... FROM DUAL) s ... via text() | No — follow-up SELECT; detects ORA-00001 for conflict races |
mssql | Hand-written MERGE INTO ... USING (SELECT ...) s ... via text() with WITH (HOLDLOCK) and SET LOCK_TIMEOUT 5000 | No — follow-up SELECT |
The Oracle and SQL Server paths exist because SQLAlchemy 2.0.51 does not provide sqlalchemy.dialects.{oracle,mssql}.insert, and the generic sqlalchemy.insert() returns an Insert object without an on_conflict_do_update method. _build_merge_sql() constructs the parameterized MERGE INTO text with named bind parameters; _prepare_merge_insert_values() fills in Python-side column defaults that SQLAlchemy would normally apply when building an INSERT construct.
All upsert calls require either conflict_fields or constraint (mutually exclusive). MySQL / MariaDB / Oracle / SQL Server require conflict_fields because they use it for the follow-up SELECT to fetch the row.
ALEMBIC_STARTUP_CHECK
ALEMBIC_STARTUP_CHECK is a nonebot_plugin_orm configuration key, not a Lingchu-specific setting. When set to true, the ORM plugin enforces an Alembic schema migration check on startup. Production deployments should set it:
ALEMBIC_STARTUP_CHECK=trueThe default is false to keep local development fast. The Docker Compose production template (docker-compose.yml) ships with ALEMBIC_STARTUP_CHECK: "true".
Lingchu Bot's model packages (under database/models/) import all models in their __init__.py so Alembic's autogenerate discovery works. Migrations must run before non-SQLite tests.
TOML store and localstore
database/toml_store/ provides an asynchronous TOML-backed key-value client used for lightweight runtime configuration that does not justify a relational table. It cooperates with nonebot_plugin_localstore:
- File paths are resolved through
get_plugin_config_file(),get_plugin_data_file(), orget_plugin_cache_file()— never hard-codedPath("..."). - The main client is
RobustAsyncTOMLDB, supporting nested key paths, atomic writes, optional file watching, and explicit close semantics. - Module-level helpers:
ensure_toml_dict_file_async(),ensure_toml_dict_file_sync(),load_toml_dict_async(),load_toml_dict_sync(),write_toml_dict_file_async().
ensure_toml_dict_file_async() only creates missing files; use write_toml_dict_file_async() to overwrite an existing file. Runtime config defaults must be JSON-serializable; dump Pydantic defaults with mode="json" when writing them to TOML.
ORM helpers
database/orm_crud/ is split into three modules:
| Module | Exports |
|---|---|
_base.py | Shared helpers: _combined_conditions, _get_column_map, _is_fk_constraint_violation, _orders, _validate_column_values, DatabaseError, ROWCOUNT_UNKNOWN |
_single.py | create, get_one, get_or_create, update, update_or_create, delete, exists, count |
_bulk.py | bulk_create, upsert, list_items, async_iterate_safe |
bulk_create(..., partial=True) uses per-row savepoints so a failing row is skipped and reported instead of aborting the whole batch. async_iterate_safe() streams large result sets with yield_per and an async callback, optionally collecting items.
Blocking paths
File storage, TOML parsing, deepcopy, command parsing, and translation catalog loading are moved to worker threads. Do not call synchronous storage methods directly from async handlers.
See also
For message-store-specific configuration and retention, see Message Store. For runtime configuration files backed by the TOML store, see Architecture.
Last updated on