orbit-orm 0.3.0

An ORM for Rocket.rs apps, with MySQL and MariaDB, PostgreSQL and SQLite
Documentation
# Differences from Laravel

Orbit follows Laravel's Eloquent and query builder, with these exceptions. Each one is listed here so it is not a surprise to someone who knows Laravel.

- **`first()` has no default order.** As in Laravel, `first()` takes the first row the database returns. Call `order_by` when the row matters. Laravel's `sole`, `chunk` and `lazy` enforce an order; Orbit does not add one.
- **Columns outside `FILLABLE` are an error.** `insert` and `update` return `Error::NotFillable` for a column that is not in the model's `FILLABLE` list. Laravel's `Model::create` drops such an attribute without a message, so a misspelt column is ignored. Orbit stops instead, so the mistake shows up at once. Laravel's query builder does not check `$fillable` at all; Orbit checks it on every write.
- **`insert` returns the id.** It returns the auto-increment id as `u64`. Laravel's query builder `insert` returns a boolean, and `insertGetId` returns the id.
- **`where_eq(column, None)` is `IS NULL`**, and `Operator::Ne` with `None` is `IS NOT NULL`, as in Laravel. Other operators with `None` match nothing, where Laravel throws.
- **Terminal methods take the database.** `get(&db)` and the other terminal methods take the `Database` explicitly. Laravel uses a default connection.
- **Transactions take an async closure that receives the transaction.** `Database::transaction` passes the transaction as `tx`, and queries run on it when given `&mut *tx`. Laravel's `DB::transaction` takes no argument and uses the default connection. The closure's error type must convert from `Error`.
- **Transactions do not retry.** Laravel's `DB::transaction($callback, $attempts)` retries on a deadlock. Orbit returns `Error::Deadlock` and leaves the retry to the caller.
- **Grouped conditions are `where_group` and `or_where_group`.** They take a closure that receives an empty query, as Laravel's `where(fn)` does. Only the conditions are kept from the group; `order_by`, `limit` and the other settings inside it are ignored.
- **Errors are typed.** Unique and foreign key violations, deadlocks and lock wait timeouts are variants of `Error`. Laravel's `QueryException` carries the SQLSTATE code instead. The index or constraint name is read from the MySQL message, or taken from the driver on PostgreSQL; SQLite gives none. See [Differences between databases](#differences-between-databases).
- **`replace` is Diesel's `replace_into`, not Laravel's `upsert`.** `Query::replace` issues `REPLACE INTO` on MySQL and SQLite, with Diesel's semantics. It deletes the row that has the same unique key or primary key, then inserts the new one. So the auto-increment id changes, columns not in the values take their defaults, and the returned count is the rows affected as the database reports them (2 for a replaced row on MySQL, 1 on SQLite). Laravel's `upsert` is `INSERT ... ON DUPLICATE KEY UPDATE` or `ON CONFLICT`, which updates the existing row in place and keeps its id. That is a different operation, and Orbit does not implement it. PostgreSQL has no `REPLACE`; see [Differences between databases](#differences-between-databases).
- **Raw SQL returns typed rows.** `orbit_orm::raw::fetch_all` and `fetch_optional` decode each row as a type that implements `FromRow`, such as a tuple or a struct. Laravel's `DB::select` returns `stdClass` objects. As with Laravel's raw queries, the SQL string is not escaped, so values go in the binds, which Orbit sends as parameters.
- **Timestamps are naive.** `Value::DateTime` holds a `NaiveDateTime`, which has no time zone. Orbit writes it and reads it back as given, as MySQL stores `DATETIME` and PostgreSQL stores `TIMESTAMP WITHOUT TIME ZONE`. It does not convert between time zones, so a Carbon-style zone is not applied.
- **Migrations table.** The applied migrations are recorded in `__diesel_schema_migrations`, Diesel's table, not in Laravel's `migrations`. This is the owner's decision, and it overrides the Laravel default for this crate. It is the same name on every database (MySQL, PostgreSQL and SQLite), with the same columns; see [Differences between databases](#differences-between-databases). The table has Diesel's columns: `version VARCHAR(50) NOT NULL PRIMARY KEY`, which holds the 14-digit timestamp prefix of the folder name (Diesel's "version"), and `run_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP`. Orbit adds `batch INT NOT NULL DEFAULT 0` for batch rollback. Lookups match on the prefix, which is unique because the constructor rejects duplicate timestamps. Because the table is shared, a database that Diesel migrated already has its history, in batch 0. A table that has only Diesel's two columns is upgraded in place the first time Orbit uses it: `batch` is added with `ALTER TABLE ... ADD COLUMN`, and no row or column is dropped or renamed. The upgrade runs first in `run`, `status` and `rollback`, and running it again does nothing. `unmatched_diesel_versions` only reads the table and does not upgrade it. Rollback reverts the highest batch, in reverse order. Batch 0 is the history Diesel recorded, so a rollback that reaches it reverts those migrations and removes their rows from Diesel's table, and Diesel would then run them again.
- **`import_diesel_history` is deprecated since 0.1.1.** It was the step that upgraded a Diesel table, and it now only returns an empty list. Nothing needs to be called after the table is upgraded: `run`, `status` and `rollback` do that upgrade on first use, and Diesel's rows are already the applied history in the shared table. The function stays so existing callers still compile, and it can be removed in a later release. Callers that still use it get a deprecation warning, which `#[allow(deprecated)]` silences.
- **The blocking API refuses async threads.** `orbit_orm::blocking` (feature `blocking`) runs the async queries on a runtime that the `Database` owns. Laravel has no sync or async split, so this follows Diesel, which Ignite uses. A blocking call on a thread that runs async code returns `Error::BlockingInRuntime`, rather than panicking inside Tokio or stalling the runtime. Call it from `tokio::task::spawn_blocking`, as Ignite does. Each call blocks its thread, so the blocking pool is the place for it. The `Database` shuts its runtime down in the background when dropped, so dropping it on an async thread is safe.

## Schema builder

`orbit_orm::schema` follows Laravel's `Schema` and `Blueprint`, with these exceptions.

- **`create_if_not_exists` is not in Laravel.** A Laravel migration writes `if (! Schema::hasTable('users'))`, which asks a live connection. A schema migration here is rendered without one, so the check is in the SQL: `CREATE TABLE IF NOT EXISTS`. An existing table is left as it is, even when its columns differ. On PostgreSQL and SQLite a plain `index` is a separate `CREATE INDEX IF NOT EXISTS`, so it is still created when the table already exists. On MySQL the index is inside `CREATE TABLE` and is skipped with the table.
- **One statement per operation.** In `schema::table`, each added column, changed column, rename, drop and key is its own statement, on every database. Laravel combines several into one `ALTER TABLE` on MySQL and PostgreSQL. The end schema is the same; only the number of statements differs. The statements run in call order, as in Laravel 11 and later, so a column can be dropped or renamed and then added again under the same name.
- **Adding a column to a SQLite table that has rows.** SQLite cannot add a column with `use_current` ("Cannot add a column with non-constant default") or a `NOT NULL` column without a default to a table that has rows. Laravel has the same limit.
- **`.change()` is unsupported on SQLite.** SQLite cannot change a column in place. Laravel 11 rebuilds the table, which needs the live schema; Orbit returns `Error::Unsupported` instead. `.change()` on an `id()` column or on a column marked `.primary()` is `Error::Unsupported` on every database.
- **Foreign keys on an existing SQLite table are skipped.** SQLite cannot add a constraint to an existing table. A `foreign` in `schema::table` renders nothing on SQLite, and `drop_foreign` renders nothing there. Laravel 10 skipped adding the foreign key on an existing SQLite table, but threw an exception on dropping one; Orbit skips both so `up` and `down` stay symmetric. Laravel 11 rebuilds the table. A foreign key declared in `schema::create` works on SQLite as usual.
- **`primary` on an existing SQLite table is unsupported.** For the same reason, `primary([...])`, `.primary()` and `id()` in `schema::table` are `Error::Unsupported` on SQLite.
- **`primary` takes no name.** Laravel's `primary($columns, $name)` accepts a constraint name, which MySQL and Laravel's PostgreSQL grammar do not use. Orbit's `primary` takes the columns only.
- **`Column::primary` is inline.** `.primary()` writes `PRIMARY KEY` at the end of the column definition. Laravel adds a separate primary key command for the table. The schema is the same.
- **`drop_index` and `drop_foreign` take a name only.** Laravel also accepts an array of columns and builds the default name from it. Pass the name, for example `drop_foreign("posts_user_id_foreign")`.
- **No `drop_unique` or `drop_primary`.** On MySQL `drop_index` drops a unique index too. On SQLite it drops a unique only when `unique` added it in `schema::table` (`CREATE UNIQUE INDEX`); a unique declared in `schema::create` is an inline constraint and cannot be dropped with it. On PostgreSQL `unique` always adds a constraint, and `DROP INDEX` cannot drop the index of a constraint, so `drop_index` does not drop a unique there.
- **`rename_column` has no fallback for old MySQL servers.** It renders `RENAME COLUMN`, which needs MySQL 8.0.3 or later, or MariaDB 10.5.2 or later. Laravel falls back to `CHANGE old new <definition>` on older servers; Orbit does not.
- **`timestamps()` creates `date_time` columns.** Laravel's `timestamps()` creates `timestamp` columns. Orbit 0.2.0 already rendered `DATETIME` (`TIMESTAMP(0) WITHOUT TIME ZONE` on PostgreSQL), and that SQL is kept. Declare `timestamp("created_at").nullable()` and `timestamp("updated_at").nullable()` for Laravel's types. Laravel's are nullable, and a `NOT NULL` `TIMESTAMP` gets an implicit default on MariaDB before 10.10.
- **`useCurrentOnUpdate` is not provided.** `use_current` sets the default to the current time. Nothing sets `ON UPDATE CURRENT_TIMESTAMP`; write it with `schema::statement` and `only(Dialect::MySql)` when it is needed.
- **`TIMESTAMP` on MySQL and MariaDB.** With `explicit_defaults_for_timestamp` off (the MariaDB default before 10.10), the first `TIMESTAMP` column of a table that is `NOT NULL` and has no default gets `DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` from the server. Laravel's columns get the same. `.nullable()` or a default avoids it.
- **`precision` is not range-checked.** The valid range is 0 to 6. MySQL and MariaDB reject a larger value, and PostgreSQL lowers it to 6 with a warning.
- **`binary` is `BLOB` on MySQL**, as Laravel's `binary`, which holds up to 64 KB. For a `MEDIUMBLOB`, use `schema::statement` with `only(Dialect::MySql)`, as a Laravel migration uses `DB::statement` behind a driver check.
- **`Statement::only` replaces a driver check.** A Laravel migration writes `if (DB::getDriverName() === 'mysql') { ... }`. A schema migration has no connection while it is built, so `.only(Dialect::MySql)` marks the statement instead, and it renders nothing on the other databases.
- **`schema::statement` takes no bindings.** Laravel's `DB::statement` accepts bound values. `schema::statement` takes the SQL only, and runs it as written on every database. Quote values in the SQL yourself, and never put user input in it.

## Differences between databases

Orbit uses one API on MySQL and MariaDB, PostgreSQL and SQLite. Each row below is a behaviour that differs by database, and how Orbit handles it. Laravel has the same kinds of differences between its grammars, and follows the same choices where Laravel has one.

| Area | MySQL / MariaDB | PostgreSQL | SQLite |
|---|---|---|---|
| Placeholders | `?` | `$1`, `$2`, ... (Orbit numbers them) | `?` |
| Identifier quotes | backticks | double quotes | double quotes |
| Identifier check | rejects empty names and names with a backtick or a double quote, on every database | same | same |
| `replace` | `REPLACE INTO`; returns 2 for a replaced row | **`Error::Unsupported`**: `REPLACE` does not exist, and `INSERT ... ON CONFLICT` keeps the old row, so it would not be Diesel's `replace` | `REPLACE INTO`; returns 1 for a replaced row |
| `for_update` | `FOR UPDATE` | `FOR UPDATE` | no lock clause (a no-op). SQLite serialises writers itself, and Laravel's SQLite grammar also omits the lock |
| `for_update` with `count` / `exists` | `FOR UPDATE` is allowed | PostgreSQL rejects `FOR UPDATE` on an aggregate, so the query fails | no-op, as above |
| Insert id | `last_insert_id` | `RETURNING "pk"::BIGINT`; the primary key must be an integer column | `last_insert_rowid` |
| Empty insert | `INSERT ... () VALUES ()` | `INSERT ... DEFAULT VALUES` | `INSERT ... DEFAULT VALUES` |
| `offset` without `limit` | `LIMIT 18446744073709551615` | `OFFSET` alone | `LIMIT -1` |
| Rows affected by `update` and `delete` | rows matched (the driver is asked for found rows) | rows matched | rows matched |
| Unique violation | `Error::UniqueViolation`; index name from the message | `Error::UniqueViolation`; constraint name from the driver | `Error::UniqueViolation`; no index name |
| Foreign key violation | `Error::ForeignKeyViolation`; constraint name from the message | `Error::ForeignKeyViolation`; constraint name from the driver | `Error::ForeignKeyViolation`; no constraint name |
| Deadlock | error 1213 | SQLSTATE 40P01 | none (not raised) |
| Lock wait timeout | error 1205 | SQLSTATE 55P03 | none; a write that waits longer than the driver's busy timeout fails as a database error |
| `u64` values | bound as unsigned | bound as `i64`; larger values are an error | bound as `i64`; larger values are an error |
| `NULL` values | bound | written into the SQL as `NULL`, because PostgreSQL types a bound parameter | bound |
| Date and time | `DATETIME(6)` | `TIMESTAMP(6)` | `TEXT` in sqlx's format (`YYYY-MM-DD HH:MM:SS.ffffff`) |
| `NaiveDate` | `DATE` | `DATE` | `TEXT` (`YYYY-MM-DD`) |
| `bool` | `TINYINT(1)` | `BOOLEAN` | `INTEGER` (0 or 1) |
| Bytes | `MEDIUMBLOB` (bound values; the `binary` column type is `BLOB`) | `BYTEA` | `BLOB` |
| Migrations table | `__diesel_schema_migrations`; `version VARCHAR(50)`, `run_on TIMESTAMP`, `batch INT` | the same name and columns; `TIMESTAMP` is without time zone | the same name; `version TEXT`, `run_on TEXT`, `batch INTEGER` |
| Column check on upgrade | `information_schema.columns` | `information_schema.columns` | `pragma_table_info` |
| Migrations lock | named lock `GET_LOCK('orbit_migrations:<database>')` | advisory lock `pg_try_advisory_lock` on a key from an FNV-1a hash of `orbit_migrations:<database>`, polled until the timeout | process-local only (see below) |
| Migration DDL | not rolled back on failure | rolled back on failure | rolled back on failure |
| Schema: `.change()` | `MODIFY COLUMN` | `ALTER COLUMN ... TYPE`, then nullability and default | **`Error::Unsupported`** |
| Schema: `foreign` and `drop_foreign` in `schema::table` | `ADD CONSTRAINT`, `DROP FOREIGN KEY` | `ADD CONSTRAINT`, `DROP CONSTRAINT` | render nothing |
| Schema: `primary` and `id()` in `schema::table` | `ADD PRIMARY KEY`, `ADD COLUMN` | `ADD PRIMARY KEY`, `ADD COLUMN` | **`Error::Unsupported`** |
| Schema: `unique` in `schema::table` | `ADD UNIQUE` | `ADD CONSTRAINT ... UNIQUE` | `CREATE UNIQUE INDEX` |
| Schema: `drop_index` on a unique | drops it | does not drop it (it is a constraint) | drops it when `schema::table` added it |
| Schema: plain index with `create_if_not_exists` | inside `CREATE TABLE`, skipped with the table | `CREATE INDEX IF NOT EXISTS` | `CREATE INDEX IF NOT EXISTS` |
| Schema: `rename_column` | MySQL 8.0.3+, MariaDB 10.5.2+ | any | SQLite 3.25+ |
| Schema: `drop_column` | any | any | SQLite 3.35+ |

**Migrations table name.** The table is `__diesel_schema_migrations` on every database. The owner decided this, so it is a difference from Laravel's `migrations` on every driver, not only MySQL. Diesel and Orbit share the table on all three databases.

**`replace` on PostgreSQL.** `Query::replace` returns `Error::Unsupported` on PostgreSQL. It is not implemented as `INSERT ... ON CONFLICT (pk) DO UPDATE`, because that updates the row in place and keeps its id, which is not Diesel's delete-then-insert. It is also not implemented as a delete followed by an insert, because a separate statement is not atomic unless it runs in a transaction the caller did not ask for. Callers who want an upsert on PostgreSQL can use `raw::execute` with their own `ON CONFLICT` statement. Laravel's `upsert` is the Laravel answer for that case, and Orbit does not wrap it.

**`for_update` on SQLite.** SQLite has no row locks. `for_update` is accepted and emits no lock clause, so `get`, `first` and `find` run without one. Writers are serialised by SQLite's database lock, which is what a `FOR UPDATE` would otherwise give. A read-then-write in a transaction is therefore not protected by `for_update` on SQLite; use `BEGIN IMMEDIATE` through `raw::execute` if the read must block other writers.

**The migrations lock on SQLite.** SQLite has no named or advisory lock. Orbit keeps a set of held lock names in the process, keyed by the database file path, and a second `run` or `rollback` in the same process waits for it up to the lock timeout, then fails with `MigrationsLocked`. A second process is not held apart by this lock. Both processes write through SQLite's own locking, so they can both reach the migrations table, and one may fail on a duplicate version. Run migrations from one process when SQLite is the database, for example from a deploy step. In-memory SQLite is one database per connection, so the migrations lock and the migrations are per connection; use a file.

**Upgrading the migrations table.** `ensure_table` checks for the `batch` column with each database's catalog, adds it when it is missing, and changes nothing else. A duplicate column error from a concurrent process is accepted: MySQL 1060, PostgreSQL 42701, or SQLite's "duplicate column name".

**Identifiers.** A table or column name that contains a double quote is rejected on every database, so the same names work everywhere. Before this change, MySQL accepted a double quote inside a backtick-quoted name. Such a name is now `Error::InvalidIdentifier`.

**Unsupported operations.** The `Error::Unsupported` variant names an operation and the database that lacks it. It is returned instead of a silently different result.

**Models on several databases.** The `OrbitModel` derive emits one `FromRow` impl generic over the sqlx row type, so it works on every enabled database. A model that you build with more than one database feature must decode every type that each enabled database supports. A `u64` field, for example, does not decode on PostgreSQL or SQLite, so it cannot be used when those features are on. A single-database build is not affected.

**Choosing the database.** The URL's scheme chooses the database; there is no default. `Database::pool()`, `Transaction::connection()` and `blocking::Database::pool()` return MySQL types, exist only with the `mysql` feature, and panic on another database. Check `driver()` first, or use `postgres_pool()` and `sqlite_pool()`.

## Matches Laravel, with limits

- **`for_update` runs outside a transaction.** Outside a transaction, `for_update` runs `SELECT ... FOR UPDATE` in autocommit, and the lock lasts for that statement. This matches Laravel's `lockForUpdate()`.
- **The default table name follows Laravel's inflector, with gaps.** Without `table`, the name is `Str::snake(Str::pluralStudly(name))`, as in Eloquent: only the last word is pluralized, so `UserProfile` gives `user_profiles` and `Person` gives `people`. The plural rules are ported from doctrine/inflector 2.1.x, the English rules Laravel's `Pluralizer` uses, with the same uncountable words, irregular words and suffix rules in the same order. Checked against Laravel v13.42.0 (`illuminate/support` 13.42.0), which requires `doctrine/inflector ^2.0.5` and resolves to 2.1.1: 1,693 distinct class names (1,862 rows over two lists) give the same `Str::snake(Str::pluralStudly(name))` as Orbit. Before the fixes from that check, 13 of them differed (`Media`, `People`, `Trivia`, `Maltese` and `Testis`, alone and in compounds); those are fixed. Gaps: the rules are ASCII only, so a name that ends in a non-ASCII letter gets a plain `s`; the check covered doctrine/inflector 2.1.1 only, and names outside the tested set are not proven to match every doctrine rule; a word that matches no rule gets an `s`, as Laravel does. `table = "..."` overrides the default, and an explicit name is used as written.