Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ class PostgresConfig:
query: QuerySettingsProtocol = MyQuerySettings()
application_name: str = "my-service"
db_schema: str | None = None
use_orjson_serialization: bool = True
use_orjson_serialization: bool = False
jit: str | None = None

def to_dsn(self) -> str:
Expand Down
6 changes: 4 additions & 2 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ With the `settings` extra, `sqlalchemy_foundation_kit.contrib.settings` implemen
| `ConnectionSettings` | `host="localhost"`, `port=5432` (1–65535), `user="postgres"`, `password: SecretStr` **required**, `database: str` **required** |
| `PoolSettings` | `kind="async_adapted_queue"`, `size=10`, `max_overflow=20`, `pre_ping=True`, `recycle=3600`, `timeout=30.0`. `kind="static"` with `size > 1` raises: `StaticPool` is one shared connection, for tests. A fixed-size pool is `async_adapted_queue` with `max_overflow=0`. `size` / `max_overflow` / `timeout` reach queue pools only |
| `QuerySettings` | `echo=False`, `statement_cache_size=0`, `prepared_statement_cache_size=0`, `isolation_level=None` |
| `BasePostgresConfig` | `connection` **required**, `pool`, `query`, `application_name: str` **required**, `db_schema=None`, `use_orjson_serialization=True`, `jit=None`, `metrics_enabled=False`. `to_dsn(driver="asyncpg", mask_password=False)`; `__repr__` prints the masked DSN. `db_schema` is a `search_path` value applied per transaction (rule 16); `jit` is a startup parameter and is sent only when set |
| `BasePostgresConfig` | `connection` **required**, `pool`, `query`, `application_name: str` **required**, `db_schema=None`, `use_orjson_serialization=False`, `jit=None`, `metrics_enabled=False`. `to_dsn(driver="asyncpg", mask_password=False)`; `__repr__` prints the masked DSN. `db_schema` is a `search_path` value applied per transaction (rule 16); `jit` is a startup parameter and is sent only when set |
| `BasePostgresMigrationsConfig` | `postgres: BasePostgresConfig`, with `env_nested_delimiter="__"` and `extra="ignore"` |

`BasePostgresConfig` declares no `model_config` of its own, so it reads environment
Expand Down Expand Up @@ -434,7 +434,8 @@ decorator.
13. **Extras are not optional where you use them.** `contrib.settings` needs `[settings]`,
`contrib.metrics` needs `[metrics]`, `contrib.telemetry` needs `[telemetry]`,
`contrib.di` needs `[dishka]`, `contrib.dependency_injector` needs
`[dependency-injector]`, and `use_orjson=True` needs `[orjson]`. The DI packages fail
`[dependency-injector]`, and `use_orjson=True` / `use_orjson_serialization=True` need
`[orjson]` — both are off by default, so `[settings]` alone runs. The DI packages fail
on *import* without their extra, with an `ImportError` naming the extra to install.
14. **Metrics never raise into your code.** Every recorder call is wrapped: a broken
metrics backend is logged at exception level and discarded, and the query proceeds.
Expand Down Expand Up @@ -489,6 +490,7 @@ is what to do on the version the row names.
| `postgres_db_connection_timeouts_total` (0.4.0) | counted asyncpg statement timeouts — a slow query, on a healthy connection — and missed connect timeouts, which never reach `handle_error`. A database refusing connections moved no series at all | alert on `connection_errors_total{error_type="TimeoutError"}` for slow statements, and on the checkout wait for connections |
| `uow.transaction()` with a failing flush (0.4.0 and earlier, `flush_before_commit=True`, the default) | logged `str(exc)` at WARNING as `extra={"error": ...}` before re-raising: the statement's parameters and PostgreSQL's `DETAIL: Key (...)=(...)` row values went to the logs, and `hide_parameters=True` removed only the parameters | `flush_before_commit=False`; the same exception still reaches the caller |
| `uow.query()` (0.4.0 and earlier) | documented as read-only and transaction-free, but ran an ordinary autobegun transaction: a write followed by `qx.session.commit()` was persisted, and without the commit it was rolled back silently | do reads in `query()` and every write in `transaction()` |
| `create_async_session_manager(BasePostgresConfig(...))` with `[settings]` only (0.4.0 and earlier) | `ImportError: orjson is required` before connecting: `use_orjson_serialization` defaulted to `True` while orjson ships only with `[orjson]` / `[all]` | `use_orjson_serialization=False`, or install `[orjson]` |
| `postgres_db_connection_checkout_duration_seconds` (0.3.0 and earlier) | the only checkout histogram there was, and it measures the time between the `checkout` and `checkin` events — how long a connection was *held*, which is query duration seen from the pool. The time a caller waited for a connection, which is what the name suggests and what predicts a pool outage, was not exposed at all | none; the wait was not measurable from outside the pool |

`IsolationLevel` itself was always fine — `READ_UNCOMMITTED`, `READ_COMMITTED`,
Expand Down
11 changes: 7 additions & 4 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ QuerySettings(
|-----------|------|---------|-------------|
| `application_name` | `str` | **required** | Application identifier in PostgreSQL logs |
| `db_schema` | `str \| None` | `None` | `search_path` applied to every transaction with `SET LOCAL` — a schema, or a comma-separated list |
| `use_orjson_serialization` | `bool` | `True` | Use `orjson` for JSON serialization (requires `[orjson]` extra) |
| `use_orjson_serialization` | `bool` | `False` | Use `orjson` for JSON serialization (requires `[orjson]` extra) |
| `jit` | `"off" \| "on" \| None` | `None` | PostgreSQL JIT setting, sent as a startup parameter only when set |
| `metrics_enabled` | `bool` | `False` | Enable Prometheus metrics (requires `[metrics]` extra) |

Expand Down Expand Up @@ -335,7 +335,7 @@ search_path="tenant_123")` or `AsyncSessionManagerBuilder(url).with_search_path(

**orjson Serialization:**

Install with `[orjson]` for faster JSON serialization:
Off by default. Install `[orjson]` and turn it on for faster JSON serialization:

```bash
pip install sqlalchemy-foundation-kit[orjson]
Expand All @@ -348,7 +348,10 @@ BasePostgresConfig(
)
```

Automatically used by `PydanticJSONB` type for better performance.
It becomes the engine's JSON serializer, so every `JSON` / `JSONB` column uses it,
`PydanticJSONB` included. orjson also serializes values `json.dumps` rejects — `datetime`,
`UUID`, `Decimal`, non-`str` keys — so a service that turns it off again has to keep its
JSON columns to plain JSON types. Up to 0.4.0 it was on by default.

**JIT (Just-In-Time Compilation):**

Expand Down Expand Up @@ -412,7 +415,7 @@ class MyPostgresConfig:
query: QuerySettingsProtocol
application_name: str = "my-service"
db_schema: str | None = None
use_orjson_serialization: bool = True
use_orjson_serialization: bool = False
jit: str | None = None
metrics_enabled: bool = False

Expand Down
2 changes: 1 addition & 1 deletion sqlalchemy_foundation_kit/config/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ class PostgresSettingsProtocol(Protocol):
... query: QuerySettingsProtocol
... application_name: str = "my-app"
... db_schema: str | None = None
... use_orjson_serialization: bool = True
... use_orjson_serialization: bool = False
... jit: str | None = None
...
... def to_dsn(self) -> str:
Expand Down
8 changes: 5 additions & 3 deletions sqlalchemy_foundation_kit/contrib/settings/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ class BasePostgresConfig(BaseSettings):
application_name: Application name for connection identification.
db_schema: Optional PostgreSQL ``search_path`` — a schema, or a comma-separated
list — applied to every transaction with ``SET LOCAL`` semantics.
use_orjson_serialization: Use orjson for JSON serialization (requires orjson).
use_orjson_serialization: Serialize JSON / JSONB columns with orjson. Off by default:
it needs the ``orjson`` extra, and orjson serializes some values (``datetime``,
``UUID``, ``Decimal``, non-``str`` keys) that ``json.dumps`` rejects.
jit: JIT compilation setting (off/on), sent as a startup parameter only when set.
``None`` (the default) sends nothing and leaves the server's own setting; a
transaction-mode PgBouncer rejects the parameter unless it tracks it.
Expand Down Expand Up @@ -143,8 +145,8 @@ class BasePostgresConfig(BaseSettings):
description="PostgreSQL search_path (a schema or a comma-separated list), applied to every transaction",
)
use_orjson_serialization: bool = Field(
default=True,
description="Use orjson for JSON serialization (requires orjson installed)",
default=False,
description="Use orjson for JSON serialization (requires the orjson extra)",
)
jit: PostgresJit | None = Field(
default=None,
Expand Down
47 changes: 47 additions & 0 deletions tests/unit/session/test_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,3 +617,50 @@ def test__create_async_session_manager__db_schema__becomes_the_manager_search_pa
call_kwargs = mock_manager_class.call_args[1]
assert call_kwargs["connect_args"]["server_settings"] == {"application_name": "pgbouncer-lab"}
assert call_kwargs["search_path"] == "app"


# ============================================================================
# create_async_session_manager - orjson (issue #59 scenario)
# ============================================================================


@pytest.fixture
def without_orjson(monkeypatch: pytest.MonkeyPatch) -> None:
"""Make ``import orjson`` fail, as on a ``[settings]``-only install."""
import sys

monkeypatch.setitem(sys.modules, "orjson", None)


def _quick_start_config(**overrides: Any) -> BasePostgresConfig:
return BasePostgresConfig(
connection=ConnectionSettings(password=SecretStr("secret"), database="app"),
application_name="my-service",
**overrides,
)


async def test__create_async_session_manager__quick_start_config_without_orjson__builds_the_manager(
without_orjson: None,
) -> None:
# Arrange: the quick start's config, defaults only, with nothing but [settings] installed
config = _quick_start_config()

# Act
manager = create_async_session_manager(config)

# Assert: the engine is built (nothing connects) with SQLAlchemy's own JSON serializer
assert config.use_orjson_serialization is False
assert manager.engine.dialect._json_serializer is None # type: ignore[attr-defined]
await manager.aclose()


def test__create_async_session_manager__orjson_asked_for_without_orjson__raises_naming_the_extra(
without_orjson: None,
) -> None:
# Arrange
config = _quick_start_config(use_orjson_serialization=True)

# Act & Assert: opting in without the extra stays a loud error
with pytest.raises(ImportError, match=r"sqlalchemy-foundation-kit\[orjson\]"):
create_async_session_manager(config)
Loading