Micro-ORM with Pydantic model persistence
Project description
SQLer
English | 日本語はこちら
A lightweight, JSON-first micro-ORM for SQLite (sync + async). Define Pydantic-style models, persist them as JSON, and query with a fluent API — with optional safe models that enforce optimistic versioning.
Why SQLer?
This started as a personal toolkit for very fast prototyping; small scripts that made it effortless to sketch data models, shove them into SQLite as JSON, and iterate. The result became SQLer: a tidy, dependency-light package that keeps that prototyping speed, but adds the pieces you need for real projects (indexes, relationships, integrity policies, and honest concurrency).
Features
- Document-style models backed by SQLite JSON1
- Fluent query builder:
filter,exclude,contains,isin,.any().where(...) - Relationships with simple reference storage and hydration
- Safe models with
_versionand optimistic locking (stale writes raise) - Bulk operations (
bulk_upsert) - Integrity policies on delete:
restrict,set_null,cascade - Raw SQL escape hatch (parameterized), with model hydration when returning
_id, data - Sync & Async APIs with matching semantics
- WAL-friendly concurrency via thread-local connections (many readers, one writer)
- Opt-in perf tests and practical indexing guidance
Install
pip install sqler
Requires Python 3.12+ and SQLite with JSON1 (bundled on most platforms).
Public API Contract
Each subsection carries a Contract ID. The suite in
tests/test_readme.pyexecutes these snippets using only the documented public APIs. When the contract section changes, the tests must change with it — CI proves the README.
[C01] Sync quickstart: models, save, query
from sqler import SQLerDB, SQLerModel
from sqler.query import SQLerField as F
class Prefecture(SQLerModel):
name: str
region: str
population: int
foods: list[str] | None = None
class City(SQLerModel):
name: str
population: int
prefecture: Prefecture | None = None
db = SQLerDB.in_memory()
Prefecture.set_db(db)
City.set_db(db)
kyoto = Prefecture(name="Kyoto", region="Kansai", population=2_585_000, foods=["matcha","yudofu"]).save()
osaka = Prefecture(name="Osaka", region="Kansai", population=8_839_000, foods=["takoyaki"]).save()
shiga = Prefecture(name="Shiga", region="Kansai", population=1_413_000, foods=["funazushi"]).save()
City(name="Kyoto City", population=1_469_000, prefecture=kyoto).save()
City(name="Osaka City", population=2_750_000, prefecture=osaka).save()
City(name="Otsu", population=343_000, prefecture=shiga).save()
big = Prefecture.query().filter(F("population") > 1_000_000).order_by("population", desc=True).all()
assert [p.name for p in big][:2] == ["Osaka", "Kyoto"]
[C02] Async quickstart (matching semantics)
import asyncio
from sqler import AsyncSQLerDB, AsyncSQLerModel
from sqler.query import SQLerField as F
class AUser(AsyncSQLerModel):
name: str
age: int
async def main():
db = AsyncSQLerDB.in_memory()
await db.connect()
AUser.set_db(db)
await AUser(name="Ada", age=36).save()
adults = await AUser.query().filter(F("age") >= 18).order_by("age").all()
assert any(u.name == "Ada" for u in adults)
await db.close()
asyncio.run(main())
[C03] Query builder: .any().where(...)
from sqler import SQLerDB, SQLerModel
from sqler.query import SQLerField as F
class Order(SQLerModel):
customer: str
items: list[dict] | None = None
db = SQLerDB.in_memory()
Order.set_db(db)
Order(customer="C1", items=[{"sku":"RamenSet","qty":3}, {"sku":"Gyoza","qty":1}]).save()
Order(customer="C2", items=[{"sku":"RamenSet","qty":1}]).save()
expr = F(["items"]).any().where((F("sku") == "RamenSet") & (F("qty") >= 2))
hits = Order.query().filter(expr).all()
assert [h.customer for h in hits] == ["C1"]
[C04] Relationships: hydration & cross-filtering
from sqler import SQLerDB, SQLerModel
class Address(SQLerModel):
city: str
country: str
class User(SQLerModel):
name: str
address: Address | None = None
db = SQLerDB.in_memory()
Address.set_db(db)
User.set_db(db)
home = Address(city="Kyoto", country="JP").save()
user = User(name="Alice", address=home).save()
got = User.from_id(user._id)
assert got.address.city == "Kyoto"
qs = User.query().filter(User.ref("address").field("city") == "Kyoto")
assert any(row.name == "Alice" for row in qs.all())
[C05] Index helpers, debug(), and explain_query_plan()
from sqler import SQLerDB, SQLerModel
from sqler.query import SQLerField as F
db = SQLerDB.in_memory()
class Prefecture(SQLerModel):
name: str
region: str
population: int
Prefecture.set_db(db)
Prefecture(name="A", region="x", population=10).save()
Prefecture(name="B", region="x", population=2_000_000).save()
db.create_index("prefectures", "population")
Prefecture.ensure_index("population")
q = Prefecture.query().filter(F("population") >= 1_000_000)
sql, params = q.debug()
assert isinstance(sql, str) and isinstance(params, list)
plan = q.explain_query_plan(Prefecture.db().adapter)
assert plan and len(list(plan)) >= 1
[C06] Safe models: optimistic versioning
from sqler import SQLerDB, SQLerSafeModel, StaleVersionError
class Account(SQLerSafeModel):
owner: str
balance: int
db = SQLerDB.in_memory()
Account.set_db(db)
acc = Account(owner="Ada", balance=100).save()
acc.balance = 120
acc.save()
table = getattr(Account, "__tablename__", "accounts")
db.adapter.execute(
f"UPDATE {table} SET data = json_set(data,'$._version', json_extract(data,'$._version') + 1) WHERE _id = ?;",
[acc._id],
)
db.adapter.commit()
acc.balance = 130
try:
acc.save()
except StaleVersionError:
pass
else:
raise AssertionError("stale writes must raise")
[C07] bulk_upsert — one id per input, order preserved
from sqler import SQLerDB, SQLerModel
class BU(SQLerModel):
name: str
age: int
db = SQLerDB.in_memory()
BU.set_db(db)
rows = [{"name":"A"}, {"name":"B"}, {"_id": 42, "name":"C"}]
ids = db.bulk_upsert("bus", rows)
assert ids[2] == 42
assert all(isinstance(i, int) and i > 0 for i in ids)
[C08] Raw SQL escape hatch + Model.from_id
rows = db.execute_sql(
"SELECT _id FROM bus WHERE json_extract(data,'$.name') = ?",
["A"],
)
ids = [r.get("_id") if isinstance(r, dict) else r[0] for r in rows]
hydrated = [BU.from_id(i) for i in ids]
assert all(isinstance(h, BU) for h in hydrated)
[C09] Delete policies: restrict
from sqler import SQLerDB, SQLerModel, ReferentialIntegrityError
class U(SQLerModel):
name: str
class Post(SQLerModel):
title: str
author: dict | None = None
db = SQLerDB.in_memory()
U.set_db(db)
Post.set_db(db)
u = U(name="Writer").save()
Post(title="Post A", author={"_table":"u","_id":u._id}).save()
try:
u.delete_with_policy(on_delete="restrict")
except ReferentialIntegrityError:
pass
else:
raise AssertionError("restrict deletes must block when referenced")
[C10] Index variants: unique + partial
from sqler import SQLerDB, SQLerModel
class X(SQLerModel):
name: str
email: str | None = None
db = SQLerDB.in_memory()
X.set_db(db)
db.create_index("xs", "email", unique=True)
db.create_index("xs", "name", where="json_extract(data,'$.name') IS NOT NULL")
Quickstart (Sync)
[C11] Create, query, close
from sqler import SQLerDB, SQLerModel
from sqler.query import SQLerField as F
class User(SQLerModel):
name: str
age: int
db = SQLerDB.on_disk("app.db")
User.set_db(db) # binds model to table "users" (override with table="...")
# Create / save
u = User(name="Alice", age=30)
u.save()
print(u._id) # assigned _id
# Query
adults = User.query().filter(F("age") >= 18).order_by("age").all()
print([a.name for a in adults])
db.close()
Quickstart (Async)
[C12] Async match to sync
import asyncio
from sqler import AsyncSQLerDB, AsyncSQLerModel
from sqler.query import SQLerField as F
class AUser(AsyncSQLerModel):
name: str
age: int
async def main():
db = AsyncSQLerDB.in_memory()
await db.connect()
AUser.set_db(db)
u = AUser(name="Ada", age=36)
await u.save()
adults = await AUser.query().filter(F("age") >= 18).order_by("age").all()
print([a.name for a in adults])
await db.close()
asyncio.run(main())
Safe Models & Optimistic Versioning
Use SQLerSafeModel when you need concurrency safety. New rows start with _version = 0. Updates require the in-memory _version; on success it bumps by 1. If the row changed underneath you, a StaleVersionError is raised.
[C13] Safe model collision handling
from sqler import SQLerDB, SQLerSafeModel, StaleVersionError
class Account(SQLerSafeModel):
owner: str
balance: int
db = SQLerDB.on_disk("bank.db")
Account.set_db(db)
acc = Account(owner="Ada", balance=100)
acc.save() # _version == 0
acc.balance = 120
acc.save() # _version == 1
# Simulate concurrent change
db.adapter.execute("UPDATE accounts SET _version = _version + 1 WHERE _id = ?;", [acc._id])
db.adapter.commit()
# This write is stale → raises
try:
acc.balance = 130
acc.save()
except StaleVersionError:
acc.refresh() # reloads both fields and _version
Relationships
Store references to other models, hydrate them automatically, and filter across JSON references.
[C14] Store and query relationships
from sqler import SQLerDB, SQLerModel
class Address(SQLerModel):
city: str
country: str
class User(SQLerModel):
name: str
address: Address | None = None
db = SQLerDB.in_memory()
Address.set_db(db)
User.set_db(db)
home = Address(city="Kyoto", country="JP").save()
user = User(name="Alice", address=home).save()
loaded = User.from_id(user._id)
assert loaded.address.city == "Kyoto"
q = User.query().filter(User.ref("address").field("city") == "Kyoto")
assert [row.name for row in q.all()] == ["Alice"]
Query Builder
- Fields:
F("age"),F(["items","qty"]) - Predicates:
==,!=,<,<=,>,>=,contains,isin - Boolean ops:
&(AND),|(OR),~(NOT) - Exclude: invert a predicate set
- Arrays:
.any()and scoped.any().where(...)
When you call Model.query(), introspection helpers include .debug() (returns (sql, params)), plus .sql() and .params() methods that mirror the underlying SQLerQuery properties.
[C15] Query builder patterns
from sqler import SQLerDB, SQLerModel
from sqler.query import SQLerField as F
class QueryUser(SQLerModel):
name: str
age: int
tags: list[str] | None = None
tier: int | None = None
class QueryOrder(SQLerModel):
customer: str
items: list[dict] | None = None
db = SQLerDB.in_memory()
QueryUser.set_db(db)
QueryOrder.set_db(db)
QueryUser(name="Ada", age=36, tags=["pro", "python"], tier=1).save()
QueryUser(name="Bob", age=20, tags=["hobby"], tier=3).save()
QueryOrder(customer="Ada", items=[{"sku": "ABC", "qty": 3}]).save()
QueryOrder(customer="Bob", items=[{"sku": "XYZ", "qty": 1}]).save()
q1 = QueryUser.query().filter(F("tags").contains("pro"))
assert [u.name for u in q1.all()] == ["Ada"]
q2 = QueryUser.query().filter(F("tier").isin([1, 2]))
assert [u.name for u in q2.all()] == ["Ada"]
q3 = QueryUser.query().exclude(F("name").like("test%"))
assert {u.name for u in q3.all()} == {"Ada", "Bob"}
expr = F(["items"]).any().where((F("sku") == "ABC") & (F("qty") >= 2))
q4 = QueryOrder.query().filter(expr)
assert [o.customer for o in q4.all()] == ["Ada"]
sql, params = QueryUser.query().filter(F("age") >= 18).debug()
assert isinstance(sql, str) and params == [18]
plan = QueryUser.query().filter(F("age") >= 18).explain_query_plan(QueryUser.db().adapter)
assert plan and len(list(plan)) >= 1
Data Integrity
Delete Policies (restrict, set_null, cascade)
Control how deletions affect JSON references in related rows.
restrict(default): prevent deletion if anything still references the rowset_null: null out the JSON field that holds the reference (field must be nullable)cascade: recursively delete referrers (depth-first, cycle-safe)
[C16] Delete policies in action
from sqler import SQLerDB, SQLerModel, ReferentialIntegrityError
class DIUser(SQLerModel):
name: str
class Post(SQLerModel):
title: str
author: dict | None = None
# restrict: raises while references exist
restrict_db = SQLerDB.in_memory()
DIUser.set_db(restrict_db)
Post.set_db(restrict_db)
writer = DIUser(name="Writer").save()
Post(title="Post A", author={"_table": "diusers", "_id": writer._id}).save()
try:
writer.delete_with_policy(on_delete="restrict")
except ReferentialIntegrityError:
pass
# set_null: clears JSON ref before delete
set_null_db = SQLerDB.in_memory()
DIUser.set_db(set_null_db)
Post.set_db(set_null_db)
nullable = DIUser(name="Nullable").save()
post = Post(title="Post B", author={"_table": "diusers", "_id": nullable._id}).save()
nullable.delete_with_policy(on_delete="set_null")
assert Post.from_id(post._id).author is None
# cascade: remove dependents recursively
cascade_db = SQLerDB.in_memory()
DIUser.set_db(cascade_db)
Post.set_db(cascade_db)
cascade = DIUser(name="Cascade").save()
Post(title="Post C", author={"_table": "diusers", "_id": cascade._id}).save()
cascade.delete_with_policy(on_delete="cascade")
assert Post.query().count() == 0
Reference Validation
Detect orphans proactively:
[C17] Reference validation
from sqler import SQLerDB, SQLerModel
class RefUser(SQLerModel):
name: str
class RefPost(SQLerModel):
title: str
author: dict | None = None
db = SQLerDB.in_memory()
RefUser.set_db(db)
RefPost.set_db(db)
user = RefUser(name="Ada").save()
dangling = RefPost(title="Lost", author={"_table": RefUser.__tablename__, "_id": user._id}).save()
db.delete_document(RefUser.__tablename__, user._id) # simulate manual deletion
broken = RefPost.validate_references()
assert broken and broken[0].row_id == dangling._id
# Returned items are sqler.models.BrokenRef dataclasses
Bulk Operations
Write many documents efficiently.
[C18] Bulk upsert
from sqler import SQLerDB, SQLerModel
class BulkUser(SQLerModel):
name: str
age: int | None = None
db = SQLerDB.in_memory()
BulkUser.set_db(db)
rows = [{"name": "A"}, {"name": "B"}, {"_id": 42, "name": "C"}]
ids = db.bulk_upsert(BulkUser.__tablename__, rows)
assert len(ids) == 3 and 42 in ids
Notes:
- If SQLite supports
RETURNING, SQLer uses it; otherwise a safe fallback is used. - For sustained heavy writes, favor a single-process writer (SQLite has a single writer at a time).
Advanced Usage
Raw SQL (execute_sql)
Run parameterized SQL. To hydrate models later, return _id and data columns.
[C19] Raw SQL (execute_sql)
from sqler import SQLerDB, SQLerModel
class ReportUser(SQLerModel):
name: str
email: str | None = None
db = SQLerDB.in_memory()
ReportUser.set_db(db)
ReportUser(name="Ada", email="ada@example.com").save()
ReportUser(name="Bob", email="bob@example.com").save()
rows = db.execute_sql("""
SELECT u._id, u.data
FROM reportusers u
WHERE json_extract(u.data,'$.name') LIKE ?
""", ["A%"])
assert len(rows) == 1 and rows[0]["_id"] == 1
Indexes (JSON paths)
Build indexes for fields you filter/sort on.
[C20] Index helpers
from sqler import SQLerDB, SQLerModel
class IndexedUser(SQLerModel):
name: str
age: int | None = None
email: str | None = None
address: dict | None = None
db = SQLerDB.in_memory()
IndexedUser.set_db(db)
# DB-level indexes on JSON paths
db.create_index("indexedusers", "age")
db.create_index("indexedusers", "email", unique=True)
db.create_index(
"indexedusers",
"age",
where="json_extract(data,'$.age') IS NOT NULL",
)
# Relationship-friendly indexes
db.create_index("indexedusers", "address._id")
db.create_index("indexedusers", "address.city")
Concurrency Model (WAL)
-
SQLer uses thread-local connections and enables WAL:
journal_mode=WAL,busy_timeout=5000,synchronous=NORMAL- Many readers in parallel; one writer (SQLite rule)
-
Safe models perform optimistic writes:
UPDATE ... SET data=json(?), _version=_version+1 WHERE _id=? AND _version=?;
If no rows match, a
StaleVersionErroris raised. -
Under bursts, SQLite may report “database is locked”. SQLer uses
BEGIN IMMEDIATEand a small backoff to reduce thrash. -
refresh()always re-hydrates_version.
HTTP mapping (FastAPI)
[C21] FastAPI stale version
try:
from fastapi import HTTPException
except ImportError: # pragma: no cover - docs fallback
class HTTPException(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
from sqler.models import StaleVersionError
try:
obj.save()
except StaleVersionError:
raise HTTPException(409, "Version conflict")
Performance Tips
-
Index hot JSON paths (e.g.,
users.age,orders.items.sku) -
Batch writes with
bulk_upsert -
For heavy write loads, serialize writes via one process / queue
-
Perf suite is opt-in:
pytest -q -m perf pytest -q -m perf --benchmark-save=baseline pytest -q -m perf --benchmark-compare=baseline
Errors
StaleVersionError— optimistic check failed (HTTP 409)InvariantViolationError— malformed row invariant (e.g., NULL JSON)NotConnectedError— adapter closed / not connected- SQLite exceptions (
sqlite3.*) bubble with context
Examples
See examples/ for end-to-end scripts:
sync_model_quickstart.pysync_safe_model.pyasync_model_quickstart.pyasync_safe_model.pymodel_arrays_any.py
Run:
uv run python examples/sync_model_quickstart.py
Running the FastAPI Example
SQLer ships with a minimal FastAPI demo under examples/fastapi/app.py.
To run it:
pip install fastapi uvicorn
uv run uvicorn examples.fastapi.app:app --reload
Testing
# Unit
uv run pytest -q
# Perf (opt-in)
uv run pytest -q -m perf
Contributing
-
Format & lint:
uv run ruff format . uv run ruff check .
-
Tests:
uv run pytest -q --cov=src --cov-report=term-missing
License
MIT © Contributors
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sqler-1.2025.9.24.2.tar.gz.
File metadata
- Download URL: sqler-1.2025.9.24.2.tar.gz
- Upload date:
- Size: 30.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8182e44c4dfacd8261ec8fa2c0f95cc0bb94b02a6f98674f578036fc40999dc4
|
|
| MD5 |
7e8c5f08140dbd5019073332738daa7e
|
|
| BLAKE2b-256 |
786287b16f1971bb842c87d0f13dc70544be76b618c1d044c627884599b77dc7
|
Provenance
The following attestation bundles were made for sqler-1.2025.9.24.2.tar.gz:
Publisher:
pypi.yml on gabu-quest/sqler
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sqler-1.2025.9.24.2.tar.gz -
Subject digest:
8182e44c4dfacd8261ec8fa2c0f95cc0bb94b02a6f98674f578036fc40999dc4 - Sigstore transparency entry: 555390446
- Sigstore integration time:
-
Permalink:
gabu-quest/sqler@75fcad867973fbf505b2b55482590437a18aebf8 -
Branch / Tag:
refs/tags/v1.2025.9.24.2 - Owner: https://github.com/gabu-quest
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yml@75fcad867973fbf505b2b55482590437a18aebf8 -
Trigger Event:
push
-
Statement type:
File details
Details for the file sqler-1.2025.9.24.2-py3-none-any.whl.
File metadata
- Download URL: sqler-1.2025.9.24.2-py3-none-any.whl
- Upload date:
- Size: 43.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2816319fdc676492e42f6356931b1d8c3f56777be56d40d11367d1f32136f299
|
|
| MD5 |
28d380312af74f66667f86eabd7e212c
|
|
| BLAKE2b-256 |
f3c7c1b997bd300346739d75d4ca01983349e85dc01c91692d406a5d92cfb4b5
|
Provenance
The following attestation bundles were made for sqler-1.2025.9.24.2-py3-none-any.whl:
Publisher:
pypi.yml on gabu-quest/sqler
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sqler-1.2025.9.24.2-py3-none-any.whl -
Subject digest:
2816319fdc676492e42f6356931b1d8c3f56777be56d40d11367d1f32136f299 - Sigstore transparency entry: 555390474
- Sigstore integration time:
-
Permalink:
gabu-quest/sqler@75fcad867973fbf505b2b55482590437a18aebf8 -
Branch / Tag:
refs/tags/v1.2025.9.24.2 - Owner: https://github.com/gabu-quest
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
pypi.yml@75fcad867973fbf505b2b55482590437a18aebf8 -
Trigger Event:
push
-
Statement type: