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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,9 @@ ordering.

`sqlargon.types` provides dialect-aware column types: `GUID` with `GenerateUUID` /
`GenerateUUIDV7` server defaults, `Timestamp` with a `now()` server default and `JSON`
(orjson-serialized). `sqlargon.types.pydantic` adds `Pydantic` and `ValidatedType` for
(orjson-serialized), whose comparator carries portable JSON operators — containment and
key tests, plus server-side mutation (`set_key`, `update`, `remove_key`) that rewrites a
document in the `UPDATE` itself. `sqlargon.types.pydantic` adds `Pydantic` and `ValidatedType` for
pydantic-validated columns. `sqlargon.mixins` bundles them into `UUIDModelMixin`,
`UUIDV7ModelMixin`, `CreatedUpdatedMixin` and `SoftDeleteMixin`.

Expand Down
61 changes: 56 additions & 5 deletions docs/reference/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,69 @@ await repo.list(Document.meta.json_value("owner") == "john")
| `has_any_key([...])` | `?\|` | `JSON_CONTAINS_PATH(..., 'one', ...)` | `EXISTS` over `json_each` |
| `has_all_keys([...])` | `?&` | `JSON_CONTAINS_PATH(..., 'all', ...)` | `json_each` self-join |
| `json_value(key)` | `->>` | `JSON_EXTRACT` | `JSON_EXTRACT` |
| `get(key)` | `->` | `JSON_EXTRACT` | `JSON_EXTRACT` |
| `has_key(key)` | `?` | `JSON_CONTAINS_PATH(..., 'one', ...)` | `JSON_TYPE(...) IS NOT NULL` |
| `array_length()` | `JSONB_ARRAY_LENGTH` | `JSON_LENGTH` | `JSON_ARRAY_LENGTH` |
| `keys()` | `JSONB_OBJECT_KEYS` + `JSONB_AGG` | `JSON_KEYS` | `JSON_GROUP_ARRAY` over `json_each` |

!!! warning "`has_any_key` / `has_all_keys` are portable over arrays, not objects"

Use them to test membership in a JSON **array** — that is the one meaning all three
dialects agree on. Against a JSON **object** they diverge: PostgreSQL and MySQL test the
object's *keys*, while the SQLite fallback tests the *values* produced by `json_each`.
To query a key of an object portably, use `json_value(key)` instead.
To test a key of an object portably, use `has_key(key)`, which addresses object keys on
every dialect.

The underlying function elements — `json_contains`, `json_has_any_key`, `json_has_all_keys`
and `json_value` — are importable from `sqlargon.types.json` for use outside a `JSON`
column. `has_any_key` and `has_all_keys` require string keys and raise `ValueError`
otherwise.
### Mutating a document server-side

The mutation operators rewrite a document in the `UPDATE` itself, so a single key can be
changed without reading the row into Python and writing it back — no lost update, one
round trip:

```python
await repo.update({Document.meta: Document.meta.set_key("owner", "john")}).execute()
await repo.update({Document.meta: Document.meta.update({"owner": "john", "hits": 0})}).execute()
await repo.update({Document.meta: Document.meta.remove_key("owner")}).execute()
```

| Operator | PostgreSQL | MySQL | SQLite |
| --- | --- | --- | --- |
| `set_key(key, value)` | `\|\|` | `JSON_SET` | `JSON_SET` |
| `update({...})` | `\|\|` | `JSON_SET` | `JSON_SET` |
| `remove_key(*keys)` | `-` over `text[]` | `JSON_REMOVE` | `JSON_REMOVE` |
| `insert_key(key, value)` | `\|\|`, patch on the left | `JSON_INSERT` | `JSON_INSERT` |
| `replace_key(key, value)` | `JSONB_SET(..., false)` | `JSON_REPLACE` | `JSON_REPLACE` |
| `array_append(value)` | `\|\|` + `JSONB_BUILD_ARRAY` | `JSON_ARRAY_APPEND` | `JSON_INSERT(..., '$[#]', ...)` |

`insert_key` only writes a key that is **absent**; `replace_key` only one already
**present**. Every mutation returns a JSON expression, so they nest:

```python
Document.meta.update({"c": 3}).remove_key("a")
```

!!! warning "What the mutation operators do not smooth over"

- **`NULL` in, `NULL` out.** `JSONB_SET` and `JSON_SET` both return `NULL` for a `NULL`
document, and these operators match that rather than coalescing to `{}`. Give the
column a `server_default` of `'{}'` if you need a document to always be there.
- **Objects only.** The `JSON_SET` family addresses `$."key"`, so `set_key`,
`update`, `remove_key`, `insert_key` and `replace_key` assume the document is an
object. Use `array_append` for arrays.
- **Top-level keys only.** There are no nested paths or array indices; a key is always
one level down.
- **`update` is a shallow merge.** A top-level key is replaced wholesale, not merged
into recursively — the semantics of PostgreSQL's `||`. Deep merge-patch
(`JSON_MERGE_PATCH`, `json_patch`) is deliberately absent: PostgreSQL has no builtin
for it.
- **`array_length` is portable over arrays only.** Given an object PostgreSQL raises,
SQLite answers 0 and MySQL answers 1.

The underlying function elements — `json_contains`, `json_has_any_key`, `json_has_all_keys`,
`json_value`, `json_get`, `json_has_key`, `json_array_length`, `json_keys`, `json_update`,
`json_set_key`, `json_remove_key`, `json_insert_key`, `json_replace_key` and
`json_array_append` — are importable from `sqlargon.types.json` for use outside a `JSON`
column. The key operators require string keys and raise `ValueError` otherwise.

## Pydantic-validated columns

Expand Down
40 changes: 40 additions & 0 deletions sqlargon/i18n/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from .expression import current_locale, get_locale, set_locale_getter, translated_value
from .mixin import TranslationMixin
from .repository import TranslatedRepository
from .translatable import (
TranslatableMixin,
TranslationBase,
current_translation,
translation_class,
translation_table,
)
from .translation import (
LocaleMap,
TranslatedString,
Translation,
as_translation,
fallback_chain,
select_current,
set_fallback_chain,
)

__all__ = [
"LocaleMap",
"TranslatableMixin",
"TranslatedRepository",
"TranslatedString",
"Translation",
"TranslationBase",
"TranslationMixin",
"as_translation",
"current_locale",
"current_translation",
"fallback_chain",
"get_locale",
"select_current",
"set_fallback_chain",
"set_locale_getter",
"translated_value",
"translation_class",
"translation_table",
]
100 changes: 100 additions & 0 deletions sqlargon/i18n/expression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.ext.compiler import compiles

if TYPE_CHECKING:
from collections.abc import Callable

from sqlalchemy.sql.compiler import SQLCompiler
from sqlalchemy.sql.elements import BindParameter, ColumnElement

_get_locale: Callable[[], str] | None = None


def set_locale_getter(fn: Callable[[], str]) -> None:
"""Register the callable that returns the active locale per request.

The callable is invoked at SQL execution time via a late-binding bind
parameter, so a single statement template is cached and reused across
requests of any locale.
"""
global _get_locale # noqa: PLW0603
_get_locale = fn


def get_locale() -> str:
"""Return the active locale for the current request.

Falls through to the callable registered with :func:`set_locale_getter`
at startup.
"""
if _get_locale is None:
msg = (
"No locale getter has been configured. "
"Call sqlargon.i18n.set_locale_getter() at startup."
)
raise RuntimeError(msg)
return _get_locale()


def current_locale() -> BindParameter[str]:
"""Bind parameter resolving to the active locale on every execution.

Late binding keeps cached statements -- relationship join conditions in
particular, which are built once when mappers are configured -- aware of
the locale of the request being served.
"""
return sa.bindparam(
"current_locale", callable_=get_locale, type_=sa.String, unique=True
)


def _locale_path() -> BindParameter[str]:
return sa.bindparam(
"locale_path", callable_=_current_locale_path, type_=sa.String, unique=True
)


def _current_locale_path() -> str:
return f'$."{get_locale()}"'


class translated_value(sa.FunctionElement[str]):
"""Text stored under the active locale key of a JSON translation column."""

name = "translated_value"
type = sa.String()
inherit_cache = True


def _operand(element: translated_value) -> ColumnElement[Any]:
"""Read the wrapped column off the element itself.

Clone and adapt machinery -- `ClauseAdapter`, `with_loader_criteria`,
`with_polymorphic` -- rewrites only the traversed ``clauses``, so anything
cached on the instance would still point at the pre-adaption column.
"""
return next(iter(element.clauses))


@compiles(translated_value, "postgresql")
def _compile_postgresql(
element: translated_value, compiler: SQLCompiler, **kwargs: Any
) -> str:
column = sa.type_coerce(_operand(element), postgresql.JSONB)
return compiler.process(
column.op("->>")(sa.cast(current_locale(), sa.Text)), **kwargs
)


@compiles(translated_value)
def _compile_default(
element: translated_value, compiler: SQLCompiler, **kwargs: Any
) -> str:
return compiler.process(
sa.func.json_extract(_operand(element), _locale_path()), **kwargs
)
46 changes: 46 additions & 0 deletions sqlargon/i18n/mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from __future__ import annotations

from .expression import get_locale
from .translation import LocaleMap, Translation, as_translation, select_current


class TranslationMixin:
"""Multi-locale read and write helpers shared by both backends.

Plain attribute access stays transparent: ``model.title`` reads as the text
of the active locale, while these helpers reach the other locales. Writing
merges -- no write drops a locale it does not name, except
`clear_translations`.
"""

def get_translations(self, field: str) -> LocaleMap:
"""Return every known translation of ``field``, keyed by locale."""
translation = as_translation(getattr(self, field))
return {} if translation is None else translation.data

def get_translation(self, field: str, locale: str | None = None) -> str | None:
"""Return the text of ``field`` for ``locale``, the active one by default.

An explicit ``locale`` is looked up as given; only the active locale
walks its fallback chain.
"""
data = self.get_translations(field)
if locale is None:
return select_current(data)
return data.get(locale)

def set_translation(
self, field: str, value: str, locale: str | None = None
) -> None:
"""Store ``value`` under ``locale``, keeping the other translations."""
data = self.get_translations(field)
data[locale or get_locale()] = value
setattr(self, field, Translation(select_current(data) or value, data))

def clear_translations(self, field: str) -> None:
"""Drop every translation of ``field``, leaving it empty rather than unset.

The column keeps holding a translation -- an empty one -- so a model may
declare it non-nullable.
"""
setattr(self, field, Translation("", {}))
39 changes: 39 additions & 0 deletions sqlargon/i18n/repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from sqlargon.repository import SQLAlchemyRepository

if TYPE_CHECKING:
from typing import Any

from typing_extensions import Self


class TranslatedRepository(SQLAlchemyRepository, abstract=True):
"""Repository for a model whose fields are backed by a translation table.

Every ``select()`` outer-joins the active-locale translation row, so
filtering and ordering on translated columns -- the
:class:`~sqlalchemy.ext.hybrid.hybrid_property` class-level expressions
resolve to the translation table's columns -- works without an explicit
join in the calling code.

The model must use :class:`TranslatableMixin`, whose
``_current_translation`` relationship carries the join condition that
matches the model's primary key and the active locale.
"""

def select(
self,
*args: Any,
**kwargs: Any,
) -> Self:
return (
super()
.select(*args, **kwargs)
.join(
self.model._current_translation, # noqa: SLF001
isouter=True, # type: ignore[union-attr]
)
)
Loading
Loading