Repository: reagento/adaptix (language: python)

Issue:
Add support for multiple fields linking



Discussion / hints:

Fix patch:
diff --git a/benchmarks/benchmarks/pybench/director_api.py b/benchmarks/benchmarks/pybench/director_api.py
index 579dd54e..13af657c 100644
--- a/benchmarks/benchmarks/pybench/director_api.py
+++ b/benchmarks/benchmarks/pybench/director_api.py
@@ -45,7 +45,7 @@ class BenchSchema:
     kwargs: Mapping[str, Any]
     used_distributions: Sequence[str]
     skip_if: Optional[Callable[[EnvSpec], bool]] = None
-    check_params: Callable[[EnvSpec], CheckParams] = lambda env_spec: CheckParams()  # noqa: E731
+    check_params: Callable[[EnvSpec], CheckParams] = lambda env_spec: CheckParams()
 
 
 @dataclass(frozen=True)
diff --git a/docs/changelog/fragments/+link_function.feature.rst b/docs/changelog/fragments/+link_function.feature.rst
new file mode 100644
index 00000000..e39cb625
--- /dev/null
+++ b/docs/changelog/fragments/+link_function.feature.rst
@@ -0,0 +1,3 @@
+Now, you can use :func:`.conversion.link_function` to access model directly and merge data from several fields.
+
+See :ref:`link_function` for details.
diff --git a/docs/changelog/fragments/+many-relationship.bugfix.rst b/docs/changelog/fragments/+many-relationship.bugfix.rst
new file mode 100644
index 00000000..2d54f7cb
--- /dev/null
+++ b/docs/changelog/fragments/+many-relationship.bugfix.rst
@@ -0,0 +1,1 @@
+Fix processing list relationships in SQLAlchemy
diff --git a/docs/changelog/fragments/+sqlalchemy-json.feature.rst b/docs/changelog/fragments/+sqlalchemy-json.feature.rst
new file mode 100644
index 00000000..7cedcc61
--- /dev/null
+++ b/docs/changelog/fragments/+sqlalchemy-json.feature.rst
@@ -0,0 +1,3 @@
+Add SQLAlchemy column using adaptix to serialize json
+
+See :ref:`sqlalchemy_json` for details.
diff --git a/docs/conversion/extended-usage.rst b/docs/conversion/extended-usage.rst
new file mode 100644
index 00000000..4eafa641
--- /dev/null
+++ b/docs/conversion/extended-usage.rst
@@ -0,0 +1,127 @@
+==================
+Extended usage
+==================
+
+This section continues the tutorial to illuminate some more complex topics.
+
+.. _link_function:
+
+Link function
+========================
+
+Using :func:`.conversion.link_function` you can write your functions
+that will retrieve the necessary data directly from the model.
+
+Link model
+------------------------
+
+Let's start with an example of code.
+
+.. literalinclude:: /examples/conversion/extended_usage/link_function_model.py
+
+The first argument of function receives a model instance. The function has to return the value of the field.
+
+The input and output types are not checked, because there is no runtime tool to ensure that types are compatible.
+
+Using converter extra parameters
+----------------------------------
+
+Additional parameters of the function are matched with additional parameters of the converter.
+
+.. literalinclude:: /examples/conversion/extended_usage/link_function_with_extra_parameters.py
+
+After linking, a default coercing mechanism is applied.
+
+.. dropdown:: Example with coercing
+
+  .. literalinclude:: /examples/conversion/extended_usage/link_function_with_extra_parameters_type_coercion.py
+
+Merging several fields
+---------------------------
+
+You can get fields from the model, but it requires manual type casting.
+All keyword-only are linked to the model field followed by common type coercing policies.
+
+.. literalinclude:: /examples/conversion/extended_usage/link_function_fields.py
+
+If the first parameter is keyword-only, it will be matched with the model field.
+
+
+.. hint::
+
+   :func:`.conversion.link_function` can not call a function with zero arguments
+   if the function has more than zero parameters.
+   So, it can not be used with callables like ``list`` or ``dict`` natively.
+   You should use :func:`.conversion.link_constant` with ``factory`` parameter for this case.
+
+
+Link constant
+========================
+
+You can use :func:`.conversion.link_constant` to pass a constant value to the field.
+
+.. literalinclude:: /examples/conversion/extended_usage/link_constant.py
+
+To pass mutable objects you can use ``factory`` parameter. It takes callable accepting zero arguments.
+
+
+Using default value for fields
+================================
+
+By default, all fields of the destination model must be linked to something
+even field is not required (has a default value).
+
+.. hint::
+
+   Such policy prevents bugs in converters.
+   If forget to link two same fields with different names, an error will occur.
+
+
+You can control this policy
+via :func:`.conversion.allow_unlinked_optional` and :func:`.conversion.forbid_unlinked_optional`.
+
+.. literalinclude:: /examples/conversion/extended_usage/using_default_value_for_fields.py
+
+Each parameter of these functions are predicate defining the target scope of the policy.
+You can use them without arguments to apply new policies to all fields.
+
+.. dropdown:: Redefine policy globally (for all fields)
+
+  .. literalinclude:: /examples/conversion/extended_usage/global_allow_unlinked_optional.py
+
+
+What is a recipe really?
+==============================
+
+The recipe is the main concept of adaptix configuration.
+It consists of objects defining (or redefining) some piece of behavior.
+Each of these objects is called a `provider`.
+
+Recipe system implements `chain-of-responsibility <https://en.wikipedia.org/wiki/Chain-of-responsibility_pattern>`__
+design pattern.
+
+Let's explore the scenario of creating links for the destination field.
+Initially, adaptix filters providers skipping that can't make linking.
+Subsequently, adaptix scans the remaining recipe and applies the predicates of each provider.
+The first match will be used, causing initial providers to potentially overlap with subsequent ones.
+
+
+Eliminating recipe duplication via ``ConversionRetort``
+==========================================================
+
+Object holding recipe is called ``retort``.
+You can use :class:`.conversion.ConversionRetort`
+that exposes all high-level converting functions as methods
+(``convert``, ``get_converter`` and ``impl_converter``).
+
+.. literalinclude:: /examples/conversion/extended_usage/retort_introduction.py
+
+This allows reusing your configuration recipe.
+Parameter ``recipe`` of ``get_converter`` and ``impl_converter`` methods
+inserts new providers into the beginning of the recipe, so you can override previously defined behavior.
+
+No changes can be made after the retort creation. You can only make a new retort object based on the existing one.
+
+Using ``.extend`` method you can add items to the recipe beginning.
+
+.. literalinclude:: /examples/conversion/extended_usage/retort_extend.py
diff --git a/examples/sqlalchemy_json/__init__.py b/docs/examples/conversion/extended_usage/__init__.py
similarity index 100%
rename from examples/sqlalchemy_json/__init__.py
rename to docs/examples/conversion/extended_usage/__init__.py
diff --git a/docs/examples/conversion/extended_usage/global_allow_unlinked_optional.py b/docs/examples/conversion/extended_usage/global_allow_unlinked_optional.py
new file mode 100644
index 00000000..99a738e3
--- /dev/null
+++ b/docs/examples/conversion/extended_usage/global_allow_unlinked_optional.py
@@ -0,0 +1,47 @@
+from dataclasses import dataclass, field
+from typing import List, Optional
+
+from adaptix.conversion import allow_unlinked_optional, get_converter
+
+
+@dataclass
+class Book:
+    title: str
+    price: int
+    author: str
+
+
+@dataclass
+class BookDTO:
+    title: str
+    price: int
+    author: str
+    collection_id: Optional[int] = None
+    bookmarks_ids: List[str] = field(default_factory=list)
+
+
+convert_book_to_dto = get_converter(
+    Book,
+    BookDTO,
+    recipe=[
+        allow_unlinked_optional(),
+    ],
+)
+
+assert (
+    convert_book_to_dto(
+        Book(
+            title="Fahrenheit 451",
+            price=100,
+            author="Ray Bradbury",
+        ),
+    )
+    ==
+    BookDTO(
+        title="Fahrenheit 451",
+        price=100,
+        author="Ray Bradbury",
+        collection_id=None,
+        bookmarks_ids=[],
+    )
+)
diff --git a/docs/examples/conversion/extended_usage/link_constant.py b/docs/examples/conversion/extended_usage/link_constant.py
new file mode 100644
index 00000000..61d1a1ff
--- /dev/null
+++ b/docs/examples/conversion/extended_usage/link_constant.py
@@ -0,0 +1,49 @@
+from dataclasses import dataclass
+from typing import List, Optional
+
+from adaptix import P
+from adaptix.conversion import get_converter, link_constant
+
+
+@dataclass
+class Book:
+    title: str
+    price: int
+    author: str
+
+
+@dataclass
+class BookDTO:
+    title: str
+    price: int
+    author: str
+    collection_id: Optional[int]
+    bookmarks_ids: List[str]
+
+
+convert_book_to_dto = get_converter(
+    Book,
+    BookDTO,
+    recipe=[
+        link_constant(P[BookDTO].collection_id, value=None),
+        link_constant(P[BookDTO].bookmarks_ids, factory=list),
+    ],
+)
+
+assert (
+    convert_book_to_dto(
+        Book(
+            title="Fahrenheit 451",
+            price=100,
+            author="Ray Bradbury",
+        ),
+    )
+    ==
+    BookDTO(
+        title="Fahrenheit 451",
+        price=100,
+        author="Ray Bradbury",
+        collection_id=None,
+        bookmarks_ids=[],
+    )
+)
diff --git a/docs/examples/conversion/extended_usage/link_function_fields.py b/docs/examples/conversion/extended_usage/link_function_fields.py
new file mode 100644
index 00000000..4f3ea717
--- /dev/null
+++ b/docs/examples/conversion/extended_usage/link_function_fields.py
@@ -0,0 +1,58 @@
+# mypy: disable-error-code="empty-body"
+from dataclasses import dataclass
+from datetime import date
+
+from adaptix import P
+from adaptix.conversion import coercer, get_converter, link_function
+
+
+@dataclass
+class Book:
+    title: str
+    sub_title: str
+    price: int
+    author: str
+    released_at: date
+
+
+@dataclass
+class BookDTO:
+    label: str
+    price: int
+    author: str
+
+
+def make_label(book: Book, *, released_at: str) -> str:
+    return f"{book.title}. {book.sub_title} ({released_at})"
+
+
+def format_date(x: date) -> str:
+    return x.strftime("%d.%m.%Y")
+
+
+convert_book_to_dto = get_converter(
+    Book,
+    BookDTO,
+    recipe=[
+        link_function(make_label, P[BookDTO].label),
+        coercer(date, str, format_date),
+    ],
+)
+
+assert (
+    convert_book_to_dto(
+        Book(
+            title="Enchiridion",
+            sub_title="Handbook of Epictetus",
+            price=100,
+            author="Arrian",
+            released_at=date(year=1683, month=1, day=1),
+        ),
+    )
+    ==
+    BookDTO(
+        label="Enchiridion. Handbook of Epictetus (01.01.1683)",
+        price=100,
+        author="Arrian",
+    )
+)
diff --git a/docs/examples/conversion/extended_usage/link_function_model.py b/docs/examples/conversion/extended_usage/link_function_model.py
new file mode 100644
index 00000000..e062ea7b
--- /dev/null
+++ b/docs/examples/conversion/extended_usage/link_function_model.py
@@ -0,0 +1,45 @@
+from dataclasses import dataclass
+
+from adaptix import P
+from adaptix.conversion import get_converter, link_function
+
+
+@dataclass
+class Book:
+    title: str
+    sub_title: str
+    price: int
+    author: str
+
+
+@dataclass
+class BookDTO:
+    label: str
+    price: int
+    author: str
+
+
+convert_book_to_dto = get_converter(
+    Book,
+    BookDTO,
+    recipe=[
+        link_function(lambda book: f"{book.title}. {book.sub_title}", P[BookDTO].label),
+    ],
+)
+
+assert (
+    convert_book_to_dto(
+        Book(
+            title="Enchiridion",
+            sub_title="Handbook of Epictetus",
+            price=100,
+            author="Arrian",
+        ),
+    )
+    ==
+    BookDTO(
+        label="Enchiridion. Handbook of Epictetus",
+        price=100,
+        author="Arrian",
+    )
+)
diff --git a/docs/examples/conversion/extended_usage/link_function_with_extra_parameters.py b/docs/examples/conversion/extended_usage/link_function_with_extra_parameters.py
new file mode 100644
index 00000000..27efb021
--- /dev/null
+++ b/docs/examples/conversion/extended_usage/link_function_with_extra_parameters.py
@@ -0,0 +1,53 @@
+# mypy: disable-error-code="empty-body"
+from dataclasses import dataclass
+
+from adaptix import P
+from adaptix.conversion import impl_converter, link_function
+
+
+@dataclass
+class Book:
+    title: str
+    sub_title: str
+    price: int
+    author: str
+
+
+@dataclass
+class BookDTO:
+    label: str
+    price: int
+    author: str
+
+
+@impl_converter(
+    recipe=[
+        link_function(
+            lambda book, page_count: (
+                f"{book.title}. {book.sub_title} ({page_count} pages)"
+            ),
+            P[BookDTO].label,
+        ),
+    ],
+)
+def convert_book_to_dto(book: Book, page_count: int) -> BookDTO:
+    ...
+
+
+assert (
+    convert_book_to_dto(
+        Book(
+            title="Enchiridion",
+            sub_title="Handbook of Epictetus",
+            price=100,
+            author="Arrian",
+        ),
+        page_count=23,
+    )
+    ==
+    BookDTO(
+        label="Enchiridion. Handbook of Epictetus (23 pages)",
+        price=100,
+        author="Arrian",
+    )
+)
diff --git a/docs/examples/conversion/extended_usage/link_function_with_extra_parameters_type_coercion.py b/docs/examples/conversion/extended_usage/link_function_with_extra_parameters_type_coercion.py
new file mode 100644
index 00000000..881c8a53
--- /dev/null
+++ b/docs/examples/conversion/extended_usage/link_function_with_extra_parameters_type_coercion.py
@@ -0,0 +1,58 @@
+# mypy: disable-error-code="empty-body"
+from dataclasses import dataclass
+from datetime import date
+
+from adaptix import P
+from adaptix.conversion import coercer, impl_converter, link_function
+
+
+@dataclass
+class Book:
+    title: str
+    sub_title: str
+    price: int
+    author: str
+
+
+@dataclass
+class BookDTO:
+    label: str
+    price: int
+    author: str
+
+
+def make_label(book: Book, released_at: str) -> str:
+    return f"{book.title}. {book.sub_title} ({released_at})"
+
+
+def format_date(x: date) -> str:
+    return x.strftime("%d.%m.%Y")
+
+
+@impl_converter(
+    recipe=[
+        link_function(make_label, P[BookDTO].label),
+        coercer(date, str, format_date),
+    ],
+)
+def convert_book_to_dto(book: Book, released_at: date) -> BookDTO:
+    ...
+
+
+assert (
+    convert_book_to_dto(
+        Book(
+            title="Enchiridion",
+            sub_title="Handbook of Epictetus",
+            price=100,
+            author="Arrian",
+        ),
+        released_at=date(year=1683, month=1, day=1),
+    )
+    ==
+    BookDTO(
+        label="Enchiridion. Handbook of Epictetus (01.01.1683)",
+        price=100,
+        author="Arrian",
+    )
+)
diff --git a/docs/examples/conversion/extended_usage/retort_extend.py b/docs/examples/conversion/extended_usage/retort_extend.py
new file mode 100644
index 00000000..9c0b4ab9
--- /dev/null
+++ b/docs/examples/conversion/extended_usage/retort_extend.py
@@ -0,0 +1,53 @@
+from dataclasses import dataclass
+from datetime import date
+from uuid import UUID
+
+from adaptix.conversion import ConversionRetort, coercer
+
+base_retort = ConversionRetort(
+    recipe=[
+        coercer(UUID, str, str),
+        coercer(date, str, lambda x: x.strftime("%d.%m.%Y")),
+    ],
+)
+
+# another module
+
+retort = base_retort.extend(
+    recipe=[
+        coercer(date, str, lambda x: x.strftime("%d-%m-%Y")),
+    ],
+)
+
+
+@dataclass
+class Book:
+    id: UUID
+    title: str
+    release_at: date
+
+
+@dataclass
+class BookDTO:
+    id: str
+    title: str
+    release_at: str
+
+
+convert_book_to_dto = retort.get_converter(Book, BookDTO)
+
+assert (
+    convert_book_to_dto(
+        Book(
+            id=UUID("87000388-94e6-49a4-b51b-320e38577bd9"),
+            title="Fahrenheit 451",
+            release_at=date(1953, 10, 19),
+        ),
+    )
+    ==
+    BookDTO(
+        id="87000388-94e6-49a4-b51b-320e38577bd9",
+        title="Fahrenheit 451",
+        release_at="19-10-1953",
+    )
+)
diff --git a/docs/examples/conversion/extended_usage/retort_introduction.py b/docs/examples/conversion/extended_usage/retort_introduction.py
new file mode 100644
index 00000000..217248f7
--- /dev/null
+++ b/docs/examples/conversion/extended_usage/retort_introduction.py
@@ -0,0 +1,45 @@
+from dataclasses import dataclass
+from datetime import date
+from uuid import UUID
+
+from adaptix.conversion import ConversionRetort, coercer
+
+
+@dataclass
+class Book:
+    id: UUID
+    title: str
+    release_at: date
+
+
+@dataclass
+class BookDTO:
+    id: str
+    title: str
+    release_at: str
+
+
+retort = ConversionRetort(
+    recipe=[
+        coercer(UUID, str, str),
+        coercer(date, str, lambda x: x.strftime("%d.%m.%Y")),
+    ],
+)
+
+convert_book_to_dto = retort.get_converter(Book, BookDTO)
+
+assert (
+    convert_book_to_dto(
+        Book(
+            id=UUID("87000388-94e6-49a4-b51b-320e38577bd9"),
+            title="Fahrenheit 451",
+            release_at=date(1953, 10, 19),
+        ),
+    )
+    ==
+    BookDTO(
+        id="87000388-94e6-49a4-b51b-320e38577bd9",
+        title="Fahrenheit 451",
+        release_at="19.10.1953",
+    )
+)
diff --git a/docs/examples/conversion/extended_usage/using_default_value_for_fields.py b/docs/examples/conversion/extended_usage/using_default_value_for_fields.py
new file mode 100644
index 00000000..43eeb768
--- /dev/null
+++ b/docs/examples/conversion/extended_usage/using_default_value_for_fields.py
@@ -0,0 +1,46 @@
+from dataclasses import dataclass
+from typing import Optional
+
+from adaptix import P
+from adaptix.conversion import allow_unlinked_optional, get_converter
+
+
+@dataclass
+class Book:
+    title: str
+    price: int
+    author: str
+
+
+@dataclass
+class BookDTO:
+    title: str
+    price: int
+    author: str
+    collection_id: Optional[int] = None
+
+
+convert_book_to_dto = get_converter(
+    Book,
+    BookDTO,
+    recipe=[
+        allow_unlinked_optional(P[BookDTO].collection_id),
+    ],
+)
+
+assert (
+    convert_book_to_dto(
+        Book(
+            title="Fahrenheit 451",
+            price=100,
+            author="Ray Bradbury",
+        ),
+    )
+    ==
+    BookDTO(
+        title="Fahrenheit 451",
+        price=100,
+        author="Ray Bradbury",
+        collection_id=None,
+    )
+)
diff --git a/docs/examples/loading-and-dumping/extended_usage/fields_filtering_only.pytb b/docs/examples/loading-and-dumping/extended_usage/fields_filtering_only.pytb
index d9a97469..c0f73dd4 100644
--- a/docs/examples/loading-and-dumping/extended_usage/fields_filtering_only.pytb
+++ b/docs/examples/loading-and-dumping/extended_usage/fields_filtering_only.pytb
@@ -1,12 +1,12 @@
   + Exception Group Traceback (most recent call last):
   |   ...
   | adaptix.AggregateCannotProvide: Cannot create loader for model. Cannot fetch InputNameLayout (1 sub-exception)
-  | Location: type=<class 'docs.examples.extended_usage.fields_filtering_only.User'>
+  | Location: `User`
   +-+---------------- 1 ----------------
     | Traceback (most recent call last):
     |   ...
     | adaptix.CannotProvide: Required fields ['password_hash'] are skipped
-    | Location: type=<class 'docs.examples.extended_usage.fields_filtering_only.User'>
+    | Location: `User`
     +------------------------------------
 
 The above exception was the direct cause of the following exception:
@@ -14,3 +14,4 @@ The above exception was the direct cause of the following exception:
 Traceback (most recent call last):
   ...
 adaptix.NoSuitableProvider: Cannot produce loader for type <class 'docs.examples.extended_usage.fields_filtering_only.User'>
+Note: The attached exception above contains verbose description of the problem
diff --git a/docs/examples/loading-and-dumping/extended_usage/fields_filtering_skip.pytb b/docs/examples/loading-and-dumping/extended_usage/fields_filtering_skip.pytb
index 888004e1..98c2ef25 100644
--- a/docs/examples/loading-and-dumping/extended_usage/fields_filtering_skip.pytb
+++ b/docs/examples/loading-and-dumping/extended_usage/fields_filtering_skip.pytb
@@ -1,12 +1,12 @@
   + Exception Group Traceback (most recent call last):
   |   ...
   | adaptix.AggregateCannotProvide: Cannot create loader for model. Cannot fetch InputNameLayout (1 sub-exception)
-  | Location: type=<class 'docs.examples.extended_usage.fields_filtering_skip.User'>
+  | Location: `User`
   +-+---------------- 1 ----------------
     | Traceback (most recent call last):
     |   ...
     | adaptix.CannotProvide: Required fields ['password_hash'] are skipped
-    | Location: type=<class 'docs.examples.extended_usage.fields_filtering_skip.User'>
+    | Location: `User`
     +------------------------------------
 
 The above exception was the direct cause of the following exception:
@@ -14,3 +14,4 @@ The above exception was the direct cause of the following exception:
 Traceback (most recent call last):
   ...
 adaptix.NoSuitableProvider: Cannot produce loader for type <class 'docs.examples.extended_usage.fields_filtering_skip.User'>
+Note: The attached exception above contains verbose description of the problem
diff --git a/docs/examples/reference/integrations/sqlalchemy_json/__init__.py b/docs/examples/reference/integrations/sqlalchemy_json/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/examples/reference/integrations/sqlalchemy_json/basic_usage.py b/docs/examples/reference/integrations/sqlalchemy_json/basic_usage.py
new file mode 100644
index 00000000..459c69ee
--- /dev/null
+++ b/docs/examples/reference/integrations/sqlalchemy_json/basic_usage.py
@@ -0,0 +1,28 @@
+# mypy: disable-error-code="union-attr"
+from sqlalchemy import insert
+from sqlalchemy.orm import Session
+
+from .helpers import run_with_session
+from .preamble import AuditLogRecord, UserChanged, UserCreated
+
+
+def example(session: Session) -> None:
+    session.add(
+        AuditLogRecord(
+            data=UserCreated(id=1, name="Sam"),
+        ),
+    )
+
+    session.execute(
+        insert(AuditLogRecord)
+        .values(
+            data=UserChanged(id=1, name="Leo"),
+        ),
+    )
+    session.commit()
+
+    assert session.get(AuditLogRecord, 1).data == UserCreated(id=1, name="Sam")
+    assert session.get(AuditLogRecord, 2).data == UserChanged(id=1, name="Leo")
+
+
+run_with_session(example)
diff --git a/docs/examples/reference/integrations/sqlalchemy_json/helpers.py b/docs/examples/reference/integrations/sqlalchemy_json/helpers.py
new file mode 100644
index 00000000..e49505aa
--- /dev/null
+++ b/docs/examples/reference/integrations/sqlalchemy_json/helpers.py
@@ -0,0 +1,35 @@
+from contextlib import contextmanager
+from typing import Any, Callable, Iterator
+
+from sqlalchemy import Engine, create_engine
+from sqlalchemy.orm import Session, sessionmaker
+
+from .preamble import Base
+
+SessionFactory = Callable[[], Session]
+
+
+@contextmanager
+def create_prepared_engine() -> Iterator[Engine]:
+    engine = create_engine("sqlite://")
+    Base.metadata.create_all(engine)
+    try:
+        yield engine
+    finally:
+        engine.dispose()
+
+
+@contextmanager
+def create_session_factory() -> Iterator[SessionFactory]:
+    with create_prepared_engine() as engine:
+        yield sessionmaker(engine)
+
+
+def run_with_session(func: Callable[[Session], Any]):
+    with create_session_factory() as session_factory, session_factory() as session:
+        func(session)
+
+
+def run_with_session_factory(func: Callable[[SessionFactory], Any]):
+    with create_session_factory() as session_factory:
+        func(session_factory)
diff --git a/docs/examples/reference/integrations/sqlalchemy_json/mutation_tracking.py b/docs/examples/reference/integrations/sqlalchemy_json/mutation_tracking.py
new file mode 100644
index 00000000..4aa0b29b
--- /dev/null
+++ b/docs/examples/reference/integrations/sqlalchemy_json/mutation_tracking.py
@@ -0,0 +1,28 @@
+# mypy: disable-error-code="union-attr, assignment"
+from .helpers import SessionFactory, run_with_session_factory
+from .preamble import AuditLogRecord, UserCreated
+
+
+def example(session_factory: SessionFactory) -> None:
+    with session_factory() as session:
+        session.add(
+            AuditLogRecord(
+                data=UserCreated(
+                    id=1,
+                    name="Example",
+                ),
+            ),
+        )
+        session.commit()
+
+    with session_factory() as session:
+        record = session.get(AuditLogRecord, 1)
+        record.data.name = "Example2"
+        session.commit()
+
+    with session_factory() as session:
+        record = session.get(AuditLogRecord, 1)
+        assert record.data.name == "Example"  # (!) mutation tracking does not work (!)
+
+
+run_with_session_factory(example)
diff --git a/docs/examples/reference/integrations/sqlalchemy_json/preamble.py b/docs/examples/reference/integrations/sqlalchemy_json/preamble.py
new file mode 100644
index 00000000..6fc9aa1c
--- /dev/null
+++ b/docs/examples/reference/integrations/sqlalchemy_json/preamble.py
@@ -0,0 +1,40 @@
+from dataclasses import dataclass
+from typing import Literal, Union
+
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+
+from adaptix import Retort
+from adaptix.integrations.sqlalchemy import AdaptixJSON
+
+
+@dataclass
+class UserCreated:
+    id: int
+    name: str
+
+    tag: Literal["user_created"] = "user_created"
+
+
+@dataclass
+class UserChanged:
+    id: int
+    name: str
+
+    tag: Literal["user_changed"] = "user_changed"
+
+
+AnyAuditLog = Union[UserCreated, UserChanged]
+
+
+class Base(DeclarativeBase):
+    pass
+
+
+db_retort = Retort()
+
+
+class AuditLogRecord(Base):
+    __tablename__ = "audit_logs"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    data: Mapped[AnyAuditLog] = mapped_column(AdaptixJSON(db_retort, AnyAuditLog))
diff --git a/docs/examples/reference/integrations/sqlalchemy_json/querying_and_filtering.py b/docs/examples/reference/integrations/sqlalchemy_json/querying_and_filtering.py
new file mode 100644
index 00000000..ac073cee
--- /dev/null
+++ b/docs/examples/reference/integrations/sqlalchemy_json/querying_and_filtering.py
@@ -0,0 +1,27 @@
+# mypy: disable-error-code="union-attr"
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from .helpers import run_with_session
+from .preamble import AuditLogRecord, UserCreated
+
+
+def example(session: Session) -> None:
+    session.add(
+        AuditLogRecord(
+            data=UserCreated(
+                id=1,
+                name="Sam",
+            ),
+        ),
+    )
+    session.commit()
+
+    record = session.scalar(
+        select(AuditLogRecord)
+        .where(AuditLogRecord.data["id"].as_integer() == 1),
+    )
+    assert record.data == UserCreated(id=1, name="Sam")
+
+
+run_with_session(example)
diff --git a/docs/examples/reference/integrations/sqlalchemy_json/redefined_impl.py b/docs/examples/reference/integrations/sqlalchemy_json/redefined_impl.py
new file mode 100644
index 00000000..026dbf39
--- /dev/null
+++ b/docs/examples/reference/integrations/sqlalchemy_json/redefined_impl.py
@@ -0,0 +1,20 @@
+from sqlalchemy.orm import Mapped, mapped_column
+
+from adaptix._internal.integrations.sqlalchemy.orm import AdaptixJSON
+
+from .preamble import AnyAuditLog, Base, db_retort
+
+
+def example() -> None:
+    from sqlalchemy import JSON
+
+    class AuditLogRecord(Base):
+        __tablename__ = "audit_logs2"
+
+        id: Mapped[int] = mapped_column(primary_key=True)
+        data: Mapped[AnyAuditLog] = mapped_column(
+            AdaptixJSON(db_retort, AnyAuditLog, impl=JSON(none_as_null=True)),
+        )
+
+
+example()
diff --git a/docs/index.rst b/docs/index.rst
index 4a570d43..51cade35 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -21,6 +21,7 @@ Adaptix
    :caption: Conversion:
 
    conversion/tutorial
+   conversion/extended-usage
 
 
 .. toctree::
diff --git a/docs/loading-and-dumping/specific-types-behavior.rst b/docs/loading-and-dumping/specific-types-behavior.rst
index e69eb846..cba7e3e7 100644
--- a/docs/loading-and-dumping/specific-types-behavior.rst
+++ b/docs/loading-and-dumping/specific-types-behavior.rst
@@ -229,13 +229,14 @@ This doesn't applies for tuples like ``*tuple[int, str]`` (constant length tuple
 Dict and Mapping
 '''''''''''''''''''''
 
-Loader accepts any other ``Mapping`` and makes ``dict`` instances.
+Loader accepts any ``Mapping`` and makes ``dict`` instances.
 Dumper also constructs dict with converted keys and values.
 
 DefaultDict
 '''''''''''''''''''''
-Loader makes instances of ``defaultdict`` with the ``default_factory`` parameter set to ``None``.
-To customize this behavior, there are factory :func:`.default_dict` that have :paramref:`.default_dict.default_factory` parameter that can be overridden.
+Loader takes any ``Mapping`` and produces ``defaultdict`` with the ``default_factory`` parameter set to ``None``.
+To customize this behavior, there are factory :func:`.default_dict`
+that have :paramref:`.default_dict.default_factory` parameter that can be overridden.
 
 Models
 ''''''''''
diff --git a/docs/reference/examples.rst b/docs/reference/examples.rst
index 4884d3c6..a1e6d6c7 100644
--- a/docs/reference/examples.rst
+++ b/docs/reference/examples.rst
@@ -11,11 +11,6 @@ The behavior of each example is illustrated via included tests.
 
 :octicon:`mark-github` :adaptix-view-repo:`Source Code <examples/simple_api_processing>`
 
-.. include:: /../examples/sqlalchemy_json/README.md
-   :parser: myst_parser.sphinx_
-
-:octicon:`mark-github` :adaptix-view-repo:`Source Code <examples/sqlalchemy_json>`
-
 
 .. include:: /../examples/api_division/README.md
    :parser: myst_parser.sphinx_
diff --git a/docs/reference/integrations.rst b/docs/reference/integrations.rst
index 30d8373b..c9eda68a 100644
--- a/docs/reference/integrations.rst
+++ b/docs/reference/integrations.rst
@@ -2,14 +2,14 @@
 Integrations
 *******************
 
-This article describes how adaptix is workings with other packages and systems.
+This article describes how adaptix works with other packages and systems.
 
 .. _supported-model-kinds:
 
 Supported model kinds
 =======================
 
-Models are classes that have a predefined set of fields. Adaptix process models in the same, consistent way.
+Models are classes that have a predefined set of fields. Adaptix processes models in the same, consistent way.
 
 Models that are supported out of the box:
 
@@ -58,13 +58,13 @@ sqlalchemy
 
 - ``dataclass`` and ``attrs`` mapped by sqlalchemy are not supported for introspection.
 
-- It does not support registering order of mapped fields by design,
-  so you should use manual mapping to list instead automatic ``as_list=True``.
+- It does not support registering the order of mapped fields by design,
+  so you should use manual mapping to list instead of automatic ``as_list=True``.
 
 - Relationships with custom ``collection_class`` are not supported.
 
 - All input fields of foreign keys and relationships are considered as optional
-  due to user can pass only relationship instance or only foreign key value.
+  due to a user can pass only relationship instances or only foreign key values.
 
 pydantic
 ^^^^^^^^^^^^^^^^^
@@ -72,13 +72,13 @@ pydantic
 - Custom ``__init__`` function must have only one parameter
   accepting arbitrary keyword arguments (like ``**kwargs`` or ``**data``).
 
-- There are 3 category of fields: regular fields, computed fields (marked properties) and private attributes.
-  Pydantic tracks order inside one category, but does not track between categories.
-  Also, pydantic does not keep right order inside private attributes.
+- There are 3 categories of fields: regular fields, computed fields (marked properties), and private attributes.
+  Pydantic tracks order inside one category but does not track between categories.
+  Also, pydantic does not keep the right order inside private attributes.
 
   Therefore, during the dumping of fields, regular fields will come first,
   followed by computed fields, and then private attributes.
-  You can use use manual mapping to list instead automatic ``as_list=True`` to control the order.
+  You can use manual mapping to list instead of automatic ``as_list=True`` to control the order.
 
 - Fields with constraints defined by parameters (like ``f1: int = Field(gt=1, ge=10)``)
   are translated to ``Annotated`` with corresponding metadata.
@@ -107,3 +107,86 @@ For example, any aliases or config parameters defined inside the model are ignor
 You can override this behavior to use a native pydantic validation/serialization mechanism.
 
 .. literalinclude:: /examples/reference/integrations/native_pydantic.py
+
+.. _sqlalchemy_json:
+
+SQLAlchemy JSON
+=======================
+
+You can use adaptix to store structured JSON data inside a relational database.
+SQLAlchemy will automatically map JSON to your model using adaptix.
+
+Let's see how you can define database schema.
+
+.. literalinclude:: /examples/reference/integrations/sqlalchemy_json/preamble.py
+
+The constructor of ``AdaptixJSON`` takes two parameters: retort and type of data.
+Also, you can pass a custom JSON column type via ``impl`` keyword parameter.
+
+Basic usage
+----------------
+
+You can pass your model to any place where SQLAlchemy expects an instance of data.
+
+.. literalinclude:: /examples/reference/integrations/sqlalchemy_json/basic_usage.py
+   :pyobject: example
+   :lines: 2-
+   :dedent:
+
+
+Querying and filtering
+--------------------------
+
+``AdaptixJSON`` is ``TypeDecorator`` of ``JSON`` type, so you can use any method of ``JSON`` type to produce query.
+
+
+.. literalinclude:: /examples/reference/integrations/sqlalchemy_json/querying_and_filtering.py
+   :pyobject: example
+   :lines: 2-
+   :dedent:
+
+
+.. caution::
+
+  ``name_mapping`` is not applied to the query builder. Your query will use json representation of model.
+
+
+Mutation tracking
+---------------------
+
+SQLAlchemy flushes objects only if some are marked as modified (dirty).
+The instance becomes dirty when ``__setattr__`` is invoked.
+So, SQLAlchemy cannot track the mutation of the object associated with the attribute.
+
+.. literalinclude:: /examples/reference/integrations/sqlalchemy_json/mutation_tracking.py
+   :pyobject: example
+   :lines: 2-
+   :dedent:
+
+
+.. dropdown:: Workaround for mutation tracking
+
+  1. Modify the entire object graph to track mutation at any edge.
+     See for details `mutable extension <https://docs.sqlalchemy.org/en/20/orm/extensions/mutable.html>`__.
+
+  2. Save a copy of the dataclass at the 'after_attach' event
+     and compares it with an actual object at 'before_flush'.
+     If it differs, mark the instance as dirty via ``flag_modified``.
+     This will work only if ``session.flush()`` is called directly.
+     Other methods may skip calling flush (and invoking its events) if there are no dirty objects.
+
+  3. Call ``flag_modified`` directly after dataclass mutation.
+
+Finally, it is not recommended to mutate a model that will be persistent to JSON,
+because more likely it means that you should not store JSON in RDBMS.
+
+
+Redefining ``none_as_null`` parameter
+----------------------------------------
+
+To override the parameters of ``JSON`` type itself, you can pass a custom SQLAlchemy type.
+
+.. literalinclude:: /examples/reference/integrations/sqlalchemy_json/redefined_impl.py
+   :pyobject: example
+   :lines: 2-
+   :dedent:
diff --git a/examples/sqlalchemy_json/README.md b/examples/sqlalchemy_json/README.md
deleted file mode 100644
index 603a8d28..00000000
--- a/examples/sqlalchemy_json/README.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# SQLAlchemy JSON
-
-This example shows how to use Adaptix with [SQLAlchemy](https://www.sqlalchemy.org/)
-to store JSON in a relational database.
-
-Adaptix transparently converts JSON to desired dataclass and vice versa,
-your SQLAlchemy models contain already transmuted data.
-
-Be careful persisting JSON in relational databases.
-There are a few appropriate use cases for this.
diff --git a/examples/sqlalchemy_json/adapter.py b/examples/sqlalchemy_json/adapter.py
deleted file mode 100644
index 08c0b646..00000000
--- a/examples/sqlalchemy_json/adapter.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from sqlalchemy import JSON, TypeDecorator
-
-from adaptix import AdornedRetort, TypeHint
-
-
-# SQLAlchemy does not require to implement process_literal_param and python_type
-class ModelJSON(TypeDecorator):
-    impl = JSON
-    cache_ok = True
-
-    def __init__(
-        self,
-        tp: TypeHint,
-        retort: AdornedRetort,
-        *,
-        none_as_null: bool = False,
-    ):
-        super().__init__(none_as_null=none_as_null)
-        self.tp = tp
-        self.retort = retort
-        self.loader = retort.get_loader(tp)
-        self.dumper = retort.get_dumper(tp)
-
-    def process_bind_param(self, value, dialect):
-        return self.dumper(value)
-
-    def process_result_value(self, value, dialect):
-        return self.loader(value)
-
-    def coerce_compared_value(self, op, value):
-        return self.impl.coerce_compared_value(op, value)
diff --git a/examples/sqlalchemy_json/audit_logs.py b/examples/sqlalchemy_json/audit_logs.py
deleted file mode 100644
index 02c97d9e..00000000
--- a/examples/sqlalchemy_json/audit_logs.py
+++ /dev/null
@@ -1,37 +0,0 @@
-from dataclasses import dataclass
-from typing import Literal, Union
-
-
-@dataclass
-class AuditLog:
-    pass
-
-
-@dataclass
-class UserCreated(AuditLog):
-    id: int
-    name: str
-
-    tag: Literal["user_created"] = "user_created"
-
-
-@dataclass
-class UserChanged(AuditLog):
-    id: int
-    name: str
-
-    tag: Literal["user_changed"] = "user_changed"
-
-
-@dataclass
-class UserDeleted(AuditLog):
-    id: int
-
-    tag: Literal["user_deleted"] = "user_deleted"
-
-
-AnyAuditLog = Union[
-    UserCreated,
-    UserChanged,
-    UserDeleted,
-]
diff --git a/examples/sqlalchemy_json/db_models.py b/examples/sqlalchemy_json/db_models.py
deleted file mode 100644
index 505a9adb..00000000
--- a/examples/sqlalchemy_json/db_models.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from datetime import datetime
-
-from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
-
-from adaptix import Retort
-
-from .adapter import ModelJSON
-from .audit_logs import AnyAuditLog
-
-
-class Base(DeclarativeBase):
-    pass
-
-
-RETORT = Retort()
-
-
-class AuditLogRecord(Base):
-    __tablename__ = "audit_log"
-
-    id: Mapped[int] = mapped_column(primary_key=True)
-    data: Mapped[AnyAuditLog] = mapped_column(ModelJSON(AnyAuditLog, RETORT))
-
-    created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
diff --git a/pyproject.toml b/pyproject.toml
index 72f3bf85..968291f5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,9 +37,9 @@ classifiers = [
 attrs = ['attrs >= 21.3.0']
 attrs-strict = ['attrs >= 21.3.0, <= 23.2.0']
 sqlalchemy = ['sqlalchemy >= 2.0.0']
-sqlalchemy-strict = ['sqlalchemy >= 2.0.0, <= 2.0.29']
+sqlalchemy-strict = ['sqlalchemy >= 2.0.0, <= 2.0.30']
 pydantic = ['pydantic >= 2.0.0']
-pydantic-strict = ['pydantic >= 2.0.0, <= 2.7.0']
+pydantic-strict = ['pydantic >= 2.0.0, <= 2.7.1']
 
 [project.urls]
 'Homepage' = 'https://github.com/reagento/adaptix'
diff --git a/requirements/bench.txt b/requirements/bench.txt
index 54866fca..c15e8efd 100644
--- a/requirements/bench.txt
+++ b/requirements/bench.txt
@@ -26,15 +26,15 @@ packaging==24.0
     # via
     #   marshmallow
     #   pytest
-pluggy==1.4.0
+pluggy==1.5.0
     # via pytest
 psutil==5.9.5
     # via
     #   -r requirements/raw/bench.txt
     #   pyperf
-pydantic==2.7.0
+pydantic==2.7.1
     # via -r requirements/raw/bench.txt
-pydantic-core==2.18.1
+pydantic-core==2.18.2
     # via pydantic
 pyperf==2.6.1
     # via -r requirements/raw/bench.txt
diff --git a/requirements/bench_pypy38.txt b/requirements/bench_pypy38.txt
index 160c8343..adcd49cf 100644
--- a/requirements/bench_pypy38.txt
+++ b/requirements/bench_pypy38.txt
@@ -26,7 +26,7 @@ packaging==24.0
     # via
     #   marshmallow
     #   pytest
-pluggy==1.4.0
+pluggy==1.5.0
     # via pytest
 psutil==5.9.5
     # via
diff --git a/requirements/dev.txt b/requirements/dev.txt
index d99fbd92..3fa8f344 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -8,29 +8,27 @@
     # via -r requirements/raw/bench.txt
 -e ./tests/tests_helpers
     # via -r requirements/raw/test_extra_none.txt
-alabaster==0.7.13
+alabaster==0.7.16
     # via sphinx
 annotated-types==0.6.0
     # via pydantic
 astpath==0.9.1
-    # via
-    #   -r requirements/raw/lint.txt
-    #   astpath
+    # via -r requirements/raw/lint.txt
 attrs==23.2.0
     # via
     #   -r requirements/raw/test_extra_new.txt
     #   cattrs
 babel==2.14.0
     # via sphinx
-beautifulsoup4==4.12.2
+beautifulsoup4==4.12.3
     # via furo
-build==1.0.3
+build==1.2.1
     # via pip-tools
-cachetools==5.3.2
+cachetools==5.3.3
     # via tox
 cattrs==23.1.2
     # via -r requirements/raw/bench.txt
-certifi==2023.11.17
+certifi==2024.2.2
     # via requests
 cfgv==3.4.0
     # via pre-commit
@@ -46,7 +44,7 @@ colorama==0.4.6
     # via
     #   radon
     #   tox
-contourpy==1.2.0
+contourpy==1.2.1
     # via matplotlib
 coverage==7.4.4
     # via
@@ -71,25 +69,25 @@ docutils==0.20.1
     #   sphinx-paramlinks
 docutils-stubs==0.0.22
     # via -r requirements/raw/doc.txt
-filelock==3.13.1
+filelock==3.13.4
     # via
     #   tox
     #   virtualenv
-flake8==6.1.0
+flake8==7.0.0
     # via dlint
-fonttools==4.47.0
+fonttools==4.51.0
     # via matplotlib
-furo==2023.9.10
+furo==2024.1.29
     # via -r requirements/raw/doc.txt
 gitdb==4.0.11
     # via gitpython
-gitpython==3.1.40
+gitpython==3.1.43
     # via -r requirements/raw/doc.txt
-greenlet==3.0.2
+greenlet==3.0.3
     # via sqlalchemy
-identify==2.5.33
+identify==2.5.36
     # via pre-commit
-idna==3.6
+idna==3.7
     # via requests
 imagesize==1.4.1
     # via sphinx
@@ -99,14 +97,14 @@ iniconfig==2.0.0
     # via pytest
 invoke==2.2.0
     # via -r requirements/raw/runner.txt
-jinja2==3.1.2
+jinja2==3.1.3
     # via
     #   myst-parser
     #   sphinx
     #   towncrier
 kiwisolver==1.4.5
     # via matplotlib
-lxml==5.0.0
+lxml==5.2.1
     # via astpath
 mando==0.7.1
     # via radon
@@ -114,7 +112,7 @@ markdown-it-py==3.0.0
     # via
     #   mdit-py-plugins
     #   myst-parser
-markupsafe==2.1.3
+markupsafe==2.1.5
     # via jinja2
 marshmallow==3.20.1
     # via -r requirements/raw/bench.txt
@@ -138,11 +136,11 @@ myst-parser==2.0.0
     # via -r requirements/raw/doc.txt
 nodeenv==1.8.0
     # via pre-commit
-numpy==1.26.3
+numpy==1.26.4
     # via
     #   contourpy
     #   matplotlib
-packaging==23.2
+packaging==24.0
     # via
     #   build
     #   marshmallow
@@ -156,23 +154,23 @@ pbr==6.0.0
     # via sphinxcontrib-apidoc
 phonenumberslite==8.13.26
     # via -r requirements/raw/test_extra_none.txt
-pillow==10.2.0
+pillow==10.3.0
     # via matplotlib
-pip-tools==7.3.0
+pip-tools==7.4.1
     # via -r requirements/raw/dev.txt
-platformdirs==4.1.0
+platformdirs==4.2.0
     # via
     #   tox
     #   virtualenv
-plotly==5.18.0
+plotly==5.21.0
     # via
     #   -r requirements/raw/_bench-orchestrate.txt
     #   -r requirements/raw/doc.txt
-pluggy==1.3.0
+pluggy==1.5.0
     # via
     #   pytest
     #   tox
-pre-commit==3.6.2
+pre-commit==3.7.0
     # via -r requirements/raw/lint.txt
 psutil==5.9.5
     # via
@@ -180,33 +178,35 @@ psutil==5.9.5
     #   pyperf
 pycodestyle==2.11.1
     # via flake8
-pydantic==2.7.0
+pydantic==2.7.1
     # via
     #   -r requirements/raw/bench.txt
     #   -r requirements/raw/test_extra_new.txt
-pydantic-core==2.18.1
+pydantic-core==2.18.2
     # via pydantic
-pyflakes==3.1.0
+pyflakes==3.2.0
     # via flake8
 pygments==2.17.2
     # via
     #   furo
     #   sphinx
-pyparsing==3.1.1
+pyparsing==3.1.2
     # via matplotlib
 pyperf==2.6.1
     # via -r requirements/raw/bench.txt
 pyproject-api==1.6.1
     # via tox
 pyproject-hooks==1.0.0
-    # via build
+    # via
+    #   build
+    #   pip-tools
 pytest==7.4.2
     # via
     #   -r requirements/raw/bench.txt
     #   -r requirements/raw/test_extra_none.txt
-python-dateutil==2.8.2
+python-dateutil==2.9.0.post0
     # via matplotlib
-pytz==2023.3.post1
+pytz==2024.1
     # via dirty-equals
 pyyaml==6.0.1
     # via
@@ -216,7 +216,7 @@ radon==6.0.1
     # via -r requirements/raw/lint.txt
 requests==2.31.0
     # via sphinx
-ruff==0.3.3
+ruff==0.4.1
     # via -r requirements/raw/lint.txt
 schematics==2.1.1
     # via -r requirements/raw/bench.txt
@@ -230,7 +230,7 @@ snowballstemmer==2.2.0
     # via sphinx
 soupsieve==2.5
     # via beautifulsoup4
-sphinx==7.2.6
+sphinx==7.3.7
     # via
     #   -r requirements/raw/doc.txt
     #   furo
@@ -241,11 +241,6 @@ sphinx==7.2.6
     #   sphinx-paramlinks
     #   sphinx-reredirects
     #   sphinxcontrib-apidoc
-    #   sphinxcontrib-applehelp
-    #   sphinxcontrib-devhelp
-    #   sphinxcontrib-htmlhelp
-    #   sphinxcontrib-qthelp
-    #   sphinxcontrib-serializinghtml
     #   sphinxext-opengraph
 sphinx-basic-ng==1.0.0b2
     # via furo
@@ -261,58 +256,56 @@ sphinx-reredirects==0.1.3
     # via -r requirements/raw/doc.txt
 sphinxcontrib-apidoc==0.4.0
     # via -r requirements/raw/doc.txt
-sphinxcontrib-applehelp==1.0.7
+sphinxcontrib-applehelp==1.0.8
     # via sphinx
-sphinxcontrib-devhelp==1.0.5
+sphinxcontrib-devhelp==1.0.6
     # via sphinx
-sphinxcontrib-htmlhelp==2.0.4
+sphinxcontrib-htmlhelp==2.0.5
     # via sphinx
 sphinxcontrib-jsmath==1.0.1
     # via sphinx
-sphinxcontrib-qthelp==1.0.6
+sphinxcontrib-qthelp==1.0.7
     # via sphinx
-sphinxcontrib-serializinghtml==1.1.9
+sphinxcontrib-serializinghtml==1.1.10
     # via sphinx
 sphinxext-opengraph==0.9.1
     # via -r requirements/raw/doc.txt
-sqlalchemy==2.0.29
-    # via
-    #   -r requirements/raw/test_extra_new.txt
-    #   -r requirements/raw/test_extra_none.txt
+sqlalchemy==2.0.30
+    # via -r requirements/raw/test_extra_new.txt
 tenacity==8.2.3
     # via plotly
 toml==0.10.2
     # via vulture
 towncrier==23.11.0
     # via -r requirements/raw/dev.txt
-tox==4.11.4
+tox==4.14.2
     # via -r requirements/raw/runner.txt
-typing-extensions==4.9.0
+typing-extensions==4.11.0
     # via
     #   mashumaro
     #   mypy
     #   pydantic
     #   pydantic-core
     #   sqlalchemy
-urllib3==2.1.0
+urllib3==2.2.1
     # via requests
-virtualenv==20.25.0
+virtualenv==20.25.3
     # via
     #   pre-commit
     #   tox
 vulture==2.10
     # via -r requirements/raw/lint.txt
-wheel==0.42.0
+wheel==0.43.0
     # via
     #   -r requirements/raw/pre.txt
     #   pip-tools
 
 # The following packages are considered to be unsafe in a requirements file:
-pip==23.3.1
+pip==24.0
     # via
     #   -r requirements/raw/pre.txt
     #   pip-tools
-setuptools==69.0.0
+setuptools==69.5.1
     # via
     #   -r requirements/raw/dev.txt
     #   nodeenv
diff --git a/requirements/doc.txt b/requirements/doc.txt
index 7434bb10..b9276e93 100644
--- a/requirements/doc.txt
+++ b/requirements/doc.txt
@@ -33,11 +33,11 @@ docutils==0.20.1
     #   sphinx-paramlinks
 docutils-stubs==0.0.22
     # via -r requirements/raw/doc.txt
-furo==2023.9.10
+furo==2024.1.29
     # via -r requirements/raw/doc.txt
 gitdb==4.0.11
     # via gitpython
-gitpython==3.1.40
+gitpython==3.1.43
     # via -r requirements/raw/doc.txt
 idna==3.7
     # via requests
@@ -75,17 +75,17 @@ packaging==24.0
     #   sphinx
 pbr==6.0.0
     # via sphinxcontrib-apidoc
-plotly==5.18.0
+plotly==5.21.0
     # via -r requirements/raw/doc.txt
-pluggy==1.4.0
+pluggy==1.5.0
     # via pytest
 psutil==5.9.5
     # via
     #   -r requirements/raw/bench.txt
     #   pyperf
-pydantic==2.7.0
+pydantic==2.7.1
     # via -r requirements/raw/bench.txt
-pydantic-core==2.18.1
+pydantic-core==2.18.2
     # via pydantic
 pygments==2.17.2
     # via
@@ -107,7 +107,7 @@ snowballstemmer==2.2.0
     # via sphinx
 soupsieve==2.5
     # via beautifulsoup4
-sphinx==7.2.6
+sphinx==7.3.7
     # via
     #   -r requirements/raw/doc.txt
     #   furo
diff --git a/requirements/lint.txt b/requirements/lint.txt
index 7786a9d2..c3064b48 100644
--- a/requirements/lint.txt
+++ b/requirements/lint.txt
@@ -8,25 +8,23 @@
     # via -r requirements/raw/bench.txt
 -e ./tests/tests_helpers
     # via -r requirements/raw/test_extra_none.txt
-alabaster==0.7.13
+alabaster==0.7.16
     # via sphinx
 annotated-types==0.6.0
     # via pydantic
 astpath==0.9.1
-    # via
-    #   -r requirements/raw/lint.txt
-    #   astpath
+    # via -r requirements/raw/lint.txt
 attrs==23.2.0
     # via
     #   -r requirements/raw/test_extra_new.txt
     #   cattrs
 babel==2.14.0
     # via sphinx
-beautifulsoup4==4.12.2
+beautifulsoup4==4.12.3
     # via furo
 cattrs==23.1.2
     # via -r requirements/raw/bench.txt
-certifi==2023.11.17
+certifi==2024.2.2
     # via requests
 cfgv==3.4.0
     # via pre-commit
@@ -34,7 +32,7 @@ charset-normalizer==3.3.2
     # via requests
 colorama==0.4.6
     # via radon
-contourpy==1.2.0
+contourpy==1.2.1
     # via matplotlib
 coverage==7.4.4
     # via -r requirements/raw/test_extra_none.txt
@@ -57,35 +55,35 @@ docutils==0.20.1
     #   sphinx-paramlinks
 docutils-stubs==0.0.22
     # via -r requirements/raw/doc.txt
-filelock==3.13.1
+filelock==3.13.4
     # via virtualenv
-flake8==6.1.0
+flake8==7.0.0
     # via dlint
-fonttools==4.47.0
+fonttools==4.51.0
     # via matplotlib
-furo==2023.9.10
+furo==2024.1.29
     # via -r requirements/raw/doc.txt
 gitdb==4.0.11
     # via gitpython
-gitpython==3.1.40
+gitpython==3.1.43
     # via -r requirements/raw/doc.txt
-greenlet==3.0.2
+greenlet==3.0.3
     # via sqlalchemy
-identify==2.5.33
+identify==2.5.36
     # via pre-commit
-idna==3.6
+idna==3.7
     # via requests
 imagesize==1.4.1
     # via sphinx
 iniconfig==2.0.0
     # via pytest
-jinja2==3.1.2
+jinja2==3.1.3
     # via
     #   myst-parser
     #   sphinx
 kiwisolver==1.4.5
     # via matplotlib
-lxml==5.0.0
+lxml==5.2.1
     # via astpath
 mando==0.7.1
     # via radon
@@ -93,7 +91,7 @@ markdown-it-py==3.0.0
     # via
     #   mdit-py-plugins
     #   myst-parser
-markupsafe==2.1.3
+markupsafe==2.1.5
     # via jinja2
 marshmallow==3.20.1
     # via -r requirements/raw/bench.txt
@@ -117,11 +115,11 @@ myst-parser==2.0.0
     # via -r requirements/raw/doc.txt
 nodeenv==1.8.0
     # via pre-commit
-numpy==1.26.3
+numpy==1.26.4
     # via
     #   contourpy
     #   matplotlib
-packaging==23.2
+packaging==24.0
     # via
     #   marshmallow
     #   matplotlib
@@ -132,17 +130,17 @@ pbr==6.0.0
     # via sphinxcontrib-apidoc
 phonenumberslite==8.13.26
     # via -r requirements/raw/test_extra_none.txt
-pillow==10.2.0
+pillow==10.3.0
     # via matplotlib
-platformdirs==4.1.0
+platformdirs==4.2.0
     # via virtualenv
-plotly==5.18.0
+plotly==5.21.0
     # via
     #   -r requirements/raw/_bench-orchestrate.txt
     #   -r requirements/raw/doc.txt
-pluggy==1.3.0
+pluggy==1.5.0
     # via pytest
-pre-commit==3.6.2
+pre-commit==3.7.0
     # via -r requirements/raw/lint.txt
 psutil==5.9.5
     # via
@@ -150,19 +148,19 @@ psutil==5.9.5
     #   pyperf
 pycodestyle==2.11.1
     # via flake8
-pydantic==2.7.0
+pydantic==2.7.1
     # via
     #   -r requirements/raw/bench.txt
     #   -r requirements/raw/test_extra_new.txt
-pydantic-core==2.18.1
+pydantic-core==2.18.2
     # via pydantic
-pyflakes==3.1.0
+pyflakes==3.2.0
     # via flake8
 pygments==2.17.2
     # via
     #   furo
     #   sphinx
-pyparsing==3.1.1
+pyparsing==3.1.2
     # via matplotlib
 pyperf==2.6.1
     # via -r requirements/raw/bench.txt
@@ -170,9 +168,9 @@ pytest==7.4.2
     # via
     #   -r requirements/raw/bench.txt
     #   -r requirements/raw/test_extra_none.txt
-python-dateutil==2.8.2
+python-dateutil==2.9.0.post0
     # via matplotlib
-pytz==2023.3.post1
+pytz==2024.1
     # via dirty-equals
 pyyaml==6.0.1
     # via
@@ -182,7 +180,7 @@ radon==6.0.1
     # via -r requirements/raw/lint.txt
 requests==2.31.0
     # via sphinx
-ruff==0.3.3
+ruff==0.4.1
     # via -r requirements/raw/lint.txt
 schematics==2.1.1
     # via -r requirements/raw/bench.txt
@@ -196,7 +194,7 @@ snowballstemmer==2.2.0
     # via sphinx
 soupsieve==2.5
     # via beautifulsoup4
-sphinx==7.2.6
+sphinx==7.3.7
     # via
     #   -r requirements/raw/doc.txt
     #   furo
@@ -207,11 +205,6 @@ sphinx==7.2.6
     #   sphinx-paramlinks
     #   sphinx-reredirects
     #   sphinxcontrib-apidoc
-    #   sphinxcontrib-applehelp
-    #   sphinxcontrib-devhelp
-    #   sphinxcontrib-htmlhelp
-    #   sphinxcontrib-qthelp
-    #   sphinxcontrib-serializinghtml
     #   sphinxext-opengraph
 sphinx-basic-ng==1.0.0b2
     # via furo
@@ -227,42 +220,40 @@ sphinx-reredirects==0.1.3
     # via -r requirements/raw/doc.txt
 sphinxcontrib-apidoc==0.4.0
     # via -r requirements/raw/doc.txt
-sphinxcontrib-applehelp==1.0.7
+sphinxcontrib-applehelp==1.0.8
     # via sphinx
-sphinxcontrib-devhelp==1.0.5
+sphinxcontrib-devhelp==1.0.6
     # via sphinx
-sphinxcontrib-htmlhelp==2.0.4
+sphinxcontrib-htmlhelp==2.0.5
     # via sphinx
 sphinxcontrib-jsmath==1.0.1
     # via sphinx
-sphinxcontrib-qthelp==1.0.6
+sphinxcontrib-qthelp==1.0.7
     # via sphinx
-sphinxcontrib-serializinghtml==1.1.9
+sphinxcontrib-serializinghtml==1.1.10
     # via sphinx
 sphinxext-opengraph==0.9.1
     # via -r requirements/raw/doc.txt
-sqlalchemy==2.0.29
-    # via
-    #   -r requirements/raw/test_extra_new.txt
-    #   -r requirements/raw/test_extra_none.txt
+sqlalchemy==2.0.30
+    # via -r requirements/raw/test_extra_new.txt
 tenacity==8.2.3
     # via plotly
 toml==0.10.2
     # via vulture
-typing-extensions==4.9.0
+typing-extensions==4.11.0
     # via
     #   mashumaro
     #   mypy
     #   pydantic
     #   pydantic-core
     #   sqlalchemy
-urllib3==2.1.0
+urllib3==2.2.1
     # via requests
-virtualenv==20.25.0
+virtualenv==20.25.3
     # via pre-commit
 vulture==2.10
     # via -r requirements/raw/lint.txt
 
 # The following packages are considered to be unsafe in a requirements file:
-setuptools==69.0.3
+setuptools==69.5.1
     # via nodeenv
diff --git a/requirements/pre.txt b/requirements/pre.txt
index d7f10afb..8b5a7dc2 100644
--- a/requirements/pre.txt
+++ b/requirements/pre.txt
@@ -4,9 +4,9 @@
 #
 #    pip-compile --allow-unsafe --output-file=requirements/pre.txt --strip-extras requirements/raw/pre.txt
 #
-wheel==0.42.0
+wheel==0.43.0
     # via -r requirements/raw/pre.txt
 
 # The following packages are considered to be unsafe in a requirements file:
-pip==23.3.1
+pip==24.0
     # via -r requirements/raw/pre.txt
diff --git a/requirements/raw/_bench-orchestrate.txt b/requirements/raw/_bench-orchestrate.txt
index 64d9e8b7..3dde0090 100644
--- a/requirements/raw/_bench-orchestrate.txt
+++ b/requirements/raw/_bench-orchestrate.txt
@@ -1,4 +1,4 @@
 -r bench.txt
 
 matplotlib==3.8.2
-plotly==5.18.0
+plotly==5.21.0
diff --git a/requirements/raw/bench.txt b/requirements/raw/bench.txt
index 5e41d4d3..07e2e63f 100644
--- a/requirements/raw/bench.txt
+++ b/requirements/raw/bench.txt
@@ -6,7 +6,7 @@ pyperf==2.6.1
 psutil==5.9.5
 
 mashumaro==3.10
-pydantic==2.7.0
+pydantic==2.7.1
 cattrs==23.1.2
 schematics==2.1.1
 dataclass-factory==2.16
diff --git a/requirements/raw/dev.txt b/requirements/raw/dev.txt
index b74b22e7..6d0d6a39 100644
--- a/requirements/raw/dev.txt
+++ b/requirements/raw/dev.txt
@@ -1,5 +1,5 @@
-pip-tools==7.3.0
-setuptools==69.0.0
+pip-tools==7.4.1
+setuptools==69.5.1
 towncrier==23.11.0
 
 -r runner.txt
diff --git a/requirements/raw/doc.txt b/requirements/raw/doc.txt
index 77ade7c0..97d25151 100644
--- a/requirements/raw/doc.txt
+++ b/requirements/raw/doc.txt
@@ -1,6 +1,6 @@
 -r bench.txt
 
-sphinx==7.2.6
+sphinx==7.3.7
 sphinx-copybutton==0.5.2
 sphinx-design==0.5.0
 sphinx-paramlinks==0.6.0
@@ -10,9 +10,9 @@ sphinx-better-subsection==0.2
 sphinx-reredirects==0.1.3
 
 sphinxcontrib-apidoc==0.4.0
-furo==2023.9.10
+furo==2024.1.29
 
 docutils-stubs==0.0.22
 
-gitpython==3.1.40
-plotly==5.18.0
+gitpython==3.1.43
+plotly==5.21.0
diff --git a/requirements/raw/lint.txt b/requirements/raw/lint.txt
index 6248bf4a..1fbd0546 100644
--- a/requirements/raw/lint.txt
+++ b/requirements/raw/lint.txt
@@ -2,13 +2,13 @@
 -r _bench-orchestrate.txt
 -r doc.txt
 
-pre-commit==3.6.2
+pre-commit==3.7.0
 
 mypy==1.9.0
 
 vulture==2.10
 
-ruff==0.3.3
+ruff==0.4.1
 
 radon==6.0.1
 dlint==0.14.1
diff --git a/requirements/raw/pre.txt b/requirements/raw/pre.txt
index 0edfdf31..c9fbbb05 100644
--- a/requirements/raw/pre.txt
+++ b/requirements/raw/pre.txt
@@ -1,2 +1,2 @@
-pip==23.3.1
-wheel==0.42.0
+pip==24.0
+wheel==0.43.0
diff --git a/requirements/raw/runner.txt b/requirements/raw/runner.txt
index a7528361..9187419b 100644
--- a/requirements/raw/runner.txt
+++ b/requirements/raw/runner.txt
@@ -1,3 +1,3 @@
-tox==4.11.4
+tox==4.14.2
 invoke==2.2.0
 coverage==7.4.4
diff --git a/requirements/runner.txt b/requirements/runner.txt
index 86e79038..0df5be1e 100644
--- a/requirements/runner.txt
+++ b/requirements/runner.txt
@@ -4,7 +4,7 @@
 #
 #    pip-compile --allow-unsafe --output-file=requirements/runner.txt --strip-extras requirements/raw/runner.txt
 #
-cachetools==5.3.2
+cachetools==5.3.3
     # via tox
 chardet==5.2.0
     # via tox
@@ -14,25 +14,25 @@ coverage==7.4.4
     # via -r requirements/raw/runner.txt
 distlib==0.3.8
     # via virtualenv
-filelock==3.13.1
+filelock==3.13.4
     # via
     #   tox
     #   virtualenv
 invoke==2.2.0
     # via -r requirements/raw/runner.txt
-packaging==23.2
+packaging==24.0
     # via
     #   pyproject-api
     #   tox
-platformdirs==4.1.0
+platformdirs==4.2.0
     # via
     #   tox
     #   virtualenv
-pluggy==1.3.0
+pluggy==1.5.0
     # via tox
 pyproject-api==1.6.1
     # via tox
-tox==4.11.4
+tox==4.14.2
     # via -r requirements/raw/runner.txt
-virtualenv==20.25.0
+virtualenv==20.25.3
     # via tox
diff --git a/src/adaptix/_internal/conversion/coercer_provider.py b/src/adaptix/_internal/conversion/coercer_provider.py
index 50f3d92c..a87f4802 100644
--- a/src/adaptix/_internal/conversion/coercer_provider.py
+++ b/src/adaptix/_internal/conversion/coercer_provider.py
@@ -221,14 +221,14 @@ def iterable_coercer(data, ctx):
 
     def _parse_source(self, norm: BaseNormType) -> TypeHint:
         if norm.origin == tuple and norm.args[-1] != Ellipsis:
-            raise CannotProvide("Constant-length tuple is not suppoerted yet", is_demonstrative=True)
+            raise CannotProvide("Constant-length tuple is not supported yet", is_demonstrative=True)
         if norm.origin in self.CONCRETE_ORIGINS or norm.origin in self.ABC_TO_IMPL:
             return norm.args[0].source
         raise CannotProvide
 
     def _parse_destination(self, norm: BaseNormType) -> Tuple[Callable, TypeHint]:
         if norm.origin == tuple and norm.args[-1] != Ellipsis:
-            raise CannotProvide("Constant-length tuple is not suppoerted yet", is_demonstrative=True)
+            raise CannotProvide("Constant-length tuple is not supported yet", is_demonstrative=True)
         if norm.origin in self.CONCRETE_ORIGINS:
             return norm.origin, norm.args[0].source
         if norm.origin in self.ABC_TO_IMPL:
diff --git a/src/adaptix/_internal/conversion/converter_provider.py b/src/adaptix/_internal/conversion/converter_provider.py
index 179cf346..7f49cf7a 100644
--- a/src/adaptix/_internal/conversion/converter_provider.py
+++ b/src/adaptix/_internal/conversion/converter_provider.py
@@ -67,7 +67,7 @@ def _make_converter(self, mediator: Mediator, request: ConverterRequest):
             file_name=self._get_file_name(request),
         )
 
-    def register_mangled(self, namespace: CascadeNamespace, base: str, obj: object) -> str:
+    def _register_mangled(self, namespace: CascadeNamespace, base: str, obj: object) -> str:
         base = self._name_sanitizer.sanitize(base)
         if namespace.try_add_constant(base, obj):
             return base
@@ -90,7 +90,7 @@ def _produce_code(
         namespace.add_outer_constant("_closure_signature", signature)
         namespace.add_outer_constant("_stub_function", stub_function)
         namespace.add_outer_constant("_update_wrapper", update_wrapper)
-        coercer_var = self.register_mangled(namespace, "coercer", coercer)
+        coercer_var = self._register_mangled(namespace, "coercer", coercer)
 
         no_types_signature = signature.replace(
             parameters=[param.replace(annotation=Signature.empty) for param in signature.parameters.values()],
diff --git a/src/adaptix/_internal/conversion/facade/provider.py b/src/adaptix/_internal/conversion/facade/provider.py
index fd6ca849..df2fd107 100644
--- a/src/adaptix/_internal/conversion/facade/provider.py
+++ b/src/adaptix/_internal/conversion/facade/provider.py
@@ -4,10 +4,11 @@
 from ...model_tools.definitions import DefaultFactory, DefaultValue
 from ...provider.essential import Provider
 from ...provider.facade.provider import bound_by_any
-from ...provider.loc_stack_filtering import LocStackChecker, LocStackSizeChecker, Pred, create_loc_stack_checker
+from ...provider.loc_stack_filtering import LocStackChecker, Pred, create_loc_stack_checker
 from ..coercer_provider import MatchingCoercerProvider
-from ..linking_provider import ConstantLinkingProvider, MatchingLinkingProvider
+from ..linking_provider import ConstantLinkingProvider, FunctionLinkingProvider, MatchingLinkingProvider
 from ..policy_provider import UnlinkedOptionalPolicyProvider
+from ..request_filtering import FromCtxParam
 
 
 def link(src: Pred, dst: Pred, *, coercer: Optional[OneArgCoercer] = None) -> Provider:
@@ -50,6 +51,25 @@ def link_constant(dst: Pred, *, value: Any = None, factory: Any = None) -> Provi
     )
 
 
+def link_function(func: Callable, dst: Pred) -> Provider:
+    """Provider that uses function to produce value of destination field.
+
+    The entire model is passed to the first parameter of the function.
+    The type of result and first parameter are not checked, you must ensure type compatibility yourself.
+
+    Keyword-only parameters link to model fields and other parameters link to extra converter parameters.
+    After linking, the default type coercing mechanism is applied.
+
+    :param func: A function used to process several fields of source model.
+    :param dst: Predicate specifying destination point of linking. See :ref:`predicate-system` for details.
+    :return: Desired provider
+    """
+    return FunctionLinkingProvider(
+        func=func,
+        dst_lsc=create_loc_stack_checker(dst),
+    )
+
+
 def coercer(src: Pred, dst: Pred, func: OneArgCoercer) -> Provider:
     """Basic provider to define custom coercer.
 
@@ -89,6 +109,4 @@ def forbid_unlinked_optional(*preds: Pred) -> Provider:
 
 def from_param(param_name: str) -> LocStackChecker:
     """The special predicate form matching only top-level parameters by name"""
-    if not param_name.isidentifier():
-        raise ValueError("param_name must be a valid python identifier to exactly match parameter")
-    return LocStackSizeChecker(1) & create_loc_stack_checker(param_name)
+    return FromCtxParam(param_name)
diff --git a/src/adaptix/_internal/conversion/linking_provider.py b/src/adaptix/_internal/conversion/linking_provider.py
index 5b4dde4d..043cd1a0 100644
--- a/src/adaptix/_internal/conversion/linking_provider.py
+++ b/src/adaptix/_internal/conversion/linking_provider.py
@@ -1,13 +1,25 @@
 import itertools
-from typing import Iterable, Optional, Union
+from typing import Callable, Iterable, Mapping, NoReturn, Optional, TypeVar, Union
 
-from ..common import Coercer, OneArgCoercer
-from ..model_tools.definitions import DefaultFactory, DefaultValue
-from ..provider.essential import CannotProvide, Mediator
+from ..common import Coercer, OneArgCoercer, VarTuple
+from ..model_tools.definitions import DefaultFactory, DefaultValue, InputField, InputShape, Param, ParamKind
+from ..model_tools.introspection.callable import get_callable_shape
+from ..provider.essential import CannotProvide, Mediator, mandatory_apply_by_iterable
+from ..provider.fields import input_field_to_loc
 from ..provider.loc_stack_filtering import LocStackChecker
 from ..provider.location import FieldLoc
+from ..provider.request_cls import format_loc_stack
+from ..utils import add_note
 from .provider_template import LinkingProvider
-from .request_cls import ConstantLinking, FieldLinking, LinkingRequest, LinkingResult, LinkingSource
+from .request_cls import (
+    ConstantLinking,
+    FieldLinking,
+    FunctionLinking,
+    LinkingRequest,
+    LinkingResult,
+    LinkingSource,
+    ModelLinking,
+)
 
 
 class DefaultLinkingProvider(LinkingProvider):
@@ -58,3 +70,105 @@ def _provide_linking(self, mediator: Mediator, request: LinkingRequest) -> Linki
         if self._dst_lsc.check_loc_stack(mediator, request.destination):
             return LinkingResult(linking=ConstantLinking(self._default))
         raise CannotProvide
+
+
+T = TypeVar("T")
+
+
+class FunctionLinkingProvider(LinkingProvider):
+    def __init__(self, func: Callable, dst_lsc: LocStackChecker):
+        self._func = func
+        self._dst_lsc = dst_lsc
+        self._input_shape = self._get_input_shape()
+
+    def _get_input_shape(self) -> InputShape:
+        return get_callable_shape(self._func).input
+
+    def _get_linking(
+        self,
+        mediator: Mediator,
+        request: LinkingRequest,
+        name_to_field_source: Mapping[str, LinkingSource],
+        name_to_context_source: Mapping[str, LinkingSource],
+        input_field: InputField,
+        param: Param,
+        idx: int,
+    ) -> LinkingResult:
+        if param.kind == ParamKind.KW_ONLY:
+            try:
+                source = name_to_field_source[param.name]
+            except KeyError:
+                self._raise_link_error(request, input_field, "Cannot match `{}` with model field")
+        else:
+            if idx == 0:
+                return LinkingResult(linking=ModelLinking())
+
+            try:
+                source = name_to_context_source[param.name]
+            except KeyError:
+                self._raise_link_error(request, input_field, "Cannot match `{}` with converter parameter")
+
+        return LinkingResult(linking=FieldLinking(source=source, coercer=None))
+
+    def _raise_link_error(self, request: LinkingRequest, input_field: InputField, template: str) -> NoReturn:
+        dest = request.destination.append_with(input_field_to_loc(input_field).complement_with_func(self._func))
+        raise CannotProvide(
+            template.format(format_loc_stack(dest)),
+            is_terminal=True,
+            is_demonstrative=True,
+        ) from None
+
+    def _create_param_specs(
+        self,
+        mediator: Mediator,
+        request: LinkingRequest,
+        error_describer: Callable[[], str],
+    ) -> VarTuple[FunctionLinking.ParamSpec]:
+        name_to_field_source = {
+            source.last.cast_or_raise(FieldLoc, CannotProvide).field_id: source
+            for source in request.sources
+        }
+        name_to_context_source = {
+            loc_stack.last.field_id: loc_stack
+            for loc_stack in request.context.loc_stacks
+        }
+
+        param_specs = mandatory_apply_by_iterable(
+            lambda field, param, idx: FunctionLinking.ParamSpec(
+                field=field,
+                linking=self._get_linking(
+                    mediator=mediator,
+                    request=request,
+                    name_to_field_source=name_to_field_source,
+                    name_to_context_source=name_to_context_source,
+                    input_field=field,
+                    param=param,
+                    idx=idx,
+                ),
+                param_kind=param.kind,
+            ),
+            zip(self._input_shape.fields, self._input_shape.params, itertools.count()),
+            error_describer,
+        )
+        return tuple(param_specs)
+
+    def _provide_linking(self, mediator: Mediator, request: LinkingRequest) -> LinkingResult:
+        if not self._dst_lsc.check_loc_stack(mediator, request.destination):
+            raise CannotProvide
+
+        try:
+            param_specs = self._create_param_specs(
+                mediator,
+                request,
+                lambda: "Cannot create linking for function. Linkings for some parameters are not found",
+            )
+        except CannotProvide as e:
+            if len(request.sources) > 0:
+                src_desc = format_loc_stack(request.sources[0].reversed_slice(1))
+                dst_desc = format_loc_stack(request.destination)
+                add_note(e, f"Linking: `{src_desc} => {dst_desc}`")
+            raise
+
+        return LinkingResult(
+            linking=FunctionLinking(func=self._func, param_specs=param_specs),
+        )
diff --git a/src/adaptix/_internal/conversion/model_coercer_provider.py b/src/adaptix/_internal/conversion/model_coercer_provider.py
index f929324c..0189ddcd 100644
--- a/src/adaptix/_internal/conversion/model_coercer_provider.py
+++ b/src/adaptix/_internal/conversion/model_coercer_provider.py
@@ -1,5 +1,5 @@
 from inspect import Parameter, Signature
-from typing import Iterable, List, Mapping, Optional, Tuple
+from typing import Callable, Iterable, List, Mapping, Optional, Tuple, Union
 
 from ..code_tools.compiler import BasicClosureCompiler, ClosureCompiler
 from ..code_tools.name_sanitizer import BuiltinNameSanitizer, NameSanitizer
@@ -20,16 +20,18 @@
     ConversionDestItem,
     ConversionSourceItem,
     FieldLinking,
+    FunctionLinking,
     LinkingRequest,
     LinkingResult,
+    ModelLinking,
     UnlinkedOptionalPolicyRequest,
 )
 from ..model_tools.definitions import DefaultValue, InputField, InputShape, OutputShape, ParamKind, create_key_accessor
 from ..morphing.model.basic_gen import compile_closure_with_globals_capturing, fetch_code_gen_hook
 from ..provider.essential import CannotProvide, Mediator, mandatory_apply_by_iterable
 from ..provider.fields import input_field_to_loc, output_field_to_loc
-from ..provider.location import OutputFieldLoc
-from ..provider.request_cls import LocStack, TypeHintLoc
+from ..provider.location import AnyLoc, InputFieldLoc, InputFuncFieldLoc, OutputFieldLoc
+from ..provider.request_cls import LocStack
 from ..provider.shape_provider import InputShapeRequest, OutputShapeRequest, provide_generic_resolved_shape
 from ..utils import add_note
 from .provider_template import CoercerProvider
@@ -76,14 +78,20 @@ def _make_coercer(
             file_name=self._get_file_name(request),
         )
 
+    def _loc_stack_to_view_string(self, lock_stack: LocStack[AnyLoc]) -> str:
+        tp = lock_stack.last.type
+        if isinstance(tp, type):
+            return tp.__name__
+        return str(tp)
+
     def _get_closure_name(self, request: CoercerRequest) -> str:
-        src = request.src.last.cast(TypeHintLoc).type
-        dst = request.dst.last.cast(TypeHintLoc).type
+        src = self._loc_stack_to_view_string(request.src)
+        dst = self._loc_stack_to_view_string(request.dst)
         return self._name_sanitizer.sanitize(f"coerce_{src}_to_{dst}")
 
     def _get_file_name(self, request: CoercerRequest) -> str:
-        src = request.src.last.cast(TypeHintLoc).type
-        dst = request.dst.last.cast(TypeHintLoc).type
+        src = self._loc_stack_to_view_string(request.src)
+        dst = self._loc_stack_to_view_string(request.dst)
         return self._name_sanitizer.sanitize(f"coerce_{src}_to_{dst}")
 
     def _fetch_dst_shape(self, mediator: Mediator, loc_stack: LocStack[ConversionDestItem]) -> InputShape:
@@ -128,7 +136,8 @@ def fetch_field_linking(dst_field: InputField) -> Tuple[InputField, Optional[Lin
                 )
             except CannotProvide as e:
                 if dst_field.is_required:
-                    add_note(e, "Note: This is a required field, so it must take value")
+                    if not e.is_terminal:
+                        add_note(e, "Note: This is a required field, so it must take value")
                     raise
 
                 policy = mediator.mandatory_provide(
@@ -151,11 +160,11 @@ def fetch_field_linking(dst_field: InputField) -> Tuple[InputField, Optional[Lin
             lambda: "Cannot create coercer for models. Linkings for some fields are not found",
         )
 
-    def _field_linking_to_sub_plan(
+    def _generate_field_linking_to_sub_plan(
         self,
         mediator: Mediator,
         request: CoercerRequest,
-        input_field: InputField,
+        loc: Union[InputFieldLoc, InputFuncFieldLoc],
         linking: FieldLinking,
     ) -> BroachingPlan:
         if linking.coercer is not None:
@@ -165,7 +174,7 @@ def _field_linking_to_sub_plan(
                 CoercerRequest(
                     src=linking.source,
                     ctx=request.ctx,
-                    dst=request.dst.append_with(input_field_to_loc(input_field)),
+                    dst=request.dst.append_with(loc),
                 ),
             )
 
@@ -204,11 +213,44 @@ def _generate_constant_linking_to_sub_plan(
             return ConstantElement(value=linking.constant.value)
         return FunctionElement(func=linking.constant.factory, args=())
 
-    def _generate_field_to_sub_plan(
+    def _generate_model_linking_to_sub_plan(
+        self,
+        mediator: Mediator,
+        linking: ModelLinking,
+    ) -> BroachingPlan:
+        return ParameterElement("data")
+
+    def _generate_function_linking_to_sub_plan(
+        self,
+        mediator: Mediator,
+        request: CoercerRequest,
+        linking: FunctionLinking,
+    ) -> BroachingPlan:
+        args: List[FuncCallArg[BroachingPlan]] = []
+        field_to_sub_plan = self._generate_sub_plan(
+            mediator,
+            request,
+            [(param_spec.field, param_spec.linking) for param_spec in linking.param_specs],
+            parent_func=linking.func,
+        )
+        for param_spec in linking.param_specs:
+            sub_plan = field_to_sub_plan[param_spec.field]
+            if param_spec.param_kind == ParamKind.KW_ONLY:
+                args.append(KeywordArg(param_spec.field.id, sub_plan))
+            else:
+                args.append(PositionalArg(sub_plan))
+
+        return FunctionElement(
+            func=linking.func,
+            args=tuple(args),
+        )
+
+    def _generate_sub_plan(
         self,
         mediator: Mediator,
         request: CoercerRequest,
         field_linkings: Iterable[Tuple[InputField, LinkingResult]],
+        parent_func: Optional[Callable],
     ) -> Mapping[InputField, BroachingPlan]:
         def generate_sub_plan(input_field: InputField, linking_result: LinkingResult):
             if isinstance(linking_result.linking, ConstantLinking):
@@ -216,17 +258,35 @@ def generate_sub_plan(input_field: InputField, linking_result: LinkingResult):
                     mediator=mediator,
                     linking=linking_result.linking,
                 )
-            return self._field_linking_to_sub_plan(
-                mediator=mediator,
-                request=request,
-                input_field=input_field,
-                linking=linking_result.linking,
-            )
+            if isinstance(linking_result.linking, FunctionLinking):
+                return self._generate_function_linking_to_sub_plan(
+                    mediator=mediator,
+                    request=request,
+                    linking=linking_result.linking,
+                )
+            if isinstance(linking_result.linking, ModelLinking):
+                return self._generate_model_linking_to_sub_plan(
+                    mediator=mediator,
+                    linking=linking_result.linking,
+                )
+            if isinstance(linking_result.linking, FieldLinking):
+                field_loc = input_field_to_loc(input_field)
+                return self._generate_field_linking_to_sub_plan(
+                    mediator=mediator,
+                    request=request,
+                    loc=field_loc.complement_with_func(parent_func) if parent_func is not None else field_loc,
+                    linking=linking_result.linking,
+                )
+            raise TypeError
 
         field_sub_plans = mandatory_apply_by_iterable(
             generate_sub_plan,
             field_linkings,
-            lambda: "Cannot create coercer for models. Coercers for some linkings are not found",
+            lambda: (
+                "Cannot create coercer for models. Coercers for some linkings are not found"
+                if parent_func is None else
+                "Cannot create coercer for model and function. Coercers for some linkings are not found"
+            ),
         )
         return {
             dst_field: sub_plan
@@ -246,7 +306,7 @@ def _make_broaching_plan(
             dst_shape=dst_shape,
             src_shape=src_shape,
         )
-        field_to_sub_plan = self._generate_field_to_sub_plan(
+        field_to_sub_plan = self._generate_sub_plan(
             mediator=mediator,
             request=request,
             field_linkings=[
@@ -254,6 +314,7 @@ def _make_broaching_plan(
                 for dst_field, linking in field_linkings
                 if linking is not None
             ],
+            parent_func=None,
         )
         return self._make_constructor_call(
             dst_shape=dst_shape,
diff --git a/src/adaptix/_internal/conversion/request_cls.py b/src/adaptix/_internal/conversion/request_cls.py
index a605fc27..1ad526f1 100644
--- a/src/adaptix/_internal/conversion/request_cls.py
+++ b/src/adaptix/_internal/conversion/request_cls.py
@@ -3,9 +3,9 @@
 from typing import Callable, Optional, Union
 
 from ..common import Coercer, VarTuple
-from ..model_tools.definitions import DefaultFactory, DefaultValue
+from ..model_tools.definitions import DefaultFactory, DefaultValue, InputField, ParamKind
 from ..provider.essential import Request
-from ..provider.location import FieldLoc, GenericParamLoc, InputFieldLoc, OutputFieldLoc, TypeHintLoc
+from ..provider.location import FieldLoc, GenericParamLoc, InputFieldLoc, InputFuncFieldLoc, OutputFieldLoc, TypeHintLoc
 from ..provider.request_cls import LocatedRequest, LocStack
 
 
@@ -17,7 +17,7 @@ class ConverterRequest(Request):
 
 
 ConversionSourceItem = Union[FieldLoc, OutputFieldLoc, GenericParamLoc]
-ConversionDestItem = Union[TypeHintLoc, InputFieldLoc, GenericParamLoc]
+ConversionDestItem = Union[TypeHintLoc, InputFieldLoc, InputFuncFieldLoc, GenericParamLoc]
 
 
 @dataclass(frozen=True)
@@ -43,9 +43,26 @@ class ConstantLinking:
     constant: Union[DefaultValue, DefaultFactory]
 
 
+@dataclass(frozen=True)
+class ModelLinking:
+    pass
+
+
+@dataclass(frozen=True)
+class FunctionLinking:
+    @dataclass(frozen=True)
+    class ParamSpec:
+        field: InputField
+        param_kind: ParamKind
+        linking: "LinkingResult"
+
+    func: Callable
+    param_specs: VarTuple[ParamSpec]
+
+
 @dataclass(frozen=True)
 class LinkingResult:
-    linking: Union[FieldLinking, ConstantLinking]
+    linking: Union[FieldLinking, ConstantLinking, ModelLinking, FunctionLinking]
 
 
 @dataclass(frozen=True)
diff --git a/src/adaptix/_internal/conversion/request_filtering.py b/src/adaptix/_internal/conversion/request_filtering.py
new file mode 100644
index 00000000..ffc58973
--- /dev/null
+++ b/src/adaptix/_internal/conversion/request_filtering.py
@@ -0,0 +1,12 @@
+from adaptix._internal.provider.loc_stack_filtering import DirectMediator, LocStackChecker
+from adaptix._internal.provider.request_cls import LocStack
+
+
+class FromCtxParam(LocStackChecker):
+    def __init__(self, field_id: str):
+        if not field_id.isidentifier():
+            raise ValueError("param_name must be a valid python identifier to exactly match parameter")
+        self._field_id = field_id
+
+    def check_loc_stack(self, mediator: DirectMediator, loc_stack: LocStack) -> bool:
+        return len(loc_stack) == 1 and loc_stack.last.field_id == self._field_id
diff --git a/src/adaptix/_internal/datastructures.py b/src/adaptix/_internal/datastructures.py
index 442664ee..a4ea8ff5 100644
--- a/src/adaptix/_internal/datastructures.py
+++ b/src/adaptix/_internal/datastructures.py
@@ -57,7 +57,7 @@ def update(self, *args, **kwargs):
                 for k, v in obj:
                     self[k] = v
 
-        if len(args) != 0:
+        elif len(args) != 0:
             raise ValueError
 
         for k, v in kwargs.items():
diff --git a/src/adaptix/_internal/integrations/sqlalchemy/__init__.py b/src/adaptix/_internal/integrations/sqlalchemy/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/adaptix/_internal/integrations/sqlalchemy/orm.py b/src/adaptix/_internal/integrations/sqlalchemy/orm.py
new file mode 100644
index 00000000..61fe3d0a
--- /dev/null
+++ b/src/adaptix/_internal/integrations/sqlalchemy/orm.py
@@ -0,0 +1,56 @@
+from typing import Any, Optional, Type, Union
+
+from sqlalchemy import JSON, Dialect, TypeDecorator, null
+from sqlalchemy.dialects import postgresql
+from sqlalchemy.dialects.postgresql.base import PGDialect
+from sqlalchemy.sql.type_api import TypeEngine, to_instance
+
+from ...common import TypeHint
+from ...morphing.facade.retort import AdornedRetort
+
+_SQL_NULL = null()
+_JSON_NULL = JSON.NULL
+
+
+class AdaptixJSON(TypeDecorator):
+    impl = JSON
+    cache_ok = True
+
+    def __init__(
+        self,
+        retort: AdornedRetort,
+        tp: TypeHint,
+        *,
+        impl: Optional[TypeEngine] = None,
+    ):
+        super().__init__()
+        self.retort = retort
+        self.tp = tp
+        self._custom_impl = impl
+        self._loader = retort.get_loader(tp)
+        self._dumper = retort.get_dumper(tp)
+        if impl is not None:
+            self.impl = impl  # type: ignore[assignment]
+
+    def load_dialect_impl(self, dialect: Dialect) -> TypeEngine[Any]:
+        if self._custom_impl is not None:
+            return self._custom_impl
+        return to_instance(self._load_default_dialect_impl(dialect))
+
+    def _load_default_dialect_impl(self, dialect: Dialect) -> Union[TypeEngine[Any], Type[TypeEngine[Any]]]:
+        if isinstance(dialect, PGDialect):
+            return postgresql.JSONB
+        return JSON
+
+    def process_bind_param(self, value, dialect):
+        if value is None or value is _SQL_NULL or value is _JSON_NULL:
+            return value
+        return self._dumper(value)
+
+    def process_result_value(self, value, dialect):
+        if value is None:
+            return value
+        return self._loader(value)
+
+    def coerce_compared_value(self, op, value):
+        return self.impl.coerce_compared_value(op, value)
diff --git a/src/adaptix/_internal/model_tools/introspection/sqlalchemy.py b/src/adaptix/_internal/model_tools/introspection/sqlalchemy.py
index cad62453..d29056cb 100644
--- a/src/adaptix/_internal/model_tools/introspection/sqlalchemy.py
+++ b/src/adaptix/_internal/model_tools/introspection/sqlalchemy.py
@@ -153,7 +153,7 @@ def _get_input_shape(
         )
 
     for relationship in relationships:
-        if relationship.collection_class is not None:
+        if relationship.collection_class is not None and strip_alias(relationship.collection_class) != list:
             continue  # it is not supported
         if relationship.uselist is None:
             continue  # it cannot be None there
@@ -203,7 +203,7 @@ def _get_output_shape(
         if isinstance(column, sqlalchemy.Column)
     ]
     for relationship in relationships:
-        if relationship.collection_class is not None:
+        if relationship.collection_class is not None and strip_alias(relationship.collection_class) != list:
             continue  # it is not supported
         if relationship.uselist is None:
             continue  # it cannot be None there
diff --git a/src/adaptix/_internal/morphing/enum_provider.py b/src/adaptix/_internal/morphing/enum_provider.py
index 4b1af0b2..5fd39a30 100644
--- a/src/adaptix/_internal/morphing/enum_provider.py
+++ b/src/adaptix/_internal/morphing/enum_provider.py
@@ -170,10 +170,6 @@ def enum_dumper(data):
         return enum_dumper
 
 
-def _enum_exact_value_dumper(data):
-    return data.value
-
-
 class EnumExactValueProvider(BaseEnumProvider):
     """This provider represents enum members to the outside world
     by their value without any processing
@@ -211,7 +207,7 @@ def enum_exact_loader_v2m(data):
 
     def _get_exact_value_to_member(self, enum: Type[Enum]) -> Optional[Mapping[Any, Any]]:
         try:
-            value_to_member = {case.value: case for case in enum}
+            value_to_member = {member.value: member for member in enum}
         except TypeError:
             return None
 
@@ -221,7 +217,12 @@ def _get_exact_value_to_member(self, enum: Type[Enum]) -> Optional[Mapping[Any,
         return value_to_member
 
     def _provide_dumper(self, mediator: Mediator, request: DumperRequest) -> Dumper:
-        return _enum_exact_value_dumper
+        member_to_value = {member: member.value for member in get_type_from_request(request)}
+
+        def enum_exact_value_dumper(data):
+            return member_to_value[data]
+
+        return enum_exact_value_dumper
 
 
 class FlagByExactValueProvider(BaseFlagProvider):
@@ -245,7 +246,7 @@ def _provide_loader(self, mediator: Mediator, request: LoaderRequest) -> Loader:
             )
 
         def flag_loader(data):
-            if type(data) is not int: # noqa: E721
+            if type(data) is not int:  # noqa: E721
                 raise TypeLoadError(int, data)
 
             if data < 0 or data > flag_mask:
@@ -258,7 +259,10 @@ def flag_loader(data):
         return flag_loader
 
     def _provide_dumper(self, mediator: Mediator, request: DumperRequest) -> Dumper:
-        return _enum_exact_value_dumper
+        def flag_exact_value_dumper(data):
+            return data.value
+
+        return flag_exact_value_dumper
 
 
 def _extract_non_compound_cases_from_flag(enum: Type[FlagT]) -> Sequence[FlagT]:
diff --git a/src/adaptix/_internal/morphing/facade/provider.py b/src/adaptix/_internal/morphing/facade/provider.py
index 516a2224..2da3eb03 100644
--- a/src/adaptix/_internal/morphing/facade/provider.py
+++ b/src/adaptix/_internal/morphing/facade/provider.py
@@ -432,10 +432,10 @@ def validating_loader(data):
 
 
 def default_dict(pred: Pred, default_factory: Callable) -> Provider:
-    """DefaultDict provider with overriden default_factory parameter
+    """Provider for ``defaultdict`` class
 
     :param pred: Predicate specifying where the provider should be used.
         See :ref:`predicate-system` for details.
-    :param default_factory: default_factory parameter of the defaultdict instance to be created by the loader
+    :param default_factory: default_factory parameter of the ``defaultdict`` instance to be created by the loader
     """
     return bound(pred, DefaultDictProvider(default_factory))
diff --git a/src/adaptix/_internal/morphing/model/loader_gen.py b/src/adaptix/_internal/morphing/model/loader_gen.py
index 36ab10ad..411a3132 100644
--- a/src/adaptix/_internal/morphing/model/loader_gen.py
+++ b/src/adaptix/_internal/morphing/model/loader_gen.py
@@ -396,7 +396,7 @@ def _gen_raise_bad_type_error(
                 f"raise {namer.with_trail(bad_type_load_error)}",
             )
 
-    def _gen_assigment_from_parent_data(
+    def _gen_assignment_from_parent_data(
         self,
         state: GenState,
         *,
@@ -498,7 +498,7 @@ def _gen_dict_crown(self, state: GenState, crown: InpDictCrown):
         state.namespace.add_constant(state.v_required_keys, self._get_dict_crown_required_keys(crown))
 
         if state.path:
-            self._gen_assigment_from_parent_data(state, assign_to=state.v_data)
+            self._gen_assignment_from_parent_data(state, assign_to=state.v_data)
             state.builder.empty_line()
 
         if self._can_collect_extra:
@@ -539,7 +539,7 @@ def _gen_forbidden_sequence_check(self, state: GenState) -> None:
 
     def _gen_list_crown(self, state: GenState, crown: InpListCrown):
         if state.path:
-            self._gen_assigment_from_parent_data(state, assign_to=state.v_data)
+            self._gen_assignment_from_parent_data(state, assign_to=state.v_data)
             state.builder.empty_line()
 
         if self._can_collect_extra:
@@ -598,7 +598,7 @@ def _get_default_clause_expr(self, state: GenState, field: InputField) -> str:
     def _gen_field_crown(self, state: GenState, crown: InpFieldCrown):
         field = state.get_field(crown)
         if field.is_required:
-            self._gen_assigment_from_parent_data(
+            self._gen_assignment_from_parent_data(
                 state=state,
                 assign_to=state.v_raw_field(field),
             )
@@ -619,7 +619,7 @@ def _gen_field_crown(self, state: GenState, crown: InpFieldCrown):
                 on_lookup_error = f"{state.v_field(field)} = {self._get_default_clause_expr(state, field)}"
 
             if isinstance(state.path[-1], int):
-                self._gen_assigment_from_parent_data(
+                self._gen_assignment_from_parent_data(
                     state=state,
                     assign_to=state.v_raw_field(field),
                     on_lookup_error=on_lookup_error,
@@ -681,16 +681,10 @@ def _gen_optional_field_extraction_from_mapping(
 
         self._gen_unexpected_exc_catching(state)
         with state.builder("else:"):
-            state.builder(
-                f"""
-                try:
-                    value = getter({state.path[-1]!r}, sentinel)
-                """,
-            )
-            self._gen_unexpected_exc_catching(state)
-            with state.builder("else:"):  # noqa: SIM117
+            if self._debug_trail == DebugTrail.DISABLE:
                 with state.builder(
                     f"""
+                    value = getter({state.path[-1]!r}, sentinel)
                     if value is sentinel:
                         {on_lookup_error}
                     else:
@@ -702,6 +696,28 @@ def _gen_optional_field_extraction_from_mapping(
                         loader_arg="value",
                         state=state,
                     )
+            else:
+                state.builder(
+                    f"""
+                    try:
+                        value = getter({state.path[-1]!r}, sentinel)
+                    """,
+                )
+                self._gen_unexpected_exc_catching(state)
+                with state.builder("else:"):  # noqa: SIM117
+                    with state.builder(
+                        f"""
+                        if value is sentinel:
+                            {on_lookup_error}
+                        else:
+                        """,
+                    ):
+                        self._gen_field_assigment(
+                            assign_to=assign_to,
+                            field_id=field.id,
+                            loader_arg="value",
+                            state=state,
+                        )
 
     def _gen_field_assigment(
         self,
diff --git a/src/adaptix/_internal/provider/location.py b/src/adaptix/_internal/provider/location.py
index 2f26e0db..0f510206 100644
--- a/src/adaptix/_internal/provider/location.py
+++ b/src/adaptix/_internal/provider/location.py
@@ -1,5 +1,5 @@
 from dataclasses import dataclass, field
-from typing import Any, Callable, Mapping, Type, TypeVar, Union
+from typing import Any, Callable, Container, Dict, Mapping, Type, TypeVar, Union
 
 from ..common import TypeHint
 from ..model_tools.definitions import Accessor, Default
@@ -41,6 +41,11 @@ class _InputFieldLoc(_FieldLoc):
     is_required: bool
 
 
+@dataclass(frozen=True)
+class _InputFuncFieldLoc(_FieldLoc):
+    func: Callable
+
+
 @dataclass(frozen=True)
 class _OutputFieldLoc(_FieldLoc):
     accessor: Accessor
@@ -60,6 +65,17 @@ class FieldLoc(_FieldLoc):
 
 
 class InputFieldLoc(_InputFieldLoc):
+    def complement_with_func(self, func: Callable) -> "InputFuncFieldLoc":
+        return InputFuncFieldLoc(
+            type=self.type,
+            field_id=self.field_id,
+            default=self.default,
+            metadata=self.metadata,
+            func=func,
+        )
+
+
+class InputFuncFieldLoc(_InputFuncFieldLoc):
     pass
 
 
@@ -71,12 +87,13 @@ class GenericParamLoc(_GenericParamLoc):
     pass
 
 
-_CAST_SOURCES = {
-    TypeHintLoc: {TypeHintLoc, FieldLoc, InputFieldLoc, OutputFieldLoc, GenericParamLoc},
-    FieldLoc: {FieldLoc, InputFieldLoc, OutputFieldLoc},
-    InputFieldLoc: (InputFieldLoc, ),
-    OutputFieldLoc: (OutputFieldLoc, ),
-    GenericParamLoc: (GenericParamLoc, ),
+_CAST_SOURCES: Dict[Any, Container[Any]] = {
+    TypeHintLoc: {TypeHintLoc, FieldLoc, InputFieldLoc, OutputFieldLoc, GenericParamLoc, InputFuncFieldLoc},
+    FieldLoc: {FieldLoc, InputFieldLoc, OutputFieldLoc, InputFuncFieldLoc},
+    InputFieldLoc: {InputFieldLoc, InputFuncFieldLoc},
+    InputFuncFieldLoc: {InputFuncFieldLoc},
+    OutputFieldLoc: {OutputFieldLoc},
+    GenericParamLoc: {GenericParamLoc},
 }
 
-AnyLoc = Union[TypeHintLoc, FieldLoc, InputFieldLoc, OutputFieldLoc, GenericParamLoc]
+AnyLoc = Union[TypeHintLoc, FieldLoc, InputFieldLoc, InputFuncFieldLoc, OutputFieldLoc, GenericParamLoc]
diff --git a/src/adaptix/_internal/provider/request_cls.py b/src/adaptix/_internal/provider/request_cls.py
index 38de7ef5..1dbc477d 100644
--- a/src/adaptix/_internal/provider/request_cls.py
+++ b/src/adaptix/_internal/provider/request_cls.py
@@ -4,10 +4,10 @@
 from ..common import TypeHint
 from ..datastructures import ImmutableStack
 from ..definitions import DebugTrail
-from ..type_tools import BaseNormType, normalize_type
+from ..type_tools import BaseNormType, is_parametrized, normalize_type
 from ..utils import pairs
 from .essential import CannotProvide, Request
-from .location import AnyLoc, FieldLoc, TypeHintLoc
+from .location import AnyLoc, FieldLoc, InputFuncFieldLoc, TypeHintLoc
 
 LocStackT = TypeVar("LocStackT", bound="LocStack")
 AnyLocT_co = TypeVar("AnyLocT_co", bound=AnyLoc, covariant=True)
@@ -18,6 +18,36 @@ def replace_last_type(self: LocStackT, tp: TypeHint, /) -> LocStackT:
         return self.replace_last(replace(self.last, type=tp))
 
 
+def _format_type(tp: TypeHint) -> str:
+    if isinstance(tp, type) and not is_parametrized(tp):
+        return tp.__qualname__
+    str_tp = str(tp)
+    if str_tp.startswith("typing."):
+        return str_tp[7:]
+    return str_tp
+
+
+def format_loc_stack(loc_stack: LocStack[AnyLoc]) -> str:
+    fmt_tp = _format_type(loc_stack.last.type)
+
+    try:
+        field_loc = loc_stack.last.cast(FieldLoc)
+    except TypeError:
+        return fmt_tp
+    else:
+        fmt_field = f"{field_loc.field_id}: {fmt_tp}"
+
+    if loc_stack.last.is_castable(InputFuncFieldLoc):
+        func_field_loc = loc_stack.last.cast(InputFuncFieldLoc)
+        func_name = getattr(func_field_loc.func, "__qualname__", None) or repr(func_field_loc.func)
+        return f"{func_name}({fmt_field})"
+
+    if len(loc_stack) >= 2:  # noqa: PLR2004
+        src_owner = _format_type(loc_stack[-2].type)
+        return f"{src_owner}.{fmt_field}"
+    return fmt_tp
+
+
 T = TypeVar("T")
 
 
diff --git a/src/adaptix/_internal/retort/operating_retort.py b/src/adaptix/_internal/retort/operating_retort.py
index 3dcfe343..6e8ff4c3 100644
--- a/src/adaptix/_internal/retort/operating_retort.py
+++ b/src/adaptix/_internal/retort/operating_retort.py
@@ -1,13 +1,12 @@
 from abc import ABC
-from typing import Any, Dict, Iterable, List, Mapping, Optional, Type, Union
+from typing import Any, Dict, Iterable, List, Mapping, Optional, Type
 
-from ..common import TypeHint, VarTuple
-from ..conversion.request_cls import CoercerRequest, ConversionDestItem, ConversionSourceItem, LinkingRequest
+from ..common import VarTuple
+from ..conversion.request_cls import CoercerRequest, LinkingRequest
 from ..morphing.request_cls import DumperRequest, LoaderRequest
 from ..provider.essential import AggregateCannotProvide, CannotProvide, Mediator, Provider, Request
-from ..provider.location import AnyLoc, FieldLoc
-from ..provider.request_cls import LocatedRequest, LocStack
-from ..type_tools import is_parametrized
+from ..provider.location import AnyLoc
+from ..provider.request_cls import LocatedRequest, LocStack, format_loc_stack
 from ..utils import add_note, copy_exception_dunders, with_module
 from .base_retort import BaseRetort
 from .mediator import ErrorRepresentor, RecursionResolver, T
@@ -68,14 +67,6 @@ def _get_linking_request_description(self, request: LinkingRequest) -> str:
         dst_desc = self._get_loc_stack_desc(request.destination)
         return f"Cannot find paired field of `{dst_desc}` for linking"
 
-    def _get_type_desc(self, tp: TypeHint) -> str:
-        if isinstance(tp, type) and not is_parametrized(tp):
-            return tp.__qualname__
-        str_tp = str(tp)
-        if str_tp.startswith("typing."):
-            return str_tp[7:]
-        return str_tp
-
     def get_no_provider_description(self, request: Request) -> str:
         request_cls = type(request)
         if request_cls in self._NO_PROVIDER_DESCRIPTION_METHOD:
@@ -84,23 +75,8 @@ def get_no_provider_description(self, request: Request) -> str:
             return self._NO_PROVIDER_DESCRIPTION_CONST[request_cls]
         return f"There is no provider that can process {request}"
 
-    def _get_loc_stack_desc(
-        self,
-        loc_stack: LocStack[Union[ConversionSourceItem, ConversionDestItem]],
-    ) -> str:
-        tp_desc = self._get_type_desc(loc_stack.last.type)
-
-        try:
-            owner_loc = loc_stack[-2]
-        except (TypeError, IndexError):
-            return tp_desc
-
-        try:
-            field_loc = loc_stack.last.cast(FieldLoc)
-        except TypeError:
-            return tp_desc
-        src_owner = self._get_type_desc(owner_loc.type)
-        return f"{src_owner}.{field_loc.field_id}: {tp_desc}"
+    def _get_loc_stack_desc(self, loc_stack: LocStack[AnyLoc]) -> str:
+        return format_loc_stack(loc_stack)
 
     def _get_located_request_context_notes(self, request: LocatedRequest) -> Iterable[str]:
         loc_stack_desc = self._get_loc_stack_desc(request.loc_stack)
@@ -109,7 +85,7 @@ def _get_located_request_context_notes(self, request: LocatedRequest) -> Iterabl
     def _get_coercer_request_context_notes(self, request: CoercerRequest) -> Iterable[str]:
         src_desc = self._get_loc_stack_desc(request.src)
         dst_desc = self._get_loc_stack_desc(request.dst)
-        yield f"Linking: `{src_desc} -> {dst_desc}`"
+        yield f"Linking: `{src_desc} => {dst_desc}`"
 
     def get_request_context_notes(self, request: Request) -> Iterable[str]:
         if isinstance(request, LocatedRequest):
diff --git a/src/adaptix/conversion/__init__.py b/src/adaptix/conversion/__init__.py
index 3efa3334..2b89f650 100644
--- a/src/adaptix/conversion/__init__.py
+++ b/src/adaptix/conversion/__init__.py
@@ -6,6 +6,7 @@
     from_param,
     link,
     link_constant,
+    link_function,
 )
 from adaptix._internal.conversion.facade.retort import AdornedConversionRetort, ConversionRetort, FilledConversionRetort
 
@@ -15,6 +16,7 @@
     "impl_converter",
     "link",
     "link_constant",
+    "link_function",
     "coercer",
     "allow_unlinked_optional",
     "forbid_unlinked_optional",
diff --git a/src/adaptix/integrations/sqlalchemy/__init__.py b/src/adaptix/integrations/sqlalchemy/__init__.py
new file mode 100644
index 00000000..4a26e419
--- /dev/null
+++ b/src/adaptix/integrations/sqlalchemy/__init__.py
@@ -0,0 +1,5 @@
+from adaptix._internal.integrations.sqlalchemy.orm import AdaptixJSON
+
+__all__ = (
+    "AdaptixJSON",
+)


Test patch:
diff --git a/examples/sqlalchemy_json/test_example.py b/examples/sqlalchemy_json/test_example.py
deleted file mode 100644
index c3ed4a49..00000000
--- a/examples/sqlalchemy_json/test_example.py
+++ /dev/null
@@ -1,156 +0,0 @@
-import pytest
-from sqlalchemy import insert, select, update
-from sqlalchemy.orm import sessionmaker
-from tests_helpers import create_sa_engine
-
-from .audit_logs import UserChanged, UserCreated
-from .db_models import AuditLogRecord, Base
-
-
-@pytest.fixture()
-def engine():
-    engine = create_sa_engine()
-    try:
-        Base.metadata.create_all(engine)
-        yield engine
-    finally:
-        engine.dispose()
-
-
-@pytest.fixture()
-def session_factory(engine):
-    return sessionmaker(engine)
-
-
-@pytest.fixture()
-def session(session_factory):
-    with session_factory() as session:
-        yield session
-
-
-def test_add(session):
-    session.add(
-        AuditLogRecord(
-            data=UserCreated(
-                id=1,
-                name="Example",
-            ),
-        ),
-    )
-    session.commit()
-
-
-def test_insert(session):
-    session.execute(
-        insert(AuditLogRecord)
-        .values(
-            data=UserChanged(
-                id=1,
-                name="Example",
-            ),
-        ),
-    )
-    session.commit()
-
-
-def test_select(session):
-    session.add(
-        AuditLogRecord(
-            data=UserCreated(
-                id=1,
-                name="Example",
-            ),
-        ),
-    )
-    session.commit()
-
-    # All queries use json representation of model.
-    # If there are name_mapping, you should refer to dumped fields
-    record = session.scalar(
-        select(AuditLogRecord)
-        .where(AuditLogRecord.data["id"].as_integer() == 1),
-    )
-    assert record.data == UserCreated(
-        id=1,
-        name="Example",
-    )
-
-
-def test_update(session):
-    # audit logs are not supposed to be updated...
-    user_created_record = AuditLogRecord(
-        data=UserCreated(
-            id=1,
-            name="Example1",
-        ),
-    )
-    user_changed_record = AuditLogRecord(
-        data=UserChanged(
-            id=1,
-            name="Example2",
-        ),
-    )
-    session.add(user_created_record)
-    session.add(user_changed_record)
-    session.commit()
-
-    session.execute(
-        update(AuditLogRecord)
-        .where(
-            AuditLogRecord.data["id"].as_integer() == 1,
-            AuditLogRecord.data["tag"].as_string() == UserChanged.tag,
-        )
-        .values(
-            data=UserChanged(
-                id=1,
-                name="Example3",
-            ),
-        ),
-    )
-
-    session.refresh(user_created_record)
-    session.refresh(user_changed_record)
-    assert user_created_record.data.name == "Example1"
-    assert user_changed_record.data.name == "Example3"
-
-
-def test_mutation_tracking(session_factory):
-    """SQLAlchemy flushes object only if there are some that are marked as modified (dirty).
-    The instance becomes dirty when ``__setattr__`` is invoked.
-    So, SQLAlchemy cannot track the mutation of object associated with attribute.
-
-    There are 3 workarounds to solve this problem:
-    1) Modify the entire object graph to track mutation at any edge.
-       See for details https://docs.sqlalchemy.org/en/20/orm/extensions/mutable.html
-    2) Save a copy of the dataclass at the 'after_attach' event
-       and compares it with an actual object at 'before_flush'.
-       If it differs, mark the instance as dirty via `flag_modified`.
-       This will work only if `session.flush()` is called directly.
-       Other methods may skip calling flush (and invoking its events) if there are no dirty objects.
-    3) Call `flag_modified` directly after dataclass mutation.
-
-    Finally, it is not recommended to mutate a model that will be persistent to JSON,
-    because more likely it means that you should not store JSON in RDBMS.
-    """
-
-    with session_factory() as session:
-        record = AuditLogRecord(
-            data=UserCreated(
-                id=1,
-                name="Example",
-            ),
-        )
-        session.add(record)
-        session.flush()
-
-        record_id = record.id
-        session.commit()
-
-    with session_factory() as session:
-        record = session.get(AuditLogRecord, record_id)
-        record.data.name = "Example2"
-        session.commit()
-
-    with session_factory() as session:
-        record = session.get(AuditLogRecord, record_id)
-        assert record.data.name == "Example"  # (!) mutation tracking does not work (!)
diff --git a/requirements/raw/test_extra_new.txt b/requirements/raw/test_extra_new.txt
index ff2fa457..58eea503 100644
--- a/requirements/raw/test_extra_new.txt
+++ b/requirements/raw/test_extra_new.txt
@@ -1,4 +1,4 @@
 -r test_extra_none.txt
 attrs==23.2.0
-sqlalchemy==2.0.29
-pydantic==2.7.0
+sqlalchemy==2.0.30
+pydantic==2.7.1
diff --git a/requirements/raw/test_extra_new_pypy38.txt b/requirements/raw/test_extra_new_pypy38.txt
index 091b8f36..479bfb24 100644
--- a/requirements/raw/test_extra_new_pypy38.txt
+++ b/requirements/raw/test_extra_new_pypy38.txt
@@ -1,4 +1,4 @@
 -r test_extra_none.txt
 attrs==23.2.0
-sqlalchemy==2.0.29
+sqlalchemy==2.0.30
 pydantic==2.5.3   # last version with pypy38 support
diff --git a/requirements/raw/test_extra_none.txt b/requirements/raw/test_extra_none.txt
index 0798217b..b99aa9cf 100644
--- a/requirements/raw/test_extra_none.txt
+++ b/requirements/raw/test_extra_none.txt
@@ -2,7 +2,6 @@ pytest==7.4.2
 -e ./tests/tests_helpers
 
 phonenumberslite==8.13.26
-sqlalchemy>=2.0.0
 
 dirty-equals==0.7.1.post0
 
diff --git a/requirements/test_extra_new.txt b/requirements/test_extra_new.txt
index d163514b..933dd4ee 100644
--- a/requirements/test_extra_new.txt
+++ b/requirements/test_extra_new.txt
@@ -14,29 +14,27 @@ coverage==7.4.4
     # via -r requirements/raw/test_extra_none.txt
 dirty-equals==0.7.1.post0
     # via -r requirements/raw/test_extra_none.txt
-greenlet==3.0.2
+greenlet==3.0.3
     # via sqlalchemy
 iniconfig==2.0.0
     # via pytest
-packaging==23.2
+packaging==24.0
     # via pytest
 phonenumberslite==8.13.26
     # via -r requirements/raw/test_extra_none.txt
-pluggy==1.3.0
+pluggy==1.5.0
     # via pytest
-pydantic==2.7.0
+pydantic==2.7.1
     # via -r requirements/raw/test_extra_new.txt
-pydantic-core==2.18.1
+pydantic-core==2.18.2
     # via pydantic
 pytest==7.4.2
     # via -r requirements/raw/test_extra_none.txt
-pytz==2023.3.post1
+pytz==2024.1
     # via dirty-equals
-sqlalchemy==2.0.29
-    # via
-    #   -r requirements/raw/test_extra_new.txt
-    #   -r requirements/raw/test_extra_none.txt
-typing-extensions==4.9.0
+sqlalchemy==2.0.30
+    # via -r requirements/raw/test_extra_new.txt
+typing-extensions==4.11.0
     # via
     #   pydantic
     #   pydantic-core
diff --git a/requirements/test_extra_new_pypy38.txt b/requirements/test_extra_new_pypy38.txt
index eb5ddb32..8260bb8a 100644
--- a/requirements/test_extra_new_pypy38.txt
+++ b/requirements/test_extra_new_pypy38.txt
@@ -22,7 +22,7 @@ packaging==24.0
     # via pytest
 phonenumberslite==8.13.26
     # via -r requirements/raw/test_extra_none.txt
-pluggy==1.4.0
+pluggy==1.5.0
     # via pytest
 pydantic==2.5.3
     # via -r requirements/raw/test_extra_new_pypy38.txt
@@ -32,10 +32,8 @@ pytest==7.4.2
     # via -r requirements/raw/test_extra_none.txt
 pytz==2024.1
     # via dirty-equals
-sqlalchemy==2.0.29
-    # via
-    #   -r requirements/raw/test_extra_new_pypy38.txt
-    #   -r requirements/raw/test_extra_none.txt
+sqlalchemy==2.0.30
+    # via -r requirements/raw/test_extra_new_pypy38.txt
 typing-extensions==4.11.0
     # via
     #   pydantic
diff --git a/requirements/test_extra_none.txt b/requirements/test_extra_none.txt
index a50fa0f3..077a0c7f 100644
--- a/requirements/test_extra_none.txt
+++ b/requirements/test_extra_none.txt
@@ -10,21 +10,15 @@ coverage==7.4.4
     # via -r requirements/raw/test_extra_none.txt
 dirty-equals==0.7.1.post0
     # via -r requirements/raw/test_extra_none.txt
-greenlet==3.0.2
-    # via sqlalchemy
 iniconfig==2.0.0
     # via pytest
-packaging==23.2
+packaging==24.0
     # via pytest
 phonenumberslite==8.13.26
     # via -r requirements/raw/test_extra_none.txt
-pluggy==1.3.0
+pluggy==1.5.0
     # via pytest
 pytest==7.4.2
     # via -r requirements/raw/test_extra_none.txt
-pytz==2023.3.post1
+pytz==2024.1
     # via dirty-equals
-sqlalchemy==2.0.23
-    # via -r requirements/raw/test_extra_none.txt
-typing-extensions==4.9.0
-    # via sqlalchemy
diff --git a/requirements/test_extra_old.txt b/requirements/test_extra_old.txt
index 0a05bb50..d1bfe81d 100644
--- a/requirements/test_extra_old.txt
+++ b/requirements/test_extra_old.txt
@@ -22,7 +22,7 @@ packaging==24.0
     # via pytest
 phonenumberslite==8.13.26
     # via -r requirements/raw/test_extra_none.txt
-pluggy==1.4.0
+pluggy==1.5.0
     # via pytest
 pydantic==2.0
     # via -r requirements/raw/test_extra_old.txt
@@ -33,9 +33,7 @@ pytest==7.4.2
 pytz==2024.1
     # via dirty-equals
 sqlalchemy==2.0.0
-    # via
-    #   -r requirements/raw/test_extra_none.txt
-    #   -r requirements/raw/test_extra_old.txt
+    # via -r requirements/raw/test_extra_old.txt
 typing-extensions==4.6.3
     # via
     #   -r requirements/raw/test_extra_old.txt
diff --git a/tests/conftest.py b/tests/conftest.py
index afed1904..66b70517 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2,7 +2,7 @@
 from tests_helpers import ByTrailSelector, ModelSpecSchema, cond_list, parametrize_model_spec
 
 from adaptix import DebugTrail
-from adaptix._internal.feature_requirement import HAS_ATTRS_PKG, HAS_PY_312, HAS_PYDANTIC_PKG
+from adaptix._internal.feature_requirement import HAS_ATTRS_PKG, HAS_PY_312, HAS_PYDANTIC_PKG, HAS_SQLALCHEMY_PKG
 
 
 @pytest.fixture(params=[False, True], ids=lambda x: f"strict_coercion={x}")
@@ -43,6 +43,7 @@ def pytest_generate_tests(metafunc):
 
 collect_ignore_glob = [
     *cond_list(not HAS_PY_312, ["*_312.py"]),
-    *cond_list(not HAS_ATTRS_PKG, ["*_attrs.py", "*_attrs_*.py"]),
+    *cond_list(not HAS_ATTRS_PKG, ["*_attrs.py", "*_attrs_*.py", "**/attrs/**"]),
     *cond_list(not HAS_PYDANTIC_PKG, ["*_pydantic.py", "*_pydantic_*.py", "**/pydantic/**"]),
+    *cond_list(not HAS_SQLALCHEMY_PKG, ["*_sqlalchemy.py", "*_sqlalchemy_*.py", "**/sqlalchemy/**"]),
 ]
diff --git a/tests/integration/conversion/test_link_function.py b/tests/integration/conversion/test_link_function.py
new file mode 100644
index 00000000..5e367858
--- /dev/null
+++ b/tests/integration/conversion/test_link_function.py
@@ -0,0 +1,250 @@
+from tests_helpers import raises_exc, with_cause, with_notes
+
+from adaptix import AggregateCannotProvide, CannotProvide, NoSuitableProvider
+from adaptix._internal.conversion.facade.func import get_converter
+from adaptix._internal.conversion.facade.provider import coercer
+from adaptix.conversion import impl_converter, link_function
+
+
+def test_only_model(model_spec):
+    @model_spec.decorator
+    class SourceModel(*model_spec.bases):
+        field1: int
+        field2: int
+
+    @model_spec.decorator
+    class DestModel(*model_spec.bases):
+        field1: int
+        field3: int
+
+    def my_function(model):
+        return model_spec.get_field(model, "field2")
+
+    @impl_converter(recipe=[link_function(my_function, "field3")])
+    def convert(a: SourceModel) -> DestModel:
+        ...
+
+    assert convert(SourceModel(field1=1, field2=10)) == DestModel(field1=1, field3=10)
+
+
+def test_converter_parameter(model_spec):
+    @model_spec.decorator
+    class SourceModel(*model_spec.bases):
+        field1: int
+        field2: int
+
+    @model_spec.decorator
+    class DestModel(*model_spec.bases):
+        field1: int
+        field3: int
+
+    def my_function(model, foo: int):
+        return model_spec.get_field(model, "field2") + foo
+
+    @impl_converter(recipe=[link_function(my_function, "field3")])
+    def convert(a: SourceModel, foo: int) -> DestModel:
+        ...
+
+    assert convert(SourceModel(field1=1, field2=10), foo=20) == DestModel(field1=1, field3=30)
+
+
+def test_model_field(model_spec):
+    @model_spec.decorator
+    class SourceModel(*model_spec.bases):
+        field1: int
+        field2: int
+
+    @model_spec.decorator
+    class DestModel(*model_spec.bases):
+        field1: int
+        field3: str
+
+    def my_function(model, *, field2: str):
+        return field2
+
+    @impl_converter(
+        recipe=[
+            link_function(my_function, "field3"),
+            coercer(int, str, func=str),
+        ],
+    )
+    def convert(a: SourceModel) -> DestModel:
+        ...
+
+    assert convert(SourceModel(field1=1, field2=10)) == DestModel(field1=1, field3="10")
+
+
+def test_only_keyword_only_parameters(model_spec):
+    @model_spec.decorator
+    class SourceModel(*model_spec.bases):
+        field1: int
+        field2: int
+
+    @model_spec.decorator
+    class DestModel(*model_spec.bases):
+        field1: int
+        field3: int
+
+    def my_function(*, field1: int, field2: int):
+        return field1 + field2
+
+    @impl_converter(
+        recipe=[
+            link_function(my_function, "field3"),
+        ],
+    )
+    def convert(a: SourceModel) -> DestModel:
+        ...
+
+    assert convert(SourceModel(field1=1, field2=10)) == DestModel(field1=1, field3=11)
+
+
+def test_linking_error(model_spec):
+    @model_spec.decorator
+    class SourceModel(*model_spec.bases):
+        field1: int
+        field2: int
+
+    @model_spec.decorator
+    class DestModel(*model_spec.bases):
+        field1: int
+        field3: str
+
+    def my_function(model, p1: str, *, f1: str):
+        return model
+
+    raises_exc(
+        with_cause(
+            with_notes(
+                NoSuitableProvider(
+                    f"Cannot produce converter for"
+                    f" <Signature (src: {SourceModel.__module__}.{SourceModel.__qualname__}, /)"
+                    f" -> {DestModel.__module__}.{DestModel.__qualname__}>",
+                ),
+                "Note: The attached exception above contains verbose description of the problem",
+            ),
+            AggregateCannotProvide(
+                "Cannot create top-level coercer",
+                [
+                    with_notes(
+                        AggregateCannotProvide(
+                            "Cannot create coercer for models. Linkings for some fields are not found",
+                            [
+                                with_notes(
+                                    AggregateCannotProvide(
+                                        "Cannot create linking for function."
+                                        " Linkings for some parameters are not found",
+                                        [
+                                            with_notes(
+                                                CannotProvide(
+                                                    f"Cannot match `{my_function.__qualname__}(p1: str)`"
+                                                    f" with converter parameter",
+                                                    is_terminal=True,
+                                                    is_demonstrative=True,
+                                                ),
+                                            ),
+                                            with_notes(
+                                                CannotProvide(
+                                                    f"Cannot match `{my_function.__qualname__}(f1: str)`"
+                                                    f" with model field",
+                                                    is_terminal=True,
+                                                    is_demonstrative=True,
+                                                ),
+                                            ),
+                                        ],
+                                        is_terminal=True,
+                                        is_demonstrative=True,
+                                    ),
+                                    f"Linking: `{SourceModel.__qualname__} => {DestModel.__qualname__}.field3: str`",
+                                ),
+                            ],
+                            is_terminal=True,
+                            is_demonstrative=True,
+                        ),
+                        f"Linking: `{SourceModel.__qualname__} => {DestModel.__qualname__}`",
+                    ),
+                ],
+                is_terminal=True,
+                is_demonstrative=True,
+            ),
+        ),
+        lambda: get_converter(SourceModel, DestModel, recipe=[link_function(my_function, "field3")]),
+    )
+
+
+def test_cannot_find_coercer_error(model_spec):
+    @model_spec.decorator
+    class SourceModel(*model_spec.bases):
+        field1: int
+        field2: int
+
+    @model_spec.decorator
+    class DestModel(*model_spec.bases):
+        field1: int
+        field3: str
+
+    def my_function(model, p1: str, *, field1: str):
+        return model
+
+    def make_converter():
+        @impl_converter(recipe=[link_function(my_function, "field3")])
+        def convert(src: SourceModel, p1: int) -> DestModel:
+            pass
+
+    raises_exc(
+        with_cause(
+            with_notes(
+                NoSuitableProvider(
+                    f"Cannot produce converter for"
+                    f" <Signature (src: {SourceModel.__module__}.{SourceModel.__qualname__}, p1: int)"
+                    f" -> {DestModel.__module__}.{DestModel.__qualname__}>",
+                ),
+                "Note: The attached exception above contains verbose description of the problem",
+            ),
+            AggregateCannotProvide(
+                "Cannot create top-level coercer",
+                [
+                    with_notes(
+                        AggregateCannotProvide(
+                            "Cannot create coercer for models. Coercers for some linkings are not found",
+                            [
+                                with_notes(
+                                    AggregateCannotProvide(
+                                        "Cannot create coercer for model and function."
+                                        " Coercers for some linkings are not found",
+                                        [
+                                            with_notes(
+                                                CannotProvide(
+                                                    "Cannot find coercer",
+                                                    is_terminal=False,
+                                                    is_demonstrative=True,
+                                                ),
+                                                f"Linking: `int => {my_function.__qualname__}(p1: str)`",
+                                            ),
+                                            with_notes(
+                                                CannotProvide(
+                                                    "Cannot find coercer",
+                                                    is_terminal=False,
+                                                    is_demonstrative=True,
+                                                ),
+                                                f"Linking: `{SourceModel.__qualname__}.field1: int"
+                                                f" => {my_function.__qualname__}(field1: str)`",
+                                            ),
+                                        ],
+                                        is_terminal=True,
+                                        is_demonstrative=True,
+                                    ),
+                                ),
+                            ],
+                            is_terminal=True,
+                            is_demonstrative=True,
+                        ),
+                        f"Linking: `{SourceModel.__qualname__} => {DestModel.__qualname__}`",
+                    ),
+                ],
+                is_terminal=True,
+                is_demonstrative=True,
+            ),
+        ),
+        make_converter,
+    )
diff --git a/tests/integration/morphing/test_sqlalchemy.py b/tests/integration/morphing/test_sqlalchemy.py
index 650ccee1..0d97a4b5 100644
--- a/tests/integration/morphing/test_sqlalchemy.py
+++ b/tests/integration/morphing/test_sqlalchemy.py
@@ -1,10 +1,12 @@
-from typing import Optional
+from typing import List, Optional
 
+import pytest
 from sqlalchemy import ForeignKey
 from sqlalchemy.orm import Mapped, mapped_column, registry, relationship
-from tests_helpers import sqlalchemy_equals
+from tests_helpers import cond_list, sqlalchemy_equals
 
 from adaptix import Retort
+from adaptix._internal.feature_requirement import HAS_STD_CLASSES_GENERICS
 
 
 def test_simple(accum):
@@ -29,7 +31,7 @@ class Book:
     assert dumper(Book(id=1, title="abc", price=100)) == {"id": 1, "title": "abc", "price": 100}
 
 
-def test_relationship(accum):
+def test_o2o_relationship(accum):
     mapper_registry = registry()
 
     @mapper_registry.mapped
@@ -87,3 +89,53 @@ class Declarative2:
         ==
         {"id": 1, "text": "abc", "parent_id": 100, "parent": {"id": 100}}
     )
+
+
+@pytest.mark.parametrize(
+    "list_tp",
+    [
+        List,
+        *cond_list(
+            HAS_STD_CLASSES_GENERICS,
+            [list],
+        ),
+    ],
+)
+def test_o2m_relationship(accum, list_tp):
+    mapper_registry = registry()
+
+    @mapper_registry.mapped
+    class Declarative1:
+        __tablename__ = "DeclarativeModel1"
+
+        id: Mapped[int] = mapped_column(primary_key=True)
+        parent_id: Mapped[int] = mapped_column(ForeignKey("DeclarativeModel2.id"))
+
+        __eq__ = sqlalchemy_equals
+
+    @mapper_registry.mapped
+    class Declarative2:
+        __tablename__ = "DeclarativeModel2"
+
+        id: Mapped[int] = mapped_column(primary_key=True)
+        text: Mapped[str]
+
+        children: Mapped[list_tp[Declarative1]] = relationship()
+
+        __eq__ = sqlalchemy_equals
+
+    retort = Retort(recipe=[accum])
+
+    loader = retort.get_loader(Declarative2)
+    assert (
+        loader({"id": 1, "text": "abc", "children": [{"id": 2}]})
+        ==
+        Declarative2(id=1, text="abc", children=[Declarative1(id=2)])
+    )
+
+    dumper = retort.get_dumper(Declarative2)
+    assert (
+        dumper(Declarative2(id=1, text="abc", children=[Declarative1(id=2)]))
+        ==
+        {"id": 1, "text": "abc", "children": [{"id": 2, "parent_id": None}]}
+    )
diff --git a/tests/test_doc.py b/tests/test_doc.py
index 2f076329..68e6fbec 100644
--- a/tests/test_doc.py
+++ b/tests/test_doc.py
@@ -1,13 +1,20 @@
 import importlib
+from fnmatch import fnmatch
 from pathlib import Path
+from typing import Optional
 
 import pytest
 
-from adaptix._internal.feature_requirement import HAS_PY_311, HAS_SUPPORTED_PYDANTIC_PKG
+from adaptix._internal.feature_requirement import (
+    HAS_PY_311,
+    HAS_SUPPORTED_PYDANTIC_PKG,
+    HAS_SUPPORTED_SQLALCHEMY_PKG,
+    Requirement,
+)
 
 REPO_ROOT = Path(__file__).parent.parent
 DOCS_EXAMPLES_ROOT = REPO_ROOT / "docs" / "examples"
-EXCLUDE = ["__init__.py"]
+EXCLUDE = ["__init__.py", "helpers.py"]
 GLOB = "*.py"
 
 
@@ -33,20 +40,28 @@ def pytest_generate_tests(metafunc):
     )
 
 
-CASES_REQUIREMENTS = {
+GLOB_REQUIREMENTS = {
     "loading-and-dumping/tutorial/unexpected_error": HAS_PY_311,
     "reference/integrations/native_pydantic": HAS_SUPPORTED_PYDANTIC_PKG,
     "loading-and-dumping/extended_usage/private_fields_including_no_rename_pydantic": HAS_SUPPORTED_PYDANTIC_PKG,
     "loading-and-dumping/extended_usage/private_fields_including_pydantic": HAS_SUPPORTED_PYDANTIC_PKG,
     "loading-and-dumping/extended_usage/private_fields_skipping_pydantic": HAS_SUPPORTED_PYDANTIC_PKG,
+    "reference/integrations/sqlalchemy_json/*": HAS_SUPPORTED_SQLALCHEMY_PKG,
+    "conversion/tutorial/tldr": HAS_SUPPORTED_SQLALCHEMY_PKG,
 }
 
 
+def _find_requirement(case_id: str) -> Optional[Requirement]:
+    for glob, requirement in GLOB_REQUIREMENTS.items():
+        if fnmatch(case_id, glob):
+            return requirement
+    return None
+
+
 def test_example(import_path: str, case_id: str):
-    if case_id in CASES_REQUIREMENTS:
-        requirement = CASES_REQUIREMENTS[case_id]
-        if not requirement:
-            pytest.skip(requirement.fail_reason)
+    requirement = _find_requirement(case_id)
+    if requirement is not None and not requirement:
+        pytest.skip(requirement.fail_reason)
 
     pytest.register_assert_rewrite(import_path)
     importlib.import_module(import_path)
diff --git a/tests/test_meta.py b/tests/test_meta.py
index aee895a8..9243321f 100644
--- a/tests/test_meta.py
+++ b/tests/test_meta.py
@@ -1,9 +1,22 @@
 import importlib
 from pkgutil import walk_packages
 
+import pytest
+from tests_helpers import full_match
+
 import adaptix
+from adaptix._internal.feature_requirement import HAS_SQLALCHEMY_PKG
 
 
-def test_all_modules_is_importable():
+def test_all_modules_are_importable():
     for module_info in walk_packages(adaptix.__path__, f"{adaptix.__name__}."):
-        importlib.import_module(module_info.name)
+        if (
+            module_info.name.startswith(
+                ("adaptix._internal.integrations.sqlalchemy.", "adaptix.integrations.sqlalchemy"),
+            )
+            and not HAS_SQLALCHEMY_PKG
+        ):
+            with pytest.raises(ModuleNotFoundError, match=full_match("No module named 'sqlalchemy'")):
+                importlib.import_module(module_info.name)
+        else:
+            importlib.import_module(module_info.name)
diff --git a/tests/tests_helpers/tests_helpers/__init__.py b/tests/tests_helpers/tests_helpers/__init__.py
index 5f3f5cca..7b091267 100644
--- a/tests/tests_helpers/tests_helpers/__init__.py
+++ b/tests/tests_helpers/tests_helpers/__init__.py
@@ -5,7 +5,6 @@
     FailedRequirement,
     PlaceholderProvider,
     cond_list,
-    create_sa_engine,
     full_match,
     load_namespace,
     load_namespace_keeping_module,
diff --git a/tests/tests_helpers/tests_helpers/misc.py b/tests/tests_helpers/tests_helpers/misc.py
index 64baa044..1e6043d0 100644
--- a/tests/tests_helpers/tests_helpers/misc.py
+++ b/tests/tests_helpers/tests_helpers/misc.py
@@ -11,7 +11,6 @@
 from uuid import uuid4
 
 import pytest
-from sqlalchemy import Engine, create_engine
 
 from adaptix import CannotProvide, DebugTrail, Mediator, NoSuitableProvider, Provider, Request
 from adaptix._internal.compat import CompatExceptionGroup
@@ -150,10 +149,6 @@ def pretty_typehint_test_id(config, val, argname):
             return None
 
 
-def create_sa_engine(**kwargs) -> Engine:
-    return create_engine("sqlite://", **kwargs)
-
-
 T1 = TypeVar("T1")
 T2 = TypeVar("T2")
 T3 = TypeVar("T3")
diff --git a/tests/unit/integrations/sqlalchemy/__init__.py b/tests/unit/integrations/sqlalchemy/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/integrations/sqlalchemy/test_orm.py b/tests/unit/integrations/sqlalchemy/test_orm.py
new file mode 100644
index 00000000..dbad5b9f
--- /dev/null
+++ b/tests/unit/integrations/sqlalchemy/test_orm.py
@@ -0,0 +1,221 @@
+from dataclasses import dataclass
+from typing import Literal, Optional, Union
+
+import pytest
+from sqlalchemy import JSON, create_engine, insert, null, select, update
+from sqlalchemy.orm import DeclarativeBase, Mapped, defer, mapped_column, sessionmaker
+
+from adaptix import Retort
+from adaptix.integrations.sqlalchemy import AdaptixJSON
+
+
+@dataclass
+class UserCreated:
+    id: int
+    name: str
+
+    tag: Literal["user_created"] = "user_created"
+
+
+@dataclass
+class UserChanged:
+    id: int
+    name: str
+
+    tag: Literal["user_changed"] = "user_changed"
+
+
+AnyAuditLog = Union[UserCreated, UserChanged]
+
+
+class Base(DeclarativeBase):
+    pass
+
+
+retort = Retort()
+
+
+class AuditLogRecord(Base):
+    __tablename__ = "audit_log"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    data: Mapped[AnyAuditLog] = mapped_column(AdaptixJSON(retort, AnyAuditLog))
+
+
+@pytest.fixture()
+def engine():
+    engine = create_engine("sqlite://")
+    try:
+        Base.metadata.create_all(engine)
+        yield engine
+    finally:
+        engine.dispose()
+
+
+@pytest.fixture()
+def session_factory(engine):
+    return sessionmaker(engine)
+
+
+@pytest.fixture()
+def session(session_factory):
+    with session_factory() as session:
+        yield session
+
+
+def test_add(session):
+    session.add(
+        AuditLogRecord(
+            data=UserCreated(
+                id=1,
+                name="Example",
+            ),
+        ),
+    )
+    session.commit()
+
+
+def test_insert(session):
+    session.execute(
+        insert(AuditLogRecord)
+        .values(
+            data=UserChanged(
+                id=1,
+                name="Example",
+            ),
+        ),
+    )
+    session.commit()
+
+
+def test_select(session):
+    session.add(
+        AuditLogRecord(
+            data=UserCreated(
+                id=1,
+                name="Example",
+            ),
+        ),
+    )
+    session.commit()
+
+    record = session.scalar(
+        select(AuditLogRecord)
+        .where(AuditLogRecord.data["id"].as_integer() == 1),
+    )
+    assert record.data == UserCreated(
+        id=1,
+        name="Example",
+    )
+
+
+def test_update(session):
+    session.add(
+        AuditLogRecord(
+            data=UserCreated(
+                id=1,
+                name="Example1",
+            ),
+        ),
+    )
+    session.add(
+        AuditLogRecord(
+            data=UserChanged(
+                id=1,
+                name="Example2",
+            ),
+        ),
+    )
+    session.commit()
+
+    session.execute(
+        update(AuditLogRecord)
+        .where(
+            AuditLogRecord.data["id"].as_integer() == 1,
+            AuditLogRecord.data["tag"].as_string() == UserChanged.tag,
+        )
+        .values(
+            data=UserChanged(
+                id=1,
+                name="Example3",
+            ),
+        ),
+    )
+    assert session.get(AuditLogRecord, 1).data.name == "Example1"
+    assert session.get(AuditLogRecord, 2).data.name == "Example3"
+
+
+def test_mutation_tracking(session_factory):
+    with session_factory() as session:
+        session.add(
+            AuditLogRecord(
+                data=UserCreated(
+                    id=1,
+                    name="Example",
+                ),
+            ),
+        )
+        session.commit()
+
+    with session_factory() as session:
+        record = session.get(AuditLogRecord, 1)
+        record.data.name = "Example2"
+        session.commit()
+
+    with session_factory() as session:
+        record = session.get(AuditLogRecord, 1)
+        assert record.data.name == "Example"  # (!) mutation tracking does not work (!)
+
+
+@pytest.mark.parametrize(
+    "record_factory",
+    [
+        pytest.param(lambda: AuditLogRecord(data=None), id="explicit None"),
+        pytest.param(lambda: AuditLogRecord(), id="explicit None"),
+    ],
+)
+def test_late_init(session_factory, record_factory):
+    with session_factory() as session:
+        record = record_factory()
+        record.data = UserCreated(
+            id=1,
+            name="Example",
+        )
+        session.add(record)
+        session.commit()
+
+    with session_factory() as session:
+        record = session.get(AuditLogRecord, 1, options=[defer(AuditLogRecord.data)])
+        assert record.data == UserCreated(
+            id=1,
+            name="Example",
+        )
+
+
+class AuditLogRecordNullable(Base):
+    __tablename__ = "audit_log_nullable"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    data: Mapped[Optional[AnyAuditLog]] = mapped_column(AdaptixJSON(retort, AnyAuditLog))
+
+
+@pytest.mark.parametrize(
+    "none_value",
+    [
+        None,
+        null(),
+        JSON.NULL,
+    ],
+)
+def test_insert_none(session_factory, none_value):
+    with session_factory() as session:
+        session.add(
+            AuditLogRecordNullable(
+                data=none_value,
+            ),
+        )
+        session.commit()
+
+    with session_factory() as session:
+        record = session.get(AuditLogRecordNullable, 1)
+        assert record.data is None
diff --git a/tests/unit/morphing/model/test_loader_provider.py b/tests/unit/morphing/model/test_loader_provider.py
index 70aa17da..e9a19624 100644
--- a/tests/unit/morphing/model/test_loader_provider.py
+++ b/tests/unit/morphing/model/test_loader_provider.py
@@ -219,6 +219,76 @@ def test_direct(debug_ctx, debug_trail, extra_policy, trail_select):
     )
 
 
+def test_direct_all_optional_kw(debug_ctx, debug_trail, extra_policy, trail_select):
+    loader_getter = make_loader_getter(
+        shape=shape(
+            TestField("a", ParamKind.KW_ONLY, is_required=False, default=DefaultValue(0)),
+            TestField("b", ParamKind.KW_ONLY, is_required=False, default=DefaultValue(1)),
+        ),
+        name_layout=InputNameLayout(
+            crown=InpDictCrown(
+                {
+                    "a": InpFieldCrown("a"),
+                    "b": InpFieldCrown("b"),
+                },
+                extra_policy=extra_policy,
+            ),
+            extra_move=None,
+        ),
+        debug_trail=debug_trail,
+        debug_ctx=debug_ctx,
+    )
+
+    if extra_policy == ExtraCollect():
+        pytest.raises(ValueError, loader_getter).match(
+            "Cannot create loader that collect extra data if InputShape does not take extra data",
+        )
+        return
+
+    loader = loader_getter()
+    assert loader({"a": 1, "b": 2}) == gauge(a=1, b=2)
+
+    if extra_policy == ExtraSkip():
+        assert loader({"a": 1, "b": 2, "c": 3}) == gauge(a=1, b=2)
+    if extra_policy == ExtraForbid():
+        data = {"a": 1, "b": 2, "c": 3}
+        raises_exc(
+            trail_select(
+                disable=ExtraFieldsLoadError({"c"}, data),
+                first=ExtraFieldsLoadError({"c"}, data),
+                all=AggregateLoadError(
+                    f"while loading model {Gauge}",
+                    [ExtraFieldsLoadError({"c"}, data)],
+                ),
+            ),
+            lambda: loader(data),
+        )
+
+    raises_exc(
+        trail_select(
+            disable=LoadError(),
+            first=with_trail(LoadError(), ["b"]),
+            all=AggregateLoadError(
+                f"while loading model {Gauge}",
+                [with_trail(LoadError(), ["b"])],
+            ),
+        ),
+        lambda: loader({"a": 1, "b": LoadError()}),
+    )
+
+    raises_exc(
+        trail_select(
+            disable=TypeLoadError(CollectionsMapping, "bad input value"),
+            first=TypeLoadError(CollectionsMapping, "bad input value"),
+            all=AggregateLoadError(
+                f"while loading model {Gauge}",
+                [TypeLoadError(CollectionsMapping, "bad input value")],
+            ),
+        ),
+        lambda: loader("bad input value"),
+    )
+
+
 @pytest.mark.parametrize("extra_policy", [ExtraSkip(), ExtraForbid()])
 def test_direct_list(debug_ctx, debug_trail, extra_policy, trail_select, strict_coercion):
     loader_getter = make_loader_getter(
diff --git a/tests/unit/retort/test_operating_retort.py b/tests/unit/retort/test_operating_retort.py
index f64d0eac..f47b7e4f 100644
--- a/tests/unit/retort/test_operating_retort.py
+++ b/tests/unit/retort/test_operating_retort.py
@@ -142,7 +142,7 @@ class BookDTO:
                             is_terminal=True,
                             is_demonstrative=True,
                         ),
-                        f"Linking: `{Book.__qualname__} -> {BookDTO.__qualname__}`",
+                        f"Linking: `{Book.__qualname__} => {BookDTO.__qualname__}`",
                     ),
                 ],
                 is_terminal=True,
@@ -196,7 +196,7 @@ class BookDTO:
                             is_terminal=True,
                             is_demonstrative=True,
                         ),
-                        f"Linking: `{Book.__qualname__} -> {BookDTO.__qualname__}`",
+                        f"Linking: `{Book.__qualname__} => {BookDTO.__qualname__}`",
                     ),
                 ],
                 is_terminal=True,
@@ -243,13 +243,13 @@ class BookDTO:
                                         is_terminal=False,
                                         is_demonstrative=True,
                                     ),
-                                    f"Linking: `{Book.__qualname__}.author: int -> {BookDTO.__qualname__}.author: str`",
+                                    f"Linking: `{Book.__qualname__}.author: int => {BookDTO.__qualname__}.author: str`",
                                 ),
                             ],
                             is_terminal=True,
                             is_demonstrative=True,
                         ),
-                        f"Linking: `{Book.__qualname__} -> {BookDTO.__qualname__}`",
+                        f"Linking: `{Book.__qualname__} => {BookDTO.__qualname__}`",
                     ),
                 ],
                 is_terminal=True,
@@ -307,7 +307,7 @@ class BookDTO:
                                                     is_terminal=False,
                                                     is_demonstrative=True,
                                                 ),
-                                                "Linking: `str -> int`",
+                                                "Linking: `str => int`",
                                             ),
                                         ],
                                         is_terminal=True,
@@ -315,13 +315,13 @@ class BookDTO:
                                     ),
                                     f"Linking:"
                                     f" `{Book.__qualname__}.authors: {list_tp_name}[str]"
-                                    f" -> {BookDTO.__qualname__}.authors: {list_tp_name}[int]`",
+                                    f" => {BookDTO.__qualname__}.authors: {list_tp_name}[int]`",
                                 ),
                             ],
                             is_terminal=True,
                             is_demonstrative=True,
                         ),
-                        f"Linking: `{Book.__qualname__} -> {BookDTO.__qualname__}`",
+                        f"Linking: `{Book.__qualname__} => {BookDTO.__qualname__}`",
                     ),
                 ],
                 is_terminal=True,