Lingchu Bot documentation is now live — check it out!
Lingchu Bot

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:

BackendDriver (URL scheme)Notes
SQLitesqlite+aiosqlite://Default; no URL required
PostgreSQLpostgresql+psycopg:// or postgresql+asyncpg://Uses on_conflict_do_update
MySQLmysql+aiomysql://Uses on_duplicate_key_update
MariaDBmariadb+aiomysql://Shares the MySQL path; aiomysql driver
Oracleoracle+oracledb://Thin mode by default; MERGE INTO upsert
SQL Servermssql+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:

HelperBehavior
CompatBooleanNative BOOLEAN on most backends; NUMBER(1) on Oracle pre-23c
CompatDateTimeTZDateTime(timezone=True) on most backends; DATETIME(fsp=6) on MySQL / MariaDB
CompatTextTEXT 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)ImplementationRETURNING support
sqlitesqlite_insert(model).on_conflict_do_update(...)Yes — uses RETURNING
postgresqlpostgresql_insert(model).on_conflict_do_update(...)Yes — uses RETURNING
mysql, mariadbmysql_insert(model).on_duplicate_key_update(...)No — follow-up SELECT by conflict_fields
oracleHand-written MERGE INTO ... USING (SELECT ... FROM DUAL) s ... via text()No — follow-up SELECT; detects ORA-00001 for conflict races
mssqlHand-written MERGE INTO ... USING (SELECT ...) s ... via text() with WITH (HOLDLOCK) and SET LOCK_TIMEOUT 5000No — 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=true

The 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(), or get_plugin_cache_file() — never hard-coded Path("...").
  • 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:

ModuleExports
_base.pyShared helpers: _combined_conditions, _get_column_map, _is_fk_constraint_violation, _orders, _validate_column_values, DatabaseError, ROWCOUNT_UNKNOWN
_single.pycreate, get_one, get_or_create, update, update_or_create, delete, exists, count
_bulk.pybulk_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

On this page