├── .github ├── dependabot.yml └── workflows │ └── build-release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── README.rst ├── RELEASING.md ├── SECURITY.md ├── docs ├── _static │ ├── custom.css │ └── marshmallow-sqlalchemy-logo.png ├── api_reference.rst ├── authors.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── index.rst ├── license.rst └── recipes.rst ├── pyproject.toml ├── src └── marshmallow_sqlalchemy │ ├── __init__.py │ ├── convert.py │ ├── exceptions.py │ ├── fields.py │ ├── load_instance_mixin.py │ ├── py.typed │ └── schema.py ├── tests ├── __init__.py ├── conftest.py ├── test_conversion.py └── test_sqlalchemy_schema.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: ["dev"] 5 | tags: ["*"] 6 | pull_request: 7 | jobs: 8 | tests: 9 | name: ${{ matrix.name }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - { name: "3.9", python: "3.9", tox: py39-marshmallow3 } 16 | - { name: "3.13", python: "3.13", tox: py313-marshmallow3 } 17 | - { name: "lowest", python: "3.9", tox: py39-lowest } 18 | - { name: "dev", python: "3.13", tox: py313-marshmallowdev } 19 | - { name: "mypy-ma3", python: "3.13", tox: mypy-marshmallow3 } 20 | - { name: "mypy-madev", python: "3.13", tox: mypy-marshmallowdev } 21 | steps: 22 | - uses: actions/checkout@v4.0.0 23 | - uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python }} 26 | - run: python -m pip install tox 27 | - run: python -m tox -e${{ matrix.tox }} 28 | build: 29 | name: Build package 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: actions/setup-python@v5 34 | with: 35 | python-version: "3.13" 36 | - name: Install pypa/build 37 | run: python -m pip install build 38 | - name: Build a binary wheel and a source tarball 39 | run: python -m build 40 | - name: Install twine 41 | run: python -m pip install twine 42 | - name: Check build 43 | run: python -m twine check --strict dist/* 44 | - name: Store the distribution packages 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: python-package-distributions 48 | path: dist/ 49 | # this duplicates pre-commit.ci, so only run it on tags 50 | # it guarantees that linting is passing prior to a release 51 | lint-pre-release: 52 | if: startsWith(github.ref, 'refs/tags') 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/checkout@v4.0.0 56 | - uses: actions/setup-python@v5 57 | with: 58 | python-version: "3.13" 59 | - run: python -m pip install tox 60 | - run: python -m tox -e lint 61 | publish-to-pypi: 62 | name: PyPI release 63 | if: startsWith(github.ref, 'refs/tags/') 64 | needs: [build, tests, lint-pre-release] 65 | runs-on: ubuntu-latest 66 | environment: 67 | name: pypi 68 | url: https://pypi.org/p/marshmallow-sqlalchemy 69 | permissions: 70 | id-token: write 71 | steps: 72 | - name: Download all the dists 73 | uses: actions/download-artifact@v4 74 | with: 75 | name: python-package-distributions 76 | path: dist/ 77 | - name: Publish distribution to PyPI 78 | uses: pypa/gh-action-pypi-publish@release/v1 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | .pytest_cache 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | # Complexity 39 | output/*.html 40 | output/*/index.html 41 | 42 | # Sphinx 43 | docs/_build 44 | README.html 45 | 46 | # konch 47 | .konchrc 48 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | repos: 4 | - repo: https://github.com/astral-sh/ruff-pre-commit 5 | rev: v0.11.12 6 | hooks: 7 | - id: ruff 8 | - id: ruff-format 9 | - repo: https://github.com/python-jsonschema/check-jsonschema 10 | rev: 0.33.0 11 | hooks: 12 | - id: check-github-workflows 13 | - id: check-readthedocs 14 | - repo: https://github.com/asottile/blacken-docs 15 | rev: 1.19.1 16 | hooks: 17 | - id: blacken-docs 18 | additional_dependencies: [black==24.10.0 ] 19 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | sphinx: 3 | configuration: docs/conf.py 4 | formats: 5 | - pdf 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3.13" 10 | python: 11 | install: 12 | - method: pip 13 | path: . 14 | extra_requirements: 15 | - docs 16 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | Authors 3 | ******* 4 | 5 | Leads 6 | ===== 7 | 8 | - Steven Loria `@sloria `_ 9 | 10 | Contributors 11 | ============ 12 | 13 | - Rob MacKinnon `@rmackinnon `_ 14 | - Josh Carp `@jmcarp `_ 15 | - Jason Mitchell `@mitchej123 `_ 16 | - Douglas Russell `@dpwrussell `_ 17 | - Rudá Porto Filgueiras `@rudaporto `_ 18 | - Sean Harrington `@seanharr11 `_ 19 | - Eric Wittle `@ewittle `_ 20 | - Alex Rothberg `@cancan101 `_ 21 | - Vlad Frolov `@frol `_ 22 | - Kelvin Hammond `@kelvinhammond `_ 23 | - Yuri Heupa `@YuriHeupa `_ 24 | - Jeremy Muhlich `@jmuhlich `_ 25 | - Ilya Chistyakov `@ilya-chistyakov `_ 26 | - Victor Gavro `@vgavro `_ 27 | - Maciej Barański `@gtxm `_ 28 | - Jared Deckard `@deckar01 `_ 29 | - AbdealiJK `@AbdealiJK `_ 30 | - jean-philippe serafin `@jeanphix `_ 31 | - Jack Smith `@jacksmith15 `_ 32 | - Kazantcev Andrey `@heckad `_ 33 | - Samuel Searles-Bryant `@samueljsb `_ 34 | - Michaela Ockova `@evelyn9191 `_ 35 | - Pierre Verkest `@petrus-v `_ 36 | - Erik Cederstrand `@ecederstrand `_ 37 | - Daven Quinn `@davenquinn `_ 38 | - Peter Schutt `@peterschutt `_ 39 | - Arash Fatahzade `@ArashFatahzade `_ 40 | - David Malakh `@Unix-Code `_ 41 | - Martijn Pieters `@mjpieters `_ 42 | - Bruce Adams `@bruceadams `_ 43 | - Justin Crown `@mrname `_ 44 | - Jeppe Fihl-Pearson `@Tenzer `_ 45 | - Indivar `@indiVar0508 `_ 46 | - David Doyon `@ddoyon92 `_ 47 | - Hippolyte Henry `@zippolyte `_ 48 | - Alexandre Detiste tchet@debian.org 49 | - PandaByte `@panda-byte `_ 50 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | --------- 3 | 4 | 1.4.2 (2025-04-09) 5 | ++++++++++++++++++ 6 | 7 | Bug fixes: 8 | 9 | * Fix memory usage regression in 1.4.1 (:issue:`665`). 10 | Thanks :user:`mistercrunch` for reporting and sending a PR. 11 | 12 | 1.4.1 (2025-02-10) 13 | ++++++++++++++++++ 14 | 15 | Bug fixes: 16 | 17 | * Fix inheritance of declared fields that match then name of a foreign key column 18 | when the ``include_fk`` option is set to ``False`` (:pr:`657`). 19 | Thanks :user:`carterjc` for the PR. 20 | 21 | 1.4.0 (2025-01-19) 22 | ++++++++++++++++++ 23 | 24 | Bug fixes: 25 | 26 | * Fix handling of arrays of enums and multidimensional arrays (:issue:`653`). 27 | Thanks :user:`carterjc` for reporting and investigating the fix. 28 | * Fix handling of `sqlalchemy.PickleType` columns (:issue:`394`) 29 | Thanks :user:`Eyon42` for reporting. 30 | 31 | Other changes: 32 | 33 | * Passing arbitrary keyword arguments to `auto_field ` 34 | is no longer supported (:pr:`647`). Use the ``metadata`` argument to pass metadata 35 | to the generated field instead. 36 | 37 | .. code-block:: python 38 | 39 | # Before 40 | auto_field(description="The name of the artist") 41 | # On marshmallow 3, this raises a warning: "RemovedInMarshmallow4Warning: Passing field metadata as keyword arguments is deprecated." 42 | # On marshmallow 4, this raises an error: "TypeError: Field.__init__() got an unexpected keyword argument 'description'" 43 | 44 | # After 45 | auto_field(metadata=dict(description="The name of the artist")) 46 | 47 | 1.3.0 (2025-01-11) 48 | ++++++++++++++++++ 49 | 50 | Features: 51 | 52 | * Typing: Add type annotations to `fields `. 53 | 54 | Bug fixes: 55 | 56 | * Fix auto-generation of `marshmallow.fields.Enum` field from `sqlalchemy.Enum` columns (:issue:`615`). 57 | Thanks :user:`joaquimvl` for reporting. 58 | * Fix behavior of ``include_fk = False`` in options when parent 59 | schema sets ``include_fk = True`` (:issue:`440`). 60 | Thanks :user:`uhnomoli` for reporting. 61 | * Fields generated from non-nullable `sqlalchemy.orm.relationship` 62 | correctly set ``required=True`` and ``allow_none=False`` (:issue:`336`, :issue:`163`). 63 | Thanks :user:`AbdealiLoKo` for reporting. 64 | 65 | Other changes: 66 | 67 | * Docs: Add more documentation for `marshmallow_sqlalchemy.fields.Related` (:issue:`162`). 68 | Thanks :user:`GabrielC101` for the suggestion. 69 | * Docs: Document methods of `SQLAlchemySchema ` 70 | and `SQLAlchemyAutoSchema ` (:issue:`619`). 71 | * Docs: Various documentation improvements (:pr:`635`, :pr:`636`, :pr:`639`, :pr:`641`, :pr:`642`). 72 | 73 | 1.2.0 (2025-01-09) 74 | ++++++++++++++++++ 75 | 76 | Features: 77 | 78 | * Typing: Improve type coverage (:pr:`631`, :pr:`632`, :pr:`634`). 79 | 80 | Other changes: 81 | 82 | * Drop support for Python 3.8, which is EOL. Support Python 3.9-3.13. 83 | 84 | 1.1.1 (2025-01-06) 85 | ++++++++++++++++++ 86 | 87 | Bug fixes: 88 | 89 | * Fix compatibility with marshmallow 3.24.0 and 4.0.0 (:pr:`628`). 90 | 91 | Other changes: 92 | 93 | * Test against Python 3.13 (:issue:`629`). 94 | 95 | 1.1.0 (2024-08-14) 96 | ++++++++++++++++++ 97 | 98 | Features: 99 | 100 | * ``sqlalchemy.Enum`` fields generate a corresponding ``marshmallow.fields.Enum`` field (:issue:`485`, :issue:`112`). 101 | Thanks :user:`panda-byte` for the PR. 102 | 103 | Support: 104 | 105 | * Drop support for marshmallow<3.18.0. 106 | 107 | 1.0.0 (2024-01-30) 108 | +++++++++++++++++++ 109 | 110 | * Remove ``__version__`` attribute. Use feature detection or 111 | ``importlib.metadata.version("marshmallow-sqlalchemy")`` instead (:pr:`568`). 112 | * Support marshmallow>=3.10.0 (:pr:`566`). 113 | * Passing `info={"marshmallow": ...}` to SQLAlchemy columns is removed, as it is redundant with 114 | the ``auto_field`` functionality (:pr:`567`). 115 | * Remove ``packaging`` as a dependency (:pr:`566`). 116 | * Support Python 3.12. 117 | 118 | 0.30.0 (2024-01-07) 119 | +++++++++++++++++++ 120 | 121 | Features: 122 | 123 | * Use ``Session.get()`` load instances to improve deserialization performance (:pr:`548`). 124 | Thanks :user:`zippolyte` for the PR. 125 | 126 | Other changes: 127 | 128 | * Drop support for Python 3.7, which is EOL (:pr:`540`). 129 | 130 | 0.29.0 (2023-02-27) 131 | +++++++++++++++++++ 132 | 133 | Features: 134 | 135 | * Support SQLAlchemy 2.0 (:pr:`494`). 136 | Thanks :user:`dependabot` for the PR. 137 | * Enable (in tests) and fix SQLAlchemy 2.0 compatibility warnings (:pr:`493`). 138 | 139 | Bug fixes: 140 | 141 | * Use mapper ``.attrs`` rather than ``.get_property`` and ``.iterate_properties`` 142 | to ensure ``registry.configure`` is called (call removed in SQLAlchemy 2.0.2) 143 | (:issue:`487`). 144 | Thanks :user:`ddoyon92` for the PR. 145 | 146 | Other changes: 147 | 148 | * Drop support for SQLAlchemy 1.3, which is EOL (:pr:`493`). 149 | 150 | 0.28.2 (2023-02-23) 151 | +++++++++++++++++++ 152 | 153 | Bug fixes: 154 | 155 | * Use .scalar_subquery() for SQLAlchemy>1.4 to suppress a warning (:issue:`459`). 156 | Thanks :user:`indiVar0508` for the PR. 157 | 158 | Other changes: 159 | 160 | * Lock SQLAlchemy<2.0 in setup.py. SQLAlchemy 2.x is not supported (:pr:`486`). 161 | * Test against Python 3.11 (:pr:`486`). 162 | 163 | 0.28.1 (2022-07-18) 164 | +++++++++++++++++++ 165 | 166 | Bug fixes: 167 | 168 | * Address ``DeprecationWarning`` re: usage of ``distutils`` (:pr:`435`). 169 | Thanks :user:`Tenzer` for the PR. 170 | 171 | 0.28.0 (2022-03-09) 172 | +++++++++++++++++++ 173 | 174 | Features: 175 | 176 | * Add support for generating fields from `column_property` (:issue:`97`). 177 | Thanks :user:`mrname` for the PR. 178 | 179 | Other changes: 180 | 181 | * Drop support for Python 3.6, which is EOL. 182 | * Drop support for SQLAlchemy 1.2, which is EOL. 183 | 184 | 0.27.0 (2021-12-18) 185 | +++++++++++++++++++ 186 | 187 | Features: 188 | 189 | * Distribute type information per `PEP 561 `_ (:pr:`420`). 190 | Thanks :user:`bruceadams` for the PR. 191 | 192 | Other changes: 193 | 194 | * Test against Python 3.10 (:pr:`421`). 195 | 196 | 0.26.1 (2021-06-05) 197 | +++++++++++++++++++ 198 | 199 | Bug fixes: 200 | 201 | * Fix generating fields for ``postgreql.ARRAY`` columns (:issue:`392`). 202 | Thanks :user:`mjpieters` for the catch and patch. 203 | 204 | 0.26.0 (2021-05-26) 205 | +++++++++++++++++++ 206 | 207 | Bug fixes: 208 | 209 | * Unwrap proxied columns to handle models for subqueries (:issue:`383`). 210 | Thanks :user:`mjpieters` for the catch and patch 211 | * Fix setting ``transient`` on a per-instance basis when the 212 | ``transient`` Meta option is set (:issue:`388`). 213 | Thanks again :user:`mjpieters`. 214 | 215 | Other changes: 216 | 217 | * *Backwards-incompatible*: Remove deprecated ``ModelSchema`` and ``TableSchema`` classes. 218 | 219 | 220 | 0.25.0 (2021-05-02) 221 | +++++++++++++++++++ 222 | 223 | * Add ``load_instance`` as a parameter to `SQLAlchemySchema` and `SQLAlchemyAutoSchema` (:pr:`380`). 224 | Thanks :user:`mjpieters` for the PR. 225 | 226 | 0.24.3 (2021-04-26) 227 | +++++++++++++++++++ 228 | 229 | * Fix deprecation warnings from marshmallow 3.10 and SQLAlchemy 1.4 (:pr:`369`). 230 | Thanks :user:`peterschutt` for the PR. 231 | 232 | 0.24.2 (2021-02-07) 233 | +++++++++++++++++++ 234 | 235 | * ``auto_field`` supports ``association_proxy`` fields with local multiplicity 236 | (``uselist=True``) (:issue:`364`). Thanks :user:`Unix-Code` 237 | for the catch and patch. 238 | 239 | 0.24.1 (2020-11-20) 240 | +++++++++++++++++++ 241 | 242 | * ``auto_field`` works with ``association_proxy`` (:issue:`338`). 243 | Thanks :user:`AbdealiJK`. 244 | 245 | 0.24.0 (2020-10-20) 246 | +++++++++++++++++++ 247 | 248 | * *Backwards-incompatible*: Drop support for marshmallow 2.x, which is now EOL. 249 | * Test against Python 3.9. 250 | 251 | 0.23.1 (2020-05-30) 252 | +++++++++++++++++++ 253 | 254 | Bug fixes: 255 | 256 | * Don't add no-op `Length` validator (:pr:`315`). Thanks :user:`taion` for the PR. 257 | 258 | 0.23.0 (2020-04-26) 259 | +++++++++++++++++++ 260 | 261 | Bug fixes: 262 | 263 | * Fix data keys when using ``Related`` with a ``Column`` that is named differently 264 | from its attribute (:issue:`299`). Thanks :user:`peterschutt` for the catch and patch. 265 | * Fix bug that raised an exception when using the `ordered = True` option on a schema that has an `auto_field` (:issue:`306`). 266 | Thanks :user:`KwonL` for reporting and thanks :user:`peterschutt` for the PR. 267 | 268 | 0.22.3 (2020-03-01) 269 | +++++++++++++++++++ 270 | 271 | Bug fixes: 272 | 273 | * Fix ``DeprecationWarning`` getting raised even when user code does not use 274 | ``TableSchema`` or ``ModelSchema`` (:issue:`289`). 275 | Thanks :user:`5uper5hoot` for reporting. 276 | 277 | 0.22.2 (2020-02-09) 278 | +++++++++++++++++++ 279 | 280 | Bug fixes: 281 | 282 | * Avoid error when using ``SQLAlchemyAutoSchema``, ``ModelSchema``, or ``fields_for_model`` 283 | with a model that has a ``SynonymProperty`` (:issue:`190`). 284 | Thanks :user:`TrilceAC` for reporting. 285 | * ``auto_field`` and ``field_for`` work with ``SynonymProperty`` (:pr:`280`). 286 | 287 | Other changes: 288 | 289 | * Add hook in ``ModelConverter`` for changing field names based on SQLA columns and properties (:issue:`276`). 290 | Thanks :user:`davenquinn` for the suggestion and the PR. 291 | 292 | 0.22.1 (2020-02-09) 293 | +++++++++++++++++++ 294 | 295 | Bug fixes: 296 | 297 | * Fix behavior when passing ``table`` to ``auto_field`` (:pr:`277`). 298 | 299 | 0.22.0 (2020-02-09) 300 | +++++++++++++++++++ 301 | 302 | Features: 303 | 304 | * Add ``SQLAlchemySchema`` and ``SQLAlchemyAutoSchema``, 305 | which have an improved API for generating marshmallow fields 306 | and overriding their arguments via ``auto_field`` (:issue:`240`). 307 | Thanks :user:`taion` for the idea and original implementation. 308 | 309 | .. code-block:: python 310 | 311 | # Before 312 | from marshmallow_sqlalchemy import ModelSchema, field_for 313 | 314 | from . import models 315 | 316 | 317 | class ArtistSchema(ModelSchema): 318 | class Meta: 319 | model = models.Artist 320 | 321 | id = field_for(models.Artist, "id", dump_only=True) 322 | created_at = field_for(models.Artist, "created_at", dump_only=True) 323 | 324 | 325 | # After 326 | from marshmallow_sqlalchemy import SQLAlchemySchema, auto_field 327 | 328 | from . import models 329 | 330 | 331 | class ArtistSchema(SQLAlchemyAutoSchema): 332 | class Meta: 333 | model = models.Artist 334 | 335 | id = auto_field(dump_only=True) 336 | created_at = auto_field(dump_only=True) 337 | 338 | * Add ``load_instance`` option to configure deserialization to model instances (:issue:`193`, :issue:`270`). 339 | * Add ``include_relationships`` option to configure generation of marshmallow fields for relationship properties (:issue:`98`). 340 | Thanks :user:`dusktreader` for the suggestion. 341 | 342 | Deprecations: 343 | 344 | * ``ModelSchema`` and ``TableSchema`` are deprecated, 345 | since ``SQLAlchemyAutoSchema`` has equivalent functionality. 346 | 347 | .. code-block:: python 348 | 349 | # Before 350 | from marshmallow_sqlalchemy import ModelSchema, TableSchema 351 | 352 | from . import models 353 | 354 | 355 | class ArtistSchema(ModelSchema): 356 | class Meta: 357 | model = models.Artist 358 | 359 | 360 | class AlbumSchema(TableSchema): 361 | class Meta: 362 | table = models.Album.__table__ 363 | 364 | 365 | # After 366 | from marshmallow_sqlalchemy import SQLAlchemyAutoSchema 367 | 368 | from . import models 369 | 370 | 371 | class ArtistSchema(SQLAlchemyAutoSchema): 372 | class Meta: 373 | model = models.Artist 374 | include_relationships = True 375 | load_instance = True 376 | 377 | 378 | class AlbumSchema(SQLAlchemyAutoSchema): 379 | class Meta: 380 | table = models.Album.__table__ 381 | 382 | * Passing `info={"marshmallow": ...}` to SQLAlchemy columns is deprecated, as it is redundant with 383 | the ``auto_field`` functionality. 384 | 385 | Other changes: 386 | 387 | * *Backwards-incompatible*: ``fields_for_model`` does not include relationships by default. 388 | Use ``fields_for_model(..., include_relationships=True)`` to preserve the old behavior. 389 | 390 | 0.21.0 (2019-12-04) 391 | +++++++++++++++++++ 392 | 393 | * Add support for ``postgresql.OID`` type (:pr:`262`). 394 | Thanks :user:`petrus-v` for the PR. 395 | * Remove imprecise Python 3 classifier from PyPI metadata (:pr:`255`). 396 | Thanks :user:`ecederstrand`. 397 | 398 | 0.20.0 (2019-12-01) 399 | +++++++++++++++++++ 400 | 401 | * Add support for ``mysql.DATETIME`` and ``mysql.INTEGER`` type (:issue:`204`). 402 | * Add support for ``postgresql.CIDR`` type (:issue:`183`). 403 | * Add support for ``postgresql.DATE`` and ``postgresql.TIME`` type. 404 | 405 | Thanks :user:`evelyn9191` for the PR. 406 | 407 | 0.19.0 (2019-09-05) 408 | +++++++++++++++++++ 409 | 410 | * Drop support for Python 2.7 and 3.5 (:issue:`241`). 411 | * Drop support for marshmallow<2.15.2. 412 | * Only support sqlalchemy>=1.2.0. 413 | 414 | 0.18.0 (2019-09-05) 415 | +++++++++++++++++++ 416 | 417 | Features: 418 | 419 | * ``marshmallow_sqlalchemy.fields.Nested`` propagates the value of ``transient`` on the call to ``load`` (:issue:`177`, :issue:`206`). 420 | Thanks :user:`leonidumanskiy` for reporting. 421 | 422 | Note: This is the last release to support Python 2.7 and 3.5. 423 | 424 | 0.17.2 (2019-08-31) 425 | +++++++++++++++++++ 426 | 427 | Bug fixes: 428 | 429 | * Fix error handling when passing an invalid type to ``Related`` (:issue:`223`). 430 | Thanks :user:`heckad` for reporting. 431 | * Address ``DeprecationWarning`` raised when using ``Related`` with marshmallow 3 (:pr:`243`). 432 | 433 | 0.17.1 (2019-08-31) 434 | +++++++++++++++++++ 435 | 436 | Bug fixes: 437 | 438 | * Add ``marshmallow_sqlalchemy.fields.Nested`` field that inherits its session from its schema. This fixes a bug where an exception was raised when using ``Nested`` within a ``ModelSchema`` (:issue:`67`). 439 | Thanks :user:`nickw444` for reporting and thanks :user:`samueljsb` for the PR. 440 | 441 | User code should be updated to use marshmallow-sqlalchemy's ``Nested`` instead of ``marshmallow.fields.Nested``. 442 | 443 | .. code-block:: python 444 | 445 | # Before 446 | from marshmallow import fields 447 | from marshmallow_sqlalchemy import ModelSchema 448 | 449 | 450 | class ArtistSchema(ModelSchema): 451 | class Meta: 452 | model = models.Artist 453 | 454 | 455 | class AlbumSchema(ModelSchema): 456 | class Meta: 457 | model = models.Album 458 | 459 | artist = fields.Nested(ArtistSchema) 460 | 461 | 462 | # After 463 | from marshmallow import fields 464 | from marshmallow_sqlalchemy import ModelSchema 465 | from marshmallow_sqlalchemy.fields import Nested 466 | 467 | 468 | class ArtistSchema(ModelSchema): 469 | class Meta: 470 | model = models.Artist 471 | 472 | 473 | class AlbumSchema(ModelSchema): 474 | class Meta: 475 | model = models.Album 476 | 477 | artist = Nested(ArtistSchema) 478 | 479 | 0.17.0 (2019-06-22) 480 | +++++++++++++++++++ 481 | 482 | Features: 483 | 484 | * Add support for ``postgresql.MONEY`` type (:issue:`218`). Thanks :user:`heckad` for the PR. 485 | 486 | 0.16.4 (2019-06-15) 487 | +++++++++++++++++++ 488 | 489 | Bug fixes: 490 | 491 | * Compatibility with marshmallow 3.0.0rc7. Thanks :user:`heckad` for the catch and patch. 492 | 493 | 0.16.3 (2019-05-05) 494 | +++++++++++++++++++ 495 | 496 | Bug fixes: 497 | 498 | * Compatibility with marshmallow 3.0.0rc6. 499 | 500 | 0.16.2 (2019-04-10) 501 | +++++++++++++++++++ 502 | 503 | Bug fixes: 504 | 505 | * Prevent ValueError when using the ``exclude`` class Meta option with 506 | ``TableSchema`` (:pr:`202`). 507 | 508 | 0.16.1 (2019-03-11) 509 | +++++++++++++++++++ 510 | 511 | Bug fixes: 512 | 513 | * Fix compatibility with SQLAlchemy 1.3 (:issue:`185`). 514 | 515 | 0.16.0 (2019-02-03) 516 | +++++++++++++++++++ 517 | 518 | Features: 519 | 520 | * Add support for deserializing transient objects (:issue:`62`). 521 | Thanks :user:`jacksmith15` for the PR. 522 | 523 | 0.15.0 (2018-11-05) 524 | +++++++++++++++++++ 525 | 526 | Features: 527 | 528 | * Add ``ModelConverter._should_exclude_field`` hook (:pr:`139`). 529 | Thanks :user:`jeanphix` for the PR. 530 | * Allow field ``kwargs`` to be overriden by passing 531 | ``info['marshmallow']`` to column properties (:issue:`21`). 532 | Thanks :user:`dpwrussell` for the suggestion and PR. 533 | Thanks :user:`jeanphix` for the final implementation. 534 | 535 | 0.14.2 (2018-11-03) 536 | +++++++++++++++++++ 537 | 538 | Bug fixes: 539 | 540 | - Fix behavior of ``Related`` field (:issue:`150`). Thanks :user:`zezic` 541 | for reporting and thanks :user:`AbdealiJK` for the PR. 542 | - ``Related`` now works with ``AssociationProxy`` fields (:issue:`151`). 543 | Thanks :user:`AbdealiJK` for the catch and patch. 544 | 545 | Other changes: 546 | 547 | - Test against Python 3.7. 548 | - Bring development environment in line with marshmallow. 549 | 550 | 0.14.1 (2018-07-19) 551 | +++++++++++++++++++ 552 | 553 | Bug fixes: 554 | 555 | - Fix behavior of ``exclude`` with marshmallow 3.0 (:issue:`131`). 556 | Thanks :user:`yaheath` for reporting and thanks :user:`deckar01` for 557 | the fix. 558 | 559 | 0.14.0 (2018-05-28) 560 | +++++++++++++++++++ 561 | 562 | Features: 563 | 564 | - Make ``ModelSchema.session`` a property, which allows session to be 565 | retrieved from ``context`` (:issue:`129`). Thanks :user:`gtxm`. 566 | 567 | Other changes: 568 | 569 | - Drop official support for Python 3.4. Python>=3.5 and Python 2.7 are supported. 570 | 571 | 0.13.2 (2017-10-23) 572 | +++++++++++++++++++ 573 | 574 | Bug fixes: 575 | 576 | - Unset ``instance`` attribute when an error occurs during a ``load`` 577 | call (:issue:`114`). Thanks :user:`vgavro` for the catch and patch. 578 | 579 | 0.13.1 (2017-04-06) 580 | +++++++++++++++++++ 581 | 582 | Bug fixes: 583 | 584 | - Prevent unnecessary queries when using the `fields.Related` (:issue:`106`). Thanks :user:`xarg` for reporting and thanks :user:`jmuhlich` for the PR. 585 | 586 | 0.13.0 (2017-03-12) 587 | +++++++++++++++++++ 588 | 589 | Features: 590 | 591 | - Invalid inputs for compound primary keys raise a ``ValidationError`` when deserializing a scalar value (:issue:`103`). Thanks :user:`YuriHeupa` for the PR. 592 | 593 | Bug fixes: 594 | 595 | - Fix compatibility with marshmallow>=3.x. 596 | 597 | 0.12.1 (2017-01-05) 598 | +++++++++++++++++++ 599 | 600 | Bug fixes: 601 | 602 | - Reset ``ModelSchema.instance`` after each ``load`` call, allowing schema instances to be reused (:issue:`78`). Thanks :user:`georgexsh` for reporting. 603 | 604 | Other changes: 605 | 606 | - Test against Python 3.6. 607 | 608 | 0.12.0 (2016-10-08) 609 | +++++++++++++++++++ 610 | 611 | Features: 612 | 613 | - Add support for TypeDecorator-based types (:issue:`83`). Thanks :user:`frol`. 614 | 615 | Bug fixes: 616 | 617 | - Fix bug that caused a validation errors for custom column types that have the ``python_type`` of ``uuid.UUID`` (:issue:`54`). Thanks :user:`wkevina` and thanks :user:`kelvinhammond` for the fix. 618 | 619 | Other changes: 620 | 621 | - Drop official support for Python 3.3. Python>=3.4 and Python 2.7 are supported. 622 | 623 | 0.11.0 (2016-10-01) 624 | +++++++++++++++++++ 625 | 626 | Features: 627 | 628 | - Allow overriding field class returned by ``field_for`` by adding the ``field_class`` param (:issue:`81`). Thanks :user:`cancan101`. 629 | 630 | 0.10.0 (2016-08-14) 631 | +++++++++++++++++++ 632 | 633 | Features: 634 | 635 | - Support for SQLAlchemy JSON type (in SQLAlchemy>=1.1) (:issue:`74`). Thanks :user:`ewittle` for the PR. 636 | 637 | 0.9.0 (2016-07-02) 638 | ++++++++++++++++++ 639 | 640 | Features: 641 | 642 | - Enable deserialization of many-to-one nested objects that do not exist in the database (:issue:`69`). Thanks :user:`seanharr11` for the PR. 643 | 644 | Bug fixes: 645 | 646 | - Depend on SQLAlchemy>=0.9.7, since marshmallow-sqlalchemy uses ``sqlalchemy.dialects.postgresql.JSONB`` (:issue:`65`). Thanks :user:`alejom99` for reporting. 647 | 648 | 0.8.1 (2016-02-21) 649 | ++++++++++++++++++ 650 | 651 | Bug fixes: 652 | 653 | - ``ModelSchema`` and ``TableSchema`` respect field order if the ``ordered=True`` class Meta option is set (:issue:`52`). Thanks :user:`jeffwidman` for reporting and :user:`jmcarp` for the patch. 654 | - Declared fields are not introspected in order to support, e.g. ``column_property`` (:issue:`57`). Thanks :user:`jmcarp`. 655 | 656 | 0.8.0 (2015-12-28) 657 | ++++++++++++++++++ 658 | 659 | Features: 660 | 661 | - ``ModelSchema`` and ``TableSchema`` will respect the ``TYPE_MAPPING`` class variable of Schema subclasses when converting ``Columns`` to ``Fields`` (:issue:`42`). Thanks :user:`dwieeb` for the suggestion. 662 | 663 | 0.7.1 (2015-12-13) 664 | ++++++++++++++++++ 665 | 666 | Bug fixes: 667 | 668 | - Don't make marshmallow fields required for non-nullable columns if a column has a default value or autoincrements (:issue:`47`). Thanks :user:`jmcarp` for the fix. Thanks :user:`AdrielVelazquez` for reporting. 669 | 670 | 0.7.0 (2015-12-07) 671 | ++++++++++++++++++ 672 | 673 | Features: 674 | 675 | - Add ``include_fk`` class Meta option (:issue:`36`). Thanks :user:`jmcarp`. 676 | - Non-nullable columns will generated required marshmallow Fields (:issue:`40`). Thanks :user:`jmcarp`. 677 | - Improve support for MySQL BIT field (:issue:`41`). Thanks :user:`rudaporto`. 678 | - *Backwards-incompatible*: Remove ``fields.get_primary_columns`` in favor of ``fields.get_primary_keys``. 679 | - *Backwards-incompatible*: Remove ``Related.related_columns`` in favor of ``fields.related_keys``. 680 | 681 | Bug fixes: 682 | 683 | - Fix serializing relationships when using non-default column names (:issue:`44`). Thanks :user:`jmcarp` for the fix. Thanks :user:`repole` for the bug report. 684 | 685 | 0.6.0 (2015-09-29) 686 | ++++++++++++++++++ 687 | 688 | Features: 689 | 690 | - Support for compound primary keys. Thanks :user:`jmcarp`. 691 | 692 | Other changes: 693 | 694 | - Supports marshmallow>=2.0.0. 695 | 696 | 0.5.0 (2015-09-27) 697 | ++++++++++++++++++ 698 | 699 | - Add ``instance`` argument to ``ModelSchema`` constructor and ``ModelSchema.load`` which allows for updating existing DB rows (:issue:`26`). Thanks :user:`sssilver` for reporting and :user:`jmcarp` for the patch. 700 | - Don't autogenerate fields that are in ``Meta.exclude`` (:issue:`27`). Thanks :user:`jmcarp`. 701 | - Raise ``ModelConversionError`` if converting properties whose column don't define a ``python_type``. Thanks :user:`jmcarp`. 702 | - *Backwards-incompatible*: ``ModelSchema.make_object`` is removed in favor of decorated ``make_instance`` method for compatibility with marshmallow>=2.0.0rc2. 703 | 704 | 0.4.1 (2015-09-13) 705 | ++++++++++++++++++ 706 | 707 | Bug fixes: 708 | 709 | - Now compatible with marshmallow>=2.0.0rc1. 710 | - Correctly pass keyword arguments from ``field_for`` to generated ``List`` fields (:issue:`25`). Thanks :user:`sssilver` for reporting. 711 | 712 | 713 | 0.4.0 (2015-09-03) 714 | ++++++++++++++++++ 715 | 716 | Features: 717 | 718 | - Add ``TableSchema`` for generating ``Schemas`` from tables (:issue:`4`). Thanks :user:`jmcarp`. 719 | 720 | Bug fixes: 721 | 722 | - Allow ``session`` to be passed to ``ModelSchema.validate``, since it requires it. Thanks :user:`dpwrussell`. 723 | - When serializing, don't skip overriden fields that are part of a polymorphic hierarchy (:issue:`18`). Thanks again :user:`dpwrussell`. 724 | 725 | Support: 726 | 727 | - Docs: Add new recipe for automatic generation of schemas. Thanks :user:`dpwrussell`. 728 | 729 | 0.3.0 (2015-08-27) 730 | ++++++++++++++++++ 731 | 732 | Features: 733 | 734 | - *Backwards-incompatible*: Relationships are (de)serialized by a new, more efficient ``Related`` column (:issue:`7`). Thanks :user:`jmcarp`. 735 | - Improve support for MySQL types (:issue:`1`). Thanks :user:`rmackinnon`. 736 | - Improve support for Postgres ARRAY types (:issue:`6`). Thanks :user:`jmcarp`. 737 | - ``ModelSchema`` no longer requires the ``sqla_session`` class Meta option. A ``Session`` can be passed to the constructor or to the ``ModelSchema.load`` method (:issue:`11`). Thanks :user:`dtheodor` for the suggestion. 738 | 739 | Bug fixes: 740 | 741 | - Null foreign keys are serialized correctly as ``None`` (:issue:`8`). Thanks :user:`mitchej123`. 742 | - Properly handle a relationship specifies ``uselist=False`` (:issue:`#17`). Thanks :user:`dpwrussell`. 743 | 744 | 0.2.0 (2015-05-03) 745 | ++++++++++++++++++ 746 | 747 | Features: 748 | 749 | - Add ``field_for`` function for generating marshmallow Fields from SQLAlchemy mapped class properties. 750 | 751 | Support: 752 | 753 | - Docs: Add "Overriding generated fields" section to "Recipes". 754 | 755 | 0.1.1 (2015-05-02) 756 | ++++++++++++++++++ 757 | 758 | Bug fixes: 759 | 760 | - Fix ``keygetter`` class Meta option. 761 | 762 | 0.1.0 (2015-04-28) 763 | ++++++++++++++++++ 764 | 765 | - First release. 766 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing guidelines 2 | ======================= 3 | 4 | Questions, feature requests, bug reports, and feedback. . . 5 | ----------------------------------------------------------- 6 | 7 | …should all be reported on the `Github Issue Tracker`_ . 8 | 9 | .. _`Github Issue Tracker`: https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues?state=open 10 | 11 | Setting up for local development 12 | -------------------------------- 13 | 14 | 1. Fork marshmallow-sqlalchemy_ on Github. 15 | 16 | .. code-block:: shell-session 17 | 18 | $ git clone https://github.com/marshmallow-code/marshmallow-sqlalchemy.git 19 | $ cd marshmallow-sqlalchemy 20 | 21 | 2. Install development requirements. **It is highly recommended that you use a virtualenv.** 22 | Use the following command to install an editable version of 23 | marshmallow-sqlalchemy along with its development requirements. 24 | 25 | .. code-block:: shell-session 26 | 27 | # After activating your virtualenv 28 | $ pip install -e '.[dev]' 29 | 30 | 3. Install the pre-commit hooks, which will format and lint your git staged files. 31 | 32 | .. code-block:: shell-session 33 | 34 | # The pre-commit CLI was installed above 35 | $ pre-commit install 36 | 37 | Pull requests 38 | -------------- 39 | 40 | 1. Create a new local branch. 41 | 42 | .. code-block:: shell-session 43 | 44 | # For a new feature 45 | $ git checkout -b name-of-feature dev 46 | 47 | # For a bugfix 48 | $ git checkout -b fix-something 1.2-line 49 | 50 | 2. Commit your changes. Write `good commit messages `_. 51 | 52 | .. code-block:: shell-session 53 | 54 | $ git commit -m "Detailed commit message" 55 | $ git push origin name-of-feature 56 | 57 | 3. Before submitting a pull request, check the following: 58 | 59 | - If the pull request adds functionality, it is tested and the docs are updated. 60 | - You've added yourself to ``AUTHORS.rst``. 61 | 62 | 4. Submit a pull request to ``marshmallow-code:dev`` or the appropriate maintenance branch. 63 | The `CI `_ build 64 | must be passing before your pull request is merged. 65 | 66 | Running tests 67 | ------------- 68 | 69 | To run all tests: 70 | 71 | .. code-block:: shell-session 72 | 73 | $ pytest 74 | 75 | To run formatting and syntax checks: 76 | 77 | .. code-block:: shell-session 78 | 79 | $ tox -e lint 80 | 81 | (Optional) To run tests in all supported Python versions in their own virtual environments (must have each interpreter installed): 82 | 83 | .. code-block:: shell-session 84 | 85 | $ tox 86 | 87 | Documentation 88 | ------------- 89 | 90 | Contributions to the documentation are welcome. Documentation is written in `reStructuredText`_ (rST). A quick rST reference can be found `here `_. Builds are powered by Sphinx_. 91 | 92 | To build and serve the docs in "watch" mode: 93 | 94 | .. code-block:: shell-session 95 | 96 | $ tox -e docs-serve 97 | 98 | Changes to documentation will automatically trigger a rebuild. 99 | 100 | 101 | .. _Sphinx: https://www.sphinx-doc.org/ 102 | .. _`reStructuredText`: https://docutils.sourceforge.io/rst.html 103 | 104 | .. _`marshmallow-sqlalchemy`: https://github.com/marshmallow-code/marshmallow-sqlalchemy 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Steven Loria and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ********************** 2 | marshmallow-sqlalchemy 3 | ********************** 4 | 5 | |pypi-package| |build-status| |docs| |marshmallow-support| 6 | 7 | Homepage: https://marshmallow-sqlalchemy.readthedocs.io/ 8 | 9 | `SQLAlchemy `_ integration with the `marshmallow `_ (de)serialization library. 10 | 11 | 12 | Declare your models 13 | =================== 14 | 15 | .. code-block:: python 16 | 17 | import sqlalchemy as sa 18 | from sqlalchemy.orm import ( 19 | DeclarativeBase, 20 | backref, 21 | relationship, 22 | sessionmaker, 23 | ) 24 | 25 | from marshmallow_sqlalchemy import SQLAlchemySchema, auto_field 26 | 27 | engine = sa.create_engine("sqlite:///:memory:") 28 | Session = sessionmaker(engine) 29 | 30 | 31 | class Base(DeclarativeBase): 32 | pass 33 | 34 | 35 | class Author(Base): 36 | __tablename__ = "authors" 37 | id = sa.Column(sa.Integer, primary_key=True) 38 | name = sa.Column(sa.String, nullable=False) 39 | 40 | def __repr__(self): 41 | return f"" 42 | 43 | 44 | class Book(Base): 45 | __tablename__ = "books" 46 | id = sa.Column(sa.Integer, primary_key=True) 47 | title = sa.Column(sa.String) 48 | author_id = sa.Column(sa.Integer, sa.ForeignKey("authors.id")) 49 | author = relationship("Author", backref=backref("books")) 50 | 51 | 52 | Base.metadata.create_all(engine) 53 | 54 | .. start elevator-pitch 55 | 56 | Generate marshmallow schemas 57 | ============================ 58 | 59 | .. code-block:: python 60 | 61 | from marshmallow_sqlalchemy import SQLAlchemySchema, auto_field 62 | 63 | 64 | class AuthorSchema(SQLAlchemySchema): 65 | class Meta: 66 | model = Author 67 | load_instance = True # Optional: deserialize to model instances 68 | 69 | id = auto_field() 70 | name = auto_field() 71 | books = auto_field() 72 | 73 | 74 | class BookSchema(SQLAlchemySchema): 75 | class Meta: 76 | model = Book 77 | load_instance = True 78 | 79 | id = auto_field() 80 | title = auto_field() 81 | author_id = auto_field() 82 | 83 | You can automatically generate fields for a model's columns using `SQLAlchemyAutoSchema`. 84 | The following schema classes are equivalent to the above. 85 | 86 | .. code-block:: python 87 | 88 | from marshmallow_sqlalchemy import SQLAlchemyAutoSchema 89 | 90 | 91 | class AuthorSchema(SQLAlchemyAutoSchema): 92 | class Meta: 93 | model = Author 94 | include_relationships = True 95 | load_instance = True 96 | 97 | 98 | class BookSchema(SQLAlchemyAutoSchema): 99 | class Meta: 100 | model = Book 101 | include_fk = True 102 | load_instance = True 103 | 104 | 105 | Make sure to declare `Models` before instantiating `Schemas`. Otherwise `sqlalchemy.orm.configure_mappers() `_ will run too soon and fail. 106 | 107 | (De)serialize your data 108 | ======================= 109 | 110 | .. code-block:: python 111 | 112 | author = Author(name="Chuck Paluhniuk") 113 | author_schema = AuthorSchema() 114 | book = Book(title="Fight Club", author=author) 115 | 116 | with Session() as session: 117 | session.add(author) 118 | session.add(book) 119 | session.commit() 120 | 121 | dump_data = author_schema.dump(author) 122 | print(dump_data) 123 | # {'id': 1, 'name': 'Chuck Paluhniuk', 'books': [1]} 124 | 125 | with Session() as session: 126 | load_data = author_schema.load(dump_data, session=session) 127 | print(load_data) 128 | # 129 | 130 | Get it now 131 | ========== 132 | 133 | .. code-block:: shell-session 134 | 135 | $ pip install -U marshmallow-sqlalchemy 136 | 137 | 138 | Requires Python >= 3.9, marshmallow >= 3.18.0, and SQLAlchemy >= 1.4.40. 139 | 140 | .. end elevator-pitch 141 | 142 | Documentation 143 | ============= 144 | 145 | Documentation is available at https://marshmallow-sqlalchemy.readthedocs.io/ . 146 | 147 | Project links 148 | ============= 149 | 150 | - Docs: https://marshmallow-sqlalchemy.readthedocs.io/ 151 | - Changelog: https://marshmallow-sqlalchemy.readthedocs.io/en/latest/changelog.html 152 | - Contributing Guidelines: https://marshmallow-sqlalchemy.readthedocs.io/en/latest/contributing.html 153 | - PyPI: https://pypi.python.org/pypi/marshmallow-sqlalchemy 154 | - Issues: https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues 155 | 156 | License 157 | ======= 158 | 159 | MIT licensed. See the bundled `LICENSE `_ file for more details. 160 | 161 | 162 | .. |pypi-package| image:: https://badgen.net/pypi/v/marshmallow-sqlalchemy 163 | :target: https://pypi.org/project/marshmallow-sqlalchemy/ 164 | :alt: Latest version 165 | .. |build-status| image:: https://github.com/marshmallow-code/marshmallow-sqlalchemy/actions/workflows/build-release.yml/badge.svg 166 | :target: https://github.com/marshmallow-code/marshmallow-sqlalchemy/actions/workflows/build-release.yml 167 | :alt: Build status 168 | .. |docs| image:: https://readthedocs.org/projects/marshmallow-sqlalchemy/badge/ 169 | :target: http://marshmallow-sqlalchemy.readthedocs.io/ 170 | :alt: Documentation 171 | .. |marshmallow-support| image:: https://badgen.net/badge/marshmallow/3,4?list=1 172 | :target: https://marshmallow.readthedocs.io/en/latest/upgrading.html 173 | :alt: marshmallow 3|4 compatible 174 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | 1. Bump version in `pyproject.toml` and update the changelog 4 | with today's date. 5 | 2. Commit: `git commit -m "Bump version and update changelog"` 6 | 3. Tag the commit: `git tag x.y.z` 7 | 4. Push: `git push --tags origin dev`. CI will take care of the 8 | PyPI release. 9 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Contact Information 2 | 3 | To report a security vulnerability, please use the 4 | [Tidelift security contact](https://tidelift.com/security). 5 | Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | /* Headings */ 2 | 3 | h2, h3, h4, h5, h6 { 4 | font-weight: 400; 5 | } 6 | 7 | /* UI elements: left and right sidebars, "Back to top" button, admonitions, Copy button */ 8 | .sidebar-drawer, .toc-drawer, .back-to-top, .admonition, .copybtn { 9 | /* Sans-serif system font stack */ 10 | font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif; 11 | } 12 | 13 | /* Hide ToC caption text within the main body (but leave them in the side-bar). */ 14 | /* https://github.com/hynek/structlog/blob/b488a8bf589a01aabc41e3bf8df81a9848cd426c/docs/_static/custom.css#L17-L20 */ 15 | #furo-main-content span.caption-text { 16 | display: none; 17 | } 18 | -------------------------------------------------------------------------------- /docs/_static/marshmallow-sqlalchemy-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marshmallow-code/marshmallow-sqlalchemy/fa29cf454304e1bc0b5d7ee8e02a042be958942a/docs/_static/marshmallow-sqlalchemy-logo.png -------------------------------------------------------------------------------- /docs/api_reference.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | Top-level API 4 | ============= 5 | 6 | .. Explicitly list which methods to document because :inherited-members: documents 7 | .. all of Schema's methods, which we don't want 8 | .. autoclass:: marshmallow_sqlalchemy.SQLAlchemySchema 9 | :members: load,get_instance,make_instance,validate,session,transient 10 | 11 | .. autoclass:: marshmallow_sqlalchemy.SQLAlchemyAutoSchema 12 | :members: load,get_instance,make_instance,validate,session,transient 13 | 14 | .. automodule:: marshmallow_sqlalchemy 15 | :members: 16 | :exclude-members: SQLAlchemySchema,SQLAlchemyAutoSchema 17 | 18 | Fields 19 | ====== 20 | 21 | .. automodule:: marshmallow_sqlalchemy.fields 22 | :members: 23 | :exclude-members: get_value,default_error_messages,get_primary_keys 24 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. _changelog: .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | extensions = [ 4 | "sphinx.ext.autodoc", 5 | "sphinx.ext.autodoc.typehints", 6 | "sphinx.ext.intersphinx", 7 | "sphinx.ext.viewcode", 8 | "sphinx_copybutton", 9 | "sphinx_design", 10 | "sphinx_issues", 11 | "sphinxext.opengraph", 12 | ] 13 | 14 | primary_domain = "py" 15 | default_role = "py:obj" 16 | 17 | intersphinx_mapping = { 18 | "python": ("https://python.readthedocs.io/en/latest/", None), 19 | "marshmallow": ("https://marshmallow.readthedocs.io/en/latest/", None), 20 | "sqlalchemy": ("http://www.sqlalchemy.org/docs/", None), 21 | } 22 | 23 | issues_github_path = "marshmallow-code/marshmallow-sqlalchemy" 24 | 25 | source_suffix = ".rst" 26 | master_doc = "index" 27 | 28 | project = "marshmallow-sqlalchemy" 29 | copyright = "Steven Loria and contributors" # noqa: A001 30 | 31 | version = release = importlib.metadata.version("marshmallow-sqlalchemy") 32 | 33 | exclude_patterns = ["_build"] 34 | 35 | # THEME 36 | 37 | html_theme = "furo" 38 | html_logo = "_static/marshmallow-sqlalchemy-logo.png" 39 | html_theme_options = { 40 | "source_repository": "https://github.com/marshmallow-code/marshmallow-sqlalchemy", 41 | "source_branch": "dev", 42 | "source_directory": "docs/", 43 | "sidebar_hide_name": True, 44 | "light_css_variables": { 45 | # Serif system font stack: https://systemfontstack.com/ 46 | "font-stack": "Iowan Old Style, Apple Garamond, Baskerville, Times New Roman, Droid Serif, Times, Source Serif Pro, serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;", 47 | }, 48 | "top_of_page_buttons": ["view"], 49 | } 50 | pygments_dark_style = "lightbulb" 51 | html_static_path = ["_static"] 52 | html_css_files = ["custom.css"] 53 | html_show_sourcelink = False 54 | ogp_image = "_static/marshmallow-sqlalchemy-logo.png" 55 | 56 | # Strip the dollar prompt when copying code 57 | # https://sphinx-copybutton.readthedocs.io/en/latest/use.html#strip-and-configure-input-prompts-for-code-cells 58 | copybutton_prompt_text = "$ " 59 | 60 | autodoc_typehints = "description" 61 | autodoc_member_order = "bysource" 62 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. meta:: 2 | :description: 3 | SQLALchemy integration with the marshmallow (de)serialization library. 4 | 5 | ********************** 6 | marshmallow-sqlalchemy 7 | ********************** 8 | 9 | `SQLAlchemy `_ integration with the `marshmallow `_ (de)serialization library. 10 | 11 | Release v\ |version| (:ref:`Changelog `) 12 | 13 | ---- 14 | 15 | Declare your models 16 | =================== 17 | 18 | .. tab-set:: 19 | 20 | .. tab-item:: SQLAlchemy 1.4 21 | :sync: sqla1 22 | 23 | .. code-block:: python 24 | 25 | import sqlalchemy as sa 26 | from sqlalchemy.orm import ( 27 | DeclarativeBase, 28 | backref, 29 | relationship, 30 | sessionmaker, 31 | ) 32 | 33 | from marshmallow_sqlalchemy import SQLAlchemySchema, auto_field 34 | 35 | engine = sa.create_engine("sqlite:///:memory:") 36 | Session = sessionmaker(engine) 37 | 38 | 39 | class Base(DeclarativeBase): 40 | pass 41 | 42 | 43 | class Author(Base): 44 | __tablename__ = "authors" 45 | id = sa.Column(sa.Integer, primary_key=True) 46 | name = sa.Column(sa.String, nullable=False) 47 | 48 | def __repr__(self): 49 | return f"" 50 | 51 | 52 | class Book(Base): 53 | __tablename__ = "books" 54 | id = sa.Column(sa.Integer, primary_key=True) 55 | title = sa.Column(sa.String) 56 | author_id = sa.Column(sa.Integer, sa.ForeignKey("authors.id")) 57 | author = relationship("Author", backref=backref("books")) 58 | 59 | .. tab-item:: SQLAlchemy 2 60 | :sync: sqla2 61 | 62 | .. code-block:: python 63 | 64 | import sqlalchemy as sa 65 | from sqlalchemy.orm import ( 66 | DeclarativeBase, 67 | backref, 68 | relationship, 69 | sessionmaker, 70 | mapped_column, 71 | Mapped, 72 | ) 73 | 74 | from marshmallow_sqlalchemy import SQLAlchemySchema, auto_field 75 | 76 | engine = sa.create_engine("sqlite:///:memory:") 77 | Session = sessionmaker(engine) 78 | 79 | 80 | class Base(DeclarativeBase): 81 | pass 82 | 83 | 84 | class Author(Base): 85 | __tablename__ = "authors" 86 | id: Mapped[int] = mapped_column(primary_key=True) 87 | name: Mapped[str] = mapped_column(nullable=False) 88 | 89 | def __repr__(self): 90 | return f"" 91 | 92 | 93 | class Book(Base): 94 | __tablename__ = "books" 95 | id: Mapped[int] = mapped_column(primary_key=True) 96 | title: Mapped[str] = mapped_column() 97 | author_id: Mapped[int] = mapped_column(sa.ForeignKey("authors.id")) 98 | author: Mapped["Author"] = relationship("Author", backref=backref("books")) 99 | 100 | 101 | .. include:: ../README.rst 102 | :start-after: .. start elevator-pitch 103 | :end-before: .. end elevator-pitch 104 | 105 | .. toctree:: 106 | :maxdepth: 1 107 | :hidden: 108 | :titlesonly: 109 | 110 | Home 111 | 112 | Learn 113 | ===== 114 | 115 | .. toctree:: 116 | :caption: Learn 117 | :maxdepth: 2 118 | 119 | recipes 120 | 121 | API reference 122 | ============= 123 | 124 | .. toctree:: 125 | :caption: API reference 126 | :maxdepth: 1 127 | 128 | api_reference 129 | 130 | Project info 131 | ============ 132 | 133 | .. toctree:: 134 | :caption: Project info 135 | :maxdepth: 1 136 | 137 | changelog 138 | contributing 139 | authors 140 | license 141 | 142 | .. toctree:: 143 | :hidden: 144 | :caption: Useful links 145 | 146 | marshmallow-sqlalchemy @ PyPI 147 | marshmallow-sqlalchemy @ GitHub 148 | Issue Tracker 149 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | License 3 | ******* 4 | 5 | .. literalinclude:: ../LICENSE 6 | -------------------------------------------------------------------------------- /docs/recipes.rst: -------------------------------------------------------------------------------- 1 | .. _recipes: 2 | 3 | ******* 4 | Recipes 5 | ******* 6 | 7 | 8 | Base ``Schema`` I 9 | ================= 10 | 11 | A common pattern with marshmallow is to define a base `Schema ` class which has common configuration and behavior for your application's schemas. 12 | 13 | You may want to define a common session object, e.g. a `scoped_session ` to use for all `Schemas `. 14 | 15 | 16 | .. code-block:: python 17 | 18 | # myproject/db.py 19 | import sqlalchemy as sa 20 | from sqlalchemy import orm 21 | 22 | Session = orm.scoped_session(orm.sessionmaker()) 23 | Session.configure(bind=engine) 24 | 25 | .. code-block:: python 26 | 27 | # myproject/schemas.py 28 | 29 | from marshmallow_sqlalchemy import SQLAlchemySchema 30 | 31 | from .db import Session 32 | 33 | 34 | class BaseSchema(SQLAlchemySchema): 35 | class Meta: 36 | sqla_session = Session 37 | 38 | 39 | .. code-block:: python 40 | :emphasize-lines: 9 41 | 42 | # myproject/users/schemas.py 43 | 44 | from ..schemas import BaseSchema 45 | from .models import User 46 | 47 | 48 | class UserSchema(BaseSchema): 49 | # Inherit BaseSchema's options 50 | class Meta(BaseSchema.Meta): 51 | model = User 52 | 53 | Base ``Schema`` II 54 | ================== 55 | 56 | Here is an alternative way to define a BaseSchema class with a common `Session ` object. 57 | 58 | .. code-block:: python 59 | 60 | # myproject/schemas.py 61 | 62 | from marshmallow_sqlalchemy import SQLAlchemySchemaOpts, SQLAlchemySchema 63 | from .db import Session 64 | 65 | 66 | class BaseOpts(SQLAlchemySchemaOpts): 67 | def __init__(self, meta, ordered=False): 68 | if not hasattr(meta, "sqla_session"): 69 | meta.sqla_session = Session 70 | super(BaseOpts, self).__init__(meta, ordered=ordered) 71 | 72 | 73 | class BaseSchema(SQLAlchemySchema): 74 | OPTIONS_CLASS = BaseOpts 75 | 76 | 77 | This allows you to define class Meta options without having to subclass ``BaseSchema.Meta``. 78 | 79 | .. code-block:: python 80 | :emphasize-lines: 8 81 | 82 | # myproject/users/schemas.py 83 | 84 | from ..schemas import BaseSchema 85 | from .models import User 86 | 87 | 88 | class UserSchema(BaseSchema): 89 | class Meta: 90 | model = User 91 | 92 | Using `Related ` to serialize relationships 93 | ================================================================================== 94 | 95 | The `Related ` field can be used to serialize a 96 | SQLAlchemy `relationship ` as a nested dictionary. 97 | 98 | .. code-block:: python 99 | :emphasize-lines: 34 100 | 101 | import sqlalchemy as sa 102 | from sqlalchemy.orm import DeclarativeBase, relationship 103 | 104 | from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field 105 | from marshmallow_sqlalchemy.fields import Related 106 | 107 | 108 | class Base(DeclarativeBase): 109 | pass 110 | 111 | 112 | class User(Base): 113 | __tablename__ = "user" 114 | id = sa.Column(sa.Integer, primary_key=True) 115 | full_name = sa.Column(sa.String(255)) 116 | 117 | 118 | class BlogPost(Base): 119 | __tablename__ = "blog_post" 120 | id = sa.Column(sa.Integer, primary_key=True) 121 | title = sa.Column(sa.String(255), nullable=False) 122 | 123 | author_id = sa.Column(sa.Integer, sa.ForeignKey(User.id), nullable=False) 124 | author = relationship(User) 125 | 126 | 127 | class BlogPostSchema(SQLAlchemyAutoSchema): 128 | class Meta: 129 | model = BlogPost 130 | 131 | id = auto_field() 132 | # Blog's author will be serialized as a dictionary with 133 | # `id` and `name` pulled from the related User. 134 | author = Related(["id", "full_name"]) 135 | 136 | Serialization will look like this: 137 | 138 | .. code-block:: python 139 | 140 | from pprint import pprint 141 | 142 | from sqlalchemy.orm import sessionmaker 143 | 144 | engine = sa.create_engine("sqlite:///:memory:") 145 | Session = sessionmaker(engine) 146 | 147 | Base.metadata.create_all(engine) 148 | 149 | with Session() as session: 150 | user = User(full_name="Freddie Mercury") 151 | post = BlogPost(title="Bohemian Rhapsody Revisited", author=user) 152 | 153 | session.add_all([user, post]) 154 | session.commit() 155 | 156 | blog_post_schema = BlogPostSchema() 157 | data = blog_post_schema.dump(post) 158 | pprint(data, indent=2) 159 | # { 'author': {'full_name': 'Freddie Mercury', 'id': 1}, 160 | # 'id': 1, 161 | # 'title': 'Bohemian Rhapsody Revisited'} 162 | 163 | Introspecting generated fields 164 | ============================== 165 | 166 | It is often useful to introspect what fields are generated for a `SQLAlchemyAutoSchema `. 167 | 168 | Generated fields are added to a `Schema's` ``_declared_fields`` attribute. 169 | 170 | .. code-block:: python 171 | 172 | AuthorSchema._declared_fields["books"] 173 | # , ...> 174 | 175 | 176 | You can also use marshmallow-sqlalchemy's conversion functions directly. 177 | 178 | 179 | .. code-block:: python 180 | 181 | from marshmallow_sqlalchemy import property2field 182 | 183 | id_prop = Author.__mapper__.attrs.get("id") 184 | 185 | property2field(id_prop) 186 | # , ...> 187 | 188 | Overriding generated fields 189 | =========================== 190 | 191 | Any field generated by a `SQLAlchemyAutoSchema ` can be overridden. 192 | 193 | .. code-block:: python 194 | 195 | from marshmallow import fields 196 | from marshmallow_sqlalchemy import SQLAlchemyAutoSchema 197 | from marshmallow_sqlalchemy.fields import Nested 198 | 199 | 200 | class AuthorSchema(SQLAlchemyAutoSchema): 201 | class Meta: 202 | model = Author 203 | 204 | # Override books field to use a nested representation rather than pks 205 | books = Nested(BookSchema, many=True, exclude=("author",)) 206 | 207 | You can use the `auto_field ` function to generate a marshmallow `Field ` based on single model property. This is useful for passing additional keyword arguments to the generated field. 208 | 209 | .. code-block:: python 210 | 211 | from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, field_for 212 | 213 | 214 | class AuthorSchema(SQLAlchemyAutoSchema): 215 | class Meta: 216 | model = Author 217 | 218 | # Generate a field, passing in an additional dump_only argument 219 | date_created = auto_field(dump_only=True) 220 | 221 | If a field's external data key differs from the model's column name, you can pass a column name to `auto_field `. 222 | 223 | .. code-block:: python 224 | 225 | class AuthorSchema(SQLAlchemyAutoSchema): 226 | class Meta: 227 | model = Author 228 | # Exclude date_created because we're aliasing it below 229 | exclude = ("date_created",) 230 | 231 | # Generate "created_date" field from "date_created" column 232 | created_date = auto_field("date_created", dump_only=True) 233 | 234 | Automatically generating schemas for SQLAlchemy models 235 | ====================================================== 236 | 237 | It can be tedious to implement a large number of schemas if not overriding any of the generated fields as detailed above. SQLAlchemy has a hook that can be used to trigger the creation of the schemas, assigning them to ``Model.__marshmallow__``. 238 | 239 | .. code-block:: python 240 | 241 | from marshmallow_sqlalchemy import ModelConversionError, SQLAlchemyAutoSchema 242 | 243 | 244 | def setup_schema(Base, session): 245 | # Create a function which incorporates the Base and session information 246 | def setup_schema_fn(): 247 | for class_ in Base._decl_class_registry.values(): 248 | if hasattr(class_, "__tablename__"): 249 | if class_.__name__.endswith("Schema"): 250 | raise ModelConversionError( 251 | "For safety, setup_schema can not be used when a" 252 | "Model class ends with 'Schema'" 253 | ) 254 | 255 | class Meta(object): 256 | model = class_ 257 | sqla_session = session 258 | 259 | schema_class_name = "%sSchema" % class_.__name__ 260 | 261 | schema_class = type( 262 | schema_class_name, (SQLAlchemyAutoSchema,), {"Meta": Meta} 263 | ) 264 | 265 | setattr(class_, "__marshmallow__", schema_class) 266 | 267 | return setup_schema_fn 268 | 269 | Usage: 270 | 271 | .. code-block:: python 272 | 273 | import sqlalchemy as sa 274 | from sqlalchemy.orm import declarative_base, sessionmaker 275 | from sqlalchemy import event 276 | from sqlalchemy.orm import mapper 277 | 278 | # Either import or declare setup_schema here 279 | 280 | engine = sa.create_engine("sqlite:///:memory:") 281 | Session = sessionmaker(engine) 282 | Base = declarative_base() 283 | 284 | 285 | class Author(Base): 286 | __tablename__ = "authors" 287 | id = sa.Column(sa.Integer, primary_key=True) 288 | name = sa.Column(sa.String) 289 | 290 | def __repr__(self): 291 | return "".format(self=self) 292 | 293 | 294 | # Listen for the SQLAlchemy event and run setup_schema. 295 | # Note: This has to be done after Base and session are setup 296 | event.listen(mapper, "after_configured", setup_schema(Base, session)) 297 | 298 | Base.metadata.create_all(engine) 299 | 300 | with Session() as session: 301 | author = Author(name="Chuck Paluhniuk") 302 | session.add(author) 303 | session.commit() 304 | 305 | # Model.__marshmallow__ returns the Class not an instance of the schema 306 | # so remember to instantiate it 307 | author_schema = Author.__marshmallow__() 308 | 309 | print(author_schema.dump(author)) 310 | 311 | This is inspired by functionality from `ColanderAlchemy `_. 312 | 313 | Smart ``Nested`` field 314 | ====================== 315 | 316 | To serialize nested attributes to primary keys unless they are already loaded, you can use this custom field. 317 | 318 | .. code-block:: python 319 | 320 | from marshmallow_sqlalchemy.fields import Nested 321 | 322 | 323 | class SmartNested(Nested): 324 | def serialize(self, attr, obj, accessor=None): 325 | if attr not in obj.__dict__: 326 | return {"id": int(getattr(obj, attr + "_id"))} 327 | return super().serialize(attr, obj, accessor) 328 | 329 | An example of then using this: 330 | 331 | .. code-block:: python 332 | 333 | from marshmallow_sqlalchemy import SQLAlchemySchema, auto_field 334 | 335 | 336 | class BookSchema(SQLAlchemySchema): 337 | id = auto_field() 338 | author = SmartNested(AuthorSchema) 339 | 340 | class Meta: 341 | model = Book 342 | sqla_session = Session 343 | 344 | 345 | book = Book(id=1) 346 | book.author = Author(name="Chuck Paluhniuk") 347 | session.add(book) 348 | session.commit() 349 | 350 | book = Book.query.get(1) 351 | print(BookSchema().dump(book)["author"]) 352 | # {'id': 1} 353 | 354 | book = Book.query.options(joinedload("author")).get(1) 355 | print(BookSchema().dump(book)["author"]) 356 | # {'id': 1, 'name': 'Chuck Paluhniuk'} 357 | 358 | Transient object creation 359 | ========================= 360 | 361 | Sometimes it might be desirable to deserialize instances that are transient (not attached to a session). In these cases you can specify the `transient` option in the `Meta ` class of a `SQLAlchemySchema `. 362 | 363 | 364 | .. code-block:: python 365 | 366 | from marshmallow_sqlalchemy import SQLAlchemyAutoSchema 367 | 368 | 369 | class AuthorSchema(SQLAlchemyAutoSchema): 370 | class Meta: 371 | model = Author 372 | load_instance = True 373 | transient = True 374 | 375 | 376 | dump_data = {"id": 1, "name": "John Steinbeck"} 377 | print(AuthorSchema().load(dump_data)) 378 | # 379 | 380 | You may also explicitly specify an override by passing the same argument to `load `. 381 | 382 | .. code-block:: python 383 | 384 | from marshmallow_sqlalchemy import SQLAlchemyAutoSchema 385 | 386 | 387 | class AuthorSchema(SQLAlchemyAutoSchema): 388 | class Meta: 389 | model = Author 390 | sqla_session = Session 391 | load_instance = True 392 | 393 | 394 | dump_data = {"id": 1, "name": "John Steinbeck"} 395 | print(AuthorSchema().load(dump_data, transient=True)) 396 | # 397 | 398 | Note that transience propagates to relationships (i.e. auto-generated schemas for nested items will also be transient). 399 | 400 | 401 | .. seealso:: 402 | 403 | See `State Management `_ to understand session state management. 404 | 405 | Controlling instance loading 406 | ============================ 407 | 408 | You can override the schema ``load_instance`` flag by passing in a ``load_instance`` argument when creating the schema instance. Use this to switch between loading to a dictionary or to a model instance: 409 | 410 | .. code-block:: python 411 | 412 | from marshmallow_sqlalchemy import SQLAlchemyAutoSchema 413 | 414 | 415 | class AuthorSchema(SQLAlchemyAutoSchema): 416 | class Meta: 417 | model = Author 418 | sqla_session = Session 419 | load_instance = True 420 | 421 | 422 | dump_data = {"id": 1, "name": "John Steinbeck"} 423 | print(AuthorSchema().load(dump_data)) # loading an instance 424 | # 425 | print(AuthorSchema(load_instance=False).load(dump_data)) # loading a dict 426 | # {"id": 1, "name": "John Steinbeck"} 427 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "marshmallow-sqlalchemy" 3 | version = "1.4.2" 4 | description = "SQLAlchemy integration with the marshmallow (de)serialization library" 5 | readme = "README.rst" 6 | license = { file = "LICENSE" } 7 | maintainers = [{ name = "Steven Loria", email = "sloria1@gmail.com" }] 8 | classifiers = [ 9 | "Intended Audience :: Developers", 10 | "License :: OSI Approved :: MIT License", 11 | "Programming Language :: Python :: 3", 12 | "Programming Language :: Python :: 3.9", 13 | "Programming Language :: Python :: 3.10", 14 | "Programming Language :: Python :: 3.11", 15 | "Programming Language :: Python :: 3.12", 16 | "Programming Language :: Python :: 3.13", 17 | ] 18 | requires-python = ">=3.9" 19 | dependencies = [ 20 | "marshmallow>=3.18.0", 21 | "SQLAlchemy>=1.4.40,<3.0", 22 | "typing-extensions; python_version < '3.10'", 23 | ] 24 | 25 | [project.urls] 26 | Changelog = "https://marshmallow-sqlalchemy.readthedocs.io/en/latest/changelog.html" 27 | Funding = "https://opencollective.com/marshmallow" 28 | Issues = "https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues" 29 | Source = "https://github.com/marshmallow-code/marshmallow-sqlalchemy" 30 | 31 | [project.optional-dependencies] 32 | docs = [ 33 | "furo==2024.8.6", 34 | "sphinx-copybutton==0.5.2", 35 | "sphinx-design==0.6.1", 36 | "sphinx-issues==5.0.1", 37 | "sphinx==8.2.3; python_version >= '3.11'", 38 | "sphinxext-opengraph==0.10.0", 39 | ] 40 | tests = ["pytest<9", "pytest-lazy-fixtures"] 41 | dev = ["marshmallow-sqlalchemy[tests]", "tox", "pre-commit>=3.5,<5.0"] 42 | 43 | [build-system] 44 | requires = ["flit_core<4"] 45 | build-backend = "flit_core.buildapi" 46 | 47 | [tool.flit.sdist] 48 | include = ["docs/", "tests/", "CHANGELOG.rst", "CONTRIBUTING.rst", "tox.ini"] 49 | exclude = ["docs/_build/"] 50 | 51 | [tool.ruff] 52 | src = ["src", "tests"] 53 | fix = true 54 | show-fixes = true 55 | output-format = "full" 56 | 57 | [tool.ruff.format] 58 | docstring-code-format = true 59 | 60 | [tool.ruff.lint] 61 | # use all checks available in ruff except the ones explicitly ignored below 62 | select = ["ALL"] 63 | ignore = [ 64 | "A005", # "module {name} shadows a Python standard-library module" 65 | "ANN", # let mypy handle annotation checks 66 | "ARG", # unused arguments are common w/ interfaces 67 | "C901", # don't enforce complexity level 68 | "COM", # let formatter take care commas 69 | "D", # don't require docstrings 70 | "DTZ007", # ignore false positives due to https://github.com/astral-sh/ruff/issues/1306 71 | "E501", # leave line-length enforcement to formatter 72 | "EM", # allow string messages in exceptions 73 | "FIX", # allow "FIX" comments in code 74 | "INP001", # allow Python files outside of packages 75 | "N804", # metaclass methods aren't properly handled by this rule 76 | "N806", # allow uppercase variable names for variables that are classes 77 | "PERF203", # allow try-except within loops 78 | "PLR0912", # "Too many branches" 79 | "PLR0913", # "Too many arguments" 80 | "PLR2004", # "Magic value used in comparison" 81 | "PTH", # don't require using pathlib instead of os 82 | "RUF012", # allow mutable class variables 83 | "SIM102", # Sometimes nested ifs are more readable than if...and... 84 | "SIM105", # "Use `contextlib.suppress(...)` instead of `try`-`except`-`pass`" 85 | "SIM108", # sometimes if-else is more readable than a ternary 86 | "SLF001", # allow private member access 87 | "TD", # allow TODO comments to be whatever we want 88 | "TRY003", # allow long messages passed to exceptions 89 | "TRY004", # allow ValueError for invalid argument types 90 | ] 91 | 92 | [tool.ruff.lint.per-file-ignores] 93 | "tests/*" = [ 94 | "ARG", # unused arguments are fine in tests 95 | "C408", # allow dict() instead of dict literal 96 | "DTZ", # allow naive datetimes 97 | "FBT003", # allow boolean positional argument 98 | "N802", # allow uppercasing in test names, e.g. test_convert_TSVECTOR 99 | "N803", # fixture names might be uppercase 100 | "PLR0915", # allow lots of statements 101 | "PT007", # ignore false positives due to https://github.com/astral-sh/ruff/issues/14743 102 | "PT011", # don't require match when using pytest.raises 103 | "S", # allow asserts 104 | "SIM117", # allow nested with statements because it's more readable sometimes 105 | ] 106 | 107 | [tool.mypy] 108 | files = ["src", "tests"] 109 | ignore_missing_imports = true 110 | warn_unreachable = true 111 | warn_unused_ignores = true 112 | warn_redundant_casts = true 113 | no_implicit_optional = true 114 | 115 | [[tool.mypy.overrides]] 116 | module = "tests.*" 117 | check_untyped_defs = true 118 | -------------------------------------------------------------------------------- /src/marshmallow_sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | from .convert import ( 2 | ModelConverter, 3 | column2field, 4 | field_for, 5 | fields_for_model, 6 | property2field, 7 | ) 8 | from .exceptions import ModelConversionError 9 | from .schema import ( 10 | SQLAlchemyAutoSchema, 11 | SQLAlchemyAutoSchemaOpts, 12 | SQLAlchemySchema, 13 | SQLAlchemySchemaOpts, 14 | auto_field, 15 | ) 16 | 17 | __all__ = [ 18 | "ModelConversionError", 19 | "ModelConverter", 20 | "SQLAlchemyAutoSchema", 21 | "SQLAlchemyAutoSchemaOpts", 22 | "SQLAlchemySchema", 23 | "SQLAlchemySchemaOpts", 24 | "auto_field", 25 | "column2field", 26 | "field_for", 27 | "fields_for_model", 28 | "property2field", 29 | ] 30 | -------------------------------------------------------------------------------- /src/marshmallow_sqlalchemy/convert.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import inspect 5 | import uuid 6 | from typing import ( 7 | TYPE_CHECKING, 8 | Any, 9 | Callable, 10 | Literal, 11 | Union, 12 | cast, 13 | overload, 14 | ) 15 | 16 | # Remove when dropping Python 3.9 17 | try: 18 | from typing import TypeAlias, TypeGuard 19 | except ImportError: 20 | from typing_extensions import TypeAlias, TypeGuard 21 | 22 | import marshmallow as ma 23 | import sqlalchemy as sa 24 | from marshmallow import fields, validate 25 | from sqlalchemy.dialects import mssql, mysql, postgresql 26 | from sqlalchemy.orm import SynonymProperty 27 | 28 | from .exceptions import ModelConversionError 29 | from .fields import Related, RelatedList 30 | 31 | if TYPE_CHECKING: 32 | from collections.abc import Iterable 33 | 34 | from sqlalchemy.ext.declarative import DeclarativeMeta 35 | from sqlalchemy.orm import MapperProperty 36 | from sqlalchemy.types import TypeEngine 37 | 38 | PropertyOrColumn: TypeAlias = MapperProperty | sa.Column 39 | 40 | _FieldPartial: TypeAlias = Callable[[], fields.Field] 41 | # TODO: Use more specific type for second argument 42 | _FieldClassFactory: TypeAlias = Callable[ 43 | ["ModelConverter", Any], Union[type[fields.Field], _FieldPartial] 44 | ] 45 | 46 | 47 | def _is_field(value: Any) -> TypeGuard[type[fields.Field]]: 48 | return isinstance(value, type) and issubclass(value, fields.Field) 49 | 50 | 51 | def _base_column(column): 52 | """Unwrap proxied columns""" 53 | if column not in column.base_columns and len(column.base_columns) == 1: 54 | [base] = column.base_columns 55 | return base 56 | return column 57 | 58 | 59 | def _has_default(column) -> bool: 60 | return ( 61 | column.default is not None 62 | or column.server_default is not None 63 | or _is_auto_increment(column) 64 | ) 65 | 66 | 67 | def _is_auto_increment(column) -> bool: 68 | return column.table is not None and column is column.table._autoincrement_column 69 | 70 | 71 | def _list_field_factory( 72 | converter: ModelConverter, data_type: postgresql.ARRAY 73 | ) -> Callable[[], fields.List]: 74 | FieldClass = converter._get_field_class_for_data_type(data_type.item_type) 75 | inner = FieldClass() 76 | if not data_type.dimensions or data_type.dimensions == 1: 77 | return functools.partial(fields.List, inner) 78 | 79 | # For multi-dimensional arrays, nest the Lists 80 | dimensions = data_type.dimensions 81 | for _ in range(dimensions - 1): 82 | inner = fields.List(inner) 83 | 84 | return functools.partial(fields.List, inner) 85 | 86 | 87 | def _enum_field_factory( 88 | converter: ModelConverter, data_type: sa.Enum 89 | ) -> Callable[[], fields.Field]: 90 | return ( 91 | functools.partial(fields.Enum, enum=data_type.enum_class) 92 | if data_type.enum_class 93 | else fields.Raw 94 | ) 95 | 96 | 97 | class ModelConverter: 98 | """Converts a SQLAlchemy model into a dictionary of corresponding 99 | marshmallow `Fields `. 100 | """ 101 | 102 | SQLA_TYPE_MAPPING: dict[ 103 | type[TypeEngine], type[fields.Field] | _FieldClassFactory 104 | ] = { 105 | sa.Enum: _enum_field_factory, 106 | sa.JSON: fields.Raw, 107 | sa.ARRAY: _list_field_factory, 108 | sa.PickleType: fields.Raw, 109 | postgresql.BIT: fields.Integer, 110 | postgresql.OID: fields.Integer, 111 | postgresql.UUID: fields.UUID, 112 | postgresql.MACADDR: fields.String, 113 | postgresql.INET: fields.String, 114 | postgresql.CIDR: fields.String, 115 | postgresql.JSON: fields.Raw, 116 | postgresql.JSONB: fields.Raw, 117 | postgresql.HSTORE: fields.Raw, 118 | postgresql.ARRAY: _list_field_factory, 119 | postgresql.MONEY: fields.Decimal, 120 | postgresql.DATE: fields.Date, 121 | postgresql.TIME: fields.Time, 122 | mysql.BIT: fields.Integer, 123 | mysql.YEAR: fields.Integer, 124 | mysql.SET: fields.List, 125 | mysql.ENUM: fields.Field, 126 | mysql.INTEGER: fields.Integer, 127 | mysql.DATETIME: fields.DateTime, 128 | mssql.BIT: fields.Integer, 129 | mssql.UNIQUEIDENTIFIER: fields.UUID, 130 | } 131 | DIRECTION_MAPPING = {"MANYTOONE": False, "MANYTOMANY": True, "ONETOMANY": True} 132 | 133 | def __init__(self, schema_cls: type[ma.Schema] | None = None): 134 | self.schema_cls = schema_cls 135 | 136 | @property 137 | def type_mapping(self) -> dict[type, type[fields.Field]]: 138 | if self.schema_cls: 139 | return self.schema_cls.TYPE_MAPPING 140 | return ma.Schema.TYPE_MAPPING 141 | 142 | def fields_for_model( 143 | self, 144 | model: type[DeclarativeMeta], 145 | *, 146 | include_fk: bool = False, 147 | include_relationships: bool = False, 148 | fields: Iterable[str] | None = None, 149 | exclude: Iterable[str] | None = None, 150 | base_fields: dict | None = None, 151 | dict_cls: type[dict] = dict, 152 | ) -> dict[str, fields.Field]: 153 | """Generate a dict of field_name: `marshmallow.fields.Field` pairs for the given model. 154 | Note: SynonymProperties are ignored. Use an explicit field if you want to include a synonym. 155 | 156 | :param model: The SQLAlchemy model 157 | :param bool include_fk: Whether to include foreign key fields in the output. 158 | :param bool include_relationships: Whether to include relationships fields in the output. 159 | :return: dict of field_name: Field instance pairs 160 | """ 161 | result = dict_cls() 162 | base_fields = base_fields or {} 163 | 164 | for prop in sa.inspect(model).attrs: # type: ignore[union-attr] 165 | key = self._get_field_name(prop) 166 | if self._should_exclude_field(prop, fields=fields, exclude=exclude): 167 | # Allow marshmallow to validate and exclude the field key. 168 | result[key] = None 169 | continue 170 | if isinstance(prop, SynonymProperty): 171 | continue 172 | if hasattr(prop, "columns"): 173 | if not include_fk: 174 | # Only skip a column if there is no overriden column 175 | # which does not have a Foreign Key. 176 | for column in prop.columns: 177 | if not column.foreign_keys: 178 | break 179 | else: 180 | continue 181 | if not include_relationships and hasattr(prop, "direction"): 182 | continue 183 | field = base_fields.get(key) or self.property2field(prop) 184 | if field: 185 | result[key] = field 186 | return result 187 | 188 | def fields_for_table( 189 | self, 190 | table: sa.Table, 191 | *, 192 | include_fk: bool = False, 193 | fields: Iterable[str] | None = None, 194 | exclude: Iterable[str] | None = None, 195 | base_fields: dict | None = None, 196 | dict_cls: type[dict] = dict, 197 | ) -> dict[str, fields.Field]: 198 | result = dict_cls() 199 | base_fields = base_fields or {} 200 | for column in table.columns: 201 | key = self._get_field_name(column) 202 | if self._should_exclude_field(column, fields=fields, exclude=exclude): 203 | # Allow marshmallow to validate and exclude the field key. 204 | result[key] = None 205 | continue 206 | if not include_fk and column.foreign_keys: 207 | continue 208 | # Overridden fields are specified relative to key generated by 209 | # self._get_key_for_column(...), rather than keys in source model 210 | field = base_fields.get(key) or self.column2field(column) 211 | if field: 212 | result[key] = field 213 | return result 214 | 215 | @overload 216 | def property2field( 217 | self, 218 | prop: MapperProperty, 219 | *, 220 | instance: Literal[True] = ..., 221 | field_class: type[fields.Field] | None = ..., 222 | **kwargs, 223 | ) -> fields.Field: ... 224 | 225 | @overload 226 | def property2field( 227 | self, 228 | prop: MapperProperty, 229 | *, 230 | instance: Literal[False] = ..., 231 | field_class: type[fields.Field] | None = ..., 232 | **kwargs, 233 | ) -> type[fields.Field]: ... 234 | 235 | def property2field( 236 | self, 237 | prop: MapperProperty, 238 | *, 239 | instance: bool = True, 240 | field_class: type[fields.Field] | None = None, 241 | **kwargs, 242 | ) -> fields.Field | type[fields.Field]: 243 | """Convert a SQLAlchemy `Property` to a field instance or class. 244 | 245 | :param Property prop: SQLAlchemy Property. 246 | :param bool instance: If `True`, return `Field` instance, computing relevant kwargs 247 | from the given property. If `False`, return the `Field` class. 248 | :param kwargs: Additional keyword arguments to pass to the field constructor. 249 | :return: A `marshmallow.fields.Field` class or instance. 250 | """ 251 | # handle synonyms 252 | # Attribute renamed "_proxied_object" in 1.4 253 | for attr in ("_proxied_property", "_proxied_object"): 254 | proxied_obj = getattr(prop, attr, None) 255 | if proxied_obj is not None: 256 | prop = proxied_obj 257 | field_class = field_class or self._get_field_class_for_property(prop) 258 | if not instance: 259 | return field_class 260 | field_kwargs = self._get_field_kwargs_for_property(prop) 261 | field_kwargs.update(kwargs) 262 | ret = field_class(**field_kwargs) 263 | if ( 264 | hasattr(prop, "direction") 265 | and self.DIRECTION_MAPPING[prop.direction.name] 266 | and prop.uselist is True 267 | ): 268 | ret = RelatedList(ret, **{**self.get_base_kwargs(), **kwargs}) 269 | return ret 270 | 271 | @overload 272 | def column2field( 273 | self, column, *, instance: Literal[True] = ..., **kwargs 274 | ) -> fields.Field: ... 275 | 276 | @overload 277 | def column2field( 278 | self, column, *, instance: Literal[False] = ..., **kwargs 279 | ) -> type[fields.Field]: ... 280 | 281 | def column2field( 282 | self, column, *, instance: bool = True, **kwargs 283 | ) -> fields.Field | type[fields.Field]: 284 | """Convert a SQLAlchemy `Column ` to a field instance or class. 285 | 286 | :param sqlalchemy.schema.Column column: SQLAlchemy Column. 287 | :param bool instance: If `True`, return `Field` instance, computing relevant kwargs 288 | from the given property. If `False`, return the `Field` class. 289 | :return: A `marshmallow.fields.Field` class or instance. 290 | """ 291 | field_class = self._get_field_class_for_column(column) 292 | if not instance: 293 | return field_class 294 | field_kwargs = self.get_base_kwargs() 295 | self._add_column_kwargs(field_kwargs, column) 296 | return field_class(**{**field_kwargs, **kwargs}) 297 | 298 | @overload 299 | def field_for( 300 | self, 301 | model: type[DeclarativeMeta], 302 | property_name: str, 303 | *, 304 | instance: Literal[True] = ..., 305 | field_class: type[fields.Field] | None = ..., 306 | **kwargs, 307 | ) -> fields.Field: ... 308 | 309 | @overload 310 | def field_for( 311 | self, 312 | model: type[DeclarativeMeta], 313 | property_name: str, 314 | *, 315 | instance: Literal[False] = ..., 316 | field_class: type[fields.Field] | None = None, 317 | **kwargs, 318 | ) -> type[fields.Field]: ... 319 | 320 | def field_for( 321 | self, 322 | model: type[DeclarativeMeta], 323 | property_name: str, 324 | *, 325 | instance: bool = True, 326 | field_class: type[fields.Field] | None = None, 327 | **kwargs, 328 | ) -> fields.Field | type[fields.Field]: 329 | """Convert a property for a mapped SQLAlchemy class to a marshmallow `Field`. 330 | Example: :: 331 | 332 | date_created = field_for(Author, "date_created", dump_only=True) 333 | author = field_for(Book, "author") 334 | 335 | :param type model: A SQLAlchemy mapped class. 336 | :param str property_name: The name of the property to convert. 337 | :param kwargs: Extra keyword arguments to pass to `property2field` 338 | :return: A `marshmallow.fields.Field` class or instance. 339 | """ 340 | target_model = model 341 | prop_name = property_name 342 | attr = getattr(model, property_name) 343 | remote_with_local_multiplicity = False 344 | if hasattr(attr, "remote_attr"): 345 | target_model = attr.target_class 346 | prop_name = attr.value_attr 347 | remote_with_local_multiplicity = attr.local_attr.prop.uselist 348 | prop: MapperProperty = sa.inspect(target_model).attrs.get(prop_name) # type: ignore[union-attr] 349 | converted_prop = self.property2field( 350 | prop, 351 | # To satisfy type checking, need to pass a literal bool 352 | instance=True if instance else False, # noqa: SIM210 353 | field_class=field_class, 354 | **kwargs, 355 | ) 356 | if remote_with_local_multiplicity: 357 | return RelatedList(converted_prop, **{**self.get_base_kwargs(), **kwargs}) 358 | return converted_prop 359 | 360 | def _get_field_name(self, prop_or_column: PropertyOrColumn) -> str: 361 | return prop_or_column.key 362 | 363 | def _get_field_class_for_column(self, column: sa.Column) -> type[fields.Field]: 364 | return self._get_field_class_for_data_type(column.type) 365 | 366 | def _get_field_class_for_data_type( 367 | self, data_type: TypeEngine 368 | ) -> type[fields.Field]: 369 | field_cls: type[fields.Field] | _FieldPartial | None = None 370 | types = inspect.getmro(type(data_type)) 371 | # First search for a field class from self.SQLA_TYPE_MAPPING 372 | for col_type in types: 373 | if col_type in self.SQLA_TYPE_MAPPING: 374 | field_or_factory = self.SQLA_TYPE_MAPPING[col_type] 375 | if _is_field(field_or_factory): 376 | field_cls = field_or_factory 377 | else: 378 | field_cls = cast("_FieldClassFactory", field_or_factory)( 379 | self, data_type 380 | ) 381 | break 382 | else: 383 | # Try to find a field class based on the column's python_type 384 | try: 385 | python_type = data_type.python_type 386 | except NotImplementedError: 387 | python_type = None 388 | 389 | if python_type in self.type_mapping: 390 | field_cls = self.type_mapping[python_type] 391 | else: 392 | if hasattr(data_type, "impl"): 393 | return self._get_field_class_for_data_type(data_type.impl) 394 | raise ModelConversionError( 395 | f"Could not find field column of type {types[0]}." 396 | ) 397 | return cast("type[fields.Field]", field_cls) 398 | 399 | def _get_field_class_for_property(self, prop) -> type[fields.Field]: 400 | field_cls: type[fields.Field] 401 | if hasattr(prop, "direction"): 402 | field_cls = Related 403 | else: 404 | column = _base_column(prop.columns[0]) 405 | field_cls = self._get_field_class_for_column(column) 406 | return field_cls 407 | 408 | def _get_field_kwargs_for_property(self, prop: PropertyOrColumn) -> dict[str, Any]: 409 | kwargs = self.get_base_kwargs() 410 | if hasattr(prop, "columns"): 411 | column = _base_column(prop.columns[0]) 412 | self._add_column_kwargs(kwargs, column) 413 | prop = column 414 | if hasattr(prop, "direction"): # Relationship property 415 | self._add_relationship_kwargs(kwargs, prop) 416 | if getattr(prop, "doc", None): # Useful for documentation generation 417 | kwargs["metadata"]["description"] = prop.doc 418 | return kwargs 419 | 420 | def _add_column_kwargs(self, kwargs: dict[str, Any], column: sa.Column) -> None: 421 | """Add keyword arguments to kwargs (in-place) based on the passed in 422 | `Column `. 423 | """ 424 | if hasattr(column, "nullable"): 425 | if column.nullable: 426 | kwargs["allow_none"] = True 427 | kwargs["required"] = not column.nullable and not _has_default(column) 428 | # If there is no nullable attribute, we are dealing with a property 429 | # that does not derive from the Column class. Mark as dump_only. 430 | else: 431 | kwargs["dump_only"] = True 432 | 433 | if hasattr(column.type, "enum_class") and column.type.enum_class is not None: 434 | kwargs["enum"] = column.type.enum_class 435 | elif hasattr(column.type, "enums") and not kwargs.get("dump_only"): 436 | kwargs["validate"].append(validate.OneOf(choices=column.type.enums)) 437 | 438 | # Add a length validator if a max length is set on the column 439 | # Skip UUID columns 440 | # (see https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues/54) 441 | if hasattr(column.type, "length") and not kwargs.get("dump_only"): 442 | column_length = column.type.length 443 | if column_length is not None: 444 | try: 445 | python_type = column.type.python_type 446 | except (AttributeError, NotImplementedError): 447 | python_type = None 448 | if not python_type or not issubclass(python_type, uuid.UUID): 449 | kwargs["validate"].append(validate.Length(max=column_length)) 450 | 451 | if getattr(column.type, "asdecimal", False): 452 | kwargs["places"] = getattr(column.type, "scale", None) 453 | 454 | def _add_relationship_kwargs( 455 | self, kwargs: dict[str, Any], prop: PropertyOrColumn 456 | ) -> None: 457 | """Add keyword arguments to kwargs (in-place) based on the passed in 458 | relationship `Property`. 459 | """ 460 | nullable = True 461 | for pair in prop.local_remote_pairs: 462 | if not pair[0].nullable: 463 | if ( 464 | prop.uselist is True 465 | or self.DIRECTION_MAPPING[prop.direction.name] is False 466 | ): 467 | nullable = False 468 | break 469 | kwargs.update({"allow_none": nullable, "required": not nullable}) 470 | 471 | def _should_exclude_field( 472 | self, 473 | column: PropertyOrColumn, 474 | fields: Iterable[str] | None = None, 475 | exclude: Iterable[str] | None = None, 476 | ) -> bool: 477 | key = self._get_field_name(column) 478 | if fields and key not in fields: 479 | return True 480 | return bool(exclude and key in exclude) 481 | 482 | def get_base_kwargs(self): 483 | return {"validate": [], "metadata": {}} 484 | 485 | 486 | default_converter = ModelConverter() 487 | 488 | fields_for_model = default_converter.fields_for_model 489 | property2field = default_converter.property2field 490 | column2field = default_converter.column2field 491 | field_for = default_converter.field_for 492 | -------------------------------------------------------------------------------- /src/marshmallow_sqlalchemy/exceptions.py: -------------------------------------------------------------------------------- 1 | class MarshmallowSQLAlchemyError(Exception): 2 | """Base exception class from which all exceptions related to 3 | marshmallow-sqlalchemy inherit. 4 | """ 5 | 6 | 7 | class ModelConversionError(MarshmallowSQLAlchemyError): 8 | """Raised when an error occurs in converting a SQLAlchemy construct 9 | to a marshmallow object. 10 | """ 11 | 12 | 13 | class IncorrectSchemaTypeError(ModelConversionError): 14 | """Raised when a ``SQLAlchemyAutoField`` is bound to ``Schema`` that 15 | is not an instance of ``SQLAlchemySchema``. 16 | """ 17 | -------------------------------------------------------------------------------- /src/marshmallow_sqlalchemy/fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import warnings 4 | from typing import TYPE_CHECKING, Any, cast 5 | 6 | from marshmallow import fields 7 | from marshmallow.utils import is_iterable_but_not_string 8 | from sqlalchemy import inspect 9 | from sqlalchemy.orm.exc import NoResultFound 10 | 11 | if TYPE_CHECKING: 12 | from sqlalchemy.ext.declarative import DeclarativeMeta 13 | from sqlalchemy.orm import MapperProperty 14 | 15 | 16 | class RelatedList(fields.List): 17 | def get_value(self, obj, attr, accessor=None): 18 | # Do not call `fields.List`'s get_value as it calls the container's 19 | # `get_value` if the container has `attribute`. 20 | # Instead call the `get_value` from the parent of `fields.List` 21 | # so the special handling is avoided. 22 | return super(fields.List, self).get_value(obj, attr, accessor=accessor) 23 | 24 | 25 | class Related(fields.Field): 26 | """Related data represented by a SQLAlchemy `relationship`. Must be attached 27 | to a `Schema ` class whose options includes a SQLAlchemy `model`, such 28 | as `SQLAlchemySchema `. 29 | 30 | :param columns: Optional column names on related model. If not provided, 31 | the primary key(s) of the related model will be used. 32 | """ 33 | 34 | default_error_messages = { 35 | "invalid": "Could not deserialize related value {value!r}; " 36 | "expected a dictionary with keys {keys!r}" 37 | } 38 | 39 | def __init__( 40 | self, 41 | columns: list[str] | str | None = None, 42 | column: str | None = None, 43 | **kwargs, 44 | ): 45 | if column is not None: 46 | warnings.warn( 47 | "`column` parameter is deprecated and will be removed in future releases. " 48 | "Use `columns` instead.", 49 | DeprecationWarning, 50 | stacklevel=2, 51 | ) 52 | if columns is None: 53 | columns = column 54 | super().__init__(**kwargs) 55 | self.columns: list[str] = ensure_list(columns or []) 56 | 57 | @property 58 | def model(self) -> type[DeclarativeMeta] | None: 59 | if self.root is None: 60 | raise RuntimeError("Cannot access model before field is bound to schema.") 61 | return self.root.opts.model 62 | 63 | @property 64 | def related_model(self) -> type[DeclarativeMeta]: 65 | if self.model is None: 66 | raise RuntimeError( 67 | "Cannot access related_model if schema does not have a model." 68 | ) 69 | model_attr = getattr(self.model, cast("str", self.attribute or self.name)) 70 | if hasattr(model_attr, "remote_attr"): # handle association proxies 71 | model_attr = model_attr.remote_attr 72 | return model_attr.property.mapper.class_ 73 | 74 | @property 75 | def related_keys(self): 76 | if self.columns: 77 | insp = inspect(self.related_model) 78 | return [insp.attrs[column] for column in self.columns] 79 | return get_primary_keys(self.related_model) 80 | 81 | @property 82 | def session(self): 83 | return self.root.session 84 | 85 | @property 86 | def transient(self): 87 | return self.root.transient 88 | 89 | def _serialize(self, value, attr, obj): 90 | ret = {prop.key: getattr(value, prop.key, None) for prop in self.related_keys} 91 | return ret if len(ret) > 1 else next(iter(ret.values())) 92 | 93 | def _deserialize(self, value, *args, **kwargs): 94 | """Deserialize a serialized value to a model instance. 95 | 96 | If the parent schema is transient, create a new (transient) instance. 97 | Otherwise, attempt to find an existing instance in the database. 98 | :param value: The value to deserialize. 99 | """ 100 | if not isinstance(value, dict): 101 | if len(self.related_keys) != 1: 102 | keys = [prop.key for prop in self.related_keys] 103 | raise self.make_error("invalid", value=value, keys=keys) 104 | value = {self.related_keys[0].key: value} 105 | if self.transient: 106 | return self.related_model(**value) 107 | try: 108 | result = self._get_existing_instance(self.related_model, value) 109 | except NoResultFound: 110 | # The related-object DNE in the DB, but we still want to deserialize it 111 | # ...perhaps we want to add it to the DB later 112 | return self.related_model(**value) 113 | return result 114 | 115 | def _get_existing_instance(self, related_model, value): 116 | """Retrieve the related object from an existing instance in the DB. 117 | 118 | :param related_model: The related model to query 119 | :param value: The serialized value to mapto an existing instance. 120 | :raises NoResultFound: if there is no matching record. 121 | """ 122 | if self.columns: 123 | result = ( 124 | self.session.query(related_model) 125 | .filter_by( 126 | **{prop.key: value.get(prop.key) for prop in self.related_keys} 127 | ) 128 | .one() 129 | ) 130 | else: 131 | # Use a faster path if the related key is the primary key. 132 | lookup_values = [value.get(prop.key) for prop in self.related_keys] 133 | try: 134 | result = self.session.get(related_model, lookup_values) 135 | except TypeError as error: 136 | keys = [prop.key for prop in self.related_keys] 137 | raise self.make_error("invalid", value=value, keys=keys) from error 138 | if result is None: 139 | raise NoResultFound 140 | return result 141 | 142 | 143 | class Nested(fields.Nested): 144 | """Nested field that inherits the session from its parent.""" 145 | 146 | def _deserialize(self, *args, **kwargs): 147 | if hasattr(self.schema, "session"): 148 | self.schema.session = self.root.session 149 | self.schema.transient = self.root.transient 150 | return super()._deserialize(*args, **kwargs) 151 | 152 | 153 | def get_primary_keys(model: type[DeclarativeMeta]) -> list[MapperProperty]: 154 | """Get primary key properties for a SQLAlchemy model. 155 | 156 | :param model: SQLAlchemy model class 157 | """ 158 | mapper = model.__mapper__ # type: ignore[attr-defined] 159 | return [mapper.get_property_by_column(column) for column in mapper.primary_key] 160 | 161 | 162 | def ensure_list(value: Any) -> list: 163 | return list(value) if is_iterable_but_not_string(value) else [value] 164 | -------------------------------------------------------------------------------- /src/marshmallow_sqlalchemy/load_instance_mixin.py: -------------------------------------------------------------------------------- 1 | """Mixin that adds model instance loading behavior. 2 | 3 | .. warning:: 4 | 5 | This module is treated as private API. 6 | Users should not need to use this module directly. 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | import importlib.metadata 12 | from collections.abc import Iterable, Mapping, Sequence 13 | from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union, cast 14 | 15 | import marshmallow as ma 16 | from sqlalchemy.ext.declarative import DeclarativeMeta 17 | from sqlalchemy.orm.exc import ObjectDeletedError 18 | 19 | from .fields import get_primary_keys 20 | 21 | if TYPE_CHECKING: 22 | from sqlalchemy.orm import Session 23 | 24 | _LoadDataV3 = Union[Mapping[str, Any], Iterable[Mapping[str, Any]]] 25 | _LoadDataV4 = Union[Mapping[str, Any], Sequence[Mapping[str, Any]]] 26 | _LoadDataT = TypeVar("_LoadDataT", _LoadDataV3, _LoadDataV4) 27 | _ModelType = TypeVar("_ModelType", bound=DeclarativeMeta) 28 | 29 | 30 | def _cast_data(data): 31 | if int(importlib.metadata.version("marshmallow")[0]) >= 4: 32 | return cast("_LoadDataV4", data) 33 | return cast("_LoadDataV3", data) 34 | 35 | 36 | class LoadInstanceMixin: 37 | class Opts: 38 | model: type[DeclarativeMeta] | None 39 | sqla_session: Session | None 40 | load_instance: bool 41 | transient: bool 42 | 43 | def __init__(self, meta, *args, **kwargs): 44 | super().__init__(meta, *args, **kwargs) 45 | self.model = getattr(meta, "model", None) 46 | self.sqla_session = getattr(meta, "sqla_session", None) 47 | self.load_instance = getattr(meta, "load_instance", False) 48 | self.transient = getattr(meta, "transient", False) 49 | 50 | class Schema(Generic[_ModelType]): 51 | opts: LoadInstanceMixin.Opts 52 | instance: _ModelType | None 53 | _session: Session | None 54 | _transient: bool | None 55 | _load_instance: bool 56 | 57 | @property 58 | def session(self) -> Session | None: 59 | """The SQLAlchemy session used to load models.""" 60 | return self._session or self.opts.sqla_session 61 | 62 | @session.setter 63 | def session(self, session: Session) -> None: 64 | self._session = session 65 | 66 | @property 67 | def transient(self) -> bool: 68 | """Whether model instances are loaded in a transient state.""" 69 | if self._transient is not None: 70 | return self._transient 71 | return self.opts.transient 72 | 73 | @transient.setter 74 | def transient(self, transient: bool) -> None: 75 | self._transient = transient 76 | 77 | def __init__(self, *args, **kwargs): 78 | self._session = kwargs.pop("session", None) 79 | self.instance = kwargs.pop("instance", None) 80 | self._transient = kwargs.pop("transient", None) 81 | self._load_instance = kwargs.pop("load_instance", self.opts.load_instance) 82 | super().__init__(*args, **kwargs) 83 | 84 | def get_instance(self, data) -> _ModelType | None: 85 | """Retrieve an existing record by primary key(s). If the schema instance 86 | is transient, return `None`. 87 | 88 | :param data: Serialized data to inform lookup. 89 | """ 90 | if self.transient: 91 | return None 92 | model = cast("type[_ModelType]", self.opts.model) 93 | props = get_primary_keys(model) 94 | filters = {prop.key: data.get(prop.key) for prop in props} 95 | if None not in filters.values(): 96 | try: 97 | return cast("Session", self.session).get(model, filters) 98 | except ObjectDeletedError: 99 | return None 100 | return None 101 | 102 | @ma.post_load 103 | def make_instance(self, data, **kwargs) -> _ModelType: 104 | """Deserialize data to an instance of the model if self.load_instance is True. 105 | 106 | Update an existing row if specified in `self.instance` or loaded by primary 107 | key(s) in the data; else create a new row. 108 | 109 | :param data: Data to deserialize. 110 | """ 111 | if not self._load_instance: 112 | return data 113 | instance = self.instance or self.get_instance(data) 114 | if instance is not None: 115 | for key, value in data.items(): 116 | setattr(instance, key, value) 117 | return instance 118 | kwargs, association_attrs = self._split_model_kwargs_association(data) 119 | ModelClass = cast("DeclarativeMeta", self.opts.model) 120 | instance = ModelClass(**kwargs) 121 | for attr, value in association_attrs.items(): 122 | setattr(instance, attr, value) 123 | return instance 124 | 125 | def load( 126 | self, 127 | data: _LoadDataT, 128 | *, 129 | session: Session | None = None, 130 | instance: _ModelType | None = None, 131 | transient: bool = False, 132 | **kwargs, 133 | ) -> Any: 134 | """Deserialize data. If ``load_instance`` is set to `True` 135 | in the schema meta options, load the data as model instance(s). 136 | 137 | :param data: The data to deserialize. 138 | :param session: SQLAlchemy `session `. 139 | :param instance: Existing model instance to modify. 140 | :param transient: If `True`, load transient model instance(s). 141 | :param kwargs: Same keyword arguments as `marshmallow.Schema.load`. 142 | """ 143 | self._session = session or self._session 144 | self._transient = transient or self._transient 145 | if self._load_instance and not (self.transient or self.session): 146 | raise ValueError("Deserialization requires a session") 147 | self.instance = instance or self.instance 148 | try: 149 | return cast("ma.Schema", super()).load(_cast_data(data), **kwargs) 150 | finally: 151 | self.instance = None 152 | 153 | def validate( 154 | self, 155 | data: _LoadDataT, 156 | *, 157 | session: Session | None = None, 158 | **kwargs, 159 | ) -> dict[str, list[str]]: 160 | """Same as `marshmallow.Schema.validate` but allows passing a ``session``.""" 161 | self._session = session or self._session 162 | if not (self.transient or self.session): 163 | raise ValueError("Validation requires a session") 164 | return cast("ma.Schema", super()).validate(_cast_data(data), **kwargs) 165 | 166 | def _split_model_kwargs_association(self, data): 167 | """Split serialized attrs to ensure association proxies are passed separately. 168 | 169 | This is necessary for Python < 3.6.0, as the order in which kwargs are passed 170 | is non-deterministic, and associations must be parsed by sqlalchemy after their 171 | intermediate relationship, unless their `creator` has been set. 172 | 173 | Ignore invalid keys at this point - behaviour for unknowns should be 174 | handled elsewhere. 175 | 176 | :param data: serialized dictionary of attrs to split on association_proxy. 177 | """ 178 | association_attrs = { 179 | key: value 180 | for key, value in data.items() 181 | # association proxy 182 | if hasattr(getattr(self.opts.model, key, None), "remote_attr") 183 | } 184 | kwargs = { 185 | key: value 186 | for key, value in data.items() 187 | if (hasattr(self.opts.model, key) and key not in association_attrs) 188 | } 189 | return kwargs, association_attrs 190 | -------------------------------------------------------------------------------- /src/marshmallow_sqlalchemy/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marshmallow-code/marshmallow-sqlalchemy/fa29cf454304e1bc0b5d7ee8e02a042be958942a/src/marshmallow_sqlalchemy/py.typed -------------------------------------------------------------------------------- /src/marshmallow_sqlalchemy/schema.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from typing import TYPE_CHECKING, Any, cast 5 | 6 | import sqlalchemy as sa 7 | from marshmallow.fields import Field 8 | from marshmallow.schema import Schema, SchemaMeta, SchemaOpts, _get_fields 9 | 10 | from .convert import ModelConverter 11 | from .exceptions import IncorrectSchemaTypeError 12 | from .load_instance_mixin import LoadInstanceMixin, _ModelType 13 | 14 | if TYPE_CHECKING: 15 | from sqlalchemy.ext.declarative import DeclarativeMeta 16 | 17 | 18 | # This isn't really a field; it's a placeholder for the metaclass. 19 | # This should be considered private API. 20 | class SQLAlchemyAutoField(Field): 21 | def __init__( 22 | self, 23 | *, 24 | column_name: str | None = None, 25 | model: type[DeclarativeMeta] | None = None, 26 | table: sa.Table | None = None, 27 | field_kwargs: dict[str, Any], 28 | ): 29 | super().__init__() 30 | 31 | if model and table: 32 | raise ValueError("Cannot pass both `model` and `table` options.") 33 | 34 | self.column_name = column_name 35 | self.model = model 36 | self.table = table 37 | self.field_kwargs = field_kwargs 38 | 39 | def create_field( 40 | self, 41 | schema_opts: SQLAlchemySchemaOpts, 42 | column_name: str, 43 | converter: ModelConverter, 44 | ): 45 | model = self.model or schema_opts.model 46 | if model: 47 | return converter.field_for(model, column_name, **self.field_kwargs) 48 | table = self.table if self.table is not None else schema_opts.table 49 | column = getattr(cast("sa.Table", table).columns, column_name) 50 | return converter.column2field(column, **self.field_kwargs) 51 | 52 | # This field should never be bound to a schema. 53 | # If this method is called, it's probably because the schema is not a SQLAlchemySchema. 54 | def _bind_to_schema(self, field_name: str, parent: Schema | Field) -> None: 55 | raise IncorrectSchemaTypeError( 56 | f"Cannot bind SQLAlchemyAutoField. Make sure that {parent} is a SQLAlchemySchema or SQLAlchemyAutoSchema." 57 | ) 58 | 59 | 60 | class SQLAlchemySchemaOpts(LoadInstanceMixin.Opts, SchemaOpts): 61 | """Options class for `SQLAlchemySchema`. 62 | Adds the following options: 63 | 64 | - ``model``: The SQLAlchemy model to generate the `Schema` from (mutually exclusive with ``table``). 65 | - ``table``: The SQLAlchemy table to generate the `Schema` from (mutually exclusive with ``model``). 66 | - ``load_instance``: Whether to load model instances. 67 | - ``sqla_session``: SQLAlchemy session to be used for deserialization. 68 | This is only needed when ``load_instance`` is `True`. You can also pass a session to the Schema's `load` method. 69 | - ``transient``: Whether to load model instances in a transient state (effectively ignoring the session). 70 | Only relevant when ``load_instance`` is `True`. 71 | - ``model_converter``: `ModelConverter` class to use for converting the SQLAlchemy model to marshmallow fields. 72 | """ 73 | 74 | table: sa.Table | None 75 | model_converter: type[ModelConverter] 76 | 77 | def __init__(self, meta, *args, **kwargs): 78 | super().__init__(meta, *args, **kwargs) 79 | 80 | self.table = getattr(meta, "table", None) 81 | if self.model is not None and self.table is not None: 82 | raise ValueError("Cannot set both `model` and `table` options.") 83 | self.model_converter = getattr(meta, "model_converter", ModelConverter) 84 | 85 | 86 | class SQLAlchemyAutoSchemaOpts(SQLAlchemySchemaOpts): 87 | """Options class for `SQLAlchemyAutoSchema`. 88 | Has the same options as `SQLAlchemySchemaOpts`, with the addition of: 89 | 90 | - ``include_fk``: Whether to include foreign fields; defaults to `False`. 91 | - ``include_relationships``: Whether to include relationships; defaults to `False`. 92 | """ 93 | 94 | include_fk: bool 95 | include_relationships: bool 96 | 97 | def __init__(self, meta, *args, **kwargs): 98 | super().__init__(meta, *args, **kwargs) 99 | self.include_fk = getattr(meta, "include_fk", False) 100 | self.include_relationships = getattr(meta, "include_relationships", False) 101 | if self.table is not None and self.include_relationships: 102 | raise ValueError("Cannot set `table` and `include_relationships = True`.") 103 | 104 | 105 | class SQLAlchemySchemaMeta(SchemaMeta): 106 | @classmethod 107 | def get_declared_fields( 108 | mcs, 109 | klass, 110 | cls_fields: list[tuple[str, Field]], 111 | inherited_fields: list[tuple[str, Field]], 112 | dict_cls: type[dict] = dict, 113 | ) -> dict[str, Field]: 114 | opts = klass.opts 115 | Converter: type[ModelConverter] = opts.model_converter 116 | converter = Converter(schema_cls=klass) 117 | fields = super().get_declared_fields( 118 | klass, 119 | cls_fields, 120 | # Filter out fields generated from foreign key columns 121 | # if include_fk is set to False in the options 122 | mcs._maybe_filter_foreign_keys(inherited_fields, opts=opts, klass=klass), 123 | dict_cls, 124 | ) 125 | fields.update(mcs.get_declared_sqla_fields(fields, converter, opts, dict_cls)) 126 | fields.update(mcs.get_auto_fields(fields, converter, opts, dict_cls)) 127 | return fields 128 | 129 | @classmethod 130 | def get_declared_sqla_fields( 131 | mcs, 132 | base_fields: dict[str, Field], 133 | converter: ModelConverter, 134 | opts: Any, 135 | dict_cls: type[dict], 136 | ) -> dict[str, Field]: 137 | return {} 138 | 139 | @classmethod 140 | def get_auto_fields( 141 | mcs, 142 | fields: dict[str, Field], 143 | converter: ModelConverter, 144 | opts: Any, 145 | dict_cls: type[dict], 146 | ) -> dict[str, Field]: 147 | return dict_cls( 148 | { 149 | field_name: field.create_field( 150 | opts, field.column_name or field_name, converter 151 | ) 152 | for field_name, field in fields.items() 153 | if isinstance(field, SQLAlchemyAutoField) 154 | and field_name not in opts.exclude 155 | } 156 | ) 157 | 158 | @staticmethod 159 | def _maybe_filter_foreign_keys( 160 | fields: list[tuple[str, Field]], 161 | *, 162 | opts: SQLAlchemySchemaOpts, 163 | klass: SchemaMeta, 164 | ) -> list[tuple[str, Field]]: 165 | if opts.model is not None or opts.table is not None: 166 | if not hasattr(opts, "include_fk") or opts.include_fk is True: 167 | return fields 168 | foreign_keys = { 169 | column.key 170 | for column in sa.inspect(opts.model or opts.table).columns # type: ignore[union-attr] 171 | if column.foreign_keys 172 | } 173 | 174 | non_auto_schema_bases = [ 175 | base 176 | for base in inspect.getmro(klass) 177 | if issubclass(base, Schema) 178 | and not issubclass(base, SQLAlchemyAutoSchema) 179 | ] 180 | 181 | # Pre-compute declared fields only once 182 | declared_fields: set[Any] = set() 183 | for base in non_auto_schema_bases: 184 | base_fields = getattr(base, "_declared_fields", base.__dict__) 185 | declared_fields.update(name for name, _ in _get_fields(base_fields)) 186 | 187 | return [ 188 | (name, field) 189 | for name, field in fields 190 | if name not in foreign_keys or name in declared_fields 191 | ] 192 | return fields 193 | 194 | 195 | class SQLAlchemyAutoSchemaMeta(SQLAlchemySchemaMeta): 196 | @classmethod 197 | def get_declared_sqla_fields( 198 | cls, base_fields, converter: ModelConverter, opts, dict_cls 199 | ): 200 | fields = dict_cls() 201 | if opts.table is not None: 202 | fields.update( 203 | converter.fields_for_table( 204 | opts.table, 205 | fields=opts.fields, 206 | exclude=opts.exclude, 207 | include_fk=opts.include_fk, 208 | base_fields=base_fields, 209 | dict_cls=dict_cls, 210 | ) 211 | ) 212 | elif opts.model is not None: 213 | fields.update( 214 | converter.fields_for_model( 215 | opts.model, 216 | fields=opts.fields, 217 | exclude=opts.exclude, 218 | include_fk=opts.include_fk, 219 | include_relationships=opts.include_relationships, 220 | base_fields=base_fields, 221 | dict_cls=dict_cls, 222 | ) 223 | ) 224 | return fields 225 | 226 | 227 | class SQLAlchemySchema( 228 | LoadInstanceMixin.Schema[_ModelType], Schema, metaclass=SQLAlchemySchemaMeta 229 | ): 230 | """Schema for a SQLAlchemy model or table. 231 | Use together with `auto_field` to generate fields from columns. 232 | 233 | Example: :: 234 | 235 | from marshmallow_sqlalchemy import SQLAlchemySchema, auto_field 236 | 237 | from mymodels import User 238 | 239 | 240 | class UserSchema(SQLAlchemySchema): 241 | class Meta: 242 | model = User 243 | 244 | id = auto_field() 245 | created_at = auto_field(dump_only=True) 246 | name = auto_field() 247 | """ 248 | 249 | OPTIONS_CLASS = SQLAlchemySchemaOpts 250 | 251 | 252 | class SQLAlchemyAutoSchema( 253 | SQLAlchemySchema[_ModelType], metaclass=SQLAlchemyAutoSchemaMeta 254 | ): 255 | """Schema that automatically generates fields from the columns of 256 | a SQLAlchemy model or table. 257 | 258 | Example: :: 259 | 260 | from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field 261 | 262 | from mymodels import User 263 | 264 | 265 | class UserSchema(SQLAlchemyAutoSchema): 266 | class Meta: 267 | model = User 268 | # OR 269 | # table = User.__table__ 270 | 271 | created_at = auto_field(dump_only=True) 272 | """ 273 | 274 | OPTIONS_CLASS = SQLAlchemyAutoSchemaOpts 275 | 276 | 277 | def auto_field( 278 | column_name: str | None = None, 279 | *, 280 | model: type[DeclarativeMeta] | None = None, 281 | table: sa.Table | None = None, 282 | # TODO: add type annotations for **kwargs 283 | **kwargs, 284 | ) -> SQLAlchemyAutoField: 285 | """Mark a field to autogenerate from a model or table. 286 | 287 | :param column_name: Name of the column to generate the field from. 288 | If ``None``, matches the field name. If ``attribute`` is unspecified, 289 | ``attribute`` will be set to the same value as ``column_name``. 290 | :param model: Model to generate the field from. 291 | If ``None``, uses ``model`` specified on ``class Meta``. 292 | :param table: Table to generate the field from. 293 | If ``None``, uses ``table`` specified on ``class Meta``. 294 | :param kwargs: Field argument overrides. 295 | """ 296 | if column_name is not None: 297 | kwargs.setdefault("attribute", column_name) 298 | return SQLAlchemyAutoField( 299 | column_name=column_name, model=model, table=table, field_kwargs=kwargs 300 | ) 301 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marshmallow-code/marshmallow-sqlalchemy/fa29cf454304e1bc0b5d7ee8e02a042be958942a/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime as dt 4 | from dataclasses import dataclass 5 | from enum import Enum 6 | from typing import Any 7 | 8 | import pytest 9 | import sqlalchemy as sa 10 | from sqlalchemy.ext.associationproxy import association_proxy 11 | from sqlalchemy.orm import ( 12 | DeclarativeMeta, 13 | Mapped, 14 | backref, 15 | column_property, 16 | declarative_base, 17 | relationship, 18 | sessionmaker, 19 | synonym, 20 | ) 21 | 22 | mapped_column: Any 23 | try: 24 | from sqlalchemy.orm import mapped_column 25 | except ImportError: # compat with sqlalchemy<2 26 | mapped_column = sa.Column 27 | 28 | 29 | class AnotherInteger(sa.Integer): 30 | """Use me to test if MRO works like we want""" 31 | 32 | 33 | class AnotherText(sa.types.TypeDecorator): 34 | """Use me to test if MRO and `impl` virtual type works like we want""" 35 | 36 | impl = sa.UnicodeText 37 | 38 | 39 | @pytest.fixture 40 | def Base() -> type: 41 | return declarative_base() 42 | 43 | 44 | @pytest.fixture 45 | def engine(): 46 | engine = sa.create_engine("sqlite:///:memory:", echo=False, future=True) 47 | yield engine 48 | engine.dispose() 49 | 50 | 51 | @pytest.fixture 52 | def session(Base, models, engine): 53 | Session = sessionmaker(bind=engine) 54 | Base.metadata.create_all(bind=engine) 55 | with Session() as session: 56 | yield session 57 | 58 | 59 | CourseLevel = Enum("CourseLevel", "PRIMARY SECONDARY") 60 | 61 | 62 | @dataclass 63 | class Models: 64 | Course: type[DeclarativeMeta] 65 | School: type[DeclarativeMeta] 66 | Student: type[DeclarativeMeta] 67 | Teacher: type[DeclarativeMeta] 68 | SubstituteTeacher: type[DeclarativeMeta] 69 | Paper: type[DeclarativeMeta] 70 | GradedPaper: type[DeclarativeMeta] 71 | Seminar: type[DeclarativeMeta] 72 | Lecture: type[DeclarativeMeta] 73 | Keyword: type[DeclarativeMeta] 74 | 75 | 76 | @pytest.fixture 77 | def models(Base: type) -> Models: 78 | # models adapted from https://github.com/wtforms/wtforms-sqlalchemy/blob/master/tests/tests.py 79 | student_course = sa.Table( 80 | "student_course", 81 | Base.metadata, # type: ignore[attr-defined] 82 | sa.Column("student_id", sa.Integer, sa.ForeignKey("student.id")), 83 | sa.Column("course_id", sa.Integer, sa.ForeignKey("course.id")), 84 | ) 85 | 86 | class Course(Base): 87 | __tablename__ = "course" 88 | id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) 89 | name: Mapped[str] = mapped_column(sa.String(255), nullable=False) 90 | # These are for better model form testing 91 | cost: Mapped[float] = mapped_column(sa.Numeric(5, 2), nullable=False) 92 | description: Mapped[str] = mapped_column(sa.Text, nullable=True) 93 | level: Mapped[CourseLevel] = mapped_column(sa.Enum("Primary", "Secondary")) 94 | level_with_enum_class: Mapped[CourseLevel] = mapped_column(sa.Enum(CourseLevel)) 95 | has_prereqs: Mapped[bool] = mapped_column(sa.Boolean, nullable=False) 96 | started: Mapped[dt.datetime] = mapped_column(sa.DateTime, nullable=False) 97 | grade: Mapped[int] = mapped_column(AnotherInteger, nullable=False) 98 | transcription: Mapped[str] = mapped_column(AnotherText, nullable=False) 99 | 100 | @property 101 | def url(self): 102 | return f"/courses/{self.id}" 103 | 104 | class School(Base): 105 | __tablename__ = "school" 106 | id = sa.Column("school_id", sa.Integer, primary_key=True) 107 | name = sa.Column(sa.String(255), nullable=False) 108 | 109 | student_ids = association_proxy( 110 | "students", "id", creator=lambda sid: Student(id=sid) 111 | ) 112 | 113 | @property 114 | def url(self): 115 | return f"/schools/{self.id}" 116 | 117 | class Student(Base): 118 | __tablename__ = "student" 119 | id = sa.Column(sa.Integer, primary_key=True) 120 | full_name = sa.Column(sa.String(255), nullable=False, unique=True) 121 | dob = sa.Column(sa.Date(), nullable=True) 122 | date_created = sa.Column( 123 | sa.DateTime, 124 | default=lambda: dt.datetime.now(dt.timezone.utc), 125 | doc="date the student was created", 126 | ) 127 | 128 | current_school_id = sa.Column( 129 | sa.Integer, sa.ForeignKey(School.id), nullable=False 130 | ) 131 | current_school = relationship(School, backref=backref("students")) 132 | possible_teachers = association_proxy("current_school", "teachers") 133 | 134 | courses = relationship( 135 | Course, 136 | secondary=student_course, 137 | backref=backref("students", lazy="dynamic"), 138 | ) 139 | 140 | # Test complex column property 141 | course_count = column_property( 142 | sa.select(sa.func.count(student_course.c.course_id)) 143 | .where(student_course.c.student_id == id) 144 | .scalar_subquery() 145 | ) 146 | 147 | @property 148 | def url(self): 149 | return f"/students/{self.id}" 150 | 151 | class Teacher(Base): 152 | __tablename__ = "teacher" 153 | id = sa.Column(sa.Integer, primary_key=True) 154 | 155 | full_name = sa.Column( 156 | sa.String(255), nullable=False, unique=True, default="Mr. Noname" 157 | ) 158 | 159 | current_school_id = sa.Column( 160 | sa.Integer, sa.ForeignKey(School.id), nullable=True 161 | ) 162 | current_school = relationship(School, backref=backref("teachers")) 163 | curr_school_id = synonym("current_school_id") 164 | 165 | substitute = relationship("SubstituteTeacher", uselist=False, backref="teacher") 166 | 167 | data = sa.Column(sa.PickleType) 168 | 169 | @property 170 | def fname(self): 171 | return self.full_name 172 | 173 | class SubstituteTeacher(Base): 174 | __tablename__ = "substituteteacher" 175 | id = sa.Column(sa.Integer, sa.ForeignKey("teacher.id"), primary_key=True) 176 | 177 | class Paper(Base): 178 | __tablename__ = "paper" 179 | 180 | satype = sa.Column(sa.String(50)) 181 | __mapper_args__ = {"polymorphic_identity": "paper", "polymorphic_on": satype} 182 | 183 | id = sa.Column(sa.Integer, primary_key=True) 184 | name = sa.Column(sa.String, nullable=False, unique=True) 185 | 186 | class GradedPaper(Paper): 187 | __tablename__ = "gradedpaper" 188 | 189 | __mapper_args__ = {"polymorphic_identity": "gradedpaper"} 190 | 191 | id = sa.Column(sa.Integer, sa.ForeignKey("paper.id"), primary_key=True) 192 | 193 | marks_available = sa.Column(sa.Integer) 194 | 195 | class Seminar(Base): 196 | __tablename__ = "seminar" 197 | 198 | title = sa.Column(sa.String, primary_key=True) 199 | semester = sa.Column(sa.String, primary_key=True) 200 | 201 | label = column_property(title + ": " + semester) 202 | 203 | lecturekeywords_table = sa.Table( 204 | "lecturekeywords", 205 | Base.metadata, # type: ignore[attr-defined] 206 | sa.Column("keyword_id", sa.Integer, sa.ForeignKey("keyword.id")), 207 | sa.Column("lecture_id", sa.Integer, sa.ForeignKey("lecture.id")), 208 | ) 209 | 210 | class Keyword(Base): 211 | __tablename__ = "keyword" 212 | 213 | id = sa.Column(sa.Integer, primary_key=True) 214 | keyword = sa.Column(sa.String) 215 | 216 | class Lecture(Base): 217 | __tablename__ = "lecture" 218 | __table_args__ = ( 219 | sa.ForeignKeyConstraint( 220 | ["seminar_title", "seminar_semester"], 221 | ["seminar.title", "seminar.semester"], 222 | ), 223 | ) 224 | 225 | id = sa.Column(sa.Integer, primary_key=True) 226 | topic = sa.Column(sa.String) 227 | seminar_title = sa.Column(sa.String, sa.ForeignKey(Seminar.title)) 228 | seminar_semester = sa.Column(sa.String, sa.ForeignKey(Seminar.semester)) 229 | seminar = relationship( 230 | Seminar, foreign_keys=[seminar_title, seminar_semester], backref="lectures" 231 | ) 232 | kw = relationship("Keyword", secondary=lecturekeywords_table) 233 | keywords = association_proxy( 234 | "kw", "keyword", creator=lambda kw: Keyword(keyword=kw) 235 | ) 236 | 237 | return Models( 238 | Course=Course, 239 | School=School, 240 | Student=Student, 241 | Teacher=Teacher, 242 | SubstituteTeacher=SubstituteTeacher, 243 | Paper=Paper, 244 | GradedPaper=GradedPaper, 245 | Seminar=Seminar, 246 | Lecture=Lecture, 247 | Keyword=Keyword, 248 | ) 249 | -------------------------------------------------------------------------------- /tests/test_conversion.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import decimal 3 | import uuid 4 | from typing import cast 5 | 6 | import pytest 7 | import sqlalchemy as sa 8 | from marshmallow import Schema, fields, validate 9 | from sqlalchemy import Integer, String 10 | from sqlalchemy.dialects import mysql, postgresql 11 | from sqlalchemy.orm import Mapped, Session, column_property 12 | 13 | from marshmallow_sqlalchemy import ( 14 | ModelConversionError, 15 | ModelConverter, 16 | column2field, 17 | field_for, 18 | fields_for_model, 19 | property2field, 20 | ) 21 | from marshmallow_sqlalchemy.fields import Related, RelatedList 22 | 23 | from .conftest import CourseLevel, mapped_column 24 | 25 | 26 | def contains_validator(field, v_type): 27 | for v in field.validators: 28 | if isinstance(v, v_type): 29 | return v 30 | return False 31 | 32 | 33 | class TestModelFieldConversion: 34 | def test_fields_for_model_types(self, models): 35 | fields_ = fields_for_model(models.Student, include_fk=True) 36 | assert type(fields_["id"]) is fields.Int 37 | assert type(fields_["full_name"]) is fields.Str 38 | assert type(fields_["dob"]) is fields.Date 39 | assert type(fields_["current_school_id"]) is fields.Int 40 | assert type(fields_["date_created"]) is fields.DateTime 41 | 42 | def test_fields_for_model_handles_exclude(self, models): 43 | fields_ = fields_for_model(models.Student, exclude=("dob",)) 44 | assert type(fields_["id"]) is fields.Int 45 | assert type(fields_["full_name"]) is fields.Str 46 | assert fields_["dob"] is None 47 | 48 | def test_fields_for_model_handles_custom_types(self, models): 49 | fields_ = fields_for_model(models.Course, include_fk=True) 50 | assert type(fields_["grade"]) is fields.Int 51 | assert type(fields_["transcription"]) is fields.Str 52 | 53 | def test_fields_for_model_saves_doc(self, models): 54 | fields_ = fields_for_model(models.Student, include_fk=True) 55 | assert ( 56 | fields_["date_created"].metadata["description"] 57 | == "date the student was created" 58 | ) 59 | 60 | def test_length_validator_set(self, models): 61 | fields_ = fields_for_model(models.Student) 62 | validator = contains_validator(fields_["full_name"], validate.Length) 63 | assert validator 64 | assert validator.max == 255 65 | 66 | def test_none_length_validator_not_set(self, models): 67 | fields_ = fields_for_model(models.Course) 68 | assert not contains_validator(fields_["transcription"], validate.Length) 69 | 70 | def test_sets_allow_none_for_nullable_fields(self, models): 71 | fields_ = fields_for_model(models.Student) 72 | assert fields_["dob"].allow_none is True 73 | 74 | def test_enum_with_choices_converted_to_field_with_validator(self, models): 75 | fields_ = fields_for_model(models.Course) 76 | validator = contains_validator(fields_["level"], validate.OneOf) 77 | assert validator 78 | assert list(validator.choices) == ["Primary", "Secondary"] 79 | 80 | def test_enum_with_class_converted_to_enum_field(self, models): 81 | fields_ = fields_for_model(models.Course) 82 | field = fields_["level_with_enum_class"] 83 | assert type(field) is fields.Enum 84 | assert contains_validator(field, validate.OneOf) is False 85 | assert field.enum is CourseLevel 86 | 87 | def test_many_to_many_relationship(self, models): 88 | student_fields = fields_for_model(models.Student, include_relationships=True) 89 | courses_field = student_fields["courses"] 90 | assert type(courses_field) is RelatedList 91 | assert courses_field.required is False 92 | 93 | course_fields = fields_for_model(models.Course, include_relationships=True) 94 | students_field = course_fields["students"] 95 | assert type(students_field) is RelatedList 96 | assert students_field.required is False 97 | 98 | def test_many_to_one_relationship(self, models): 99 | student_fields = fields_for_model(models.Student, include_relationships=True) 100 | current_school_field = student_fields["current_school"] 101 | assert type(current_school_field) is Related 102 | assert current_school_field.allow_none is False 103 | assert current_school_field.required is True 104 | 105 | school_fields = fields_for_model(models.School, include_relationships=True) 106 | assert type(school_fields["students"]) is RelatedList 107 | 108 | teacher_fields = fields_for_model(models.Teacher, include_relationships=True) 109 | current_school_field = teacher_fields["current_school"] 110 | assert type(current_school_field) is Related 111 | assert current_school_field.required is False 112 | 113 | def test_many_to_many_uselist_false_relationship(self, models): 114 | teacher_fields = fields_for_model(models.Teacher, include_relationships=True) 115 | substitute_field = teacher_fields["substitute"] 116 | assert type(substitute_field) is Related 117 | assert substitute_field.required is False 118 | 119 | def test_include_fk(self, models): 120 | student_fields = fields_for_model(models.Student, include_fk=False) 121 | assert "current_school_id" not in student_fields 122 | 123 | student_fields2 = fields_for_model(models.Student, include_fk=True) 124 | assert "current_school_id" in student_fields2 125 | 126 | def test_overridden_with_fk(self, models): 127 | graded_paper_fields = fields_for_model(models.GradedPaper, include_fk=False) 128 | assert "id" in graded_paper_fields 129 | 130 | def test_rename_key(self, models): 131 | class RenameConverter(ModelConverter): 132 | def _get_field_name(self, prop): 133 | if prop.key == "name": 134 | return "title" 135 | return prop.key 136 | 137 | converter = RenameConverter() 138 | fields = converter.fields_for_model(models.Paper) 139 | assert "title" in fields 140 | assert "name" not in fields 141 | 142 | def test_subquery_proxies(self, session: Session, Base: type, models): 143 | # Model from a subquery, columns are proxied. 144 | # https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues/383 145 | first_graders = session.query(models.Student).filter( 146 | models.Student.courses.any(models.Course.grade == 1) 147 | ) 148 | 149 | class FirstGradeStudent(Base): 150 | __table__ = first_graders.subquery("first_graders") 151 | 152 | fields_ = fields_for_model(FirstGradeStudent) 153 | assert fields_["dob"].allow_none is True 154 | 155 | 156 | def make_property(*column_args, **column_kwargs): 157 | return column_property(sa.Column(*column_args, **column_kwargs)) 158 | 159 | 160 | class TestPropertyFieldConversion: 161 | @pytest.fixture 162 | def converter(self): 163 | return ModelConverter() 164 | 165 | def test_convert_custom_type_mapping_on_schema(self): 166 | class MyDateTimeField(fields.DateTime): 167 | pass 168 | 169 | class MySchema(Schema): 170 | TYPE_MAPPING = Schema.TYPE_MAPPING.copy() 171 | TYPE_MAPPING.update({dt.datetime: MyDateTimeField}) 172 | 173 | converter = ModelConverter(schema_cls=MySchema) 174 | prop = make_property(sa.DateTime()) 175 | field = converter.property2field(prop) 176 | assert type(field) is MyDateTimeField 177 | 178 | @pytest.mark.parametrize( 179 | ("sa_type", "field_type"), 180 | ( 181 | (sa.String, fields.Str), 182 | (sa.Unicode, fields.Str), 183 | (sa.LargeBinary, fields.Str), 184 | (sa.Text, fields.Str), 185 | (sa.Date, fields.Date), 186 | (sa.DateTime, fields.DateTime), 187 | (sa.Boolean, fields.Bool), 188 | (sa.Float, fields.Float), 189 | (sa.SmallInteger, fields.Int), 190 | (sa.Interval, fields.TimeDelta), 191 | (postgresql.UUID, fields.UUID), 192 | (postgresql.MACADDR, fields.Str), 193 | (postgresql.INET, fields.Str), 194 | (postgresql.BIT, fields.Integer), 195 | (postgresql.OID, fields.Integer), 196 | (postgresql.CIDR, fields.String), 197 | (postgresql.DATE, fields.Date), 198 | (postgresql.TIME, fields.Time), 199 | (mysql.INTEGER, fields.Integer), 200 | (mysql.DATETIME, fields.DateTime), 201 | ), 202 | ) 203 | def test_convert_types(self, converter, sa_type, field_type): 204 | prop = make_property(sa_type()) 205 | field = converter.property2field(prop) 206 | assert type(field) is field_type 207 | 208 | def test_convert_Numeric(self, converter): 209 | prop = make_property(sa.Numeric(scale=2)) 210 | field = converter.property2field(prop) 211 | assert type(field) is fields.Decimal 212 | assert field.places == decimal.Decimal((0, (1,), -2)) 213 | 214 | def test_convert_ARRAY_String(self, converter): 215 | prop = make_property(postgresql.ARRAY(sa.String())) 216 | field = converter.property2field(prop) 217 | assert type(field) is fields.List 218 | inner_field = getattr(field, "inner", getattr(field, "container", None)) 219 | assert type(inner_field) is fields.Str 220 | 221 | def test_convert_ARRAY_Integer(self, converter): 222 | prop = make_property(postgresql.ARRAY(sa.Integer)) 223 | field = converter.property2field(prop) 224 | assert type(field) is fields.List 225 | inner_field = getattr(field, "inner", getattr(field, "container", None)) 226 | assert type(inner_field) is fields.Int 227 | 228 | @pytest.mark.parametrize( 229 | "array_property", 230 | ( 231 | pytest.param(make_property(sa.ARRAY(sa.Enum(CourseLevel))), id="sa.ARRAY"), 232 | pytest.param( 233 | make_property(postgresql.ARRAY(sa.Enum(CourseLevel))), 234 | id="postgresql.ARRAY", 235 | ), 236 | ), 237 | ) 238 | def test_convert_ARRAY_Enum(self, converter, array_property): 239 | field = converter.property2field(array_property) 240 | assert type(field) is fields.List 241 | inner_field = field.inner 242 | assert type(inner_field) is fields.Enum 243 | 244 | @pytest.mark.parametrize( 245 | "array_property", 246 | ( 247 | pytest.param( 248 | make_property(sa.ARRAY(sa.Float, dimensions=2)), id="sa.ARRAY" 249 | ), 250 | pytest.param( 251 | make_property(postgresql.ARRAY(sa.Float, dimensions=2)), 252 | id="postgresql.ARRAY", 253 | ), 254 | ), 255 | ) 256 | def test_convert_multidimensional_ARRAY(self, converter, array_property): 257 | field = converter.property2field(array_property) 258 | assert type(field) is fields.List 259 | assert type(field.inner) is fields.List 260 | assert type(field.inner.inner) is fields.Float 261 | 262 | def test_convert_one_dimensional_ARRAY(self, converter): 263 | prop = make_property(postgresql.ARRAY(sa.Float, dimensions=1)) 264 | field = converter.property2field(prop) 265 | assert type(field) is fields.List 266 | assert type(field.inner) is fields.Float 267 | 268 | def test_convert_TSVECTOR(self, converter): 269 | prop = make_property(postgresql.TSVECTOR) 270 | with pytest.raises(ModelConversionError): 271 | converter.property2field(prop) 272 | 273 | def test_convert_default(self, converter): 274 | prop = make_property(sa.String, default="ack") 275 | field = converter.property2field(prop) 276 | assert field.required is False 277 | 278 | def test_convert_server_default(self, converter): 279 | prop = make_property(sa.String, server_default=sa.text("sysdate")) 280 | field = converter.property2field(prop) 281 | assert field.required is False 282 | 283 | def test_convert_autoincrement(self, models, converter): 284 | prop = models.Course.__mapper__.attrs.get("id") 285 | field = converter.property2field(prop) 286 | assert field.required is False 287 | 288 | def test_handle_expression_based_column_property(self, models, converter): 289 | """ 290 | Tests ability to handle a column_property with a mapped expression value. 291 | Such properties should be marked as dump_only, and the type should be properly 292 | inferred. 293 | """ 294 | prop = models.Student.__mapper__.attrs.get("course_count") 295 | field = converter.property2field(prop) 296 | assert type(field) is fields.Integer 297 | assert field.dump_only is True 298 | 299 | def test_handle_simple_column_property(self, models, converter): 300 | """ 301 | Tests handling of column properties that do not derive directly from Column 302 | """ 303 | prop = models.Seminar.__mapper__.attrs.get("label") 304 | field = converter.property2field(prop) 305 | assert type(field) is fields.String 306 | assert field.dump_only is True 307 | 308 | 309 | class TestPropToFieldClass: 310 | def test_property2field(self): 311 | prop = make_property(sa.Integer()) 312 | field = property2field(prop, instance=True) 313 | 314 | assert type(field) is fields.Int 315 | 316 | field_cls = property2field(prop, instance=False) 317 | assert field_cls is fields.Int 318 | 319 | def test_can_pass_extra_kwargs(self): 320 | prop = make_property(sa.String()) 321 | field = property2field( 322 | prop, instance=True, metadata=dict(description="just a string") 323 | ) 324 | assert field.metadata["description"] == "just a string" 325 | 326 | 327 | class TestColumnToFieldClass: 328 | def test_column2field(self): 329 | column = sa.Column(sa.String(255)) 330 | field = column2field(column, instance=True) 331 | 332 | assert type(field) is fields.String 333 | 334 | field_cls = column2field(column, instance=False) 335 | assert field_cls is fields.String 336 | 337 | def test_can_pass_extra_kwargs(self): 338 | column = sa.Column(sa.String(255)) 339 | field = column2field( 340 | column, instance=True, metadata=dict(description="just a string") 341 | ) 342 | assert field.metadata["description"] == "just a string" 343 | 344 | def test_uuid_column2field(self): 345 | class UUIDType(sa.types.TypeDecorator): 346 | python_type = uuid.UUID 347 | impl = sa.BINARY(16) 348 | 349 | column = sa.Column(UUIDType) 350 | assert issubclass(column.type.python_type, uuid.UUID) # Test against test check 351 | assert hasattr(column.type, "length") # Test against test check 352 | assert column.type.length == 16 # Test against test 353 | field = column2field(column, instance=True) 354 | 355 | uuid_val = uuid.uuid4() 356 | assert field.deserialize(str(uuid_val)) == uuid_val 357 | 358 | 359 | class TestFieldFor: 360 | def test_field_for(self, models): 361 | field = field_for(models.Student, "full_name") 362 | assert type(field) is fields.Str 363 | 364 | field = field_for(models.Student, "current_school") 365 | assert type(field) is Related 366 | 367 | field = field_for(models.Student, "full_name", field_class=fields.Date) 368 | assert type(field) is fields.Date 369 | 370 | def test_related_initialization_warning(self, models, session): 371 | with pytest.warns( 372 | DeprecationWarning, 373 | match="column` parameter is deprecated and will be removed in future releases. Use `columns` instead.", 374 | ): 375 | Related(column="TestCol") 376 | 377 | def test_related_initialization_with_columns(self, models, session): 378 | ret = Related(columns=["TestCol"]) 379 | assert len(ret.columns) == 1 380 | assert ret.columns[0] == "TestCol" 381 | ret = Related(columns="TestCol") 382 | assert isinstance(ret.columns, list) 383 | assert len(ret.columns) == 1 384 | assert ret.columns[0] == "TestCol" 385 | 386 | def test_field_for_can_override_validators(self, models, session): 387 | field = field_for( 388 | models.Student, "full_name", validate=[validate.Length(max=20)] 389 | ) 390 | assert len(field.validators) == 1 391 | validator = cast("validate.Length", field.validators[0]) 392 | assert validator.max == 20 393 | 394 | field = field_for(models.Student, "full_name", validate=[]) 395 | assert field.validators == [] 396 | 397 | def tests_postgresql_array_with_args(self, Base: type): 398 | # regression test for #392 399 | class ModelWithArray(Base): 400 | __tablename__ = "model_with_array" 401 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 402 | bar: Mapped[list[str]] = mapped_column(postgresql.ARRAY(String)) 403 | 404 | field = field_for(ModelWithArray, "bar", dump_only=True) 405 | assert type(field) is fields.List 406 | assert field.dump_only is True 407 | -------------------------------------------------------------------------------- /tests/test_sqlalchemy_schema.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from importlib.metadata import version 4 | 5 | import marshmallow 6 | import pytest 7 | import sqlalchemy as sa 8 | from marshmallow import Schema, ValidationError, fields, validate 9 | from pytest_lazy_fixtures import lf 10 | 11 | from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, SQLAlchemySchema, auto_field 12 | from marshmallow_sqlalchemy.exceptions import IncorrectSchemaTypeError 13 | from marshmallow_sqlalchemy.fields import Related 14 | 15 | # ----------------------------------------------------------------------------- 16 | 17 | 18 | @pytest.fixture 19 | def teacher(models, session): 20 | school = models.School(id=42, name="Univ. Of Whales") 21 | teacher_ = models.Teacher( 22 | id=24, full_name="Teachy McTeachFace", current_school=school 23 | ) 24 | session.add(teacher_) 25 | session.flush() 26 | return teacher_ 27 | 28 | 29 | @pytest.fixture 30 | def school(models, session): 31 | school = models.School(id=42, name="Univ. Of Whales") 32 | students = [ 33 | models.Student(id=35, full_name="Bob Smith", current_school=school), 34 | models.Student(id=53, full_name="John Johnson", current_school=school), 35 | ] 36 | 37 | session.add_all(students) 38 | session.flush() 39 | return school 40 | 41 | 42 | class EntityMixin: 43 | id = auto_field(dump_only=True) 44 | 45 | 46 | # Auto schemas with default options 47 | 48 | 49 | @pytest.fixture 50 | def sqla_auto_model_schema(models, request) -> SQLAlchemyAutoSchema: 51 | class TeacherSchema(SQLAlchemyAutoSchema[models.Teacher]): 52 | class Meta: 53 | model = models.Teacher 54 | 55 | full_name = auto_field(validate=validate.Length(max=20)) 56 | 57 | return TeacherSchema() 58 | 59 | 60 | @pytest.fixture 61 | def sqla_auto_table_schema(models, request) -> SQLAlchemyAutoSchema: 62 | class TeacherSchema(SQLAlchemyAutoSchema): 63 | class Meta: 64 | table = models.Teacher.__table__ 65 | 66 | full_name = auto_field(validate=validate.Length(max=20)) 67 | 68 | return TeacherSchema() 69 | 70 | 71 | # Schemas with relationships 72 | 73 | 74 | @pytest.fixture 75 | def sqla_schema_with_relationships(models, request) -> SQLAlchemySchema: 76 | class TeacherSchema(EntityMixin, SQLAlchemySchema[models.Teacher]): 77 | class Meta: 78 | model = models.Teacher 79 | 80 | full_name = auto_field(validate=validate.Length(max=20)) 81 | current_school = auto_field() 82 | substitute = auto_field() 83 | data = auto_field() 84 | 85 | return TeacherSchema() 86 | 87 | 88 | @pytest.fixture 89 | def sqla_auto_model_schema_with_relationships(models, request) -> SQLAlchemyAutoSchema: 90 | class TeacherSchema(SQLAlchemyAutoSchema[models.Teacher]): 91 | class Meta: 92 | model = models.Teacher 93 | include_relationships = True 94 | 95 | full_name = auto_field(validate=validate.Length(max=20)) 96 | 97 | return TeacherSchema() 98 | 99 | 100 | # Schemas with foreign keys 101 | 102 | 103 | @pytest.fixture 104 | def sqla_schema_with_fks(models, request) -> SQLAlchemySchema: 105 | class TeacherSchema(EntityMixin, SQLAlchemySchema[models.Teacher]): 106 | class Meta: 107 | model = models.Teacher 108 | 109 | full_name = auto_field(validate=validate.Length(max=20)) 110 | current_school_id = auto_field() 111 | data = auto_field() 112 | 113 | return TeacherSchema() 114 | 115 | 116 | @pytest.fixture 117 | def sqla_auto_model_schema_with_fks(models, request) -> SQLAlchemyAutoSchema: 118 | class TeacherSchema(SQLAlchemyAutoSchema[models.Teacher]): 119 | class Meta: 120 | model = models.Teacher 121 | include_fk = True 122 | include_relationships = False 123 | 124 | full_name = auto_field(validate=validate.Length(max=20)) 125 | 126 | return TeacherSchema() 127 | 128 | 129 | # ----------------------------------------------------------------------------- 130 | 131 | 132 | @pytest.mark.parametrize( 133 | "schema", 134 | ( 135 | lf("sqla_schema_with_relationships"), 136 | lf("sqla_auto_model_schema_with_relationships"), 137 | ), 138 | ) 139 | def test_dump_with_relationships(teacher, schema): 140 | assert schema.dump(teacher) == { 141 | "id": teacher.id, 142 | "full_name": teacher.full_name, 143 | "current_school": 42, 144 | "substitute": None, 145 | "data": None, 146 | } 147 | 148 | 149 | @pytest.mark.parametrize( 150 | "schema", 151 | ( 152 | lf("sqla_schema_with_fks"), 153 | lf("sqla_auto_model_schema_with_fks"), 154 | ), 155 | ) 156 | def test_dump_with_foreign_keys(teacher, schema): 157 | assert schema.dump(teacher) == { 158 | "id": teacher.id, 159 | "full_name": teacher.full_name, 160 | "current_school_id": 42, 161 | "data": None, 162 | } 163 | 164 | 165 | def test_table_schema_dump(teacher, sqla_auto_table_schema): 166 | assert sqla_auto_table_schema.dump(teacher) == { 167 | "id": teacher.id, 168 | "full_name": teacher.full_name, 169 | "data": None, 170 | } 171 | 172 | 173 | @pytest.mark.parametrize( 174 | "schema", 175 | ( 176 | lf("sqla_schema_with_relationships"), 177 | lf("sqla_schema_with_fks"), 178 | lf("sqla_auto_model_schema"), 179 | lf("sqla_auto_table_schema"), 180 | ), 181 | ) 182 | def test_load(schema): 183 | assert schema.load({"full_name": "Teachy T"}) == {"full_name": "Teachy T"} 184 | 185 | 186 | class TestLoadInstancePerSchemaInstance: 187 | @pytest.fixture 188 | def schema_no_load_instance(self, models, session): 189 | class TeacherSchema(SQLAlchemySchema[models.Teacher]): # type: ignore[name-defined] 190 | class Meta: 191 | model = models.Teacher 192 | sqla_session = session 193 | # load_instance = False is the default 194 | 195 | full_name = auto_field(validate=validate.Length(max=20)) 196 | current_school = auto_field() 197 | substitute = auto_field() 198 | 199 | return TeacherSchema 200 | 201 | @pytest.fixture 202 | def schema_with_load_instance(self, schema_no_load_instance: type): 203 | class TeacherSchema(schema_no_load_instance): 204 | class Meta(schema_no_load_instance.Meta): # type: ignore[name-defined] 205 | load_instance = True 206 | 207 | return TeacherSchema 208 | 209 | @pytest.fixture 210 | def auto_schema_no_load_instance(self, models, session): 211 | class TeacherSchema(SQLAlchemyAutoSchema[models.Teacher]): # type: ignore[name-defined] 212 | class Meta: 213 | model = models.Teacher 214 | sqla_session = session 215 | # load_instance = False is the default 216 | 217 | return TeacherSchema 218 | 219 | @pytest.fixture 220 | def auto_schema_with_load_instance(self, auto_schema_no_load_instance: type): 221 | class TeacherSchema(auto_schema_no_load_instance): 222 | class Meta(auto_schema_no_load_instance.Meta): # type: ignore[name-defined] 223 | load_instance = True 224 | 225 | return TeacherSchema 226 | 227 | @pytest.mark.parametrize( 228 | "Schema", 229 | ( 230 | lf("schema_no_load_instance"), 231 | lf("schema_with_load_instance"), 232 | lf("auto_schema_no_load_instance"), 233 | lf("auto_schema_with_load_instance"), 234 | ), 235 | ) 236 | def test_toggle_load_instance_per_schema(self, models, Schema): 237 | tname = "Teachy T" 238 | source = {"full_name": tname} 239 | 240 | # No per-instance override 241 | load_instance_default = Schema() 242 | result = load_instance_default.load(source) 243 | default = load_instance_default.opts.load_instance 244 | 245 | default_type = models.Teacher if default else dict 246 | assert isinstance(result, default_type) 247 | 248 | # Override the default 249 | override = Schema(load_instance=not default) 250 | result = override.load(source) 251 | 252 | override_type = dict if default else models.Teacher 253 | assert isinstance(result, override_type) 254 | 255 | 256 | @pytest.mark.parametrize( 257 | "schema", 258 | ( 259 | lf("sqla_schema_with_relationships"), 260 | lf("sqla_schema_with_fks"), 261 | lf("sqla_auto_model_schema"), 262 | lf("sqla_auto_table_schema"), 263 | ), 264 | ) 265 | def test_load_validation_errors(schema): 266 | with pytest.raises(ValidationError): 267 | schema.load({"full_name": "x" * 21}) 268 | 269 | 270 | def test_auto_field_on_plain_schema_raises_error(): 271 | class BadSchema(Schema): 272 | name = auto_field() 273 | 274 | with pytest.raises(IncorrectSchemaTypeError): 275 | BadSchema() 276 | 277 | 278 | def test_cannot_set_both_model_and_table(models): 279 | with pytest.raises(ValueError, match="Cannot set both"): 280 | 281 | class BadWidgetSchema(SQLAlchemySchema): 282 | class Meta: 283 | model = models.Teacher 284 | table = models.Teacher 285 | 286 | 287 | def test_passing_model_to_auto_field(models, teacher): 288 | class TeacherSchema(SQLAlchemySchema): 289 | current_school_id = auto_field(model=models.Teacher) 290 | 291 | schema = TeacherSchema() 292 | assert schema.dump(teacher) == {"current_school_id": teacher.current_school_id} 293 | 294 | 295 | def test_passing_table_to_auto_field(models, teacher): 296 | class TeacherSchema(SQLAlchemySchema): 297 | current_school_id = auto_field(table=models.Teacher.__table__) 298 | 299 | schema = TeacherSchema() 300 | assert schema.dump(teacher) == {"current_school_id": teacher.current_school_id} 301 | 302 | 303 | # https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues/190 304 | def test_auto_schema_skips_synonyms(models): 305 | class TeacherSchema(SQLAlchemyAutoSchema[models.Teacher]): # type: ignore[name-defined] 306 | class Meta: 307 | model = models.Teacher 308 | include_fk = True 309 | 310 | schema = TeacherSchema() 311 | assert "current_school_id" in schema.fields 312 | assert "curr_school_id" not in schema.fields 313 | 314 | 315 | def test_auto_field_works_with_synonym(models): 316 | class TeacherSchema(SQLAlchemyAutoSchema): 317 | class Meta: 318 | model = models.Teacher 319 | include_fk = True 320 | 321 | curr_school_id = auto_field() 322 | 323 | schema = TeacherSchema() 324 | assert "current_school_id" in schema.fields 325 | assert "curr_school_id" in schema.fields 326 | 327 | 328 | # Regresion test https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues/306 329 | def test_auto_field_works_with_ordered_flag(models): 330 | class StudentSchema(SQLAlchemyAutoSchema[models.Student]): # type: ignore[name-defined] 331 | class Meta: 332 | model = models.Student 333 | ordered = True 334 | 335 | full_name = auto_field() 336 | 337 | schema = StudentSchema() 338 | # Declared fields precede auto-generated fields 339 | assert tuple(schema.fields.keys()) == ( 340 | "full_name", 341 | "course_count", 342 | "id", 343 | "dob", 344 | "date_created", 345 | ) 346 | 347 | 348 | class TestAliasing: 349 | @pytest.fixture 350 | def aliased_schema(self, models): 351 | class TeacherSchema(SQLAlchemySchema): 352 | class Meta: 353 | model = models.Teacher 354 | 355 | # Generate field from "full_name", pull from "full_name" attribute, output to "name" 356 | name = auto_field("full_name") 357 | 358 | return TeacherSchema() 359 | 360 | @pytest.fixture 361 | def aliased_auto_schema(self, models): 362 | class TeacherSchema(SQLAlchemyAutoSchema): 363 | class Meta: 364 | model = models.Teacher 365 | exclude = ("full_name",) 366 | 367 | # Generate field from "full_name", pull from "full_name" attribute, output to "name" 368 | name = auto_field("full_name") 369 | 370 | return TeacherSchema() 371 | 372 | @pytest.fixture 373 | def aliased_attribute_schema(self, models): 374 | class TeacherSchema(SQLAlchemySchema): 375 | class Meta: 376 | model = models.Teacher 377 | 378 | # Generate field from "full_name", pull from "fname" attribute, output to "name" 379 | name = auto_field("full_name", attribute="fname") 380 | 381 | return TeacherSchema() 382 | 383 | @pytest.mark.parametrize( 384 | "schema", 385 | ( 386 | lf("aliased_schema"), 387 | lf("aliased_auto_schema"), 388 | ), 389 | ) 390 | def test_passing_column_name(self, schema, teacher): 391 | assert schema.fields["name"].attribute == "full_name" 392 | dumped = schema.dump(teacher) 393 | assert dumped["name"] == teacher.full_name 394 | 395 | def test_passing_column_name_and_attribute(self, teacher, aliased_attribute_schema): 396 | assert aliased_attribute_schema.fields["name"].attribute == "fname" 397 | dumped = aliased_attribute_schema.dump(teacher) 398 | assert dumped["name"] == teacher.fname 399 | 400 | 401 | class TestModelInstanceDeserialization: 402 | @pytest.fixture 403 | def sqla_schema_class(self, models, session): 404 | class TeacherSchema(SQLAlchemySchema): 405 | class Meta: 406 | model = models.Teacher 407 | load_instance = True 408 | sqla_session = session 409 | 410 | full_name = auto_field(validate=validate.Length(max=20)) 411 | current_school = auto_field() 412 | substitute = auto_field() 413 | 414 | return TeacherSchema 415 | 416 | @pytest.fixture 417 | def sqla_auto_schema_class(self, models, session): 418 | class TeacherSchema(SQLAlchemyAutoSchema): 419 | class Meta: 420 | model = models.Teacher 421 | include_relationships = True 422 | load_instance = True 423 | sqla_session = session 424 | 425 | return TeacherSchema 426 | 427 | @pytest.mark.parametrize( 428 | "SchemaClass", 429 | ( 430 | lf("sqla_schema_class"), 431 | lf("sqla_auto_schema_class"), 432 | ), 433 | ) 434 | def test_load(self, teacher, SchemaClass, models): 435 | schema = SchemaClass(unknown=marshmallow.INCLUDE) 436 | dump_data = schema.dump(teacher) 437 | load_data = schema.load(dump_data) 438 | 439 | assert isinstance(load_data, models.Teacher) 440 | 441 | def test_load_transient(self, models, teacher): 442 | class TeacherSchema(SQLAlchemyAutoSchema): 443 | class Meta: 444 | model = models.Teacher 445 | load_instance = True 446 | transient = True 447 | 448 | schema = TeacherSchema() 449 | dump_data = schema.dump(teacher) 450 | load_data = schema.load(dump_data) 451 | assert isinstance(load_data, models.Teacher) 452 | state = sa.inspect(load_data) 453 | assert state.transient 454 | 455 | def test_override_transient(self, models, teacher): 456 | # marshmallow-code/marshmallow-sqlalchemy#388 457 | class TeacherSchema(SQLAlchemyAutoSchema): 458 | class Meta: 459 | model = models.Teacher 460 | load_instance = True 461 | transient = True 462 | 463 | schema = TeacherSchema(transient=False) 464 | assert schema.transient is False 465 | 466 | 467 | def test_related_when_model_attribute_name_distinct_from_column_name( 468 | models, 469 | session, 470 | teacher, 471 | ): 472 | class TeacherSchema(SQLAlchemyAutoSchema): 473 | class Meta: 474 | model = models.Teacher 475 | load_instance = True 476 | sqla_session = session 477 | 478 | current_school = Related(["id", "name"]) 479 | 480 | dump_data = TeacherSchema().dump(teacher) 481 | assert "school_id" not in dump_data["current_school"] 482 | assert dump_data["current_school"]["id"] == teacher.current_school.id 483 | assert dump_data["current_school"]["name"] == teacher.current_school.name 484 | new_teacher = TeacherSchema().load(dump_data, transient=True) 485 | assert new_teacher.current_school.id == teacher.current_school.id 486 | assert TeacherSchema().load(dump_data) is teacher 487 | 488 | 489 | # https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues/338 490 | def test_auto_field_works_with_assoc_proxy(models): 491 | class StudentSchema(SQLAlchemySchema): 492 | class Meta: 493 | model = models.Student 494 | 495 | possible_teachers = auto_field() 496 | 497 | schema = StudentSchema() 498 | assert "possible_teachers" in schema.fields 499 | 500 | 501 | def test_dump_and_load_with_assoc_proxy_multiplicity(models, session, school): 502 | class SchoolSchema(SQLAlchemySchema): 503 | class Meta: 504 | model = models.School 505 | load_instance = True 506 | sqla_session = session 507 | 508 | student_ids = auto_field() 509 | 510 | schema = SchoolSchema() 511 | assert "student_ids" in schema.fields 512 | dump_data = schema.dump(school) 513 | assert "student_ids" in dump_data 514 | assert dump_data["student_ids"] == list(school.student_ids) 515 | new_school = schema.load(dump_data, transient=True) 516 | assert list(new_school.student_ids) == list(school.student_ids) 517 | 518 | 519 | def test_dump_and_load_with_assoc_proxy_multiplicity_dump_only_kwargs( 520 | models, session, school 521 | ): 522 | class SchoolSchema(SQLAlchemySchema): 523 | class Meta: 524 | model = models.School 525 | load_instance = True 526 | sqla_session = session 527 | 528 | student_ids = auto_field(dump_only=True, data_key="student_identifiers") 529 | 530 | schema = SchoolSchema() 531 | assert "student_ids" in schema.fields 532 | assert schema.fields["student_ids"] not in schema.load_fields.values() 533 | assert schema.fields["student_ids"] in schema.dump_fields.values() 534 | 535 | dump_data = schema.dump(school) 536 | assert "student_ids" not in dump_data 537 | assert "student_identifiers" in dump_data 538 | assert dump_data["student_identifiers"] == list(school.student_ids) 539 | 540 | with pytest.raises(ValidationError): 541 | schema.load(dump_data, transient=True) 542 | 543 | 544 | def test_dump_and_load_with_assoc_proxy_multiplicity_load_only_only_kwargs( 545 | models, session, school 546 | ): 547 | class SchoolSchema(SQLAlchemySchema): 548 | class Meta: 549 | model = models.School 550 | load_instance = True 551 | sqla_session = session 552 | 553 | student_ids = auto_field(load_only=True, data_key="student_identifiers") 554 | 555 | schema = SchoolSchema() 556 | 557 | assert "student_ids" in schema.fields 558 | assert schema.fields["student_ids"] not in schema.dump_fields.values() 559 | assert schema.fields["student_ids"] in schema.load_fields.values() 560 | 561 | dump_data = schema.dump(school) 562 | assert "student_identifers" not in dump_data 563 | 564 | new_school = schema.load( 565 | {"student_identifiers": list(school.student_ids)}, transient=True 566 | ) 567 | assert list(new_school.student_ids) == list(school.student_ids) 568 | 569 | 570 | # https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues/440 571 | def test_auto_schema_with_model_allows_subclasses_to_override_include_fk(models): 572 | class TeacherSchema(SQLAlchemyAutoSchema): 573 | inherited_field = fields.String() 574 | 575 | class Meta: 576 | model = models.Teacher 577 | include_fk = True 578 | 579 | schema = TeacherSchema() 580 | assert "current_school_id" in schema.fields 581 | 582 | class TeacherNoFkSchema(TeacherSchema): 583 | class Meta(TeacherSchema.Meta): 584 | include_fk = False 585 | 586 | schema2 = TeacherNoFkSchema() 587 | assert "id" in schema2.fields 588 | assert "inherited_field" in schema2.fields 589 | assert "current_school_id" not in schema2.fields 590 | 591 | 592 | def test_auto_schema_with_model_allows_subclasses_to_override_exclude(models): 593 | class TeacherSchema(SQLAlchemyAutoSchema): 594 | inherited_field = fields.String() 595 | 596 | class Meta: 597 | model = models.Teacher 598 | include_fk = True 599 | 600 | schema = TeacherSchema() 601 | assert "current_school_id" in schema.fields 602 | 603 | class TeacherNoFkSchema(TeacherSchema): 604 | class Meta(TeacherSchema.Meta): 605 | exclude = ("current_school_id",) 606 | 607 | schema2 = TeacherNoFkSchema() 608 | assert "id" in schema2.fields 609 | assert "inherited_field" in schema2.fields 610 | assert "current_school_id" not in schema2.fields 611 | 612 | 613 | def test_auto_schema_with_model_allows_subclasses_to_override_include_fk_with_explicit_field( 614 | models, 615 | ): 616 | class TeacherSchema(SQLAlchemyAutoSchema): 617 | inherited_field = fields.String() 618 | 619 | class Meta: 620 | model = models.Teacher 621 | include_fk = True 622 | 623 | schema = TeacherSchema() 624 | assert "current_school_id" in schema.fields 625 | 626 | class TeacherNoFkSchema(TeacherSchema): 627 | current_school_id = fields.Integer() 628 | 629 | class Meta(TeacherSchema.Meta): 630 | include_fk = False 631 | 632 | schema2 = TeacherNoFkSchema() 633 | assert "id" in schema2.fields 634 | assert "inherited_field" in schema2.fields 635 | assert "current_school_id" in schema2.fields 636 | 637 | 638 | def test_auto_schema_with_table_allows_subclasses_to_override_include_fk(models): 639 | class TeacherSchema(SQLAlchemyAutoSchema): 640 | inherited_field = fields.Integer() 641 | 642 | class Meta: 643 | table = models.Teacher.__table__ 644 | include_fk = True 645 | 646 | schema = TeacherSchema() 647 | assert "current_school_id" in schema.fields 648 | 649 | class TeacherNoFkSchema(TeacherSchema): 650 | class Meta(TeacherSchema.Meta): 651 | include_fk = False 652 | 653 | schema2 = TeacherNoFkSchema() 654 | assert "id" in schema2.fields 655 | assert "inherited_field" in schema2.fields 656 | assert "current_school_id" not in schema2.fields 657 | 658 | 659 | def test_auto_schema_with_table_allows_subclasses_to_override_include_fk_with_explicit_field( 660 | models, 661 | ): 662 | class TeacherSchema(SQLAlchemyAutoSchema): 663 | inherited_field = fields.Integer() 664 | 665 | class Meta: 666 | table = models.Teacher.__table__ 667 | include_fk = True 668 | 669 | schema = TeacherSchema() 670 | assert "current_school_id" in schema.fields 671 | 672 | class TeacherNoFkModelSchema(TeacherSchema): 673 | current_school_id = fields.Integer() 674 | 675 | class Meta(TeacherSchema.Meta): 676 | include_fk = False 677 | 678 | schema2 = TeacherNoFkModelSchema() 679 | assert "id" in schema2.fields 680 | assert "inherited_field" in schema2.fields 681 | assert "current_school_id" in schema2.fields 682 | 683 | 684 | def test_auto_schema_with_model_can_inherit_declared_field_for_foreign_key_column_when_include_fk_is_false( 685 | models, 686 | ): 687 | class BaseTeacherSchema(Schema): 688 | current_school_id = fields.Integer() 689 | 690 | class TeacherSchema(BaseTeacherSchema, SQLAlchemyAutoSchema): 691 | class Meta: 692 | model = models.Teacher 693 | include_fk = False 694 | 695 | schema = TeacherSchema() 696 | assert "current_school_id" in schema.fields 697 | 698 | 699 | def test_auto_schema_with_table_can_inherit_declared_field_for_foreign_key_column_when_include_fk_is_false( 700 | models, 701 | ): 702 | class BaseTeacherSchema(Schema): 703 | current_school_id = fields.Integer() 704 | 705 | class TeacherSchema(BaseTeacherSchema, SQLAlchemyAutoSchema): 706 | class Meta: 707 | table = models.Teacher.__table__ 708 | include_fk = False 709 | 710 | schema = TeacherSchema() 711 | assert "current_school_id" in schema.fields 712 | 713 | 714 | def test_auto_field_does_not_accept_arbitrary_kwargs(models): 715 | if int(version("marshmallow")[0]) < 4: 716 | from marshmallow.warnings import RemovedInMarshmallow4Warning 717 | 718 | with pytest.warns( 719 | RemovedInMarshmallow4Warning, 720 | match="Passing field metadata as keyword arguments is deprecated", 721 | ): 722 | 723 | class CourseSchema(SQLAlchemyAutoSchema): 724 | class Meta: 725 | model = models.Course 726 | 727 | name = auto_field(description="A course name") 728 | 729 | else: 730 | with pytest.raises(TypeError, match="unexpected keyword argument"): 731 | 732 | class CourseSchema(SQLAlchemyAutoSchema): # type: ignore[no-redef] 733 | class Meta: 734 | model = models.Course 735 | 736 | name = auto_field(description="A course name") 737 | 738 | 739 | # https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues/394 740 | def test_dumping_pickle_field(models, teacher): 741 | class TeacherSchema(SQLAlchemySchema): 742 | class Meta: 743 | model = models.Teacher 744 | 745 | data = auto_field() 746 | 747 | teacher.data = {"foo": "bar"} 748 | 749 | schema = TeacherSchema() 750 | assert schema.dump(teacher) == { 751 | "data": {"foo": "bar"}, 752 | } 753 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist= 3 | lint 4 | py{39,310,311,312,313}-marshmallow3 5 | py313-marshmallowdev 6 | py39-lowest 7 | mypy-marshmallow{3,dev} 8 | docs 9 | 10 | [testenv] 11 | extras = tests 12 | deps = 13 | marshmallow3: marshmallow>=3.18.0,<4.0.0 14 | marshmallowdev: https://github.com/marshmallow-code/marshmallow/archive/dev.tar.gz 15 | lowest: marshmallow==3.18.0 16 | lowest: sqlalchemy==1.4.40 17 | commands = pytest {posargs} 18 | 19 | [testenv:mypy-marshmallow{3,dev}] 20 | deps = 21 | mypy>=1.14.1 22 | marshmallow3: marshmallow>=3.18.0,<4.0.0 23 | marshmallowdev: https://github.com/marshmallow-code/marshmallow/archive/dev.tar.gz 24 | commands = mypy 25 | 26 | [testenv:lint] 27 | deps = pre-commit~=4.0.1 28 | skip_install = true 29 | commands = pre-commit run --all-files 30 | 31 | [testenv:docs] 32 | extras = docs 33 | commands = sphinx-build docs/ docs/_build {posargs} 34 | 35 | ; Below tasks are for development only (not run in CI) 36 | 37 | [testenv:docs-serve] 38 | deps = sphinx-autobuild 39 | extras = docs 40 | commands = sphinx-autobuild --port=0 --open-browser --delay=2 docs/ docs/_build {posargs} --watch src --watch CONTRIBUTING.rst --watch README.rst 41 | 42 | [testenv:readme-serve] 43 | deps = restview 44 | skip_install = true 45 | commands = restview README.rst 46 | --------------------------------------------------------------------------------