diff --git a/Cargo.lock b/Cargo.lock
index e7ee9cc..b163d8e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -46,12 +46,56 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -377,6 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
+ "clap_derive",
]
[[package]]
@@ -385,8 +430,22 @@ version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
+ "anstream",
"anstyle",
"clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
]
[[package]]
@@ -395,6 +454,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
[[package]]
name = "combine"
version = "4.6.7"
@@ -1097,6 +1162,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
[[package]]
name = "itertools"
version = "0.10.5"
@@ -1320,6 +1391,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
[[package]]
name = "oorandom"
version = "11.1.5"
@@ -1859,6 +1936,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
+ "clap",
"criterion",
"once_cell",
"redis",
@@ -2304,6 +2382,12 @@ dependencies = [
"unicode-properties",
]
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
[[package]]
name = "subtle"
version = "2.6.1"
@@ -2633,6 +2717,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
[[package]]
name = "uuid"
version = "1.23.1"
diff --git a/Cargo.toml b/Cargo.toml
index cd58380..2212165 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -78,6 +78,9 @@ once_cell = "1"
toml = "0.8"
serde_yaml = "0.9"
+# CLI (ryx-rs binary)
+clap = { version = "4", features = ["derive"] }
+
# tracing: structured, async-aware logging. We instrument every SQL execution
# so users can enable RUST_LOG=ryx=debug for full query visibility.
tracing = "0.1"
diff --git a/README.md b/README.md
index 1061dd2..2c12999 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
Ryx ORM
- Django-style ORM — Python and Rust. Powered by Rust.
+ Django-style ORM for Python and Rust. Powered by a shared Rust SQL engine.
@@ -28,7 +28,7 @@
```python
import ryx
-from ryx import Model, CharField, Q
+from ryx import Model, BooleanField, CharField, IntField, Q
class Post(Model):
title = CharField(max_length=200)
@@ -41,6 +41,7 @@ posts = await Post.objects.filter(Q(active=True) | Q(views__gte=1000))
```rust
use ryx_rs::model;
+use ryx_rs::{ObjectsManager, Q};
#[model]
struct Post {
@@ -50,7 +51,8 @@ struct Post {
active: bool,
}
-let posts = Post::objects()
+let posts = ObjectsManager::::new()
+ .all()
.filter(Q::or(Q::new("active", true), Q::new("views__gte", 1000)))
.all().await?;
```
@@ -68,6 +70,26 @@ Full docs, guides, API reference: **[ryx.alldotpy.com](https://ryx.alldotpy.com)
- [Python quick start](https://ryx.alldotpy.com/getting-started/quick-start)
- [Rust quick start](https://ryx.alldotpy.com/getting-started/installation)
+- [API reference](https://ryx.alldotpy.com/reference/api-reference)
+- [CLI](https://ryx.alldotpy.com/advanced/cli)
+- [Configuration and routing](https://ryx.alldotpy.com/advanced/configuration-and-routing)
+
+## Feature Map
+
+| Area | Python API | Rust API |
+|---|---|---|
+| **Models** | `Model`, `Field`, `Meta`, hooks | `#[model]`, `Model`, `FromRow`, metadata |
+| **Fields** | Auto, integer, numeric, text, date/time, JSON, array, binary, relation fields | Macro-derived field metadata |
+| **Queries** | Lazy async `QuerySet`, `Q`, lookups, transforms, joins, values, annotations | `QuerySet`, `Q`, lookups, values, annotations |
+| **CRUD** | `create`, `save`, `get`, `first`, `count`, `update`, `delete`, `refresh_from_db` | `InsertBuilder`, `all`, `get`, `first`, `count`, `update`, `delete` |
+| **Bulk/streaming** | `bulk_create`, `bulk_update`, `bulk_delete`, chunked/keyset stream | streaming chunks through `QueryStream` |
+| **Transactions** | `async with transaction()` with savepoints | `transaction(|tx| async move { ... })` |
+| **Migrations** | autodetect, DDL generation, runner, CLI | state diffing, DDL generation, runner |
+| **Multi-db** | aliases, `.using()`, `Meta.database`, router, env/config auto-init | aliases, `.using()`, config init |
+| **PostgreSQL schemas** | `.schema()`, schema-aware migrations | `.schema()` |
+| **Caching** | `MemoryCache`, `QuerySet.cache()`, invalidation helpers | cache backend and cached queryset |
+| **Signals/hooks** | pre/post save/delete/update and model hooks | not part of Rust surface |
+| **Raw SQL** | raw fetch/execute and parameterized helpers | backend/query crates |
## Comparison
@@ -76,8 +98,9 @@ Full docs, guides, API reference: **[ryx.alldotpy.com](https://ryx.alldotpy.com)
| **API style** | Schema-first | Verbose builders | **Django-like** |
| **Q objects (OR/AND/NOT)** | ❌ | ❌ | ✅ |
| **Lookups** | Basic | Basic | **30+** |
-| **select_related** | ❌ | ✅ (Eager) | ✅ |
+| **select_related** | ❌ | ✅ (Eager) | Rust API; Python currently uses explicit `.join()` |
| **Migrations** | Diesel CLI | sea-orm-cli | **Built-in** |
+| **PostgreSQL schemas** | ❌ | ❌ | ✅ |
| **Backends** | PG · MySQL · SQLite | PG · MySQL · SQLite | **PG · MySQL · SQLite** |
## Architecture
diff --git a/RYK.md b/RYK.md
new file mode 100644
index 0000000..b0a71c9
--- /dev/null
+++ b/RYK.md
@@ -0,0 +1,106 @@
+## Goal
+- Complete multi-schema support for PostgreSQL across migration, query, and CLI layers in both Rust and Python, then add comprehensive tests.
+
+## Constraints & Preferences
+- Models are **schema-agnostic** — no `#[schema]` on models. Schema is specified at query time (`.schema("name")` on QuerySet) or migration time (`.schema("name")` on runner).
+- `schema=""` (default) means the backend's default schema (public for PG, ignored for MySQL/SQLite). No qualification in SQL when unset → 100% backward compat.
+- Composite key `(schema, table_name)` everywhere: `diff_states`, introspection, DDL generation.
+- `CREATE SCHEMA IF NOT EXISTS` is automatic in the runner (when schema is non-empty and backend supports it).
+- Schema support is **PostgreSQL only** — MySQL/SQLite treat schema field as no-op (`supports_schemas()` returns `false` for non-PG).
+- Performance: `Option` in QueryNode (not `Option`), `supports_schemas()` is a `const fn`, qualification done once per SQL compile (not per row).
+- Rust `gen` is reserved in edition 2024 — use `ddl_gen` instead of `gen` as variable name.
+
+## Progress
+### Done
+- **Rust — Backend.supports_schemas()** added to `ryx-query/src/backend.rs` as a `const fn`.
+- **Rust — TableState.schema**, **SchemaChange.schema**, **ChangeKind::CreateSchema** — all core types updated. `diff_states()` rewritten with composite key `(schema, name)`.
+- **Rust — DDLGenerator.schema** with `in_schema()` builder, `qn()` helper, `create_schema()`. All DDL methods (`create_table`, `drop_table`, `add_column`, `drop_column`, `alter_column`, `create_index`, `drop_index`, `add_check_constraint`, `add_foreign_key`) use `self.qn()`. `generate()` handles `CreateSchema` in zeroth pass.
+- **Rust — Operations** (`operations.rs`): `CreateSchema` variant, `schema: String` on all table operations, `schema()` accessor.
+- **Rust — Autodetect** (`autodetect.rs`): `ModelEntry.schema`, `build_target()` schema apply, `changes_to_operations()` emits `CreateSchema` + schema pass-through.
+- **Rust — Runner** (`runner.rs`): `FileRunner.schema`, `.schema()` builder, schema-aware `run_live()`, `plan_live()`, `apply_files()`, schema override per operation in `apply_files()`/`plan_files()`. `operation_to_sql()` handles `CreateSchema`.
+- **Rust — Introspection**: PostgreSQL uses `table_schema = '{schema}'`, MySQL/SQLite set `schema: ""`.
+- **Rust — Query layer** (`ryx-query/src/ast.rs`): `QueryNode.schema` (`Option`), `with_schema()` builder. Compiler (`compilr.rs`): `write_table_ref()` / `write_join_table_ref()` helpers, all compile functions (`SELECT`, `COUNT`, `DELETE`, `UPDATE`, `INSERT`, `JOIN`) use schema qualification. Plan hash includes `node.schema`.
+- **Rust — QuerySet** (`ryx-rs/src/queryset.rs`): `.schema()` builder method.
+- **Rust — CLI** (`ryx-rs/src/main.rs`): `--schema` flag on `Migrate` and `Sqlmigrate` subcommands, passed to `FileRunner.schema()` and `DDLGenerator.in_schema()`.
+- **Python — state.py**: `TableState.schema`, `ChangeKind.CREATE_SCHEMA`, `SchemaChange.schema`, `diff_states()` composite key + CreateSchema detection. `to_json()`/`from_json()` schema-aware.
+- **Python — ddl.py**: `DDLGenerator.schema` parameter, `_qn()` helper, `create_schema()`. All DDL methods use `_qn()` for table references. `generate_schema_ddl()` creates per-table schema DDLGenerator.
+- **Python — autodetect.py**: `schema: str` on all operation types (`CreateTable`, `AddField`, `AlterField`, `CreateIndex`), `to_python()` serialization includes schema, `apply_migration_to_state()` preserves schema on tables, `_changes_to_operations()` passes schema from changes.
+- **Python — runner.py**: `_schema` on `MigrationRunner`, `.schema()` builder, `_create_ddl()` helper, schema-aware `_operation_to_ddl()`, `_ddl_for_change()`, `_introspect_schema()`, `_get_tables()`, `_get_columns()`, `_apply_meta_extras()`, `_ensure_m2m_table()`.
+- **Python — query layer**: `QuerySet.schema()` method adds `("schema", name)` op to ops list. Rust `build_plan()` in `ryx-python/src/plan.rs` handles `"schema"` op via `node.with_schema()`.
+- **Python — CLI**: `--schema` flag on `migrate`, `sqlmigrate`, `inspectdb` commands in `ryx-python/ryx/__main__.py`, `ryx-python/ryx/cli/commands/migrate.py`, `sqlmigrate.py`, `inspectdb.py`.
+- **Rust unit tests** — **30 new multi-schema tests added** (75 unit total):
+ - migration.rs: 7 multi-schema diff tests (CreateSchema detection, same table different schemas, empty/noop/mixed)
+ - ddl.rs: 20 schema-qualified DDL tests (create_schema, create_table, alter, drop, index, FK, constraint, MySQL/SQLite ignore, backward compat)
+ - operations.rs: 7 operation schema tests (all variants)
+ - compilr.rs: 12 schema query tests (SELECT/COUNT/DELETE/UPDATE/INSERT/JOIN with schema, MySQL/SQLite ignore, plan hash differential)
+- **Python unit tests** — **42 new tests added**:
+ - test_migration_state.py: 11 tests (diff_states composite key, CreateSchema detection, serialization)
+ - test_migration_ddl.py: 17 tests (create_schema, qualified names, all DDL methods, backward compat)
+ - test_migration_autodetect.py: 10 tests (operation schema fields, to_python, apply_migration_to_state, AddField schema inheritance)
+- **Rust integration test**: `ryx-rs/tests/multi_schema_test.rs` — full multi-schema pipeline: migrate tenant1, migrate tenant2, verify table isolation across schemas, insert/query data independently, cleanup. Uses `PG_TEST_URL` env var (defaults to `postgres://einswilli@localhost/ryx_integration_test`). Passes.
+- **Python integration test**: `ryx-python/tests/integration/test_multi_schema.py` — same pipeline, runs in subprocess to avoid conftest's SQLite pool. Overrides `setup_database` and `clean_tables` fixtures. Passes.
+
+### In Progress
+- (none — feature complete)
+
+### Blocked
+- Python `_handle_no_migration_files` interactive prompt blocks automated migration testing (no `live` flag). Tests work around it by monkeypatching `input()`.
+
+## Key Decisions
+- **Schema at query/migration time, not on model**: Models stay agnostic → one model works in N schemas without duplication.
+- **Empty string = no schema = backward compat**: All existing code continues to work unchanged. Schema qualification only activates when explicitly set.
+- **`Option` vs `Option` in QueryNode**: `Option` for interned performance, `None` = no qualification.
+- **Composite key `(schema, name)` everywhere**: Enables correct diff across schemas. No schema = empty string matches "no schema" (no `CreateSchema` emitted).
+- **`CREATE SCHEMA IF NOT EXISTS` is automatic**: Runner creates schema before tables when schema is non-empty and backend supports schemas. Not part of migration files (idempotent).
+- **Per-op schema override**: Each operation can carry its own schema, allowing mixed-schema migration files (unlikely but supported).
+- **Temporary DDLGenerator per change**: Used in `generate()`, `apply_files()`, `plan_files()` to switch schema context. Acceptable cost since migration ops are rare.
+- **Python QuerySet delegates schema to Rust FFI**: `("schema", name)` op is stored in ops list and handled by Rust `build_plan()`. This means all SQL compilation is schema-aware with zero additional Python overhead.
+- **Integration tests run in subprocess for Python**: The `ryx` package auto-initializes the pool from `ryx.toml` at import time. Python's conftest also inits a SQLite pool. PG tests must run separately via subprocess with `RYX_AUTO_INITIALIZE=0`.
+
+## Next Steps
+1. Add `live=True` flag to Python `MigrationRunner` to skip interactive prompt in tests (feature gap).
+2. Documentation update: multi-schema section in migrations.mdx, `.schema()` API reference.
+
+## Critical Context
+- **Rust integration tests**: 76 unit + 2 integration (SQLite full pipeline + PG multi-schema).
+- **Python integration tests**: 42 unit + 1 integration (PG multi-schema via subprocess).
+- `diff_states()` matches tables by `(schema, name)` composite key. Two tables with same name in different schemas are treated as different tables. `CreateSchema` is emitted for schemas with non-empty string.
+- `DDLGenerator.qn()` produces `"schema"."table"` when schema is non-empty and backend supports schemas. For MySQL/SQLite, `supports_schemas()` → `false` eliminates qualification at compile time.
+- `QueryNode.with_schema(schema)` interns the schema name as `Symbol`. The compiler's `write_table_ref()` checks `node.backend.supports_schemas()` at compile time.
+- Python `QuerySet.schema()` adds an op that the Rust FFI `build_plan()` converts to `node.with_schema()`. The same Rust compiler handles qualification.
+- `__main__.py` has two parser code paths: the old argparse-based `_build_parser()` and the new registry-based `build_parser()`. `--schema` was added only to `_build_parser()` for now.
+- `inspectdb.py` hardcodes `table_schema = 'public'` for PostgreSQL introspection (the `--schema` flag passes through to the runner layer).
+- Integration test requires a running PostgreSQL on localhost:5432 with user `einswilli` (no password) and a `ryx_integration_test` database.
+- Python `ryx` package has `ryx/__init__.py:_auto_setup()` that reads `ryx.toml` and initializes the pool at `import ryx` time. Set `RYX_AUTO_INITIALIZE=0` to prevent this.
+- Python conftest adds `setup_database` (session-scoped, SQLite) and `clean_tables` (autouse, async) to all integration tests. PG tests must override both.
+
+## Relevant Files
+- **Rust modified**:
+ - `ryx-query/src/ast.rs`: `QueryNode.schema` + `with_schema()`.
+ - `ryx-query/src/compiler/compilr.rs`: `write_table_ref()`, `write_join_table_ref()`, all compile functions updated, plan hash includes schema.
+ - `ryx-query/src/backend.rs`: `supports_schemas()` const fn.
+ - `ryx-rs/src/migration.rs`: core types, `diff_states()`, introspection, test helpers, 7 new schema diff tests.
+ - `ryx-rs/src/migration/ddl.rs`: `DDLGenerator.in_schema()`, `qn()`, `create_schema()`, `generate()` zeroth pass, 20 new DDL schema tests.
+ - `ryx-rs/src/migration/operations.rs`: `CreateSchema` variant, `schema` on all ops, `schema()` accessor, 7 new operation schema tests.
+ - `ryx-rs/src/migration/autodetect.rs`: `ModelEntry.schema`, `build_target()` schema, `changes_to_operations()` CreateSchema.
+ - `ryx-rs/src/migration/runner.rs`: `FileRunner.schema`, `.schema()` builder, schema-aware run/plan/apply.
+ - `ryx-rs/src/queryset.rs`: `.schema()` builder method.
+ - `ryx-rs/src/main.rs`: `--schema` on Migrate/Sqlmigrate.
+- **Python modified**:
+ - `ryx-python/ryx/migrations/state.py`: `TableState.schema`, `ChangeKind.CREATE_SCHEMA`, `SchemaChange.schema`, `diff_states()` composite key, `to_json()`/`from_json()` schema-aware.
+ - `ryx-python/ryx/migrations/ddl.py`: `DDLGenerator.schema`, `_qn()`, `create_schema()`, all DDL methods qualified, `generate_schema_ddl()` per-table schema.
+ - `ryx-python/ryx/migrations/autodetect.py`: `schema` on all operation types, `to_python()` includes schema, `apply_migration_to_state()` preserves schema.
+ - `ryx-python/ryx/migrations/runner.py`: `_schema`, `.schema()` builder, `_create_ddl()`, all internal methods schema-aware.
+ - `ryx-python/ryx/queryset.py`: `.schema()` method.
+ - `ryx-python/src/plan.rs`: `"schema"` op handler.
+ - `ryx-python/ryx/__main__.py`: `--schema` on migrate/sqlmigrate/inspectdb.
+ - `ryx-python/ryx/cli/commands/migrate.py`: `--schema` flag + pass to runner.
+ - `ryx-python/ryx/cli/commands/sqlmigrate.py`: `--schema` flag + DDLGenerator schema.
+ - `ryx-python/ryx/cli/commands/inspectdb.py`: `--schema` flag + schema-aware introspection.
+- **Test files**:
+ - `ryx-rs/src/migration/operations.rs`: 7 new schema tests.
+ - `ryx-python/tests/unit/test_migration_state.py`: 11 new tests.
+ - `ryx-python/tests/unit/test_migration_ddl.py`: 17 new tests.
+ - `ryx-python/tests/unit/test_migration_autodetect.py`: 10 new tests.
+ - `ryx-rs/tests/multi_schema_test.rs`: PG multi-schema integration test.
+ - `ryx-python/tests/integration/test_multi_schema.py`: Python PG multi-schema integration test.
diff --git a/docs/doc/advanced/cli.mdx b/docs/doc/advanced/cli.mdx
index 609fe61..e0d20bf 100644
--- a/docs/doc/advanced/cli.mdx
+++ b/docs/doc/advanced/cli.mdx
@@ -4,122 +4,283 @@ sidebar_position: 10
# CLI
-Ryx includes a command-line interface for common database tasks.
+Ryx ships a management CLI for migrations, introspection, shell access, native
+database shells, destructive flushes, and version checks.
```bash
-python -m ryx [options]
+python -m ryx [global-options] [command-options]
+ryx [global-options] [command-options]
```
-## Commands
+Global options:
-### migrate
+| Flag | Purpose |
+|---|---|
+| `--url`, `-u` | Database URL. Overrides environment/config values |
+| `--settings`, `-s` | Settings module name. Defaults to `ryx_settings` |
+| `--verbose`, `-v` | Enable verbose output |
+| `--debug` | Enable debug mode |
-Apply migrations to the database:
+The command registry is plugin-aware, so additional commands can be registered
+by Ryx CLI plugins.
+
+## Configuration Resolution
+
+The CLI resolves configuration from:
+
+1. Global CLI flags
+2. Environment variables
+3. A settings module
+4. Config files in the current working directory
+
+Supported config file names:
+
+```text
+ryx.yaml
+ryx.yml
+ryx.toml
+ryx.json
+```
+
+Environment variables:
```bash
-python -m ryx migrate \
- --url postgres://user:pass@localhost/mydb \
- --models myapp.models
+export RYX_DATABASE_URL="postgres://user:pass@localhost/app"
+export RYX_DB_REPLICA_URL="postgres://user:pass@localhost/app_replica"
+export RYX_DB_LOGS_URL="sqlite:///logs.db"
```
-### makemigrations
+Example TOML:
-Generate migration files:
+```toml
+[urls]
+default = "postgres://user:pass@localhost/app"
+replica = "postgres://user:pass@localhost/app_replica"
-```bash
-python -m ryx makemigrations \
- --models myapp.models \
- --dir migrations/
+[pool]
+max_conn = 10
+min_conn = 1
+connect_timeout = 30
+idle_timeout = 600
+max_lifetime = 1800
+
+[models]
+files = ["myapp.models"]
+
+[migrations]
+dir = "migrations"
+```
+
+## `makemigrations`
-# Check mode — exit 1 if unapplied changes
+Detect model changes and generate migration files.
+
+```bash
+python -m ryx makemigrations --models myapp.models
+python -m ryx makemigrations --models myapp.models --name add_posts
python -m ryx makemigrations --models myapp.models --check
```
-### showmigrations
+Options:
+
+| Flag | Purpose |
+|---|---|
+| `--models MODULE` | Dotted module path containing models; can also come from config |
+| `--dir DIR` | Migrations directory. Defaults to `migrations` |
+| `--alias ALIAS` | Generate migrations in an alias-specific subdirectory |
+| `--name NAME` | Override the generated migration name slug |
+| `--check` | Exit with status `1` if changes are detected |
+| `--squash` | Reserved flag for squashing multiple migrations |
-Show migration status:
+Use `--check` in CI to fail when models and migration files drift.
+
+## `migrate`
+
+Apply pending migrations.
```bash
-python -m ryx showmigrations \
- --url postgres://user:pass@localhost/mydb \
- --dir migrations/
+python -m ryx --url postgres://user:pass@localhost/app migrate --models myapp.models
+python -m ryx migrate --models myapp.models --dry-run
+python -m ryx migrate --models myapp.models --database replica
+python -m ryx migrate --models myapp.models --schema tenant_1
```
-### sqlmigrate
+Options:
+
+| Flag | Purpose |
+|---|---|
+| `--dry-run` | Print SQL without executing |
+| `--models MODULE` | Dotted module path containing models |
+| `--dir DIR` | Migrations directory. Defaults to `migrations` |
+| `--plan` | Show migration plan without executing |
+| `--database ALIAS` | Run migrations for one database alias |
+| `--no-interactive` | Fail if no migration files are found; useful in CI |
+| `--schema SCHEMA` | PostgreSQL schema for multi-schema migrations |
-Print SQL for a specific migration:
+`migrate` initializes Ryx with the resolved URL mapping before running the
+migration runner.
+
+## `showmigrations`
+
+List migration files and whether they are applied.
```bash
-python -m ryx sqlmigrate 0001_initial --dir migrations/
+python -m ryx showmigrations --dir migrations
+python -m ryx showmigrations --unapplied
```
-### flush
+Options:
+
+| Flag | Purpose |
+|---|---|
+| `--dir DIR` | Migrations directory |
+| `--unapplied` | Show only unapplied migrations |
-Delete all data from all tables:
+## `sqlmigrate`
+
+Print SQL for one migration without executing it.
```bash
-python -m ryx flush \
- --models myapp.models \
- --url postgres://user:pass@localhost/mydb \
- --yes
+python -m ryx sqlmigrate 0001_initial --dir migrations
+python -m ryx sqlmigrate 0001_initial --backends postgres,mysql,sqlite
+python -m ryx sqlmigrate 0001_initial --schema tenant_1
```
+Options:
+
+| Flag | Purpose |
+|---|---|
+| `name` | Migration name, for example `0001_initial` |
+| `--dir DIR` | Migrations directory. Defaults to `migrations` |
+| `--backends LIST` | Comma-separated backend filter: `postgres,mysql,sqlite` |
+| `--schema SCHEMA` | PostgreSQL schema |
+
+## `flush`
+
+Delete all rows from all model tables.
+
+```bash
+python -m ryx --url sqlite:///app.db flush --models myapp.models
+python -m ryx --url sqlite:///app.db flush --models myapp.models --yes
+```
+
+Options:
+
+| Flag | Purpose |
+|---|---|
+| `--models MODULE` | Required dotted module path containing models |
+| `--yes` | Skip the confirmation prompt |
+| `--force` | Alias for `--yes` |
+
:::warning
-This deletes ALL data. Use `--yes` to skip the confirmation prompt.
+`flush` is destructive. It issues `DELETE FROM ""` for every loaded
+managed model table. Use `--yes` or `--force` only in controlled environments.
:::
-### shell
+## `shell`
-Interactive Python shell with ORM pre-loaded:
+Start an interactive Python shell.
```bash
-python -m ryx shell \
- --url postgres://user:pass@localhost/mydb \
- --models myapp.models
+python -m ryx shell --models myapp.models
+python -m ryx shell --query "await Post.objects.count()"
+python -m ryx shell --notebook
```
-### dbshell
+Options:
+
+| Flag | Purpose |
+|---|---|
+| `--models MODULE` | Pre-import models from a module |
+| `--query`, `-q` | Execute a query and print results |
+| `--ipyazthon` | Current registered flag for IPython mode |
+| `--notebook` | Launch Jupyter notebook instead of a shell |
+
+:::note
+The code currently registers the IPython flag as `--ipyazthon` while execution
+checks `ipython`. Until that implementation typo is fixed, prefer the plain
+shell or notebook mode.
+:::
+
+## `dbshell`
-Connect to the database with the native CLI client:
+Open the native database command-line client.
```bash
-python -m ryx dbshell --url postgres://user:pass@localhost/mydb
-# Opens psql
+python -m ryx --url postgres://user:pass@localhost/app dbshell
+python -m ryx --url mysql://user:pass@localhost/app dbshell
+python -m ryx --url sqlite:///app.db dbshell
+python -m ryx --url sqlite:///app.db dbshell -c ".tables"
```
-### inspectdb
+Ryx selects the native client from the URL scheme, such as `psql`, `mysql`, or
+`sqlite3`. The matching CLI tool must be installed on your system.
-Introspect an existing database and generate model stubs:
+Options:
-```bash
-# All tables
-python -m ryx inspectdb --url postgres://user:pass@localhost/mydb
+| Flag | Purpose |
+|---|---|
+| `--command`, `-c CMD` | Execute one native database command and exit |
-# Specific table
-python -m ryx inspectdb --url postgres://user:pass@localhost/mydb --table users
+## `inspectdb`
+
+Introspect an existing database and print Ryx model stubs.
+
+```bash
+python -m ryx --url postgres://user:pass@localhost/app inspectdb
+python -m ryx --url postgres://user:pass@localhost/app inspectdb --table users
+python -m ryx --url postgres://user:pass@localhost/app inspectdb --schema tenant_1 -o models.py
```
-### version
+Options:
+
+| Flag | Purpose |
+|---|---|
+| `--table TABLE` | Introspect one table |
+| `--output`, `-o FILE` | Write generated model stubs to a file |
+| `--schema SCHEMA` | Schema to inspect. Defaults to `public` |
+
+For PostgreSQL/MySQL, Ryx reads `information_schema`. For SQLite, it falls back
+to `sqlite_master`.
+
+## `version`
+
+Print the installed Ryx version.
```bash
python -m ryx version
+python -m ryx version --verbose
```
-## Configuration
+Options:
+
+| Flag | Purpose |
+|---|---|
+| `--verbose`, `-v` | Include Rust core version information when available |
-The CLI reads configuration from:
+## Plugins
-1. **CLI flags** — `--url`, `--models`, `--dir`
-2. **Environment variable** — `RYX_DATABASE_URL`
-3. **Settings module** — `ryx_settings.py` in your project
+CLI plugins can register extra commands. A plugin implements the `Plugin`
+interface and is loaded either from settings/config or Python entry points.
```python
-# ryx_settings.py
-DATABASE_URL = "postgres://user:pass@localhost/mydb"
-MODELS = ["myapp.models"]
-MIGRATIONS_DIR = "migrations/"
+from ryx.cli.plugins import Plugin
+
+class MyPlugin(Plugin):
+ name = "my-plugin"
+
+ def register_commands(self, registry):
+ registry["mycommand"] = MyCommand
```
-## Next Steps
+Use plugins when you need project-specific management commands without
+patching Ryx itself.
+
+## Operational Notes
-→ **[Reference](/reference/api-reference)** — Complete API documentation
+- Use `RYX_LOG_LEVEL=debug` for verbose query/runtime diagnostics.
+- Use `NO_COLOR=1` to disable ANSI colors.
+- Use `--no-interactive` and `makemigrations --check` in CI.
+- Prefer `--database` for `migrate`; `--alias` belongs to `makemigrations`.
+- Use config files for multi-database projects instead of repeating long URL
+ arguments in every command.
diff --git a/docs/doc/advanced/configuration-and-routing.mdx b/docs/doc/advanced/configuration-and-routing.mdx
new file mode 100644
index 0000000..35b8bd9
--- /dev/null
+++ b/docs/doc/advanced/configuration-and-routing.mdx
@@ -0,0 +1,186 @@
+---
+sidebar_position: 9
+---
+
+# Configuration and Routing
+
+Ryx supports explicit setup, import-time auto-initialization, multiple database
+aliases, per-model database metadata, and a global database router.
+
+## Explicit Setup
+
+```python
+import ryx
+
+await ryx.setup("sqlite:///app.db")
+```
+
+For multiple databases, pass a mapping:
+
+```python
+await ryx.setup({
+ "default": "postgres://user:pass@localhost/app",
+ "replica": "postgres://user:pass@localhost/app_replica",
+ "analytics": "sqlite:///analytics.db",
+})
+```
+
+Pool options:
+
+```python
+await ryx.setup(
+ {"default": "sqlite:///app.db"},
+ max_connections=10,
+ min_connections=1,
+ connect_timeout=30,
+ idle_timeout=600,
+ max_lifetime=1800,
+)
+```
+
+## Auto Initialization
+
+On import, Ryx attempts auto-initialization unless disabled:
+
+```bash
+export RYX_AUTO_INITIALIZE=0
+```
+
+Disabling values are `0`, `false`, `n`, and `no`.
+
+Auto-init reads database URLs from:
+
+```bash
+export RYX_DATABASE_URL="postgres://user:pass@localhost/app"
+export RYX_DB_REPLICA_URL="postgres://user:pass@localhost/app_replica"
+```
+
+`RYX_DATABASE_URL` becomes the `default` alias. `RYX_DB__URL` becomes the
+lowercase alias name.
+
+## Config Files
+
+Ryx looks for the first existing file in the current directory:
+
+```text
+ryx.yaml
+ryx.yml
+ryx.toml
+ryx.json
+```
+
+Example `ryx.toml`:
+
+```toml
+[urls]
+default = "postgres://user:pass@localhost/app"
+replica = "postgres://user:pass@localhost/app_replica"
+logs = "sqlite:///logs.db"
+
+[pool]
+max_conn = 10
+min_conn = 1
+connect_timeout = 30
+idle_timeout = 600
+max_lifetime = 1800
+
+[models]
+files = ["myapp.models", "billing.models"]
+
+[migrations]
+dir = "migrations"
+```
+
+The same structure can be represented as YAML or JSON.
+
+## Model-Level Database Alias
+
+Set `Meta.database` to bind a model to an alias by default:
+
+```python
+class AuditLog(Model):
+ event = CharField(max_length=200)
+
+ class Meta:
+ table_name = "audit_logs"
+ database = "logs"
+```
+
+Reads and writes fall back to `Meta.database` unless `.using()` or a router
+returns an alias first.
+
+## Query-Level Routing
+
+Use `.using(alias)` for one query:
+
+```python
+logs = await AuditLog.objects.using("logs").filter(event__icontains="login")
+posts = await Post.objects.using("replica").filter(active=True)
+```
+
+Use `.schema(schema)` for PostgreSQL multi-schema queries:
+
+```python
+tenant_posts = await Post.objects.schema("tenant_42").filter(active=True)
+```
+
+## Global Router
+
+```python
+from ryx.router import BaseRouter, set_router
+
+class ReadReplicaRouter(BaseRouter):
+ def db_for_read(self, model, **hints):
+ if getattr(model._meta, "database", None):
+ return model._meta.database
+ return "replica"
+
+ def db_for_write(self, model, **hints):
+ return getattr(model._meta, "database", None) or "default"
+
+ def allow_migrate(self, db, app_label, model_name):
+ return True
+
+set_router(ReadReplicaRouter())
+```
+
+Router methods return an alias string or `None`. Returning `None` tells Ryx to
+continue fallback resolution.
+
+## Resolution Order
+
+Read operations resolve aliases in this order:
+
+1. QuerySet `.using(alias)`
+2. `Router.db_for_read(model)`
+3. `Model.Meta.database`
+4. `"default"`
+
+Write operations resolve aliases in this order:
+
+1. Explicit `save(using=alias)` or QuerySet `.using(alias)` where supported
+2. `Router.db_for_write(model)`
+3. `Model.Meta.database`
+4. `"default"`
+
+## Introspection Helpers
+
+```python
+ryx.is_connected("default")
+ryx.list_aliases()
+ryx.pool_stats()
+```
+
+Use these helpers for startup diagnostics and health checks.
+
+## CLI Integration
+
+The CLI shares the same config discovery rules. For multi-database work:
+
+```bash
+python -m ryx migrate --models myapp.models --database default
+python -m ryx makemigrations --models myapp.models --alias logs
+```
+
+Use `--database` for applying migrations to one alias. Use `--alias` when
+generating alias-specific migration files.
diff --git a/docs/doc/advanced/index.mdx b/docs/doc/advanced/index.mdx
index 23e33d3..96f1e03 100644
--- a/docs/doc/advanced/index.mdx
+++ b/docs/doc/advanced/index.mdx
@@ -15,6 +15,7 @@ Deep-dive topics for production-ready applications.
- **[Caching](./caching)** — Query result caching
- **[Custom Lookups](./custom-lookups)** — Extend the query API
- **[Sync/Async](./sync-async)** — Bridge between sync and async code
-- **[Multi-Databases](./multi-db)** - Multi-Database Support
+- **[Multi-Databases](./multi-db)** — Multi-Database Support
+- **[PostgreSQL Multi-Schema](./postgres-multi-schema)** — Schema-per-tenant data isolation
- **[Raw SQL](./raw-sql)** — Escape hatch for complex queries
- **[CLI](./cli)** — Command-line management commands
diff --git a/docs/doc/advanced/multi-db.mdx b/docs/doc/advanced/multi-db.mdx
index c03934f..e8f11c8 100644
--- a/docs/doc/advanced/multi-db.mdx
+++ b/docs/doc/advanced/multi-db.mdx
@@ -6,6 +6,8 @@ description: Learn how to route queries across multiple databases in Ryx.
Ryx supports routing queries across multiple databases, allowing you to separate read and write workloads, split data across different servers, or use a dedicated database for specific models.
+> **Also see**: [PostgreSQL Multi-Schema](./postgres-multi-schema) for schema-per-tenant isolation within a single database.
+
## Configuration
To enable multi-database support, provide a dictionary of URLs to `ryx_core.setup` instead of a single string. Each key in the dictionary serves as an **alias** for that database pool.
diff --git a/docs/doc/advanced/postgres-multi-schema.mdx b/docs/doc/advanced/postgres-multi-schema.mdx
new file mode 100644
index 0000000..c191a90
--- /dev/null
+++ b/docs/doc/advanced/postgres-multi-schema.mdx
@@ -0,0 +1,160 @@
+---
+sidebar_position: 12
+title: PostgreSQL Multi-Schema (Schema-per-Tenant)
+description: Isolate tenant data using PostgreSQL schemas with Ryx.
+---
+
+Ryx supports PostgreSQL **database schemas** (`CREATE SCHEMA`) to isolate data per tenant, environment, or module — all within a single database connection.
+
+Each schema has its own tables, indexes, and data. Queries and migrations are scoped to a schema via the `.schema()` builder or `--schema` CLI flag.
+
+## When to Use
+
+- **Schema-per-tenant SaaS**: each tenant gets its own schema (`tenant1`, `tenant2`, …)
+- **Environment isolation**: `staging` and `production` schemas in the same DB
+- **Logical data domains**: `analytics`, `logging`, `billing` as separate schemas
+
+## Migration with a Schema
+
+### Python
+
+Use the `--schema` flag to target a specific schema:
+
+```bash
+python -m ryx migrate \
+ --url postgres://user:pass@localhost/mydb \
+ --models myapp.models \
+ --schema tenant1
+```
+
+Or set the schema programmatically via the `MigrationRunner`:
+
+```python
+from ryx.migrations import MigrationRunner
+
+runner = MigrationRunner(
+ [Author, Post],
+ schema="tenant1",
+)
+await runner.migrate()
+```
+
+Chained builder style:
+
+```python
+runner = MigrationRunner([Author, Post]).schema("tenant1")
+await runner.migrate()
+```
+
+### Rust
+
+Use `.schema()` on the `FileRunner` builder:
+
+```rust
+use ryx_rs::migration::FileRunner;
+
+FileRunner::new()
+ .model::()
+ .model::()
+ .schema("tenant1")
+ .run().await?;
+```
+
+The DDL generator also accepts schema directly:
+
+```rust
+use ryx_rs::migration::DDLGenerator;
+use ryx_query::Backend;
+
+let ddl = DDLGenerator::new(Backend::PostgreSQL)
+ .in_schema("tenant1");
+let sql = ddl.create_table(&table_state);
+// → CREATE TABLE IF NOT EXISTS "tenant1"."posts" ( ... )
+```
+
+## Querying with a Schema
+
+### Python
+
+```python
+# All queries in this schema
+posts = await Post.objects.schema("tenant1").filter(active=True)
+
+# Chained with other methods
+tenants_posts = await (
+ Post.objects
+ .schema("tenant1")
+ .filter(views__gte=100)
+ .order_by("-created_at")
+)
+```
+
+### Rust
+
+```rust
+let posts: Vec = Post::objects()
+ .schema("tenant1")
+ .filter("active", true)
+ .all().await?;
+```
+
+## Creating Schemas
+
+Ryx auto-creates schemas during migration if they don't exist. The `CREATE SCHEMA IF NOT EXISTS` statement is issued before any table DDL when a schema is specified.
+
+You can also create schemas manually:
+
+### Python
+
+```python
+from ryx.migrations.ddl import DDLGenerator
+
+gen = DDLGenerator("postgres")
+sql = gen.create_schema("tenant1")
+# → CREATE SCHEMA IF NOT EXISTS "tenant1"
+```
+
+### Rust
+
+```rust
+use ryx_rs::migration::DDLGenerator;
+
+let ddl = DDLGenerator::new(ryx_query::Backend::PostgreSQL);
+let sql = ddl.create_schema("tenant1");
+// → CREATE SCHEMA IF NOT EXISTS "tenant1"
+```
+
+## Multi-Tenant Example
+
+Here's a complete schema-per-tenant pattern with six tenants, each getting `ms_authors` and `ms_posts` tables:
+
+```python
+tenants = ["tenant1", "tenant2", "tenant3",
+ "tenant4", "tenant5", "tenant6"]
+
+for tenant in tenants:
+ runner = MigrationRunner([Author, Post], schema=tenant)
+ await runner.migrate()
+
+# Query tenant2's data
+posts = await Post.objects.schema("tenant2").filter(active=True)
+```
+
+## Migration Tracking
+
+Migration state is tracked per-schema independently. Each schema has its own `ryx_migrations` table recording which files have been applied to that schema.
+
+```bash
+# Check status for a specific schema
+python -m ryx showmigrations --schema tenant1
+```
+
+## Backend Support
+
+PostgreSQL schemas are a **PostgreSQL-only** feature. MySQL and SQLite do not support database schemas — the `--schema` flag and `.schema()` method are silently ignored on those backends.
+
+## Next Steps
+
+- **[Multi-Database Support](./multi-db)** — Route queries across multiple database connections
+- **[Migrations](/core-concepts/migrations)** — Full migration reference
+- **[CLI Reference](./cli)** — All management commands
diff --git a/docs/doc/core-concepts/migrations.mdx b/docs/doc/core-concepts/migrations.mdx
index f9cb4fa..6d62a63 100644
--- a/docs/doc/core-concepts/migrations.mdx
+++ b/docs/doc/core-concepts/migrations.mdx
@@ -4,7 +4,17 @@ sidebar_position: 5
# Migrations
-Ryx can introspect your database, detect schema changes, and generate DDL automatically.
+Ryx can introspect your database, detect schema changes, and generate DDL automatically — with full multi-database routing and colored CLI output.
+
+## CLI in Action
+
+The CLI uses ANSI colors on supported terminals (macOS, Linux). Colors auto-disable when `NO_COLOR` is set or output is piped:
+
+```
+ [ryx] ✓ 0001_initial ← bold blue prefix, green check
+ [ryx] ⚠ No migration files exist ← yellow warning
+ [ryx] Skipped database blog ← yellow "Skipped", magenta alias
+```
## Two Approaches
@@ -47,15 +57,100 @@ python -m ryx showmigrations \
--dir migrations/
```
-## How It Works
+## Migration File Format
+
+Generated migration files are clean, multi-line Python with **model class references** for automatic database routing:
+
+```python
+from myapp.models import Author, Post
+from ryx.migrations.autodetect import CreateTable, AddField, ColumnState
+
+Migration = [
+ CreateTable(
+ table='authors',
+ columns=[
+ ColumnState(name='id', db_type='INTEGER', primary_key=True, unique=True),
+ ColumnState(name='name', db_type='VARCHAR(100)'),
+ ],
+ model=Author,
+ ),
+ CreateTable(
+ table='posts',
+ columns=[
+ ColumnState(name='id', db_type='INTEGER', primary_key=True, unique=True),
+ ColumnState(name='title', db_type='VARCHAR(200)'),
+ ColumnState(name='author_id', db_type='INTEGER'),
+ ],
+ model=Post,
+ ),
+ AddField(
+ table='posts',
+ column=ColumnState(name='views', db_type='INTEGER', nullable=False),
+ model=Post,
+ ),
+]
+```
+
+Key features:
+- Each operation embeds the **Model class** (`model=Author`) so the runner knows which database it belongs to via `model._meta.database`
+- Models are imported at the top of the file (`from myapp.models import Author`)
+- Output is indented and readable, one argument per line
+- Backward compatible — legacy files without `model=` still work
+
+## File Discovery
+
+Migration files are discovered **recursively** under the migrations directory. All files matching `[0-9]*.py` are found, sorted globally by numeric prefix:
+
+```
+migrations/
+├── 0001_initial.py
+├── 0002_add_views.py
+├── blog/
+│ ├── 0001_blog_tables.py
+│ └── 0002_add_tags.py
+└── shop/
+ └── 0001_shop_tables.py
+```
+
+Files in subdirectories are found automatically — no per-alias configuration needed.
+
+## Multi-Database Routing
+
+When using multiple databases, each operation knows its target database from the embedded Model class. The runner:
+
+1. Discovers **all** migration files recursively
+2. For each database alias, filters operations whose `model._meta.database` matches
+3. Applies only relevant operations per alias
+4. Tracks applied state as `alias|stem` in `ryx_migrations`
+```bash
+# Apply only to the "blog" alias
+python -m ryx migrate --database blog
+
+# Target a specific PostgreSQL schema (schema-per-tenant)
+python -m ryx migrate --schema tenant1
+
+# Non-interactive mode (for CI/CD)
+python -m ryx migrate --no-interactive
```
-1. Introspect live DB schema → SchemaState (current)
-2. Build target from Models → SchemaState (desired)
-3. Diff the two states → List of changes
-4. Generate DDL → CREATE TABLE, ALTER COLUMN, etc.
-5. Execute → Apply to database
+
+## Interactive Fallback
+
+When no migration files exist yet, `ryx migrate` offers an interactive menu:
+
```
+ [ryx] ⚠ No migration files exist for database default
+ 3 model(s) are not yet tracked.
+
+ L)ive DDL — apply changes directly (development only)
+ A)uto-generate migration files, then migrate
+ M)anual — run ryx makemigrations first
+ S)kip this database for now
+
+ [ryx] Choice [S]:
+```
+
+Use `--no-interactive` to skip this prompt in scripts or CI/CD (exits with a hint instead).
## Migration Tracking
@@ -64,9 +159,11 @@ Ryx creates a `ryx_migrations` table to track applied migrations:
| Column | Type |
|---|---|
| `id` | INTEGER |
-| `name` | TEXT |
+| `name` | TEXT (`alias|stem` format) |
| `applied_at` | TIMESTAMP |
+Applied entries use `alias|stem` (e.g. `default|0001_initial`, `blog|0001_blog_tables`). Legacy bare-stem entries are still recognized for backward compatibility.
+
## What Migrations Handle
- Creating and dropping tables
@@ -97,12 +194,13 @@ print(gen.add_column("posts", column_state))
## Backend Differences
| Feature | PostgreSQL | MySQL | SQLite |
-|---|---|---|---|
+|---|---|---|---|---|
| `ALTER COLUMN` | Yes | Yes | No (recreate table) |
| Native UUID | Yes | No | No |
| `SERIAL` | Yes | No | No |
| `JSONB` | Yes | No | No |
| Array types | Yes | No | No |
+| **Database schemas** | **`"schema"."table"`** | No | No |
## Next Steps
diff --git a/docs/doc/internals/performance.mdx b/docs/doc/internals/performance.mdx
index c7da52c..c563d2b 100644
--- a/docs/doc/internals/performance.mdx
+++ b/docs/doc/internals/performance.mdx
@@ -4,7 +4,8 @@ sidebar_position: 4
# Performance Optimizations
-Ryx is engineered for extreme performance, with a primary target of **1-2 $\mu$s overhead** for query construction and row decoding. This is achieved by eliminating common abstractions that introduce runtime overhead.
+Ryx is engineered for low overhead in query construction and row decoding. This
+is achieved by eliminating common abstractions that introduce runtime overhead.
## 1. Enum Dispatch vs. Dynamic Dispatch
@@ -58,8 +59,8 @@ Instead of duplicating column names for every row, Ryx separates the **Structure
### Performance Impact
| Approach | Allocations per Row | Memory Layout | Complexity |
|---|---|---|---|
-| `HashMap` | $\sim$10-20 | Scattered | $O(\text{cols})$ |
-| **RowView** | **1 (The View itself)** | Linear / Contiguous | $O(1)$ |
+| `HashMap` | around 10-20 | Scattered | proportional to column count |
+| **RowView** | **1 view object** | Linear / contiguous | constant lookup after mapping |
This reduces allocator pressure by orders of magnitude and significantly improves cache locality.
diff --git a/docs/doc/intro.mdx b/docs/doc/intro.mdx
index f2106c1..cbfc5c9 100644
--- a/docs/doc/intro.mdx
+++ b/docs/doc/intro.mdx
@@ -9,12 +9,12 @@ import { Badge } from '@site/src/components/Badge';
# Ryx ORM
- Django-style Python ORM. Powered by Rust.
- Ergonomic query API. Async-native. Zero GIL blocking. Compiled performance.
+ Django-style ORM for Python and Rust. Powered by a shared Rust SQL engine.
+ Async-native Python API, Rust-native API, compiled query planning, and sqlx-backed execution.
-
v0.1.0
+
v0.1.x
Python 3.10+
PostgreSQL
MySQL
@@ -23,7 +23,7 @@ import { Badge } from '@site/src/components/Badge';
```python
import ryx
-from ryx import Model, CharField, IntField, Q, Count, Sum
+from ryx import Model, BooleanField, CharField, IntField, Q
class Post(Model):
title = CharField(max_length=200)
@@ -46,8 +46,8 @@ posts = await (
| | Django ORM | SQLAlchemy | **Ryx** |
|---|---|---|---|
| **API** | Ergonomic | Verbose | **Ergonomic** |
-| **Runtime** | Sync Python | Async Python | **Async Rust** |
-| **GIL blocking** | Yes | Yes | **Zero** |
+| **Runtime** | Sync Python | Async Python | **Async Python + Rust execution** |
+| **Query engine** | Python | Python | **Rust compiler + sqlx backend** |
| **Backends** | All | All | **PG · MySQL · SQLite** |
| **Migrations** | Built-in | Alembic | **Built-in** |
@@ -57,7 +57,7 @@ posts = await (
@@ -113,20 +113,20 @@ posts = await (
## Quick Start
```bash
-pip install maturin
-maturin develop # compile Rust + install
+pip install ryx
```
```python
import asyncio, ryx
from ryx import Model, CharField
+from ryx.migrations import MigrationRunner
class Article(Model):
title = CharField(max_length=200)
async def main():
await ryx.setup("sqlite:///app.db")
- await ryx.migrate([Article])
+ await MigrationRunner([Article]).migrate()
await Article.objects.create(title="Hello Ryx")
print(await Article.objects.all())
diff --git a/docs/doc/reference/api-reference.mdx b/docs/doc/reference/api-reference.mdx
index 2b1f239..b4292fa 100644
--- a/docs/doc/reference/api-reference.mdx
+++ b/docs/doc/reference/api-reference.mdx
@@ -4,32 +4,298 @@ sidebar_position: 2
# API Reference
-Complete public API surface of Ryx.
+This page documents the public Python API exposed by `ryx`. It is aligned with
+the current codebase: `ryx-python/ryx/__init__.py`, `models.py`,
+`queryset.py`, `fields.py`, `transaction.py`, `cache.py`, `signals.py`,
+`router.py`, and the PyO3 extension stub.
-## Setup & Connection
+## Setup and Connection Pools
```python
import ryx
-await ryx.setup(url, max_connections=10, min_connections=1, connect_timeout=30, idle_timeout=600, max_lifetime=1800)
-ryx.is_connected() # → bool
-ryx.pool_stats() # → dict with pool statistics
+await ryx.setup(
+ "sqlite:///app.db",
+ max_connections=10,
+ min_connections=1,
+ connect_timeout=30,
+ idle_timeout=600,
+ max_lifetime=1800,
+)
+```
+
+`setup()` accepts either a single URL or a mapping of aliases to URLs:
+
+```python
+await ryx.setup({
+ "default": "postgres://user:pass@localhost/app",
+ "replica": "postgres://user:pass@localhost/app_replica",
+ "logs": "sqlite:///logs.db",
+})
```
+Connection helpers:
+
+| Function | Purpose |
+|---|---|
+| `await ryx.setup(urls, **pool_options)` | Initialize one or more connection pools |
+| `ryx.is_connected(alias="default")` | Return whether a pool alias is initialized |
+| `ryx.list_aliases()` | Return configured database aliases |
+| `ryx.pool_stats()` | Return pool statistics from the Rust backend |
+
+Auto-initialization runs on import unless `RYX_AUTO_INITIALIZE` is one of
+`0`, `false`, `n`, or `no`. It reads `RYX_DATABASE_URL`,
+`RYX_DB_
_URL`, and the first config file found among `ryx.yaml`,
+`ryx.yml`, `ryx.toml`, and `ryx.json`.
+
+Logging is controlled by `RYX_LOG_LEVEL`. Use `NO_LOG`, `OFF`, or `SILENT` to
+disable Ryx logging.
+
## Models
```python
-from ryx import Model, Index, Constraint
+from ryx import Model, CharField, IntField, Index, Constraint
+
+class Post(Model):
+ title = CharField(max_length=200, db_index=True)
+ views = IntField(default=0)
-class MyModel(Model):
class Meta:
- table_name = "custom_name"
- ordering = ["-created_at"]
- unique_together = [("field1", "field2")]
- indexes = [Index(fields=["field"], name="idx")]
- constraints = [Constraint(check="col > 0", name="chk")]
+ table_name = "blog_posts"
+ app_label = "blog"
+ database = "default"
+ ordering = ["-views"]
+ unique_together = [("title", "views")]
+ index_together = [("title", "views")]
+ indexes = [Index(fields=["title"], name="idx_post_title")]
+ constraints = [Constraint(check="views >= 0", name="chk_views_positive")]
+ abstract = False
+ managed = True
+```
+
+Model behavior:
+
+| API | Purpose |
+|---|---|
+| `Model(**kwargs)` | Create an unsaved instance with field defaults applied |
+| `obj.pk` | Return the primary-key value |
+| `await obj.full_clean()` | Run field validators and `clean()` |
+| `await obj.save(validate=True, update_fields=None, using=None)` | Insert or update the row |
+| `await obj.delete()` | Delete the row and clear its primary key |
+| `await obj.refresh_from_db(fields=None)` | Reload fields from the database |
+| `Model.DoesNotExist` | Per-model exception raised by `get()` |
+| `Model.MultipleObjectsReturned` | Per-model exception raised by `get()` |
+
+Lifecycle hooks can be overridden:
+
+```python
+class Post(Model):
+ title = CharField(max_length=200)
+
+ async def clean(self):
+ if not self.title.strip():
+ raise ValidationError({"title": ["Title cannot be empty"]})
+
+ async def before_save(self, created: bool): ...
+ async def after_save(self, created: bool): ...
+ async def before_delete(self): ...
+ async def after_delete(self): ...
+```
+
+## Managers
+
+Every concrete model receives `objects = Manager()` unless explicitly
+overridden.
+
+```python
+Post.objects.all()
+Post.objects.filter(active=True)
+Post.objects.exclude(status="draft")
+Post.objects.order_by("-created_at")
+Post.objects.using("replica")
+Post.objects.cache(ttl=60)
+Post.objects.stream(chunk_size=100)
+
+await Post.objects.create(title="Hello")
+await Post.objects.get(pk=1)
+await Post.objects.first()
+await Post.objects.last()
+await Post.objects.exists()
+await Post.objects.count()
+await Post.objects.aggregate(total=Count("id"))
+await Post.objects.get_or_create(slug="hello", defaults={"title": "Hello"})
+await Post.objects.update_or_create(slug="hello", defaults={"title": "New"})
+await Post.objects.bulk_create([Post(title="A"), Post(title="B")])
+await Post.objects.bulk_update(posts, fields=["title"])
+await Post.objects.bulk_delete(Post.objects.filter(active=False))
```
+## QuerySet
+
+`QuerySet` is lazy, async, chainable, and immutable. SQL is executed only when
+the queryset is awaited or when an async terminal method is called.
+
+### Building Queries
+
+```python
+qs = (
+ Post.objects
+ .filter(active=True, views__gte=100)
+ .exclude(title__istartswith="draft")
+ .order_by("-views", "title")
+ .limit(20)
+ .offset(40)
+)
+posts = await qs
+```
+
+| Method | Purpose |
+|---|---|
+| `.filter(*q, **kwargs)` | Add AND-ed filters and `Q` expressions |
+| `.exclude(*q, **kwargs)` | Add negated filters |
+| `.all()` | Clone the queryset |
+| `.order_by(*fields)` | Add ordering; `"-field"` means descending |
+| `.limit(n)` | Add `LIMIT` |
+| `.offset(n)` | Add `OFFSET` |
+| `.distinct()` | Add `DISTINCT` |
+| `.values(*fields)` | Select fields and enable grouping for annotations |
+| `.annotate(**aggs)` | Add aggregate expressions per row |
+| `.join(table, on, alias=None, kind="INNER")` | Add an explicit SQL join |
+| `.using(alias)` | Force a database alias |
+| `.schema(schema)` | Target a PostgreSQL schema |
+| `.cache(ttl=None, key=None)` | Cache the first evaluated result |
+| `.stream(chunk_size=100, keyset=None, as_dict=False)` | Iterate in chunks |
+
+Slicing is translated to `LIMIT`/`OFFSET`:
+
+```python
+first_ten = await Post.objects.order_by("id")[:10]
+page_two = await Post.objects.order_by("id")[10:20]
+third = await Post.objects.order_by("id")[2]
+```
+
+Negative indexes and step slicing are not supported.
+
+`select_related()` exists on the Python queryset but is currently a no-op in
+the implementation. Use explicit `.join()` when you need an eager SQL join.
+
+### Terminal Methods
+
+```python
+await qs.count()
+await qs.exists()
+await qs.first()
+await qs.get(pk=1)
+await qs.update(views=100)
+await qs.delete()
+await qs.bulk_delete()
+await qs.in_bulk([1, 2, 3])
+```
+
+| Method | Return |
+|---|---|
+| `await qs` | `list[Model]` |
+| `await qs.count()` | `int` |
+| `await qs.exists()` | `bool` |
+| `await qs.first()` | `Model | None` |
+| `await qs.get(**filters)` | Exactly one model or per-model exception |
+| `await qs.update(**fields)` | Number of updated rows |
+| `await qs.delete()` | Number of deleted rows |
+| `await qs.in_bulk(ids, field_name="pk")` | `dict[value, Model]` |
+
+Debug SQL:
+
+```python
+print(Post.objects.filter(active=True).query)
+```
+
+## Q Objects
+
+```python
+from ryx import Q
+
+Post.objects.filter(Q(active=True) | Q(views__gte=1000))
+Post.objects.filter(Q(active=True) & ~Q(status="draft"))
+Post.objects.filter((Q(active=True) & Q(views__gte=100)) | Q(featured=True))
+```
+
+Multiple kwargs inside one `Q()` are AND-ed. `Q` objects can be combined with
+regular filter kwargs.
+
+## Lookups and Transforms
+
+Common lookups:
+
+```python
+exact, gt, gte, lt, lte,
+contains, icontains,
+startswith, istartswith,
+endswith, iendswith,
+isnull, in, range
+```
+
+Date/time transforms:
+
+```python
+date, year, month, day, hour, minute, second, week,
+dow, quarter, time, iso_week, iso_dow
+```
+
+JSON transforms/lookups:
+
+```python
+key, key_text, json,
+has_key, has_any, has_all, contains, contained_by
+```
+
+Examples:
+
+```python
+await Post.objects.filter(title__icontains="ryx")
+await Post.objects.filter(created_at__year__gte=2025)
+await Event.objects.filter(payload__key_text__icontains="error")
+```
+
+Runtime helpers:
+
+```python
+ryx.available_lookups()
+ryx.list_lookups()
+ryx.available_transforms()
+ryx.register_lookup("ne", "{col} <> ?")
+```
+
+Decorator form:
+
+```python
+@ryx.lookup("ne")
+def not_equal():
+ """{col} <> ?"""
+```
+
+## Aggregates
+
+```python
+from ryx import Count, Sum, Avg, Min, Max, RawAgg
+
+await Post.objects.aggregate(
+ total=Count("id"),
+ total_views=Sum("views"),
+ avg_views=Avg("views"),
+ min_views=Min("views"),
+ max_views=Max("views"),
+)
+
+rows = await (
+ Post.objects
+ .values("author_id")
+ .annotate(post_count=Count("id"), views=Sum("views"))
+)
+```
+
+`Count("field", distinct=True)` produces a distinct count. `RawAgg(sql,
+alias)` is available for backend-specific expressions.
+
## Fields
```python
@@ -45,69 +311,112 @@ from ryx import (
)
```
-## QuerySet
+Common field options:
```python
-qs.filter(**kwargs)
-qs.exclude(**kwargs)
-qs.all()
-qs.get(**kwargs)
-qs.first()
-qs.last()
-qs.exists()
-qs.count()
-qs.order_by(*fields)
-qs.limit(n)
-qs.offset(n)
-qs.distinct()
-qs.values(*fields)
-qs.annotate(**kwargs)
-qs.aggregate(**kwargs)
-qs.join(table, condition, alias=None, kind="INNER")
-qs.update(**kwargs)
-qs.delete()
-qs.cache()
-qs.stream(page_size=500)
-qs.using(db_alias)
-qs.in_bulk(id_list)
+CharField(
+ max_length=200,
+ null=False,
+ blank=False,
+ default="",
+ primary_key=False,
+ unique=False,
+ db_index=False,
+ choices=["draft", "published"],
+ validators=[],
+ editable=True,
+ help_text="",
+ verbose_name="",
+ db_column="db_column_name",
+ unique_for_date=None,
+ unique_for_month=None,
+ unique_for_year=None,
+)
```
-## Q Objects
+See [Field Reference](/reference/field-reference) for the full field matrix.
+
+## Relationships
```python
-from ryx import Q
+class Author(Model):
+ name = CharField(max_length=100)
+
+class Post(Model):
+ author = ForeignKey(Author, on_delete="CASCADE", related_name="posts")
+
+class Profile(Model):
+ author = OneToOneField(Author, on_delete="CASCADE")
+
+class Tag(Model):
+ name = CharField(max_length=50)
+
+class TaggedPost(Model):
+ post = ForeignKey(Post, on_delete="CASCADE")
+ tag = ForeignKey(Tag, on_delete="CASCADE")
-Q(field=value)
-Q(field__lookup=value)
-Q(a=True) | Q(b=True) # OR
-Q(a=True) & Q(b=True) # AND
-~Q(a=True) # NOT
+class PostTag(Model):
+ tags = ManyToManyField(Tag, through=TaggedPost)
```
-## Aggregates
+Relationship helpers include forward descriptors, reverse FK managers, and
+many-to-many managers. Reverse FK managers expose queryset-like helpers such as
+`all()`, `filter()`, `exclude()`, `order_by()`, `count()`, `exists()`,
+`first()`, `get()`, `create()`, `add()`, `remove()`, and `delete()`.
+
+## Transactions
```python
-from ryx import Count, Sum, Avg, Min, Max, RawAgg
+from ryx import transaction, get_active_transaction
+
+async with transaction("default") as tx:
+ await Account.objects.get(pk=1)
+ tx.savepoint("before_transfer")
+ tx.rollback_to("before_transfer")
+ tx.release_savepoint("before_transfer")
-Count("field", distinct=True)
-Sum("field")
-Avg("field")
-Min("field")
-Max("field")
-RawAgg("SQL expression")
+current = get_active_transaction()
```
-## Transactions
+The transaction context commits on successful exit and rolls back on exception.
+Nested transactions are implemented with savepoints by the Rust backend.
+
+## Bulk Operations and Streaming
```python
-import ryx
+from ryx import bulk_create, bulk_update, bulk_delete, stream
+
+await bulk_create([Post(title="A"), Post(title="B")], batch_size=500)
+await bulk_update(posts, fields=["title"], batch_size=500)
+await bulk_delete(Post.objects.filter(active=False))
+
+async for post in Post.objects.filter(active=True).stream(chunk_size=100):
+ ...
+
+async for row in Post.objects.stream(chunk_size=500, keyset="id", as_dict=True):
+ ...
+```
+
+`keyset` streaming uses cursor-style pagination and is preferred for large
+tables when the keyset column is indexed.
+
+## Validation
-async with ryx.transaction() as tx:
- tx.savepoint("name")
- tx.rollback_to("name")
- tx.release_savepoint("name")
+```python
+from ryx import (
+ ValidationError, Validator, FunctionValidator,
+ NotNullValidator, NotBlankValidator,
+ MaxLengthValidator, MinLengthValidator,
+ MinValueValidator, MaxValueValidator,
+ RangeValidator, RegexValidator,
+ EmailValidator, URLValidator, ChoicesValidator,
+)
+```
+
+Validation runs in `save()` by default. Pass `validate=False` to skip it:
-ryx.get_active_transaction() # → TransactionHandle | None
+```python
+await post.save(validate=False)
```
## Signals
@@ -121,118 +430,161 @@ from ryx import (
pre_bulk_delete, post_bulk_delete,
)
+@receiver(post_save, sender=Post)
+async def on_post_saved(sender, instance, created, **kwargs):
+ ...
+```
+
+Signal methods:
+
+```python
signal.connect(handler, sender=None, weak=True)
signal.disconnect(handler, sender=None)
await signal.send(sender, **kwargs)
-
-@receiver(signal, sender=Model)
-async def handler(sender, **kwargs): ...
```
-## Validation
+## Caching
```python
-from ryx import ValidationError
-from ryx.validators import (
- MaxLengthValidator, MinLengthValidator,
- MaxValueValidator, MinValueValidator,
- RangeValidator, RegexValidator,
- EmailValidator, URLValidator,
- NotBlankValidator, ChoicesValidator,
- FunctionValidator, NotNullValidator,
- UniqueValueValidator,
- run_full_validation,
-)
-```
+from ryx import MemoryCache, configure_cache, get_cache
+from ryx import invalidate, invalidate_model, invalidate_all
-## Bulk Operations
+configure_cache(MemoryCache(max_size=1000, ttl=300))
-```python
-from ryx.bulk import bulk_create, bulk_update, bulk_delete, stream
+posts = await Post.objects.filter(active=True).cache(ttl=60)
+named = await Post.objects.all().cache(key="all_posts", ttl=300)
-await bulk_create(instances, batch_size=100)
-await bulk_update(instances, fields=["field"])
-await bulk_delete(queryset)
-async for batch in stream(queryset, page_size=500): ...
+await invalidate("all_posts")
+await invalidate_model(Post)
+await invalidate_all()
+cache = get_cache()
```
-## Relations
+`MemoryCache` implements `get`, `set`, `delete`, `delete_many`, `clear`, and
+`keys`. It is process-local and TTL-aware.
+
+## Raw SQL and Parameterized Execution
```python
-from ryx.relations import apply_select_related, apply_prefetch_related
+from ryx.executor_helpers import raw_fetch, raw_execute
+from ryx.pool_ext import fetch_with_params, execute_with_params
-await apply_select_related(queryset, fields=["author"])
-await apply_prefetch_related(queryset, fields=["tags"])
+rows = await raw_fetch("SELECT * FROM posts WHERE active = true", alias="default")
+await raw_execute("CREATE INDEX IF NOT EXISTS idx_posts_active ON posts(active)")
+
+rows = await fetch_with_params("SELECT * FROM posts WHERE id = ?", [1])
+affected = await execute_with_params("UPDATE posts SET views = ? WHERE id = ?", [10, 1])
```
-## Caching
+Use parameterized helpers for user input. Raw SQL helpers execute the SQL string
+directly.
+
+## Database Routing
```python
-from ryx import configure_cache, get_cache
+from ryx.router import BaseRouter, set_router
-configure_cache(ttl=300, max_size=1000)
-cache = get_cache()
+class Router(BaseRouter):
+ def db_for_read(self, model, **hints):
+ return "replica"
+
+ def db_for_write(self, model, **hints):
+ return getattr(model._meta, "database", None) or "default"
+
+ def allow_migrate(self, db, app_label, model_name):
+ return True
+
+set_router(Router())
```
-## Custom Lookups
+Read alias resolution order:
+
+1. QuerySet `.using(alias)`
+2. `Router.db_for_read(model)`
+3. `Model.Meta.database`
+4. `"default"`
+
+Write alias resolution order:
+
+1. Explicit `save(using=alias)` or QuerySet `.using(alias)` where applicable
+2. `Router.db_for_write(model)`
+3. `Model.Meta.database`
+4. `"default"`
+
+## Migrations
```python
-import ryx
+from ryx.migrations import MigrationRunner
+from ryx.migrations.autodetect import Autodetector
+from ryx.migrations.ddl import DDLGenerator, generate_schema_ddl, detect_backend
-ryx.register_lookup("name", "{col} OPERATOR ?")
-ryx.available_lookups() # → list[str]
+runner = MigrationRunner([Author, Post], migrations_dir="migrations")
+changes = await runner.migrate()
-@ryx.lookup("name")
-def my_lookup(field, value):
- """{col} OPERATOR ?"""
+detector = Autodetector([Author, Post], migrations_dir="migrations")
+operations = detector.detect()
+path = detector.write_migration(operations)
```
-## Sync/Async
+Operation classes:
```python
-from ryx import run_sync, sync_to_async, async_to_sync, run_async
-
-run_sync(awaitable)
-sync_to_async(func)
-async_to_async(func)
-async_to_sync(awaitable)
-await run_async(func, *args)
+CreateTable(table, columns, schema="", model=None)
+AddField(table, column, schema="", model=None)
+AlterField(table, new_col, old_col=None, schema="", model=None)
+CreateIndex(table, name, fields, unique=False, schema="", model=None)
+RunSQL(sql, reverse_sql="")
```
-## Raw SQL
+DDL helpers support PostgreSQL, MySQL, and SQLite and include schema-aware SQL
+generation for PostgreSQL.
+
+## Sync and Async Bridge
```python
-from ryx.executor_helpers import raw_fetch, raw_execute
-from ryx.pool_ext import fetch_with_params, execute_with_params
+from ryx import run_sync, run_async, sync_to_async, async_to_sync
-await raw_fetch("SELECT ...", [params])
-await raw_execute("CREATE ...")
-await fetch_with_params("SELECT ... WHERE x = ?", [value])
-await execute_with_params("INSERT ...", [values])
+posts = run_sync(Post.objects.filter(active=True))
+
+async_fn = sync_to_async(blocking_function)
+result = await async_fn()
+
+wrapped = async_to_sync(async_function)
+value = wrapped()
+
+result = await run_async(blocking_function, arg1, key="value")
```
-## Migrations
+Prefer native `await` inside async applications. Use the bridge helpers for
+scripts, WSGI handlers, and synchronous integrations.
+
+## Exceptions
```python
-from ryx.migrations import MigrationRunner, DDLGenerator, generate_schema_ddl
+from ryx import (
+ RyxError,
+ DatabaseError,
+ DoesNotExist,
+ MultipleObjectsReturned,
+ PoolNotInitialized,
+ ValidationError,
+)
+```
-runner = MigrationRunner([Model1, Model2])
-await runner.migrate()
-await runner.migrate(dry_run=True)
+`DoesNotExist` and `MultipleObjectsReturned` also exist as per-model subclasses:
-stmts = generate_schema_ddl([Model1], backend="postgres")
+```python
+try:
+ await Post.objects.get(slug="missing")
+except Post.DoesNotExist:
+ ...
```
-## CLI
+## CLI Entrypoints
```bash
-python -m ryx migrate --url ... --models ...
-python -m ryx makemigrations --models ... --dir ...
-python -m ryx showmigrations --url ... --dir ...
-python -m ryx sqlmigrate --dir ...
-python -m ryx flush --models ... --url ... --yes
-python -m ryx shell --url ... --models ...
-python -m ryx dbshell --url ...
-python -m ryx inspectdb --url ... [--table ...]
-python -m ryx version
+python -m ryx --url sqlite:///app.db migrate --models myapp.models
+ryx --url sqlite:///app.db migrate --models myapp.models
```
+
+See [CLI](/advanced/cli) for command options and config file formats.
diff --git a/docs/doc/reference/field-reference.mdx b/docs/doc/reference/field-reference.mdx
index 6e86c36..9784ac8 100644
--- a/docs/doc/reference/field-reference.mdx
+++ b/docs/doc/reference/field-reference.mdx
@@ -4,78 +4,215 @@ sidebar_position: 3
# Field Reference
-Complete reference of all field types, their SQL types, and Python types.
+Ryx fields are descriptors. A field stores model metadata, validates Python
+values, converts values to database values, and exposes SQL type information to
+the migration system.
+
+## Common Field Options
+
+All field classes inherit these options from `Field`:
+
+| Option | Default | Purpose |
+|---|---:|---|
+| `null` | `False` | Allows SQL `NULL` |
+| `blank` | `False` | Allows empty values during validation |
+| `default` | none | Static value or callable default |
+| `primary_key` | `False` | Marks the model primary key |
+| `unique` | `False` | Adds uniqueness validation/constraint metadata |
+| `db_index` | `False` | Marks the field for index creation |
+| `choices` | `None` | Restricts values with `ChoicesValidator` |
+| `validators` | `[]` | Extra validator instances |
+| `editable` | `True` | Excluded from `save()` when `False`, except auto timestamp fields |
+| `help_text` | `""` | Human-readable description |
+| `verbose_name` | `""` | Human-readable label |
+| `db_column` | field name | Overrides the SQL column name |
+| `unique_for_date` | `None` | Declares date-scoped uniqueness metadata |
+| `unique_for_month` | `None` | Declares month-scoped uniqueness metadata |
+| `unique_for_year` | `None` | Declares year-scoped uniqueness metadata |
+
+```python
+title = CharField(
+ max_length=200,
+ null=False,
+ blank=False,
+ default="Untitled",
+ unique=True,
+ db_index=True,
+ choices=["draft", "published"],
+ db_column="post_title",
+)
+```
+
+If no primary key is declared on a concrete model, Ryx injects:
+
+```python
+id = AutoField(primary_key=True, editable=False)
+```
## Integer Fields
-| Field | SQL Type | Python Type | Extra Kwargs |
+| Field | SQL Type | Python Type | Extra Options |
|---|---|---|---|
-| `AutoField` | `SERIAL` | `int` | — |
-| `BigAutoField` | `BIGSERIAL` | `int` | — |
-| `SmallAutoField` | `SMALLSERIAL` | `int` | — |
+| `AutoField` | `SERIAL` | `int` | Usually primary key |
+| `BigAutoField` | `BIGSERIAL` | `int` | Large auto primary key |
+| `SmallAutoField` | `SMALLSERIAL` | `int` | Small auto primary key |
| `IntField` | `INTEGER` | `int` | `min_value`, `max_value` |
| `SmallIntField` | `SMALLINT` | `int` | `min_value`, `max_value` |
| `BigIntField` | `BIGINT` | `int` | `min_value`, `max_value` |
-| `PositiveIntField` | `INTEGER` | `int` | implicit `min_value=0` |
+| `PositiveIntField` | `INTEGER` | `int` | Adds `min_value=0` behavior |
+
+```python
+class Product(Model):
+ stock = PositiveIntField(default=0)
+ rating = SmallIntField(min_value=1, max_value=5)
+```
+
+## Numeric Fields
+
+| Field | SQL Type | Python Type | Extra Options |
+|---|---|---|---|
+| `FloatField` | `DOUBLE PRECISION` | `float` | `min_value`, `max_value` |
+| `DecimalField` | `NUMERIC(max_digits, decimal_places)` | `Decimal` | `max_digits`, `decimal_places` |
+
+```python
+price = DecimalField(max_digits=10, decimal_places=2)
+score = FloatField(min_value=0.0, max_value=1.0)
+```
+
+## Boolean Fields
+
+| Field | SQL Type | Python Type | Notes |
+|---|---|---|---|
+| `BooleanField` | `BOOLEAN` | `bool` | Standard boolean |
+| `NullBooleanField` | `BOOLEAN` | `bool | None` | Sets `null=True` |
## Text Fields
-| Field | SQL Type | Python Type | Extra Kwargs |
+| Field | SQL Type | Python Type | Extra Options |
|---|---|---|---|
-| `CharField` | `VARCHAR(n)` | `str` | `max_length`, `min_length`, `strip` |
+| `CharField` | `VARCHAR(max_length)` | `str` | `max_length`, `min_length`, `strip` |
| `TextField` | `TEXT` | `str` | `min_length` |
-| `EmailField` | `VARCHAR(254)` | `str` | auto email validation |
-| `SlugField` | `VARCHAR(50)` | `str` | auto slug validation |
-| `URLField` | `VARCHAR(200)` | `str` | auto URL validation |
-| `IPAddressField` | `VARCHAR(15)` | `str` | auto IPv4 validation |
+| `SlugField` | `VARCHAR(50)` | `str` | Slug-style validation |
+| `EmailField` | `VARCHAR(254)` | `str` | Email validation |
+| `URLField` | `VARCHAR(200)` | `str` | URL validation |
+| `IPAddressField` | `VARCHAR(15)` | `str` | IPv4 validation |
+
+```python
+slug = SlugField(unique=True)
+email = EmailField(unique=True)
+bio = TextField(blank=True)
+```
-## Date & Time Fields
+## Date and Time Fields
-| Field | SQL Type | Python Type | Extra Kwargs |
+| Field | SQL Type | Python Type | Extra Options |
|---|---|---|---|
| `DateField` | `DATE` | `date` | `auto_now`, `auto_now_add` |
| `DateTimeField` | `TIMESTAMP` | `datetime` | `auto_now`, `auto_now_add` |
| `TimeField` | `TIME` | `time` | — |
-| `DurationField` | `BIGINT` | `timedelta` | stored as microseconds |
+| `DurationField` | `BIGINT` | `timedelta` | Stored as microseconds |
+
+```python
+created_at = DateTimeField(auto_now_add=True)
+updated_at = DateTimeField(auto_now=True)
+publish_date = DateField(null=True)
+```
-## Special Fields
+`auto_now_add` updates only on insert. `auto_now` updates on every save.
-| Field | SQL Type | Python Type | Extra Kwargs |
+## Structured and Binary Fields
+
+| Field | SQL Type | Python Type | Extra Options |
|---|---|---|---|
-| `BooleanField` | `BOOLEAN` | `bool` | — |
-| `NullBooleanField` | `BOOLEAN` | `bool \| None` | implicit `null=True` |
-| `FloatField` | `DOUBLE PRECISION` | `float` | `min_value`, `max_value` |
-| `DecimalField` | `NUMERIC(p,s)` | `Decimal` | `max_digits`, `decimal_places` |
-| `UUIDField` | `UUID` | `UUID` | `auto_create` |
-| `JSONField` | `JSONB` | `dict \| list` | — |
+| `UUIDField` | `UUID` | `uuid.UUID` | `auto_create` |
+| `JSONField` | `JSONB` | `dict | list` | JSON lookups/transforms |
+| `ArrayField` | `[]` | `list` | `base_field` |
| `BinaryField` | `BYTEA` | `bytes` | — |
-| `ArrayField` | `T[]` | `list` | `base_field` |
+
+```python
+uid = UUIDField(auto_create=True, unique=True)
+metadata = JSONField(default=dict)
+tags = ArrayField(CharField(max_length=40), default=list)
+payload = BinaryField(null=True)
+```
+
+JSON lookups include `has_key`, `has_any`, `has_all`, `contains`, and
+`contained_by`. JSON transforms include `key`, `key_text`, and `json`.
## Relationship Fields
-| Field | SQL Type | Python Type | Extra Kwargs |
+| Field | Database Representation | Python Use | Extra Options |
|---|---|---|---|
-| `ForeignKey` | `INTEGER` | `int` | `on_delete`, `related_name` |
-| `OneToOneField` | `INTEGER UNIQUE` | `int` | same as FK |
-| `ManyToManyField` | *(join table)* | — | `through` |
+| `ForeignKey` | FK id column | Forward descriptor + reverse manager | `to`, `on_delete`, `related_name` |
+| `OneToOneField` | Unique FK id column | One-to-one descriptor | Same as `ForeignKey` |
+| `ManyToManyField` | Through table | Many-to-many manager | `to`, `through`, `related_name` |
-## Common Field Options
+```python
+class Author(Model):
+ name = CharField(max_length=100)
+
+class Post(Model):
+ author = ForeignKey(Author, on_delete="CASCADE", related_name="posts")
+
+class Profile(Model):
+ author = OneToOneField(Author, on_delete="CASCADE")
+```
+
+Supported `on_delete` values are interpreted by the field and migration layer.
+Use the exact values demonstrated in examples and tests, such as `"CASCADE"`.
+
+## Validation
+
+Field validation combines:
+
+1. `NotNullValidator` for non-null, non-primary-key fields
+2. `ChoicesValidator` when `choices` is provided
+3. `UniqueValueValidator` when `unique=True`
+4. Field-specific validators such as length, range, email, URL, or regex
+5. Explicit validators passed in `validators=[...]`
```python
-CharField(
- max_length=200, # Required for CharField
- min_length=5, # Optional minimum
- null=True, # Allow NULL in DB
- blank=True, # Allow empty in validation
- default="", # Default value or callable
- unique=True, # UNIQUE constraint
- db_index=True, # CREATE INDEX
- choices=["a", "b"], # Restrict values
- validators=[...], # Extra validators
- editable=True, # Include in save()
- help_text="...", # Documentation
- verbose_name="...", # Human-readable label
- db_column="col", # Override column name
- primary_key=False, # Make this the PK
+from ryx import CharField, RegexValidator
+
+username = CharField(
+ max_length=40,
+ validators=[RegexValidator(r"^[a-z0-9_]+$")],
)
```
+
+## Lookup Validation
+
+Each field class declares supported lookups and transforms. Ryx validates lookup
+chains before sending a query to Rust:
+
+```python
+await Post.objects.filter(title__icontains="ryx")
+await Post.objects.filter(created_at__year__gte=2025)
+await Post.objects.filter(metadata__key_text__icontains="error")
+```
+
+Unknown lookup suffixes fall back to exact matching during parsing, so prefer
+`ryx.available_lookups()` and this reference when authoring advanced filters.
+
+## Migration Metadata
+
+Fields expose `db_type()` and `deconstruct()` data used by:
+
+- `project_state_from_models()`
+- `Autodetector`
+- `DDLGenerator`
+- `MigrationRunner`
+
+For unmanaged legacy tables, set:
+
+```python
+class LegacyUser(Model):
+ name = CharField(max_length=100)
+
+ class Meta:
+ table_name = "legacy_users"
+ managed = False
+```
+
+`managed=False` keeps the model queryable while preventing migration generation
+from creating or altering its table.
diff --git a/docs/doc/rust/core-concepts/migrations.mdx b/docs/doc/rust/core-concepts/migrations.mdx
index 4687859..9c3455d 100644
--- a/docs/doc/rust/core-concepts/migrations.mdx
+++ b/docs/doc/rust/core-concepts/migrations.mdx
@@ -4,51 +4,259 @@ sidebar_position: 6
# Migrations
-Ryx can detect your models and create database tables automatically.
+Ryx provides a complete migration system for Rust: live auto-diff, file-based YAML migrations with per-database routing, and a CLI for every workflow.
-## Basic Usage
+## Two Approaches
+
+### Live Migration (No Files)
+
+Best for prototyping and simple projects. Introspects the DB, diffs against models, and applies DDL directly:
```rust
use ryx_rs::migration::MigrationRunner;
MigrationRunner::new()
- .model::()
+ .live(true)
.model::()
+ .model::()
.run().await?;
```
-This introspects your database, diffs against your model definitions, and generates the necessary DDL (CREATE TABLE, ALTER COLUMN, etc.).
+Set `.live(true)` to skip file discovery and apply changes directly. Omitting it enters the file-based pipeline (see below).
+
+### File-Based YAML Migrations
+
+Best for production and team projects. Migration files are plain YAML, discovered recursively, and tracked per-database alias:
+
+```bash
+# Generate migration files (coming soon — use Python for now)
+ryx makemigrations --dir migrations/
+
+# Apply pending migrations
+ryx migrate --dir migrations/
+
+# Preview SQL
+ryx sqlmigrate 0001_initial --dir migrations/ --backends postgres,mysql,sqlite
+
+# Check status
+ryx showmigrations --dir migrations/
+```
+
+## CLI Reference
+
+```bash
+ryx migrate [--dir DIR] [--database ALIAS] [--dry-run] [--no-interactive]
+ryx makemigrations --dir DIR [--alias ALIAS] [--check]
+ryx showmigrations [--dir DIR] [--unapplied]
+ryx sqlmigrate NAME [--dir DIR] [--backends postgres,mysql,sqlite]
+```
+
+The CLI uses ANSI colours when the terminal supports it. Set `NO_COLOR=1` to disable, or pipe output.
## How It Works
```
-1. Introspect live DB schema → SchemaState (current)
-2. Build target from models → SchemaState (desired)
-3. Diff the two states → List of changes
-4. Generate DDL → CREATE / ALTER statements
-5. Execute → Apply to database
+1. Discover all [0-9]*.yaml files recursively → sorted by numeric prefix
+2. For each DB alias, filter operations → via operation.database()
+3. Convert operations to backend-aware DDL → DDLGenerator
+4. Execute statements → per-backend SQL
+5. Track applied state → alias|stem in ryx_migrations
+```
+
+When no migration files exist, `ryx migrate` offers an **interactive menu**:
+
+```
+[ryx] No migration files exist for database 'default'
+ 2 model(s) are not yet tracked.
+
+ [L]ive DDL — apply changes directly (development only)
+ [A]uto-generate migration files, then migrate
+ [M]anual — run 'ryx makemigrations' first
+ [S]kip this database for now
+
+[ryx] Choice (L/A/M/S) [S]:
```
-## What Migrations Handle
+Use `--no-interactive` to skip the prompt in scripts or CI/CD (prints a hint and exits).
-- Creating and dropping tables
-- Adding, altering, and dropping columns
-- Creating and dropping indexes
-- Adding constraints
-- Creating ManyToMany join tables
+## Multi-Database Routing
-## Tracking
+Each model declares its database alias with `#[database("alias")]`:
+
+```rust
+#[model]
+#[database("blog")]
+#[table("posts")]
+struct Post {
+ #[field(pk)] id: i64,
+ title: String,
+}
+
+#[model]
+#[database("shop")]
+#[table("products")]
+struct Product {
+ #[field(pk)] id: i64,
+ name: String,
+}
+```
+
+The migration runner filters operations per alias automatically. The tracking table stores `alias|stem` keys:
+
+| Column | Type | Example |
+|---|---|---|
+| id | INTEGER | 1 |
+| name | TEXT | `default\|0001_initial` |
+| applied_at | TIMESTAMP | 2025-06-03 12:00:00 |
+
+Legacy entries (bare stems without `|`) are still recognised for backward compatibility.
+
+## YAML File Format
+
+Migration files are human-readable YAML, one file per migration:
+
+```yaml
+# migrations/0001_initial.yaml
+dependencies: []
+operations:
+ - type: CreateTable
+ table_name: authors
+ model_name: myapp::Author
+ database: default
+ columns:
+ - { name: id, db_type: INTEGER, pk: true, unique: true }
+ - { name: name, db_type: VARCHAR(100), nullable: false }
+
+ - type: CreateTable
+ table_name: posts
+ model_name: myapp::Post
+ database: blog
+ columns:
+ - { name: id, db_type: INTEGER, pk: true, unique: true }
+ - { name: title, db_type: VARCHAR(200), nullable: false }
+ - { name: author_id, db_type: INTEGER, nullable: false }
+```
+
+Supported operation types: `CreateTable`, `AddField`, `RemoveField`, `AlterField`, `CreateIndex`, `DeleteIndex`, `RunSQL`.
+
+## Programmatic API
+
+### MigrationRunner (Builder)
+
+```rust
+use ryx_rs::migration::MigrationRunner;
+
+// File-based (default)
+MigrationRunner::new()
+ .model::()
+ .migrations_dir("migrations/")
+ .db("blog")
+ .dry_run(true)
+ .run().await?;
+
+// Live mode — skip file discovery
+MigrationRunner::new()
+ .live(true)
+ .model::()
+ .run().await?;
+
+// Live mode with schema (PostgreSQL multi-schema)
+MigrationRunner::new()
+ .live(true)
+ .model::()
+ .schema("tenant1")
+ .run().await?;
+
+// Preview only
+let sql: Vec = MigrationRunner::new()
+ .model::()
+ .plan().await?;
+```
+
+### FileRunner (Programmatic)
+
+```rust
+use ryx_rs::migration::runner::FileRunner;
+
+let runner = FileRunner::new()
+ .model::()
+ .migrations_dir("migrations/")
+ .no_interactive(true);
+
+let statements: Vec = runner.run().await?;
+```
+
+### DDLGenerator
+
+```rust
+use ryx_rs::migration::ddl::DDLGenerator;
+use ryx_query::Backend;
+
+let ddl = DDLGenerator::new(Backend::PostgreSQL);
+
+let sql = ddl.create_table(&table_state);
+let sql = ddl.drop_table("posts");
+let sql = ddl.add_column("posts", &col_state);
+let sql = ddl.create_index("posts", "idx_title", &["title".into()], false);
+let sql = ddl.add_foreign_key("posts", "fk_author", "author_id", "authors", "id");
+
+// Bulk generation from SchemaChange list
+let all_sql = ddl.generate(&changes);
+```
+
+### Autodetector (Generate YAML files)
+
+```rust
+use ryx_rs::migration::autodetect::{Autodetector, ModelEntry};
+
+let entries = vec![
+ ModelEntry::from_model::(),
+ ModelEntry::from_model::(),
+];
+
+let detector = Autodetector::new(entries, "migrations/");
+
+// Detect changes → write migration file
+if let Some(path) = detector.run()? {
+ println!("Created {}", path.display());
+}
+
+// Or step-by-step
+let ops = detector.detect();
+if !ops.is_empty() {
+ detector.write_migration(&ops)?;
+}
+```
+
+## File Discovery
+
+Migration files are discovered **recursively** under the migrations directory. Files matching `[0-9]*.yaml` or `[0-9]*.yml` are found and sorted globally by numeric prefix:
+
+```
+migrations/
+├── 0001_initial.yaml
+├── 0002_add_views.yaml
+├── blog/
+│ ├── 0001_blog_tables.yaml
+│ └── 0002_add_tags.yaml
+└── shop/
+ └── 0001_shop_tables.yaml
+```
-Ryx creates a `ryx_migrations` table to track applied state.
+Files in subdirectories are found automatically — no per-alias configuration needed.
## Backend Differences
| Feature | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
-| `ALTER COLUMN` | Yes | Yes | No (recreate) |
+| `ALTER COLUMN` | `ALTER COLUMN ... TYPE` | `MODIFY COLUMN` | Manual rebuild required |
+| `IF NOT EXISTS` / `IF EXISTS` | Yes | Yes | Yes |
+| `DROP COLUMN` | Yes | Yes | Not supported (comment) |
| Native UUID | Yes | No | No |
-| `SERIAL` | Yes | No | No |
+| `SERIAL` | `BIGSERIAL` | `INT AUTO_INCREMENT` | `INTEGER PRIMARY KEY AUTOINCREMENT` |
+| **Database schemas** | **`"schema"."table"`** | No | No |
## Next Steps
-- **[Querying](/rust/querying/filtering)** — Start querying your data
+- **[Models](./models)** — Define models with `#[model]`
+- **[Querying](../querying/filtering)** — Start querying your data
diff --git a/docs/doc/rust/core-concepts/queryset.mdx b/docs/doc/rust/core-concepts/queryset.mdx
index 2d0e064..f5e2e96 100644
--- a/docs/doc/rust/core-concepts/queryset.mdx
+++ b/docs/doc/rust/core-concepts/queryset.mdx
@@ -4,7 +4,21 @@ sidebar_position: 5
# QuerySet
-`QuerySet` is the lazy, chainable query builder. Every model's `objects()` manager returns one.
+`QuerySet` is the lazy, chainable query builder. The canonical entry point is
+`ObjectsManager::::new()`.
+
+Some examples use a short `Post::objects()` helper. That helper is not required
+by the macro; define it yourself if you want the shorter style:
+
+```rust
+use ryx_rs::ObjectsManager;
+
+impl Post {
+ fn objects() -> ObjectsManager {
+ ObjectsManager::new()
+ }
+}
+```
## Entry Points
diff --git a/docs/doc/rust/getting-started/quick-start.mdx b/docs/doc/rust/getting-started/quick-start.mdx
index e68d222..93ec2d8 100644
--- a/docs/doc/rust/getting-started/quick-start.mdx
+++ b/docs/doc/rust/getting-started/quick-start.mdx
@@ -10,6 +10,7 @@ Let's go from zero to a working query in 5 minutes.
```rust
use ryx_rs::model;
+use ryx_rs::ObjectsManager;
#[model]
#[table("posts")]
@@ -35,6 +36,7 @@ use ryx_rs::migration::MigrationRunner;
async fn run() -> ryx_rs::RyxResult<()> {
ryx_rs::init().await?;
MigrationRunner::new()
+ .live(true) // apply directly — no files needed
.model::()
.run().await?;
Ok(())
@@ -43,10 +45,22 @@ async fn run() -> ryx_rs::RyxResult<()> {
`init()` reads a `ryx.toml` or uses the `DATABASE_URL` env var.
+:::tip
+For production, use **file-based YAML migrations** with the CLI:
+
+```bash
+ryx makemigrations --dir migrations/
+ryx migrate --dir migrations/
+ryx showmigrations
+```
+
+See [Migrations](../core-concepts/migrations) for the full guide.
+:::
+
## 3. Create Records
```rust
-let post = Post::objects().create()
+let post = ObjectsManager::::new().create()
.set("title", "Hello Ryx")
.set("slug", "hello-ryx")
.set("views", 42i64)
@@ -58,24 +72,24 @@ let post = Post::objects().create()
```rust
// All active posts, newest first
-let posts: Vec = Post::objects()
+let posts: Vec = ObjectsManager::::new()
.filter("active", true)
.order_by("-views")
.all().await?;
// First match
-let first: Option = Post::objects()
+let first: Option = ObjectsManager::::new()
.filter("active", true)
.order_by("title")
.first().await?;
// Count
-let count = Post::objects()
+let count = ObjectsManager::::new()
.filter("active", true)
.count().await?;
// Check existence
-let exists = Post::objects()
+let exists = ObjectsManager::::new()
.filter("title__startswith", "Draft")
.exists().await?;
```
@@ -84,13 +98,13 @@ let exists = Post::objects()
```rust
// Bulk update
-Post::objects()
+ObjectsManager::::new()
.filter("active", false)
.update(vec![("active", true)])
.await?;
// Bulk delete
-Post::objects()
+ObjectsManager::::new()
.filter("views", 0i64)
.delete().await?;
```
@@ -100,6 +114,7 @@ Post::objects()
```rust
use ryx_rs::model;
use ryx_rs::migration::MigrationRunner;
+use ryx_rs::ObjectsManager;
#[model]
#[table("posts")]
@@ -115,22 +130,23 @@ struct Post {
#[tokio::main]
async fn main() -> ryx_rs::RyxResult<()> {
ryx_rs::init().await?;
- MigrationRunner::new().model::().run().await?;
+ MigrationRunner::new().live(true).model::().run().await?;
- Post::objects().create()
+ ObjectsManager::::new().create()
.set("title", "Hello Ryx")
.set("slug", "hello-ryx")
.set("views", 100i64)
.save().await?;
- let posts: Vec = Post::objects()
+ let posts: Vec = ObjectsManager::::new()
.filter("active", true)
.order_by("-views")
.all().await?;
println!("Found {} posts", posts.len());
- let stats = Post::objects()
+ let stats = ObjectsManager::::new()
+ .all()
.aggregate(&[
ryx_rs::agg::count("total", "id"),
ryx_rs::agg::avg("avg", "views"),
diff --git a/docs/doc/rust/index.mdx b/docs/doc/rust/index.mdx
index 3178ab6..de4e3f7 100644
--- a/docs/doc/rust/index.mdx
+++ b/docs/doc/rust/index.mdx
@@ -8,7 +8,7 @@ slug: /rust
Ryx is a Django-style ORM for Rust. Async-native, with zero Python dependencies.
```rust
-use ryx_rs::model;
+use ryx_rs::{model, ObjectsManager};
#[model]
struct Post {
@@ -19,12 +19,24 @@ struct Post {
active: bool,
}
-let posts = Post::objects()
+let posts = ObjectsManager::::new()
.filter("active", true)
.order_by("-views")
.all().await?;
```
+Many older snippets use a convenience helper named `Post::objects()`. The macro
+does not need that helper; the canonical entry point is
+`ObjectsManager::::new()`. If you prefer the shorter style, define it once:
+
+```rust
+impl Post {
+ fn objects() -> ObjectsManager {
+ ObjectsManager::new()
+ }
+}
+```
+
## Crates
| Crate | Description |
@@ -37,12 +49,15 @@ let posts = Post::objects()
| Feature | Python (ryx) | Rust (ryx-rs) |
|---|---|---|
| Model definition | Class + Fields | `#[model]` struct |
-| QuerySet | `Post.objects` | `Post::objects()` |
+| QuerySet | `Post.objects` | `ObjectsManager::::new()` or a user-defined `Post::objects()` helper |
| Filtering | `.filter(active=True)` | `.filter("active", true)` |
| Q objects | `Q(active=True) \| Q(...)` | `Q::or(Q::new(...), ...)` |
| Create | `.create(title="...")` | `.create().set("title", "...").save()` |
| Migrations | `MigrationRunner([Post])` | `MigrationRunner::new().model::()` |
| Relations | `ForeignKey(Author)` | `#[relation(model = "Author", ...)]` |
+| CLI | `python -m ryx migrate` | `ryx migrate` |
+| File-based migrations | `.py` files + Autodetector | `.yaml` files + Autodetector |
+| Multi-DB routing | `Meta.database` + `model=` | `#[database("alias")]` |
## Next Steps
diff --git a/docs/doc/rust/reference.mdx b/docs/doc/rust/reference.mdx
new file mode 100644
index 0000000..21a5617
--- /dev/null
+++ b/docs/doc/rust/reference.mdx
@@ -0,0 +1,292 @@
+---
+sidebar_position: 99
+---
+
+# Rust API Reference
+
+The Rust API lives in `ryx-rs` and reuses the shared crates:
+
+- `ryx-query` for query AST, lookups, and SQL compilation
+- `ryx-backend` for sqlx-backed PostgreSQL, MySQL, and SQLite execution
+- `ryx-common` for shared errors, pool config, and SQL values
+- `ryx-macro` for model derive and attribute macros
+
+## Initialization
+
+```rust
+use ryx_rs::{init, RyxResult};
+
+#[tokio::main]
+async fn main() -> RyxResult<()> {
+ init().await?;
+ Ok(())
+}
+```
+
+`init()` loads config files and environment variables, initializes tracing from
+`RYX_LOG_LEVEL`, and initializes the global pool if configuration is present.
+
+## Model Macros
+
+```rust
+use ryx_rs::model;
+
+#[model]
+struct Post {
+ #[field(pk)]
+ id: i64,
+ title: String,
+ views: i64,
+ active: bool,
+}
+```
+
+The public model traits are:
+
+| Trait | Purpose |
+|---|---|
+| `Model` | Table name, field names, primary key, field metadata, database alias |
+| `FromRow` | Convert decoded backend rows into model instances |
+| `Relationships` | Optional relationship metadata for `select_related()` |
+
+## Objects Manager
+
+```rust
+use ryx_rs::ObjectsManager;
+
+let posts = ObjectsManager::::new().all().all().await?;
+let active = ObjectsManager::::new().filter("active", true).all().await?;
+let post = ObjectsManager::::new().all().get("id", 1).await?;
+
+let created = ObjectsManager::::new()
+ .create()
+ .set("title", "Hello")
+ .set("views", 0)
+ .set("active", true)
+ .save()
+ .await?;
+```
+
+`ObjectsManager::::new()` exposes:
+
+| Method | Purpose |
+|---|---|
+| `.all()` | Start a `QuerySet` |
+| `.filter(field, value)` | Start a filtered queryset |
+| `.exclude(field, value)` | Start an excluded queryset |
+| `.get(field, value)` | Start a queryset filtered for `get()` |
+| `.create()` | Start an `InsertBuilder` |
+
+## QuerySet
+
+```rust
+let posts = ObjectsManager::::new()
+ .all()
+ .filter(("views__gte", 100))
+ .exclude(("active", false))
+ .order_by("-views")
+ .limit(20)
+ .offset(40)
+ .all()
+ .await?;
+```
+
+Query building:
+
+| Method | Purpose |
+|---|---|
+| `.using(alias)` | Force a database alias |
+| `.schema(schema)` | Target a PostgreSQL schema |
+| `.filter(arg)` | Add a field lookup or `Q` object |
+| `.exclude(arg)` | Add a negated field lookup or negated `Q` |
+| `.order_by(field)` | Add one ordering clause |
+| `.order_by_all(fields)` | Add multiple ordering clauses |
+| `.limit(n)` | Add `LIMIT` |
+| `.offset(n)` | Add `OFFSET` |
+| `.distinct()` | Add `DISTINCT` |
+| `.sql()` | Return compiled SQL for debugging |
+
+Execution:
+
+| Method | Return |
+|---|---|
+| `.all().await` | `RyxResult>` |
+| `.get(field, value).await` | `RyxResult` |
+| `.first().await` | `RyxResult