py-store
A lightweight multi-backend data layer for Python asyncio apps — define your models as pure JSON schemas, query with GQL tree syntax, and get role-based access control out of the box. One unified MongoDB-style dialect runs on MongoDB, MySQL, SQLite and PostgreSQL.
Supported backends
| Backend | Notes |
|---|---|
| MongoDB | native aggregation pipeline (find/aggregate/$lookup) |
| MySQL | parameterized SQL, information_schema introspection |
| SQLite | parameterized SQL, sqlite_master + PRAGMA introspection |
| PostgreSQL | parameterized SQL ($n), RETURNING support |
GQL tree queries compile to a single native query per backend — never hand-write $lookup or raw SQL again.
Features
- Pure JSON schemas, zero code — a model is just a dict: fields, relations, computes, indexes.
- Read-time defaults & computed columns — writes store only user data; reads fill defaults and run
fn/asyncFncomputes. - GQL tree queries → one native query — nested relations resolve in a single query; never hand-write
$lookupagain. - Smart mutation —
mutation()auto-detects upsert by_id+ unique index and recursively fills relation children. - Soft-delete built in — every schema auto-registers a
<Model>Deletedarchive collection/table;remove()archives before deleting. - Permission context — ContextVar-based roles (
super_admin/admin/guest/creator...), schema/field-level read/write whitelists, automatic owner-condition injection. - Async-first — built on PyMongo's
AsyncMongoClient(pymongo >= 4.9).
Installation
pip install storepy
The distribution name is
storepy; the import package ispy_store:from py_store import init, store.
Requires Python 3.10+ and one supported backend (MongoDB / MySQL / SQLite / PostgreSQL).
Quick start
from pymongo import AsyncMongoClient
from py_store import init, store
client = AsyncMongoClient("mongodb://localhost:27017")
await init(client["mydb"]) # idempotently creates indexes for registered schemas
# Register a schema (pure JSON)
store.register({
"name": "Post", # model name used in GQL
"collection": "posts", # optional, defaults to name
"idPrefix": "PT", # string _id: prefix + base36 timestamp + random
"fields": {
"title": {"type": "string", "default": ""},
"status": {"type": "string", "default": "draft"},
"tags": {"type": "array", "default": []},
},
"computes": {
"statusLabel": {"type": "string", "depends": ["status"],
"fn": lambda doc: doc["status"].upper()},
},
"indexes": [{"keys": {"status": 1, "createdAt": -1}}],
})
# Write — only user data; defaults are filled on read
doc = await store.insert("Post", {"title": "Hello"})
# Query — GQL tree syntax, values referenced from params via @key
items = await store.query(
"Post($condition:@c0,$sort:@s1,$limit:@l) { title, status, statusLabel }",
{"c0": {"status": "draft"}, "s1": {"createdAt": -1}, "l": 20},
)
GQL syntax
Model($condition:@c0,$sort:@s1,$skip:@sk,$limit:@l1) {
field1, field2, obj.subField,
Relation($condition:@c2,$sort:@s3,$limit:@l2) { f3, Nested { f4 } }
}
- Values come from the params dict:
{"c0": {...}, "s1": {...}}. - Object sub-fields use dot notation; relations are declared in the schema (
type: "many" | "one") and resolved automatically — do not hand-write$lookup. $pipelinepasses a raw aggregation through as-is (no compute/defaults/permission trimming) — use with care; preferstore.aggregate(model, pipeline)for group/sum needs.
Query & write API
items = await store.query(gql, params) # list[dict]
one = await store.query_one(gql, params) # dict | None
page = await store.query_with_count(gql, params) # {'items','total','hasMore','page','pageSize'} (pageSize capped at 5000)
exists = await store.exists("Post", {"_id": pid})
n = await store.count("Post", {"status": "active"})
doc = await store.insert("Post", {...}) # auto _id / createdAt / updatedAt
docs = await store.insert_many("Post", [{...}, ...])
await store.update("Post", {"_id": pid}, {"status": "live"}) # plain fields → $set
await store.update("Post", {"_id": pid}, {"$inc": {"views": 1}}) # '$'-prefixed keys pass through as operators
await store.update_many("Post", {"type": t}, {"status": "live"})
r = await store.remove("Post", {"_id": pid}) # archives to <collection>_deleted first
await store.mutation("Post", {...}) # smart upsert + recursive relation children
await store.upsert("Post", {"code": "A1"}, {...}) # explicit-condition upsert (no relation handling)
rows = await store.aggregate("Post", pipeline) # native aggregation
Notes:
Nonevalues are stripped before persisting;_idcannot be changed viaupdate.createdAt/updatedAt(ms) are framework-maintained — do not set them manually.- Snake-case aliases available:
query_one,insert_many,update_many,parse_gql,build_pipeline, ...
Permission context
# Set once per request (in router/dependency layer)
store.set_context({"userId": uid, "roles": ["editor"]})
# Internal/cron jobs — bypass permission checks
await store.run_as_internal(lambda: store.remove("Post", {"_id": pid}))
super_admin/admin/internalroles pass everything; other roles are checked against schema-level and field-levelread/writewhitelists;guestcan never write.creatoris a pseudo-role resolved bydoc.createdBy == ctx.userId; schemas granting it automatically get owner conditions injected on queries and ownership checks on update/remove.- No context set → permission checks disabled (backward compatible).
- Denied access raises
store.PermissionError(withstatus = 403).
Schema reference
{
"name": "Order",
"collection": "orders",
"idPrefix": "OD",
"timestamps": True, # default: auto-maintain createdAt/updatedAt (ms)
"fields": {
"_id": "string", # shorthand
"title": {"type": "string", "default": ""},
"meta": {"type": "object", "default": {}, "fields": {...}}, # nested object fields
},
"relations": {
"items": {"model": "OrderItem", "type": "many",
"localField": "_id", "foreignField": "orderId"},
},
"computes": {
"total": {"type": "float", "depends": ["amount"], "fn": lambda d: d["amount"] * 1.1},
"itemCount": {"type": "int", "lookup": {"$size": {"$ifNull": ["$items", []]}}},
},
"indexes": [
{"keys": {"status": 1}},
{"keys": {"code": 1}, "options": {"unique": True}},
],
"read": ["editor", "viewer"], # optional schema-level role whitelists
"write": ["editor"],
}
Types: string | int | long | float | double | boolean | array | object | date | any.
License
Release files for storepy 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| storepy-0.1.0.tar.gz | 43.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| storepy-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size:81.9 kB
Release files / storepy-0.1.0.tar.gz
| Download URL | storepy-0.1.0.tar.gz |
|---|---|
| Size | 43.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
79a18a010038d6a6df6f782c785af7743f35c55278a5620086da2096b7ebeb1c
|
|
BLAKE2b-256 checksum How to use checksums |
67dd7296146c2f31b676c9bf264bb5cf06549aa11ef4742bff5f80a8b754253b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|
Release files / storepy-0.1.0-py3-none-any.whl
| Download URL | storepy-0.1.0-py3-none-any.whl |
|---|---|
| Size | 38.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
72cb20999fbef3a4601e2d984017dc7a1565ddf6c42cdeef3b751443fd9c4e1d
|
|
BLAKE2b-256 checksum How to use checksums |
ae2cd478a71e2db5eb24eec2c089dbd5e8a7e74adcff5de9ad00a134315b0be8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.4
|