diff --git a/README.md b/README.md index 03021a7..d8b910c 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/agents.md b/docs/agents.md index 4f9a3fc..424b6a9 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 @@ -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. @@ -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`, diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 923abdd..bfb22dd 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -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) | @@ -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] @@ -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):** @@ -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 diff --git a/sqlalchemy_foundation_kit/config/postgres.py b/sqlalchemy_foundation_kit/config/postgres.py index caf21ca..d5c95ac 100644 --- a/sqlalchemy_foundation_kit/config/postgres.py +++ b/sqlalchemy_foundation_kit/config/postgres.py @@ -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: diff --git a/sqlalchemy_foundation_kit/contrib/settings/postgres.py b/sqlalchemy_foundation_kit/contrib/settings/postgres.py index 3ca04a6..2dad6b0 100644 --- a/sqlalchemy_foundation_kit/contrib/settings/postgres.py +++ b/sqlalchemy_foundation_kit/contrib/settings/postgres.py @@ -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. @@ -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, diff --git a/tests/unit/session/test_factories.py b/tests/unit/session/test_factories.py index 5cafc75..a9f2df5 100644 --- a/tests/unit/session/test_factories.py +++ b/tests/unit/session/test_factories.py @@ -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)