Skip to content

v0.26.0

Choose a tag to compare

@0x054 0x054 released this 22 Oct 01:57
· 31 commits to main since this release

🔍 See the SQL Ferro sends: the ferro.sql statement log and .sql() on every query

Ferro now shows you every statement it sends, two ways. The ferro.sql logger records each statement as it finishes, with its parameters and how long the database took, and every query terminal carries a .sql() that returns the statement the call would send without sending it. Both read from the one place that builds the statement, so what you log and what you render are the same text, and a secret value is masked in both.

Take an account with an API key:

from pydantic import SecretStr
from ferro import Model, Field

class Account(Model):
    id: int = Field(primary_key=True)
    name: str
    api_key: SecretStr

The statement log

Turn the log on with your usual logging setup, then save a row and query it back:

import logging

logging.basicConfig(format="%(name)s %(levelname)s %(message)s")
logging.getLogger("ferro.sql").setLevel(logging.DEBUG)

await Account(id=1, name="Grace Hopper", api_key=SecretStr("sk-live-123")).save()
await Account.where(lambda account: account.api_key == SecretStr("sk-live-123")).first()

Each statement prints as it finishes, and the secret is *** in both the insert and the predicate:

ferro.sql DEBUG INSERT INTO "account" ("id", "name", "api_key") VALUES (?, ?, ?) params=(1, 'Grace Hopper', ***) 0.1ms
ferro.sql DEBUG SELECT "account"."api_key", "account"."id", "account"."name" FROM "account" WHERE "account"."api_key" = ? LIMIT ? params=(***, 1) 0.1ms

Every statement Ferro sends is one record: queries, save() and bulk_create(), update() and delete(), many-to-many add()/remove()/clear(), raw SQL, a transaction's BEGIN/SAVEPOINT/COMMIT, a session's settings batch, the Postgres catalog read, and every migration statement, from connect(auto_migrate=True) and ferro migrate alike. A statement the database rejects ends its line with failed: <error> and still raises. Each record also carries the statement as attributes (sql, params, dialect, duration_ms, operation, subject, connection, rows, error), so a structured handler reads fields instead of parsing the line.

The logger is the only switch. While ferro.sql is not enabled for DEBUG, Ferro collects nothing: no echo= flag, no setting, and nothing to pay for when it is off. A value is masked when it came out of a SecretStr or SecretBytes, in a predicate, a field or a raw argument, or is bound to a column declared as one; in tests the masked parameter is the ferro.REDACTED sentinel, so you can tell it from a literal "***".

One change to notice: Ferro no longer installs a handler of its own. The ferro logger carries a NullHandler and propagates, so your logging configuration, and pytest's caplog, decide where the records go. If you relied on Ferro's own stderr handler for ferro INFO lines (a data step's ctx.log output when you call the migration API from Python), configure a handler; the ferro migrate command line installs its own and prints as before.

Reference: Connections › Logging.

.sql() on every query terminal

Replace a terminal with its .sql twin to get the statement it would send:

rendered = await Account.where(lambda account: account.name == "Grace Hopper").all.sql()
print(rendered)
print(rendered.params, rendered.dialect)

str() of the result is the SQL alone, and params and dialect sit beside it:

SELECT "account"."api_key", "account"."id", "account"."name" FROM "account" WHERE "account"."name" = ?
('Grace Hopper',) sqlite

all, first, count, exists, update and delete each carry .sql() with the terminal's own signature (update.sql(name="G. Hopper") renders the update), on a Query, a relation and a projection alike. Rendering routes like the call, through the enclosing session, using= or session=, so the dialect and the placeholders are the database's; it opens no transaction and changes no row. A test pins .sql() equal to the record the executed call writes, for every terminal on both databases, so the two cannot drift.

The guide page for both follows in the next release; until then the API reference entries carry the examples. Design notes: ADR-0057 (docs/adr/).

🔐 SecretStr and SecretBytes fields store the real value

A SecretStr or SecretBytes field now writes its real value and loads back as the secret type. Before 0.26, save(), bulk_create() and update() wrote pydantic's mask, **********, in its place, and a fetched record held the str **********:

await Account(id=1, name="Grace Hopper", api_key=SecretStr("sk-live-123")).save()
account = await Account.get(1)
account.api_key.get_secret_value()   # 'sk-live-123'; on 0.25 the row held '**********'

A row saved by 0.25 holds the mask, and the real value cannot be recovered from it: write the value again from its source. A SecretBytes column is now binary (BYTEA on Postgres, BLOB on SQLite); 0.25 created it as varchar, and a migration changes it before 0.26 saves to it. Upgrading to 0.26 has the details, including what Postgres does with the old column if you skip the migration. (#760)

📖 The docs, rewritten

Every page on ferro-orm.x54.sh is now one kind of page (a tutorial, a how-to, a reference or an explanation), carries one running example, and shows the result of every block. On the Queries, Aggregations, Relationships and Mutations guides, the SQL sits beside every query, captured from the example's own .sql() when the docs tests run, with SQLite and PostgreSQL tabs where more than the placeholders differ:

-- predicates.py:first-query (sqlite)
SELECT "transaction"."account_id", "transaction"."amount", "transaction"."category_id", "transaction"."cleared_on", "transaction"."id", "transaction"."memo", "transaction"."posted_on"
FROM "transaction"
WHERE "transaction"."amount" <= ?
ORDER BY "transaction"."amount" ASC NULLS LAST;

Behind the pages, a harness fails the docs build when a snippet, a docstring example, a quoted migration file or a captured SQL block drifts from the code, so what you read is what the release does. The API reference reads as one: every export under its public path, Google-style entries with runnable examples, no leftover Sphinx roles. The upgrade guide is one cumulative page that reaches 0.26, and every moved page redirects. (#698)

Upgrading

Five changes in 0.26 can reach code that runs today; two show up in a type checker or an import, two in text you compare, and one in the values a secret column stores. Upgrading to 0.26 lists each with what to change.


v0.26.0 (2026-10-22)

Bug Fixes

  • SecretStr and SecretBytes fields store the real value and hydrate as secrets (#760) (#762, 20ee954)

Chores

  • docs: Rename CONTEXT.md to GLOSSARY.md, refresh agent docs, ADR-0057 for #700 (#763, c0c4046)

  • docs: Upgrade Zensical, mkdocstrings-python and pymdown-extensions (#699, 2a45de9)

Documentation

  • Every page one kind, every block runs, the SQL beside every query (#698) (#777, 23c6caf)

Features


Detailed Changes: v0.25.0...v0.26.0