storepy 2.0.0__tar.gz → 2.0.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- storepy-2.0.2/PKG-INFO +549 -0
- storepy-2.0.2/README.md +511 -0
- {storepy-2.0.0 → storepy-2.0.2}/pyproject.toml +12 -3
- storepy-2.0.2/src/storepy.egg-info/PKG-INFO +549 -0
- storepy-2.0.0/PKG-INFO +0 -296
- storepy-2.0.0/README.md +0 -258
- storepy-2.0.0/src/storepy.egg-info/PKG-INFO +0 -296
- {storepy-2.0.0 → storepy-2.0.2}/LICENSE +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/setup.cfg +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/__init__.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/core.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/crud/__init__.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/crud/exec.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/crud/id.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/crud/mutation.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/crud/query.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/crud/write.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/datasource.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/executors/__init__.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/executors/_values.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/executors/mongo.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/executors/mysql.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/executors/postgres.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/executors/sqlite.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/feedback.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/introspect/__init__.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/introspect/mysql.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/introspect/postgres.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/introspect/sqlite.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/permission.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/schema.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/py_store/sync.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/storepy.egg-info/SOURCES.txt +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/storepy.egg-info/dependency_links.txt +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/storepy.egg-info/requires.txt +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/src/storepy.egg-info/top_level.txt +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/tests/test_federation_e2e.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/tests/test_host_contract.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/tests/test_mongo_executor.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/tests/test_multi_datasource.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/tests/test_py_store.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/tests/test_real_backends_e2e.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/tests/test_require_context.py +0 -0
- {storepy-2.0.0 → storepy-2.0.2}/tests/test_scenario_course_platform.py +0 -0
storepy-2.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: storepy
|
|
3
|
+
Version: 2.0.2
|
|
4
|
+
Summary: Multi-backend data layer for Python asyncio (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control
|
|
5
|
+
Author-email: leo <coen_ddt@qq.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/coenddt/py-store
|
|
8
|
+
Project-URL: Repository, https://github.com/coenddt/py-store
|
|
9
|
+
Project-URL: Issues, https://github.com/coenddt/py-store/issues
|
|
10
|
+
Keywords: mongodb,mysql,sqlite,postgresql,postgres,asyncio,async,data-layer,query-builder,odm,orm-alternative,sqlalchemy-alternative,motor-alternative,beanie-alternative,gql,json-schema,pymongo,multi-database,cross-database,multi-tenant,acl,rbac,access-control,row-level-security,aggregation,group-by,computed-columns,soft-delete,fastapi,ai-agent,llm-tool,query-validation,natural-language-query
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Framework :: AsyncIO
|
|
19
|
+
Classifier: Topic :: Database
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: pymongo>=4.9
|
|
26
|
+
Requires-Dist: rust-store-py<3.0.0,>=2.0.0
|
|
27
|
+
Provides-Extra: mysql
|
|
28
|
+
Requires-Dist: asyncmy>=0.2.9; extra == "mysql"
|
|
29
|
+
Provides-Extra: postgres
|
|
30
|
+
Requires-Dist: asyncpg>=0.29; extra == "postgres"
|
|
31
|
+
Provides-Extra: sqlite
|
|
32
|
+
Requires-Dist: aiosqlite>=0.19; extra == "sqlite"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
35
|
+
Requires-Dist: ruff>=0.16; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# py-store
|
|
40
|
+
|
|
41
|
+
**One data layer for MongoDB, MySQL, SQLite and PostgreSQL in Python asyncio — define models as pure JSON, query them with a MongoDB-style GQL tree syntax, and get role-based access control, computed columns and soft-delete out of the box.**
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+

|
|
45
|
+

|
|
46
|
+

|
|
47
|
+
-green)
|
|
48
|
+
|
|
49
|
+
`py-store` lets a Python service talk to MongoDB (native aggregation), MySQL, PostgreSQL and SQLite through a **single schema definition and a single query dialect**. Nested relations compile to **one native query per backend** — you never hand-write `$lookup` or raw SQL.
|
|
50
|
+
|
|
51
|
+
> Also looking for the Node.js version? See [`nodejs-store`](https://github.com/coenddt/nodejs-store) (npm `nodejs-store`). Both are thin hosts over the shared Rust engine [`rust-store`](https://github.com/coenddt/rust-store).
|
|
52
|
+
> 中文文档见 [README.zh-CN.md](README.zh-CN.md)。
|
|
53
|
+
|
|
54
|
+
**Install:** the distribution name is `storepy`; the import package is `py_store`.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install storepy
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from py_store import init, store
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Table of contents
|
|
67
|
+
|
|
68
|
+
- [What it is](#what-it-is)
|
|
69
|
+
- [When to use it](#when-to-use-it)
|
|
70
|
+
- [When not to use it](#when-not-to-use-it)
|
|
71
|
+
- [How it compares](#how-it-compares)
|
|
72
|
+
- [Installation](#installation)
|
|
73
|
+
- [Quick start](#quick-start)
|
|
74
|
+
- [Supported backends](#supported-backends)
|
|
75
|
+
- [Features](#features)
|
|
76
|
+
- [GQL tree queries](#gql-syntax)
|
|
77
|
+
- [Aggregation](#aggregation)
|
|
78
|
+
- [Query & write API](#query--write-api)
|
|
79
|
+
- [Multi-datasource connections](#multi-datasource-connections)
|
|
80
|
+
- [Permission context](#permission-context)
|
|
81
|
+
- [Feedback events](#feedback-events)
|
|
82
|
+
- [Schema reference](#schema-reference)
|
|
83
|
+
- [Transactions](#transaction-boundary)
|
|
84
|
+
- [FAQ](#faq)
|
|
85
|
+
- [Related projects](#related-projects)
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## What it is
|
|
90
|
+
|
|
91
|
+
A lightweight, backend-agnostic data layer for Python asyncio. You describe your models once as pure JSON (`fields`, `relations`, `computes`, `indexes`, `read`/`write` role whitelists). From that description the library derives:
|
|
92
|
+
|
|
93
|
+
- **command planning** (GQL → Mongo command JSON) — executed by the Rust core `rust-store-py`,
|
|
94
|
+
- **dialect translation** (command JSON → parameterized SQL) for MySQL / PostgreSQL / SQLite,
|
|
95
|
+
- **permission checks** (schema-level + field-level read/write, owner-condition injection),
|
|
96
|
+
- **computed columns**, **soft-delete archives**, and **result rehydration** (flat JOIN rows → nested documents).
|
|
97
|
+
|
|
98
|
+
MongoDB is the *primary dialect*: queries are written in a MongoDB-flavoured GQL, and the three relational backends adapt to it. That is what makes one schema portable across a document store and three relational stores.
|
|
99
|
+
|
|
100
|
+
### How it relates to nodejs-store and rust-store
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
┌──────────────────────────────┐
|
|
104
|
+
Node.js ──▶ │ nodejs-store (npm, host) │ ─┐
|
|
105
|
+
└──────────────────────────────┘ │ rust-store-node (napi-rs)
|
|
106
|
+
▼
|
|
107
|
+
┌───────────────────────────────┐
|
|
108
|
+
│ rust-store/core (pure logic) │
|
|
109
|
+
│ GQL · permissions · computes │
|
|
110
|
+
│ command planning · dialects │
|
|
111
|
+
└───────────────────────────────┘
|
|
112
|
+
▲
|
|
113
|
+
┌──────────────────────────────┐ │ rust-store-py (PyO3)
|
|
114
|
+
Python ──▶ │ py-store (pip, host) │ ─┘
|
|
115
|
+
└──────────────────────────────┘
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The **Rust core** owns GQL parsing, permission checks, computed columns, command planning and SQL dialect translation — it never touches a database. The **hosts** (`py-store`, `nodejs-store`) own driver IO, callbacks and placeholder substitution. Behaviour therefore cannot drift between Python and Node.js: there is only one implementation.
|
|
119
|
+
|
|
120
|
+
## When to use it
|
|
121
|
+
|
|
122
|
+
Reach for `py-store` when any of these describe your situation:
|
|
123
|
+
|
|
124
|
+
- **One codebase, several databases.** You ship the same service against MongoDB in dev and PostgreSQL in production (or per-tenant), and you don't want two data-access layers.
|
|
125
|
+
- **You need nested / relational reads without writing `$lookup` or JOINs.** Order → items, Course → lessons, User → orders — all expressed once in the schema and resolved in a single query.
|
|
126
|
+
- **You are building an admin backend, FastAPI service or internal CRUD API** and want schema-driven CRUD, soft-delete, computed columns and role checks without a full ORM.
|
|
127
|
+
- **You need row-level / field-level access control.** Whitelists per role, `guest` can never write, `creator` ownership is checked against `doc.createdBy`, and owner conditions are injected automatically into queries.
|
|
128
|
+
- **You are building an AI / natural-language data-QA layer.** The library was designed with AI query hosts in mind: `store.build_pipeline(...)` exposes the planned query without executing it, and degraded / non-pushdownable paths emit structured feedback events instead of failing silently.
|
|
129
|
+
- **You are migrating between MongoDB and SQL** and want to keep one query syntax during the transition.
|
|
130
|
+
- **Multi-tenant SaaS.** One schema definition, N tenants: bind a schema to `(source, namespace, collection)` and re-target any query or write at execution time with a `{"source", "namespace"}` override.
|
|
131
|
+
|
|
132
|
+
Typical concrete scenarios (see [`doc/use-cases/`](doc/use-cases/) for full walkthroughs):
|
|
133
|
+
|
|
134
|
+
| Scenario | Why py-store fits |
|
|
135
|
+
| --- | --- |
|
|
136
|
+
| Multi-tenant SaaS with per-tenant schema/database | `namespace` per tenant + runtime route override, one schema |
|
|
137
|
+
| FastAPI / admin backend | Schema-driven CRUD, soft-delete, computed columns, RBAC |
|
|
138
|
+
| MongoDB today, PostgreSQL tomorrow | Same GQL + same schema, only the datasource changes |
|
|
139
|
+
| AI data-QA / text-to-query agent | Plan-only `build_pipeline`, deterministic command JSON, feedback events |
|
|
140
|
+
| Mixed SQL + Mongo in one product | Cross-source queries with native SQL pushdown and Mongo in-memory federation |
|
|
141
|
+
| Audit-friendly CRUD | Every schema auto-gets a `<Model>Deleted` archive table/collection |
|
|
142
|
+
|
|
143
|
+
## When not to use it
|
|
144
|
+
|
|
145
|
+
Being explicit about the boundary saves you time:
|
|
146
|
+
|
|
147
|
+
- **You want a full ORM with a migration engine (Alembic, Django migrations).** `py-store` is a *data layer*, not a migration tool. It can **read** a SQL backend's physical structure (`sync_schema` → introspection) but it never writes DDL back.
|
|
148
|
+
- **You want a Pydantic-model-centric ORM.** Schemas here are runtime JSON dicts, giving you cross-language parity (the same schema runs in Python and Node.js) rather than Pydantic type validation.
|
|
149
|
+
- **You only ever use one database and rarely join.** A plain driver (or a single-database ODM/ORM) will be simpler.
|
|
150
|
+
- **You need raw aggregation escape hatches.** `$pipeline` passthrough and `store.aggregate()` were deliberately removed. Use `$condition` / `$group` / `$having` / relations; anything that cannot be safely translated fails **explicitly** rather than silently.
|
|
151
|
+
|
|
152
|
+
## How it compares
|
|
153
|
+
|
|
154
|
+
General positioning, not a benchmark — always verify against each tool's current docs.
|
|
155
|
+
|
|
156
|
+
| | py-store | SQLAlchemy | Beanie / Motor | Tortoise ORM | SQLModel | Django ORM |
|
|
157
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
158
|
+
| Primary shape | JSON schema + GQL data layer | SQL toolkit + ORM | Async MongoDB ODM / driver | Async ORM | Pydantic + SQLAlchemy | ORM bundled with Django |
|
|
159
|
+
| Backends | MongoDB, MySQL, SQLite, PostgreSQL | PostgreSQL, MySQL, SQLite, Oracle, MSSQL | MongoDB | PostgreSQL, MySQL, SQLite, Oracle, MSSQL | PostgreSQL, MySQL, SQLite, … | PostgreSQL, MySQL, SQLite, Oracle |
|
|
160
|
+
| One query dialect across Mongo **and** SQL | ✅ (MongoDB-flavoured GQL) | ➖ (SQL only) | ➖ (Mongo only) | ➖ (SQL only) | ➖ (SQL only) | ➖ (SQL only) |
|
|
161
|
+
| Nested relation reads in one query | ✅ declarative relations → `$lookup` / `JOIN` | ⚠️ manual `selectinload`/joins | ✅ `Link`/`fetch_links` | ✅ `prefetch_related` | ⚠️ via SQLAlchemy | ✅ `prefetch_related` |
|
|
162
|
+
| Built-in role / field-level RBAC + owner injection | ✅ | ➖ | ➖ | ➖ | ➖ | ➖ (permissions are app-level) |
|
|
163
|
+
| Read-time computed columns (sync / async / relation-agg) | ✅ | ➖ (hybrid properties) | ➖ | ➖ | ➖ | ➖ |
|
|
164
|
+
| Soft-delete archive table auto-provisioned | ✅ | ➖ | ➖ | ➖ | ➖ | ➖ |
|
|
165
|
+
| Migration / DDL engine | ➖ (introspection read-only) | ✅ (Alembic) | ➖ | ✅ (Aerich) | ✅ (Alembic) | ✅ |
|
|
166
|
+
| Framework coupling | none (asyncio) | none | none | none | none | Django |
|
|
167
|
+
| Shared native core across Python & Node | ✅ (Rust `rust-store`) | ➖ | ➖ | ➖ | ➖ | ➖ |
|
|
168
|
+
|
|
169
|
+
### How it differs from specific libraries
|
|
170
|
+
|
|
171
|
+
Positioning only, based on those projects' public documentation at the time of writing — verify against your own requirements.
|
|
172
|
+
|
|
173
|
+
- **vs SQLAlchemy / SQLModel / Django ORM** — all SQL-only and model-class-centric: they do not target MongoDB, and none of them ships schema-declared role/field access control or read-time computed columns. `py-store` compiles one GQL to native MongoDB aggregation or to parameterized SQL.
|
|
174
|
+
- **vs Beanie / Motor** — MongoDB-only. `py-store` uses the same MongoDB-flavoured query style but the identical query also runs on MySQL, SQLite and PostgreSQL.
|
|
175
|
+
- **vs Tortoise ORM / pyloquent** — async Python ORMs over SQL backends, with model classes and (in Tortoise's case) a migration tool. `py-store` has no migration engine — introspection reads physical structure only — and describes models as plain dicts, which is exactly what makes a schema portable to the Node.js host.
|
|
176
|
+
- **vs `nodejs-store`** — the same engine and the same GQL, in JavaScript. Use whichever host matches your service; schemas and query semantics are interchangeable.
|
|
177
|
+
|
|
178
|
+
Short version: use an ORM when you want **model classes, Pydantic validation and migrations**; use `py-store` when you want **one runtime schema + one query dialect spanning MongoDB and SQL**, with RBAC and computed columns built in.
|
|
179
|
+
|
|
180
|
+
## Installation
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
pip install storepy
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
> The distribution name is `storepy`; the import package is `py_store`:
|
|
187
|
+
> `from py_store import init, store`.
|
|
188
|
+
|
|
189
|
+
Requires Python 3.10+ and one supported backend (MongoDB / MySQL / SQLite / PostgreSQL).
|
|
190
|
+
|
|
191
|
+
Optional driver extras:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
pip install "storepy[mysql]" # asyncmy
|
|
195
|
+
pip install "storepy[postgres]" # asyncpg
|
|
196
|
+
pip install "storepy[sqlite]" # aiosqlite
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Quick start
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from pymongo import AsyncMongoClient
|
|
203
|
+
from py_store import init, store
|
|
204
|
+
|
|
205
|
+
client = AsyncMongoClient("mongodb://localhost:27017")
|
|
206
|
+
await init(client["mydb"]) # idempotently creates indexes for registered schemas
|
|
207
|
+
|
|
208
|
+
# Register a schema (pure JSON)
|
|
209
|
+
store.register({
|
|
210
|
+
"name": "Post", # model name used in GQL
|
|
211
|
+
"collection": "posts", # optional, defaults to name
|
|
212
|
+
"idPrefix": "PT", # string _id: prefix + base36 timestamp + random
|
|
213
|
+
"fields": {
|
|
214
|
+
"title": {"type": "string", "default": ""},
|
|
215
|
+
"status": {"type": "string", "default": "draft"},
|
|
216
|
+
"tags": {"type": "array", "default": []},
|
|
217
|
+
},
|
|
218
|
+
"computes": {
|
|
219
|
+
"statusLabel": {"type": "string", "depends": ["status"],
|
|
220
|
+
"fn": lambda doc: doc["status"].upper()},
|
|
221
|
+
},
|
|
222
|
+
"indexes": [{"keys": {"status": 1, "createdAt": -1}}],
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
# Write — only user data; defaults are filled on read
|
|
226
|
+
doc = await store.insert("Post", {"title": "Hello"})
|
|
227
|
+
|
|
228
|
+
# Query — GQL tree syntax, values referenced from params via @key
|
|
229
|
+
items = await store.query(
|
|
230
|
+
"Post($condition:@c0,$sort:@s1,$limit:@l) { title, status, statusLabel }",
|
|
231
|
+
{"c0": {"status": "draft"}, "s1": {"createdAt": -1}, "l": 20},
|
|
232
|
+
)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The same schema and the same query run unchanged against PostgreSQL — only the `init()` datasource changes:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
await init({"default": {"kind": "postgres", "exec": exec}})
|
|
239
|
+
items = await store.query("Post($condition:@c0) { title, status }", {"c0": {"status": "draft"}})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Supported backends
|
|
243
|
+
|
|
244
|
+
| Backend | Notes |
|
|
245
|
+
| --- | --- |
|
|
246
|
+
| MongoDB | native aggregation pipeline (`find`/`aggregate`/`$lookup`), PyMongo `AsyncMongoClient` (pymongo >= 4.9) |
|
|
247
|
+
| MySQL | parameterized SQL, `information_schema` introspection (`asyncmy`) |
|
|
248
|
+
| SQLite | parameterized SQL, `sqlite_master` + `PRAGMA` introspection (`aiosqlite`) |
|
|
249
|
+
| PostgreSQL | parameterized SQL (`$n`), `RETURNING` support (`asyncpg`) |
|
|
250
|
+
|
|
251
|
+
GQL tree queries compile to a single native query per backend — never hand-write `$lookup` or raw SQL again.
|
|
252
|
+
|
|
253
|
+
## Features
|
|
254
|
+
|
|
255
|
+
- **Pure JSON schemas, zero code** — a model is just a dict: fields, relations, computes, indexes.
|
|
256
|
+
- **GQL tree queries → one native query** — nested relations resolve in a single query; never hand-write `$lookup` again.
|
|
257
|
+
- **Normalized aggregation** — root-level `$group` / `$having` and relation aggregate predicates (semi/anti-join) in the same GQL, pushed down to all four backends.
|
|
258
|
+
- **Read-time defaults & computed columns** — writes store only user data; reads fill defaults and run `fn` / `asyncFn` / relation-`agg` computes.
|
|
259
|
+
- **Smart mutation** — `mutation()` auto-detects upsert by `_id` + unique index and recursively fills relation children.
|
|
260
|
+
- **Soft-delete built in** — every schema auto-registers a `<Model>Deleted` archive collection/table; `remove()` archives before deleting.
|
|
261
|
+
- **Permission context** — `ContextVar`-based roles (`super_admin`/`admin`/`guest`/`creator`...), schema/field-level read/write whitelists, automatic owner-condition injection.
|
|
262
|
+
- **Multi-datasource & multi-tenant** — locate a schema by `(source, namespace, collection)`; re-target per request with a route override.
|
|
263
|
+
- **Async-first, Rust core** — built on PyMongo's `AsyncMongoClient` and a shared Rust core with SQL dialects.
|
|
264
|
+
|
|
265
|
+
## GQL syntax
|
|
266
|
+
|
|
267
|
+
```text
|
|
268
|
+
Model($condition:@c0,$sort:@s1,$skip:@sk,$limit:@l1) {
|
|
269
|
+
field1, field2, obj.subField,
|
|
270
|
+
Relation($condition:@c2,$sort:@s3,$limit:@l2) { f3, Nested { f4 } }
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
- Values come from the params dict: `{"c0": {...}, "s1": {...}}`.
|
|
275
|
+
- Object sub-fields use dot notation; relations are declared in the schema (`type: "many" | "one"`) and resolved automatically — **do not hand-write `$lookup`**.
|
|
276
|
+
- `many` relations return lists (`[]` when empty); `one` relations merge into the parent document (`None` when missing).
|
|
277
|
+
- Relation-level `$sort`/`$skip`/`$limit` are **per-parent top-N** (each parent gets its own window; translated to a window function on SQL).
|
|
278
|
+
|
|
279
|
+
> **Breaking change**: user `$pipeline` passthrough and `store.aggregate()` were removed (raw aggregation escape hatch). A GQL containing `$pipeline` now fails explicitly instead of being silently ignored.
|
|
280
|
+
|
|
281
|
+
## Aggregation
|
|
282
|
+
|
|
283
|
+
Normalized aggregation lives **inside GQL** — no separate API, no raw pipeline.
|
|
284
|
+
|
|
285
|
+
**Root-level `$group` + `$having`** (GROUP BY / HAVING):
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
rows = await store.query(
|
|
289
|
+
"Course($condition:@c0,$group:@g0,$having:@h0,$sort:@s0,$limit:@l0){ status, n, total }",
|
|
290
|
+
{
|
|
291
|
+
"c0": {"status": {"$ne": "deleted"}},
|
|
292
|
+
"g0": {"by": ["status"], "agg": {"n": {"$count": "*"}, "total": {"$sum": "price"}}},
|
|
293
|
+
"h0": {"n": {"$gt": 1}},
|
|
294
|
+
"s0": {"total": -1},
|
|
295
|
+
"l0": 20,
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
- Whitelisted operators: `$count` / `$sum` / `$avg` / `$min` / `$max`.
|
|
301
|
+
- Fixed execution order: `$condition` (WHERE) → `$group` (GROUP BY) → `$having` (HAVING) → `$sort` → `$skip`/`$limit` → projection.
|
|
302
|
+
- Omit `by` (or pass `[]`) for a single all-table group; the empty-input case still returns one row (`$count` → `0`, others → `None`).
|
|
303
|
+
|
|
304
|
+
**Relation aggregate predicates (semi / anti-join)** — filter parents by an aggregate over a relation, without fanning out:
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
await store.query("Product($condition:@c0,$sort:@s0){ _id, name }", {
|
|
308
|
+
"c0": {
|
|
309
|
+
"$and": [
|
|
310
|
+
{"status": "onSale"},
|
|
311
|
+
{"orders": {"$count": {"$gt": 3}}}, # has > 3 orders
|
|
312
|
+
{"$not": {"orders": {"$sum": {"$of": "amount", "$gt": 10000}}}}, # not a whale
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
"s0": {"name": 1},
|
|
316
|
+
})
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Translates to `EXISTS` / `NOT EXISTS` on SQL and `$lookup` + `$match` on MongoDB.
|
|
320
|
+
|
|
321
|
+
**Relation-rolling computed columns** — declare once in the schema, request by name:
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
"computes": {
|
|
325
|
+
"itemCount": {"type": "int", "agg": {"$count": "items"}}, # 0 when empty
|
|
326
|
+
"itemsTotal": {"type": "float", "agg": {"$sum": "items.qty"}}, # None when empty
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Query & write API
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
items = await store.query(gql, params) # list[dict]
|
|
334
|
+
one = await store.query_one(gql, params) # dict | None
|
|
335
|
+
page = await store.query_with_count(gql, params) # {'items','total','hasMore','page','pageSize'} (pageSize capped at 5000)
|
|
336
|
+
exists = await store.exists("Post", {"_id": pid})
|
|
337
|
+
n = await store.count("Post", {"status": "active"})
|
|
338
|
+
|
|
339
|
+
doc = await store.insert("Post", {...}) # auto _id / createdAt / updatedAt
|
|
340
|
+
docs = await store.insert_many("Post", [{...}, ...])
|
|
341
|
+
await store.update("Post", {"_id": pid}, {"status": "live"}) # plain fields → $set
|
|
342
|
+
await store.update("Post", {"_id": pid}, {"$inc": {"views": 1}}) # '$'-prefixed keys pass through as operators
|
|
343
|
+
await store.update_many("Post", {"type": t}, {"status": "live"})
|
|
344
|
+
r = await store.remove("Post", {"_id": pid}) # archives to <collection>_deleted first
|
|
345
|
+
await store.mutation("Post", {...}) # smart upsert + recursive relation children
|
|
346
|
+
await store.upsert("Post", {"code": "A1"}, {...}) # explicit-condition upsert (no relation handling)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Notes:
|
|
350
|
+
|
|
351
|
+
- `None` values are stripped before persisting; `_id` cannot be changed via `update`.
|
|
352
|
+
- `createdAt`/`updatedAt` are framework-maintained — do not set them manually. Unit follows the schema's `timestamps` setting: milliseconds by default, or seconds when `timestamps: "s"`.
|
|
353
|
+
- `update_many` / `remove` with an **empty condition** (`{}`, `None`, `{"$and": []}`) is rejected outright — it never falls through to a full-table write.
|
|
354
|
+
- Snake-case aliases available: `query_one`, `insert_many`, `update_many`, `build_pipeline`, ...
|
|
355
|
+
|
|
356
|
+
## Multi-datasource connections
|
|
357
|
+
|
|
358
|
+
Every schema is located by the triple `(source, namespace, collection)` — the triple must be
|
|
359
|
+
globally unique across the registry (duplicate registration raises instead of silently
|
|
360
|
+
mis-routing).
|
|
361
|
+
|
|
362
|
+
- `source` — connection key in `init({...})` (default `"default"`).
|
|
363
|
+
- `namespace` — database/schema inside the connection: Mongo db name, PG schema,
|
|
364
|
+
MySQL database, SQLite attached db. Optional; `None` = connection default.
|
|
365
|
+
- `collection` — table/collection name.
|
|
366
|
+
|
|
367
|
+
```python
|
|
368
|
+
# Multiple Mongo servers: one source per connection
|
|
369
|
+
await init({"mongo_main": db, "pg_a": {"kind": "postgres", "exec": exec}})
|
|
370
|
+
|
|
371
|
+
# Same MongoClient serving multiple databases: declare namespace (db name)
|
|
372
|
+
await init({"cluster": client})
|
|
373
|
+
store.register({"name": "User", "collection": "users", "datasource": "cluster",
|
|
374
|
+
"namespace": "tenant_42", ...})
|
|
375
|
+
|
|
376
|
+
# SQL cross-namespace joins are pushed down natively ("ns_a"."t" JOIN "ns_b"."t");
|
|
377
|
+
# only Mongo cross-db relations fall back to in-memory federation.
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
**Multi-tenant route override** — one schema definition, N tenants. Any query/write accepts
|
|
381
|
+
a `{ "source", "namespace" }` override that re-targets commands at execution time
|
|
382
|
+
(permissions and computed columns still follow the structural schema):
|
|
383
|
+
|
|
384
|
+
```python
|
|
385
|
+
await store.query('User($condition:@c0){...}', params, {"namespace": "tenant_42"})
|
|
386
|
+
await store.insert("Order", data, {"source": "pg_cluster", "namespace": "tenant_7"})
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**`route_override` is a trusted server-side parameter** — it carries no origin check, so
|
|
390
|
+
forwarding user-controlled input into it lets a caller re-target another tenant's
|
|
391
|
+
`source`/`namespace` (CWE-639 authorization-bypass surface). Never pass raw request data here.
|
|
392
|
+
|
|
393
|
+
Legacy single-db usage (`init(db)` + schema without `datasource`/`namespace`) is unchanged:
|
|
394
|
+
commands carry `source: "default"`, `namespace: None`.
|
|
395
|
+
|
|
396
|
+
## Permission context
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
# Set once per request (in router/dependency layer)
|
|
400
|
+
store.set_context({"userId": uid, "roles": ["editor"]})
|
|
401
|
+
|
|
402
|
+
# Internal/cron jobs — bypass permission checks
|
|
403
|
+
await store.run_as_internal(lambda: store.remove("Post", {"_id": pid}))
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
- `super_admin`/`admin`/`internal` roles pass everything; other roles are checked against schema-level and field-level `read`/`write` whitelists; `guest` can never write.
|
|
407
|
+
- `creator` is a pseudo-role resolved by `doc.createdBy == ctx.userId`; schemas granting it automatically get owner conditions injected on queries and ownership checks on update/remove.
|
|
408
|
+
- No context set → permission checks disabled (backward compatible).
|
|
409
|
+
- Denied access raises `store.PermissionError` (with `status = 403`).
|
|
410
|
+
|
|
411
|
+
### Fail-secure mode (opt-in)
|
|
412
|
+
|
|
413
|
+
"No context" can mean both *system call* and *caller forgot the context* — by default the
|
|
414
|
+
latter silently passes every check (fail-open, kept for backward compatibility). For
|
|
415
|
+
security-sensitive hosts, enable the context requirement once at startup:
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
store.set_require_context(True)
|
|
419
|
+
# now every query/write without a context raises `ERR_NO_CONTEXT:...`
|
|
420
|
+
# internal jobs must be explicit:
|
|
421
|
+
await store.run_as_internal(lambda: store.remove("Post", {"_id": pid}))
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
`run_as_internal` marks the call as `{"internal": True}`, which is semantically distinct
|
|
425
|
+
from a missing context and always passes. `set_require_context(False)` restores the default.
|
|
426
|
+
|
|
427
|
+
## Feedback events
|
|
428
|
+
|
|
429
|
+
Degraded / pushdown-rejection paths never fail silently — they emit a structured event:
|
|
430
|
+
|
|
431
|
+
```python
|
|
432
|
+
{type, code, layer, message, hint, ...} # federation_degraded / sql_pushdown_unsupported / ...
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
- Default sink prints to stderr; hosts (e.g. AI data-QA services) can take over for automated feedback loops:
|
|
436
|
+
|
|
437
|
+
```python
|
|
438
|
+
store.set_feedback_sink(lambda event: log.warning("store feedback: %s", event))
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
- SQL pushdown rejection raises `PushdownUnsupportedError` (a `RuntimeError`) **and** emits the event; catch it to re-run that segment on a Mongo source.
|
|
442
|
+
|
|
443
|
+
## Schema reference
|
|
444
|
+
|
|
445
|
+
```python
|
|
446
|
+
{
|
|
447
|
+
"name": "Order",
|
|
448
|
+
"collection": "orders",
|
|
449
|
+
"idPrefix": "OD",
|
|
450
|
+
"timestamps": True, # True (ms, default) | False | "ms" | "s" (seconds); auto-maintain createdAt/updatedAt
|
|
451
|
+
"fields": {
|
|
452
|
+
"_id": "string", # shorthand
|
|
453
|
+
"title": {"type": "string", "default": ""},
|
|
454
|
+
"meta": {"type": "object", "default": {}, "fields": {...}}, # nested object fields
|
|
455
|
+
},
|
|
456
|
+
"relations": {
|
|
457
|
+
"items": {"model": "OrderItem", "type": "many",
|
|
458
|
+
"localField": "_id", "foreignField": "orderId"},
|
|
459
|
+
},
|
|
460
|
+
"computes": {
|
|
461
|
+
"total": {"type": "float", "depends": ["amount"], "fn": lambda d: d["amount"] * 1.1},
|
|
462
|
+
"itemCount": {"type": "int", "agg": {"$count": "items"}},
|
|
463
|
+
},
|
|
464
|
+
"indexes": [
|
|
465
|
+
{"keys": {"status": 1}},
|
|
466
|
+
{"keys": {"code": 1}, "options": {"unique": True}},
|
|
467
|
+
],
|
|
468
|
+
"read": ["editor", "viewer"], # optional schema-level role whitelists
|
|
469
|
+
"write": ["editor"],
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Types: `string | int | long | float | double | boolean | array | object | date | any`.
|
|
474
|
+
|
|
475
|
+
Boundary rules worth knowing up front (all **fail explicitly**, never silently degrade):
|
|
476
|
+
|
|
477
|
+
- Filtering on array fields directly, on a whole object field, or on object dot-paths is rejected on every backend — model cross-entity semantics as `relations` instead.
|
|
478
|
+
- Relation predicates support **one level** of relation; paths like `orders.items.price` are rejected.
|
|
479
|
+
- An unreadable relation is an error, not a silent `False`.
|
|
480
|
+
|
|
481
|
+
## Transaction boundary
|
|
482
|
+
|
|
483
|
+
- **Single SQL source**: `mutation` parent-child step sequences and `remove` (archive + delete) run inside one driver transaction on one checked-out connection — any step failure rolls back the whole sequence.
|
|
484
|
+
- **Each SQL write command** is itself atomic: multi-statement plans (e.g. MySQL write + readback) are transaction-wrapped in the executor.
|
|
485
|
+
- **Mongo sources**: single-document writes are atomic; multi-step `mutation` and `remove` execute sequentially and are **not** atomic across steps (Mongo transactions require a replica set). If your consistency requirement spans steps on Mongo, either use an SQL source for those models or add application-level compensation.
|
|
486
|
+
- **Archive idempotency**: `remove` archives with upsert-by-`_id` semantics, so a retry after partial failure no longer fails on duplicate `_id`.
|
|
487
|
+
- **Cross-source steps** (parent and child bound to different datasources) cannot be atomic — they run sequentially by design.
|
|
488
|
+
|
|
489
|
+
## FAQ
|
|
490
|
+
|
|
491
|
+
**How do I use one schema for both MongoDB and PostgreSQL in Python?**
|
|
492
|
+
Define the schema once as a dict, call `init()` with your datasource(s), and run the same GQL against either. MongoDB uses native aggregation; MySQL/PostgreSQL/SQLite get parameterized SQL. See [Quick start](#quick-start).
|
|
493
|
+
|
|
494
|
+
**How do I query nested / related data without writing `$lookup` or JOINs?**
|
|
495
|
+
Declare the relation in `relations` (`{"model", "type": "many" | "one", "localField", "foreignField"}`) and reference the relation name inside the GQL selection set. It becomes `$lookup` on Mongo and a `JOIN` on SQL, returned as nested documents.
|
|
496
|
+
|
|
497
|
+
**Does it support GROUP BY / COUNT / SUM / AVG?**
|
|
498
|
+
Yes — normalized aggregation is part of GQL: root-level `$group` / `$having` and relation aggregate predicates. See [Aggregation](#aggregation).
|
|
499
|
+
|
|
500
|
+
**Can I filter parents by an aggregate of their children ("products with more than 3 orders")?**
|
|
501
|
+
Yes — relation aggregate predicates implement semi/anti-join without fanning out; SQL uses `EXISTS`/`NOT EXISTS`.
|
|
502
|
+
|
|
503
|
+
**How do I implement row-level permissions?**
|
|
504
|
+
Use `store.set_context({"userId": ..., "roles": [...]})` plus schema-level `read`/`write` whitelists. The `creator` pseudo-role adds automatic ownership checks and owner-condition injection. `guest` can never write. Turn on `set_require_context(True)` for fail-secure behaviour.
|
|
505
|
+
|
|
506
|
+
**How do I do soft delete?**
|
|
507
|
+
Every registered model automatically gets a `<Model>Deleted` archive collection/table. `store.remove()` archives the document first, then deletes it; re-creating the same `_id` does not collide because the archive write is upsert-by-`_id`.
|
|
508
|
+
|
|
509
|
+
**Is it usable for multi-tenant applications?**
|
|
510
|
+
Yes. Bind a schema to `(source, namespace, collection)` and pass a `{"source", "namespace"}` route override per request. Treat `route_override` as trusted server-side input only.
|
|
511
|
+
|
|
512
|
+
**Does it run migrations?**
|
|
513
|
+
No. `sync_schema()` only *reads* physical structure via introspection (introspect → merge overlay → register). Schema changes / DDL are your migration tool's job (Alembic, etc.).
|
|
514
|
+
|
|
515
|
+
**Can I see the generated query without running it?**
|
|
516
|
+
Yes — `store.build_pipeline(gql, params)` returns the compiled plan with no execution and no permission/compute application.
|
|
517
|
+
|
|
518
|
+
**What happens when SQL pushdown isn't possible?**
|
|
519
|
+
The command raises `PushdownUnsupportedError` **and** emits a structured feedback event (`sql_pushdown_unsupported`) through `set_feedback_sink`. Cross-source pagination/sort degradations emit `federation_degraded` events. Nothing fails silently.
|
|
520
|
+
|
|
521
|
+
**How is it related to nodejs-store and rust-store?**
|
|
522
|
+
`rust-store` is the shared Rust engine (GQL parsing, permissions, computed columns, command planning, SQL dialect translation — pure logic, no IO). `py-store` (pip `storepy`) and [`nodejs-store`](https://github.com/coenddt/nodejs-store) are thin hosts in front of it: they own driver IO, callbacks and placeholder substitution. Same schemas, same GQL, same semantics in Python and Node.
|
|
523
|
+
|
|
524
|
+
**Why is the pip package called `storepy` and the import `py_store`?**
|
|
525
|
+
The distribution name on PyPI is `storepy`; the importable package is `py_store`. Install with `pip install storepy`, then `from py_store import init, store`.
|
|
526
|
+
|
|
527
|
+
## Development
|
|
528
|
+
|
|
529
|
+
```bash
|
|
530
|
+
# run the full suite from the repo root (e2e cases auto-skip when MySQL/PG/Mongo are unreachable)
|
|
531
|
+
$env:PYTHONPATH='py-store/src'; python -m pytest py-store/tests/ -q
|
|
532
|
+
|
|
533
|
+
# against custom backends
|
|
534
|
+
$env:MYSQL_URI='mysql://user:pass@host:3306/db'; $env:PG_URI='postgres://user:pass@host:5432/db'; $env:MONGO_URI='mongodb://host:27017/db'
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
- Unit/contract suites (`test_py_store.py`, `test_host_contract.py`, `test_multi_datasource.py`) need no external services.
|
|
538
|
+
- The Rust core (`GQL parsing / planning / dialect`) lives in `../rust-store/core` and is consumed via the `rust-store-py` binding — pure logic never lives in this repo.
|
|
539
|
+
- `src/py_store/` is a thin Host layer: driver IO, callbacks, placeholder substitution. Keep it that way.
|
|
540
|
+
|
|
541
|
+
## Related projects
|
|
542
|
+
|
|
543
|
+
- [`nodejs-store`](https://github.com/coenddt/nodejs-store) — the Node.js twin (npm `nodejs-store`, camelCase API).
|
|
544
|
+
- [`rust-store`](https://github.com/coenddt/rust-store) — the shared Rust core and its `rust-store-node` / `rust-store-py` bindings.
|
|
545
|
+
- `text-to-query` — a skill that turns natural-language questions into GQL + params for this data layer.
|
|
546
|
+
|
|
547
|
+
## License
|
|
548
|
+
|
|
549
|
+
[MIT](LICENSE)
|