Skip to content
2 changes: 1 addition & 1 deletion docs/pages/guide/connections.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Rules for closing a session:

The [`Session`](../api/connection.md#ferro.Session) and [`engines`](../api/connection.md#ferro.engines) reference entries list every argument, including `settings=` for [row-level security](../concepts/row-level-security.md#session-settings).

!!! note "Changed in 0.13"
!!! note "Changed in 0.14"
Calls outside a session used to run on the default connection; they now raise `RuntimeError`. The [upgrade guide](../upgrading.md#connection-routing-requires-a-session) shows how to move old call sites into sessions.

## Connection options
Expand Down
10 changes: 5 additions & 5 deletions docs/pages/guide/mutations.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ The fetch, the partial save and the refresh send these statements. PostgreSQL al
LIMIT ?;

UPDATE "customer"
SET "updated_at" = ?, "name" = ?
SET "name" = ?, "updated_at" = ?
WHERE "id" = ?;

SELECT "customer"."email", "customer"."id", "customer"."name", "customer"."plan", "customer"."updated_at", "customer"."views"
Expand All @@ -316,7 +316,7 @@ The fetch, the partial save and the refresh send these statements. PostgreSQL al
LIMIT $2;

UPDATE "customer"
SET "updated_at" = CAST($1 AS timestamptz), "name" = $2
SET "name" = $1, "updated_at" = CAST($2 AS timestamptz)
WHERE "id" = $3;

SELECT "customer"."email", "customer"."id", "customer"."name", "customer"."plan", "customer"."updated_at", "customer"."views"
Expand All @@ -325,7 +325,7 @@ The fetch, the partial save and the refresh send these statements. PostgreSQL al
LIMIT $2;
```

The `UPDATE` sets only `name` and `updated_at`. Ferro writes the columns of a partial save in no fixed order, so a log may show them the other way round.
The `UPDATE` sets only `name` and `updated_at`.

`save(exclude=...)` writes every column except the ones you name:

Expand All @@ -342,7 +342,7 @@ The save and the refresh send these statements. PostgreSQL also casts the `datet
```sql
-- mutations.py:save-exclude (sqlite)
UPDATE "customer"
SET "updated_at" = ?, "name" = ?, "views" = ?, "email" = ?
SET "email" = ?, "name" = ?, "views" = ?, "updated_at" = ?
WHERE "id" = ?;

SELECT "customer"."email", "customer"."id", "customer"."name", "customer"."plan", "customer"."updated_at", "customer"."views"
Expand All @@ -356,7 +356,7 @@ The save and the refresh send these statements. PostgreSQL also casts the `datet
```sql
-- mutations.py:save-exclude (postgres)
UPDATE "customer"
SET "updated_at" = CAST($1 AS timestamptz), "name" = $2, "views" = $3, "email" = $4
SET "email" = $1, "name" = $2, "views" = $3, "updated_at" = CAST($4 AS timestamptz)
WHERE "id" = $5;

SELECT "customer"."email", "customer"."id", "customer"."name", "customer"."plan", "customer"."updated_at", "customer"."views"
Expand Down
20 changes: 14 additions & 6 deletions docs/pages/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ description: "What upgrading Ferro asks of your code, release by release, and ho

# Upgrading between versions

This guide lists what upgrading Ferro asks of your code, release by release from `0.11` to `0.25`; the [changelog](changelog.md) is the exhaustive record. After an upgrade, a query reads like this, inside a session:
This guide lists what upgrading Ferro asks of your code, release by release from `0.11` to `0.26`; the [changelog](changelog.md) is the exhaustive record. After an upgrade, a query reads like this, inside a session:

```python {test=run prelude=upgrade}
from ferro import engines
Expand Down Expand Up @@ -47,11 +47,12 @@ Sections run newest first. From version X, read every section for a release newe

| You are on | Moving to | Read |
| --- | --- | --- |
| `0.24.x` | `0.25` | [Upgrading to 0.25](#upgrading-to-025) |
| `0.15.x` | `0.25` | Every `0.16` to `0.25` section, and the [no-action table](#releases-that-need-no-action) |
| `0.14.x` | `0.25` | The `0.15` section as well |
| `0.12.x` or `0.13.x` | `0.25` | The `0.14` section as well |
| `0.11.x` or earlier | `0.25` | Every section |
| `0.25.x` | `0.26` | [Upgrading to 0.26](#upgrading-to-026) |
| `0.24.x` | `0.26` | [Upgrading to 0.26](#upgrading-to-026) and [Upgrading to 0.25](#upgrading-to-025) |
| `0.15.x` | `0.26` | Every `0.16` to `0.26` section, and the [no-action table](#releases-that-need-no-action) |
| `0.14.x` | `0.26` | The `0.15` section as well |
| `0.12.x` or `0.13.x` | `0.26` | The `0.14` section as well |
| `0.11.x` or earlier | `0.26` | Every section |
| `0.11.x` or earlier | `0.13` | [Upgrading to 0.12](#upgrading-to-012) only |

Each section with code to change includes a copy/paste prompt for a coding agent. If you cross more than one boundary, run the prompts **newest first, one at a time**, and run your tests between them.
Expand All @@ -68,6 +69,13 @@ The run catches every change a release announced with a `DeprecationWarning`. It

If you are moving from `0.12` or earlier straight past `0.14`, the deprecated APIs are already removed there and fail with `AttributeError` or `RuntimeError`, not a warning, so the run above catches nothing. Run your suite on `0.13` first, or apply the prompts before you upgrade.

## Upgrading to 0.26

`0.26` asks nothing of code that runs today. Two changes can show up in a type checker or an import:

- `Session.query` is now typed: it takes `type[M]` and returns `Query[M]`, so a type checker may flag a call that passes a class that is not a `Model`.
- The undocumented module `ferro.migrations.chunked` is now the private `ferro.migrations._chunk_loop`. `from ferro.migrations import chunked` still imports the [`chunked`](api/migrations.md#ferro.migrations.chunked) decorator, unchanged.

## Upgrading to 0.25

`0.25` adds [`ferro migrate squash`](reference/cli.md#ferro-migrate-squash-name-from-target), which asks nothing of you. It also writes two things `0.24` cannot read, which matters only while both versions share one migrations directory and one database, as in a rolling deploy:
Expand Down
13 changes: 12 additions & 1 deletion src/ferro/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,18 @@ class PoolConfig(BaseModel):
"""

@model_validator(mode="after")
def _check_bounds(self) -> "PoolConfig":
def validate(self) -> "PoolConfig":
"""Check that `min_connections` does not exceed `max_connections`.

Pydantic runs it on construction, so a `PoolConfig` you hold has
already passed it.

Returns:
This configuration, unchanged.

Raises:
ValueError: `min_connections` exceeds `max_connections`.
"""
if self.min_connections > self.max_connections:
raise ValueError("min_connections cannot exceed max_connections")
return self
Expand Down
23 changes: 18 additions & 5 deletions src/ferro/_bind_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,16 +148,28 @@ def canonicalize_wire_scalar(value: Any) -> Any:


def save_bind_payload(instance: Any) -> dict[str, Any]:
"""Column->value map for ``save``/``bulk_create``.
"""Column->value map for ``save``/``bulk_create``, in field order.

Columns go through pydantic ``model_dump(mode="json")`` (honoring field
serializers/aliases), except bytes and secret columns, which are overlaid
serializers/aliases), except bytes and secret columns, which are bound
raw: bytes verbatim, a secret as ``unwrap_secret`` gives it.

The map is in field declaration order, so a statement lists its columns
the same way on every call: the dump follows the instance's ``__dict__``,
which a fetch fills in the ``SELECT``'s column order, and the raw columns
are bound apart from it.
"""
raw = _raw_bound_fields(instance)
payload: dict[str, Any] = instance.model_dump(mode="json", exclude=set(raw))
payload.update(raw)
return payload
ordered: dict[str, Any] = {}
for name in type(instance).model_fields:
if name in raw:
ordered[name] = raw[name]
elif name in payload:
ordered[name] = payload[name]
for name, value in payload.items():
ordered.setdefault(name, value)
return ordered


def normalize_save_columns(names: object, *, param: str) -> set[str]:
Expand Down Expand Up @@ -255,7 +267,8 @@ def _keep_write_set(
f"save({param}=...) bind payload is missing "
f"{sorted(missing)!r}. Legal persisted columns: {legal}."
)
return {key: payload[key] for key in keep}
# The payload is in field order; a set's order changes with the hash seed.
return {key: value for key, value in payload.items() if key in keep}


def apply_save_only(
Expand Down
2 changes: 1 addition & 1 deletion src/ferro/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ class ForeignKey:
that `related_name` names. The models these examples run against declare
the pair like this, other fields left out:

```python
```python {test=compile reason="the reference prelude already declares these models"}
class Author(Model):
...
posts: Relation[list["Post"]] = BackRef()
Expand Down
8 changes: 2 additions & 6 deletions src/ferro/cli/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,15 +557,11 @@ def status(
steps: Annotated[
bool,
Parameter(
help="Print every migration's steps, not only those needing attention.",
negative="",
help="Print every migration's steps, not only those needing attention."
),
] = False,
json_: Annotated[
bool,
Parameter(
name="--json", negative="", help="Print the report as a JSON document."
),
bool, Parameter(name="--json", help="Print the report as a JSON document.")
] = False,
glob: Annotated[Global, Parameter(parse=False)],
) -> int:
Expand Down
2 changes: 1 addition & 1 deletion src/ferro/migrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
decisions with prompts. A refusal is raised, never returned: an application
that called `up()` must not go on serving a database the run did not reach.

```python
```python {test=compile reason="needs a project with a migrations directory"}
await ferro.connect(url)
await ferro.migrations.up() # a desktop app with no deploy step
await ferro.migrations.require_applied() # a server that migrates in its deploy step
Expand Down
2 changes: 1 addition & 1 deletion src/ferro/migrations/_chunk_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
The public declaration is [`chunked`][ferro.migrations.chunked]; this
private module runs it.

```python
```python {test=compile reason="a data step file; the migration runner calls it"}
@chunked(
lambda models: models.Author.where(lambda author: author.slug == None)
.order_by(lambda author: author.id),
Expand Down
2 changes: 1 addition & 1 deletion src/ferro/migrations/alembic.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""The Alembic bridge: Ferro's models as Alembic metadata, and one comparator that writes what the planner decides.

```python
```python {test=compile reason="an Alembic env.py fragment; Alembic runs it"}
# env.py
from ferro.migrations import ferro_options, get_metadata

Expand Down
2 changes: 1 addition & 1 deletion src/ferro/migrations/context.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""What a data step is handed: `StepContext` and `HistoricalModels`.

```python
```python {test=compile reason="a data step body; the migration runner calls it"}
@atomic
async def up(ctx):
for author in await ctx.models.Author.where(lambda author: author.slug == None).all():
Expand Down
2 changes: 1 addition & 1 deletion src/ferro/migrations/errors.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""What the migration calls raise.

```python
```python {test=compile reason="needs a database behind its migrations directory"}
await ferro.connect(url)
await ferro.migrations.require_applied()
# PendingMigrationsError: ferro.migrations: this database is behind its migrations.
Expand Down
8 changes: 4 additions & 4 deletions src/ferro/migrations/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,10 +428,10 @@ class StatusReport:
For a database with `0006_add_teams` applied and `0007_nickname`
part-way:

```python
report.head_applied.name # "0006_add_teams"
report.unfinished.name # "0007_nickname"
report.pending # True
```python {test=compile reason="needs a database with a migration part-way applied"}
assert report.head_applied.name == "0006_add_teams"
assert report.unfinished.name == "0007_nickname"
assert report.pending
```
"""

Expand Down
2 changes: 1 addition & 1 deletion src/ferro/migrations/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
0002_add_teams 01_schema applied (9 ms)
```

```python
```python {test=compile reason="needs a project with a migrations directory"}
await ferro.migrations.up() # the same run, in-process
await ferro.migrations.require_applied() # where it stands, raised when behind
```
Expand Down
6 changes: 3 additions & 3 deletions src/ferro/migrations/scaffold.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
`ferro migrate new author_slug` writes it between the expand and the
contract:

```python
```python {test=compile reason="a generated data step file; the migration runner calls it"}
# migrations/0012_author_slug/02_backfill_author.py
from ferro.migrations import chunked, nothing_to_reverse, todo

Expand Down Expand Up @@ -39,7 +39,7 @@ def down(ctx): ...
it, each given another label (the historical model's enum still declares
the removed one, and the label written is looked up in it):

```python
```python {test=compile reason="a generated data step body; the migration runner calls it"}
@chunked(
lambda models: models.Order.where(lambda order: order.status == "canceled")
.order_by(lambda order: order.id),
Expand All @@ -58,7 +58,7 @@ async def up(ctx, batch):
A hand-requested data step
(`ferro migrate new backfill_slugs --data-step Author`):

```python
```python {test=compile reason="a generated data step file; the migration runner calls it"}
from ferro.migrations import atomic, todo


Expand Down
2 changes: 1 addition & 1 deletion src/ferro/migrations/steps.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Data step declarations, `todo()`, and the loader that reads a step file.

```python
```python {test=compile reason="a data step file; the migration runner calls it"}
# migrations/0011_backfill_slugs/01_backfill_author.py
from ferro.migrations import atomic, nothing_to_reverse, todo

Expand Down
2 changes: 1 addition & 1 deletion src/ferro/migrations/target.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""The database a migration verb works on, and the connection that reaches it.

```python
```python {test=compile reason="needs a project with a migrations directory"}
await ferro.migrations.up() # the configured database, default connection
await ferro.migrations.up(database="analytics", using="reporting")
await ferro.migrations.up(url="postgres://…/app") # a private connection, closed after
Expand Down
4 changes: 2 additions & 2 deletions src/ferro/migrations/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,14 @@ class RoundTripResult:

For a chain whose every down reaches its parent:

```python
```python {test=compile reason="shows the value round_trip returns"}
RoundTripResult(applied=["0001_create_author", "0002_add_slug"],
reverted_to=None, irreversible=None)
```

and for one whose `0002` declares a step irreversible:

```python
```python {test=compile reason="shows the value round_trip returns"}
RoundTripResult(applied=["0001_create_author", "0002_add_slug"],
reverted_to="0002_add_slug",
irreversible=("0002_add_slug", "01_schema", "the index is shared"))
Expand Down
4 changes: 2 additions & 2 deletions src/ferro/migrations/translate.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""The Alembic bridge's translator: the one planner's ops as Alembic ops.

```python
```python {test=compile reason="a model fragment; its imports are left out"}
class Card(Model):
flavor: str | None = None # new
__ferro_checks__ = (Check("flavor_set", lambda card: card.flavor != None),)
```

```python
```python {test=compile reason="the revision Alembic writes; Alembic runs it"}
def upgrade():
op.add_column('card', sa.Column('flavor', sa.String(), nullable=True))
op.execute(sa.DDL('ALTER TABLE "card" ADD CONSTRAINT "ck_card_flavor_set" CHECK (...)'))
Expand Down
4 changes: 3 additions & 1 deletion src/ferro/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -791,9 +791,11 @@ def select(
[`ProjectedQuery`][ferro.query.builder.ProjectedQuery].

Raises:
AttributeError: A selector names a column the model does not have.
TypeError: The selectors mix a lambda with column names, or are
not a lambda or strings.
ValueError: A selector names a field the model does not have.
ValueError: The selection is empty, two fields share an output
name, or a string names a related column.

Examples:
```python {test=run prelude=reference}
Expand Down
1 change: 1 addition & 0 deletions src/ferro/query/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,7 @@ def select(self, *selectors: "RowSelector[T] | str") -> "Self | ProjectedQuery[T
[`ProjectedQuery`][ferro.query.builder.ProjectedQuery].

Raises:
AttributeError: A selector names a column the model does not have.
TypeError: The selectors mix a lambda with column names, select a
relation or a comparison instead of a field, or nest a dict
and a tuple.
Expand Down
2 changes: 1 addition & 1 deletion src/ferro/rowsecurity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A model says once who is allowed to see its rows:

```python
```python {test=compile reason="declares a model whose row security needs Postgres"}
class Invoice(Model):
id: int | None = Field(default=None, primary_key=True)
tenant_id: UUID
Expand Down
8 changes: 4 additions & 4 deletions src/ferro/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@
dialects = ["postgres", "sqlite"]
```

```python
```python {test=compile reason="needs a project with a ferro.toml and its database URL set"}
from ferro import FerroSettings

settings = FerroSettings()
db = settings.database() # implied: there is one
db.directory # <config dir>/migrations
db.url_for() # $DATABASE_URL, never the file
db.import_models() # imports myapp.models, returns its models
directory = db.directory # <config dir>/migrations
url = db.url_for() # $DATABASE_URL, never the file
models = db.import_models() # imports myapp.models, returns its models
```

The rules, each enforced here with a refusal that names its fix:
Expand Down
Loading
Loading