pax_global_header00006660000000000000000000000064152151554720014521gustar00rootroot0000000000000052 comment=1b321d1bc5a16a54ebf1f68a3a07fd9c21f86cde agronholm-sqlacodegen-1b321d1/000077500000000000000000000000001521515547200163115ustar00rootroot00000000000000agronholm-sqlacodegen-1b321d1/.github/000077500000000000000000000000001521515547200176515ustar00rootroot00000000000000agronholm-sqlacodegen-1b321d1/.github/FUNDING.yml000066400000000000000000000000331521515547200214620ustar00rootroot00000000000000tidelift: pypi/sqlacodegen agronholm-sqlacodegen-1b321d1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001521515547200220345ustar00rootroot00000000000000agronholm-sqlacodegen-1b321d1/.github/ISSUE_TEMPLATE/bug_report.yaml000066400000000000000000000034571521515547200251010ustar00rootroot00000000000000name: Bug Report description: File a bug report labels: ["bug"] body: - type: markdown attributes: value: > If you observed a crash in the project, or saw unexpected behavior in it, report your findings here. - type: checkboxes attributes: label: Things to check first options: - label: > I have searched the existing issues and didn't find my bug already reported there required: true - label: > I have checked that my bug is still present in the latest release required: true - type: input id: project-version attributes: label: Sqlacodegen version description: What version of Sqlacodegen were you running? validations: required: true - type: input id: sqlalchemy-version attributes: label: SQLAlchemy version description: What version of SQLAlchemy were you running? validations: required: true - type: dropdown id: rdbms attributes: label: RDBMS vendor description: > What RDBMS (relational database management system) did you run the tool against? options: - PostgreSQL - MySQL (or compatible) - SQLite - MSSQL - Oracle - DB2 - Other - N/A validations: required: true - type: textarea id: what-happened attributes: label: What happened? description: > Unless you are reporting a crash, tell us what you expected to happen instead. validations: required: true - type: textarea id: schema attributes: label: Database schema for reproducing the bug description: > If applicable, paste the database schema (as a series of `CREATE TABLE` and other SQL commands) here. agronholm-sqlacodegen-1b321d1/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000000341521515547200240210ustar00rootroot00000000000000blank_issues_enabled: false agronholm-sqlacodegen-1b321d1/.github/ISSUE_TEMPLATE/features_request.yaml000066400000000000000000000020341521515547200263050ustar00rootroot00000000000000name: Feature request description: Suggest a new feature labels: ["enhancement"] body: - type: markdown attributes: value: > If you have thought of a new feature that would increase the usefulness of this project, please use this form to send us your idea. - type: checkboxes attributes: label: Things to check first options: - label: > I have searched the existing issues and didn't find my feature already requested there required: true - type: textarea id: feature attributes: label: Feature description description: > Describe the feature in detail. The more specific the description you can give, the easier it should be to implement this feature. validations: required: true - type: textarea id: usecase attributes: label: Use case description: > Explain why you need this feature, and why you think it would be useful to others too. validations: required: true agronholm-sqlacodegen-1b321d1/.github/dependabot.yml000066400000000000000000000011061521515547200224770ustar00rootroot00000000000000# Keep GitHub Actions up to date with GitHub's Dependabot... # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem version: 2 updates: - package-ecosystem: github-actions directory: / groups: github-actions: patterns: - "*" # Group all Actions updates into a single larger pull request schedule: interval: quarterly agronholm-sqlacodegen-1b321d1/.github/pull_request_template.md000066400000000000000000000020101521515547200246030ustar00rootroot00000000000000 ## Changes Fixes #. ## Checklist If this is a user-facing code change, like a bugfix or a new feature, please ensure that you've fulfilled the following conditions (where applicable): - [ ] You've added tests (in `tests/`) which would fail without your patch - [ ] You've added a new changelog entry (in `CHANGES.rst`). If this is a trivial change, like a typo fix or a code reformatting, then you can ignore these instructions. ### Updating the changelog If there are no entries after the last release, use `**UNRELEASED**` as the version. If, say, your patch fixes issue #123, the entry should look like this: ``` - Fix big bad boo-boo in task groups (`#123 `_; PR by @yourgithubaccount) ``` If there's no issue linked, just link to your pull request instead by updating the changelog after you've created the PR. agronholm-sqlacodegen-1b321d1/.github/workflows/000077500000000000000000000000001521515547200217065ustar00rootroot00000000000000agronholm-sqlacodegen-1b321d1/.github/workflows/publish.yml000066400000000000000000000026611521515547200241040ustar00rootroot00000000000000name: Publish packages to PyPI on: push: tags: - "[0-9]+.[0-9]+.[0-9]+" - "[0-9]+.[0-9]+.[0-9]+.post[0-9]+" - "[0-9]+.[0-9]+.[0-9]+[a-b][0-9]+" - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" jobs: build: name: Build the source tarball and the wheel runs-on: ubuntu-latest environment: release steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: 3.x - name: Install dependencies run: pip install build - name: Create packages run: python -m build - name: Archive packages uses: actions/upload-artifact@v7 with: name: dist path: dist publish: name: Publish build artifacts to the PyPI needs: build runs-on: ubuntu-latest environment: release permissions: id-token: write steps: - name: Retrieve packages uses: actions/download-artifact@v8 with: name: dist path: dist - name: Upload packages uses: pypa/gh-action-pypi-publish@release/v1 release: name: Create a GitHub release needs: build runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v6 - id: changelog uses: agronholm/release-notes@v1 with: path: CHANGES.rst - uses: ncipollo/release-action@v1 with: body: ${{ steps.changelog.outputs.changelog }} agronholm-sqlacodegen-1b321d1/.github/workflows/test.yml000066400000000000000000000017551521515547200234200ustar00rootroot00000000000000name: test suite on: push: branches: [master] pull_request: jobs: test: strategy: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true cache: pip cache-dependency-path: pyproject.toml - name: Install dependencies run: pip install --group test -e .[sqlmodel,citext,geoalchemy2,pgvector] - name: Test with pytest run: coverage run -m pytest - name: Upload Coverage uses: coverallsapp/github-action@v2 with: parallel: true coveralls: name: Finish Coveralls needs: test runs-on: ubuntu-latest steps: - name: Finished uses: coverallsapp/github-action@v2 with: parallel-finished: true agronholm-sqlacodegen-1b321d1/.gitignore000066400000000000000000000002251521515547200203000ustar00rootroot00000000000000*.egg-info *.pyc .project .pydevproject .coverage .settings .tox .idea .vscode .cache .pytest_cache .mypy_cache dist build venv* docker-compose.yaml agronholm-sqlacodegen-1b321d1/.pre-commit-config.yaml000066400000000000000000000020571521515547200225760ustar00rootroot00000000000000# This is the configuration file for pre-commit (https://pre-commit.com/). # To use: # * Install pre-commit (https://pre-commit.com/#installation) # * Copy this file as ".pre-commit-config.yaml" # * Run "pre-commit install". repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-toml - id: check-yaml - id: debug-statements - id: end-of-file-fixer - id: mixed-line-ending args: [ "--fix=lf" ] - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.9 hooks: - id: ruff args: [--fix, --show-fixes] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.20.0 hooks: - id: mypy additional_dependencies: - pytest - "SQLAlchemy >= 2.0.29" - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: - id: rst-backticks - id: rst-directive-colons - id: rst-inline-touching-normal ci: autoupdate_schedule: quarterly agronholm-sqlacodegen-1b321d1/CHANGES.rst000066400000000000000000000333321521515547200201170ustar00rootroot00000000000000Version history =============== **4.0.4** - Added autoincrement to primary key columns to prevent missing field errors. (`#473 `_; PR by @jtmonroe) - Preserve dialect-specific ``ARRAY`` types (e.g. ``postgresql.ARRAY``) instead of adapting them to the generic ``sqlalchemy.ARRAY``. The generic type does not implement operators like ``.contains()``, so adapting silently broke PostgreSQL array queries on generated models. (`#441 `_) **4.0.3** - Improved rendering of ``Identity`` server defaults by explicitly rendering non-default parameters; ``Decimal`` values (as returned by some databases) are now cast to ``int`` (PR by @NotCarlosSerrano) **4.0.2** - Fixed rendering of inherited keyword arguments for dialect-specific types that use ``**kwargs`` in their initializers (such as MySQL ``CHAR`` with ``collation``) while preserving existing ``*args`` rendering behavior (PR by @hyoj0942) - Fixed missing metadata argument when rendering plain tables with the SQLModel - Added support for self-referential tables in the SQLModel generator (PR by @sheinbergon) - Fixed empty dialect kwargs (e.g. ``postgresql_include=[]``) being included in rendered indexes, tables, and columns (PR by @sheinbergon) **4.0.1** - Fix enum column definitions to explicitly include schema and name if reflected via SQLAlchemy's Metadata (pr by @sheinbergon) **4.0.0** - **BACKWARD INCOMPATIBLE** API changes (for those who customize code generation by subclassing the existing generators): * Added new optional keyword argument, ``explicit_foreign_keys`` to ``DeclarativeGenerator``, to force foreign keys to be rendered as ``ClassName.attribute_name`` string references * Removed the ``render_relationship_args()`` method from the SQLModel generator * Added two new methods for customizing relationship rendering in ``DeclarativeGenerator``: * ``render_relationship_annotation()``: returns the appropriate type annotation (without the ``Mapped`` wrapper) for the relationship * ``render_relationship_arguments()``: returns a dictionary of keyword arguments to ``sqlalchemy.orm.relationship()`` **4.0.0rc3** - **BACKWARD INCOMPATIBLE** Relationship names changed when multiple FKs or junction tables connect to the same target table. Regenerating models will break existing code. - Added support for generating Python enum classes for ``ARRAY(Enum(...))`` columns (e.g., PostgreSQL ``ARRAY(ENUM)``). Supports named/unnamed enums, shared enums across columns, and multi-dimensional arrays. Respects ``--options nonativeenums``. (PR by @sheinbergon) - Improved relationship naming: one-to-many uses FK column names (e.g., ``simple_items_parent_container``), many-to-many uses junction table names (e.g., ``students_enrollments``). Use ``--options nofknames`` to revert to old behavior. (PR by @sheinbergon) - Fixed ``Index`` kwargs (e.g. ``mysql_length``) being ignored during code generation (PR by @luliangce) - Fixed the SQLModel generator not adding the ``foreign_keys`` parameters when generating multiple relationships between the same two tables **4.0.0rc2** - Add ``values_callable`` lambda to generated native enums column definitions. This allows for proper enum value insertion when working with ORM models (PR by @sheinbergon) **4.0.0rc1** - **BACKWARD INCOMPATIBLE** ``TablesGenerator.render_column_type()`` was changed to receive the ``Column`` object instead of the column type object as its sole argument - Added Python enum generation for native database ENUM types (e.g., PostgreSQL / MySQL ENUM). Retained synthetic Python enum generation from CHECK constraints with IN clauses (e.g., ``column IN ('val1', 'val2', ...)``). Use ``--options nonativeenums`` to disable enum generation for native database enums. Use ``--options nosyntheticenums`` to disable enum generation for synthetic database enums (VARCHAR columns with check constraints). (PR by @sheinbergon) **3.2.0** - Dropped support for Python 3.9 - Fix Postgres ``DOMAIN`` adaptation regression introduced in SQLAlchemy 2.0.42 (PR by @sheinbergon) - Support disabling special naming logic for single column many-to-one and one-to-one relationships (PR by @Henkhogan, revised by @sheinbergon) - Add ``include_dialect_options`` option to render ``Table`` and ``Column`` dialect-specific kwargs and ``info`` in generated code. (PR by @jaogoy) - Add ``keep_dialect_types`` option to preserve dialect-specific column types instead of adapting to generic SQLAlchemy types. (PR by @jaogoy) **3.1.1** - Fallback ``NotImplemented`` errors encountered when accessing ``python_type`` for non-native types to ``typing.Any`` (PR by @sheinbergon, based on work by @danplischke) **3.1.0** - Type annotations for ARRAY column attributes now include the Python type of the array elements - Added support for specifying engine arguments via ``--engine-arg`` (PR by @LajosCseppento) - Fixed incorrect package name used in ``importlib.metadata.version`` for ``sqlalchemy-citext``, resolving ``PackageNotFoundError`` (PR by @oaimtiaz) - Prevent double pluralization (PR by @dkratzert) - Fixes DOMAIN extending JSON/JSONB data types (PR by @sheinbergon) - Temporarily restrict SQLAlchemy version to 2.0.41 (PR by @sheinbergon) - Fixes ``add_import`` behavior when adding imports from sqlalchemy and overall better alignment of import behavior(s) across generators - Fixes ``nullable`` column behavior for non-null columns for both ``sqlmodels`` and ``declarative`` generators (PR by @sheinbergon) **3.0.0** - Dropped support for Python 3.8 - Changed nullable relationships to include ``Optional`` in their type annotations - Fixed SQLModel code generation - Fixed two rendering issues in ``ENUM`` columns when a non-default schema is used: an unwarranted positional argument and missing the ``schema`` argument - Fixed ``AttributeError`` when metadata contains user defined column types - Fixed ``AssertionError`` when metadata contains a column type that is a type decorator with an all-uppercase name - Fixed MySQL ``DOUBLE`` column types being rendered with the wrong arguments **3.0.0rc5** - Fixed pgvector support not working **3.0.0rc4** - Dropped support for Python 3.7 - Dropped support for SQLAlchemy 1.x - Added support for the ``pgvector`` extension (with help from KellyRousselHoomano) **3.0.0rc3** - Added support for SQLAlchemy 2 (PR by rbuffat with help from mhauru) - Renamed ``--option`` to ``--options`` and made its values delimited by commas - Restored CIText and GeoAlchemy2 support (PR by stavvy-rotte) **3.0.0rc2** - Added support for generating SQLModel classes (PR by Andrii Khirilov) - Fixed code generation when a single-column index is unique or does not match the dialect's naming convention (PR by Leonardus Chen) - Fixed another problem where sequence schemas were not properly separated from the sequence name - Fixed invalid generated primary/secondaryjoin expressions in self-referential many-to-many relationships by using lambdas instead of strings - Fixed ``AttributeError`` when the declarative generator encounters a table name already in singular form when ``--option use_inflect`` is enabled - Increased minimum SQLAlchemy version to 1.4.36 to address issues with ``ForeignKey`` and indexes, and to eliminate the PostgreSQL UUID column type annotation hack **3.0.0rc1** - Migrated all packaging/testing configuration to ``pyproject.toml`` - Fixed unwarranted ``ForeignKey`` declarations appearing in column attributes when there are named, single column foreign key constraints (PR by Leonardus Chen) . Fixed ``KeyError`` when rendering an index without any columns - Fixed improper handling of schema prefixes in sequence names in server defaults - Fixed identically named tables from different schemas resulting in invalid generated code - Fixed imports caused by ``server_default`` conflicting with class attribute names - Worked around PostgreSQL UUID columns getting ``Any`` as the type annotation **3.0.0b3** - Dropped support for Python < 3.7 - Dropped support for SQLAlchemy 1.3 - Added a ``__main__`` module which can be used as an alternate entry point to the CLI - Added detection for sequence use in column defaults on PostgreSQL - Fixed ``sqlalchemy.exc.InvalidRequestError`` when encountering a column named "metadata" (regression from 2.0) - Fixed missing ``MetaData`` import with ``DeclarativeGenerator`` when only plain tables are generated - Fixed invalid data classes being generated due to some relationships having been rendered without a default value - Improved translation of column names into column attributes where the column name has whitespace at the beginning or end - Modified constraint and index rendering to add them explicitly instead of using shortcuts like ``unique=True``, ``index=True`` or ``primary=True`` when the constraint or index has a name that does not match the default naming convention **3.0.0b2** - Fixed ``IDENTITY`` columns not rendering properly when they are part of the primary key **3.0.0b1** **NOTE**: Both the API and the command line interface have been refactored in a backwards incompatible fashion. Notably several command line options have been moved to specific generators and are no longer visible from ``sqlacodegen --help``. Their replacement are documented in the README. - Dropped support for Python < 3.6 - Added support for Python 3.10 - Added support for SQLAlchemy 1.4 - Added support for bidirectional relationships (use ``--option nobidi``) to disable - Added support for multiple schemas via ``--schemas`` - Added support for ``IDENTITY`` columns - Disabled inflection during table/relationship name generation by default (use ``--option use_inflect`` to re-enable) - Refactored the old ``CodeGenerator`` class into separate generator classes, selectable via ``--generator`` - Refactored several command line options into generator specific options: - ``--noindexes`` → ``--option noindexes`` - ``--noconstraints`` → ``--option noconstraints`` - ``--nocomments`` → ``--option nocomments`` - ``--nojoined`` → ``--option nojoined`` (``declarative`` and ``dataclass`` generators only) - ``--noinflect`` → (now the default; use ``--option use_inflect`` instead) (``declarative`` and ``dataclass`` generators only) - Fixed missing import for ``JSONB`` ``astext_type`` argument - Fixed generated column or relationship names colliding with imports or each other - Fixed ``CompileError`` when encountering server defaults that contain colons (``:``) **2.3.0** - Added support for rendering computed columns - Fixed ``--nocomments`` not taking effect (fix proposed by AzuresYang) - Fixed handling of MySQL ``SET`` column types (and possibly others as well) **2.2.0** - Added support for rendering table comments (PR by David Hirschfeld) - Fixed bad identifier names being generated for plain tables (PR by softwarepk) **2.1.0** - Dropped support for Python 3.4 - Dropped support for SQLAlchemy 0.8 - Added support for Python 3.7 and 3.8 - Added support for SQLAlchemy 1.3 - Added support for column comments (requires SQLAlchemy 1.2+; based on PR by koalas8) - Fixed crash on unknown column types (``NullType``) **2.0.1** - Don't adapt dialect specific column types if they need special constructor arguments (thanks Nicholas Martin for the PR) **2.0.0** - Refactored code for better reuse - Dropped support for Python 2.6, 3.2 and 3.3 - Dropped support for SQLAlchemy < 0.8 - Worked around a bug regarding Enum on SQLAlchemy 1.2+ (``name`` was missing) - Added support for Geoalchemy2 - Fixed invalid class names being generated (fixes #60; PR by Dan O'Huiginn) - Fixed array item types not being adapted or imported (fixes #46; thanks to Martin Glauer and Shawn Koschik for help) - Fixed attribute name of columns named ``metadata`` in mapped classes (fixes #62) - Fixed rendered column types being changed from the original (fixes #11) - Fixed server defaults which contain double quotes (fixes #7, #17, #28, #33, #36) - Fixed ``secondary=`` not taking into account the association table's schema name (fixes #30) - Sort models by foreign key dependencies instead of schema and name (fixes #15, #16) **1.1.6** - Fixed compatibility with SQLAlchemy 1.0 - Added an option to only generate tables **1.1.5** - Fixed potential assignment of columns or relationships into invalid attribute names (fixes #10) - Fixed unique=True missing from unique Index declarations - Fixed several issues with server defaults - Fixed potential assignment of columns or relationships into invalid attribute names - Allowed pascal case for tables already using it - Switched from Mercurial to Git **1.1.4** - Fixed compatibility with SQLAlchemy 0.9.0 **1.1.3** - Fixed compatibility with SQLAlchemy 0.8.3+ - Migrated tests from nose to pytest **1.1.2** - Fixed non-default schema name not being present in __table_args__ (fixes #2) - Fixed self referential foreign key causing column type to not be rendered - Fixed missing "deferrable" and "initially" keyword arguments in ForeignKey constructs - Fixed foreign key and check constraint handling with alternate schemas (fixes #3) **1.1.1** - Fixed TypeError when inflect could not determine the singular name of a table for a many-to-1 relationship - Fixed _IntegerType, _StringType etc. being rendered instead of proper types on MySQL **1.1.0** - Added automatic detection of joined-table inheritance - Fixed missing class name prefix in primary/secondary joins in relationships - Instead of wildcard imports, generate explicit imports dynamically (fixes #1) - Use the inflect library to produce better guesses for table to class name conversion - Automatically detect Boolean columns based on CheckConstraints - Skip redundant CheckConstraints for Enum and Boolean columns **1.0.0** - Initial release agronholm-sqlacodegen-1b321d1/CONTRIBUTING.rst000066400000000000000000000046041521515547200207560ustar00rootroot00000000000000Contributing to sqlacodegen =========================== If you wish to contribute a fix or feature to sqlacodegen, please follow the following guidelines. When you make a pull request against the main sqlacodegen codebase, Github runs the sqlacodegen test suite against your modified code. Before making a pull request, you should ensure that the modified code passes tests locally. To that end, the use of tox_ is recommended. The default tox run first runs ``pre-commit`` and then the actual test suite. To run the checks on all environments in parallel, invoke tox with ``tox -p``. To build the documentation, run ``tox -e docs`` which will generate a directory named ``build`` in which you may view the formatted HTML documentation. sqlacodegen uses pre-commit_ to perform several code style/quality checks. It is recommended to activate pre-commit_ on your local clone of the repository (using ``pre-commit install``) to ensure that your changes will pass the same checks on GitHub. .. _tox: https://tox.readthedocs.io/en/latest/install.html .. _pre-commit: https://pre-commit.com/#installation Making a pull request on Github ------------------------------- To get your changes merged to the main codebase, you need a Github account. #. Fork the repository (if you don't have your own fork of it yet) by navigating to the `main sqlacodegen repository`_ and clicking on "Fork" near the top right corner. #. Clone the forked repository to your local machine with ``git clone git@github.com/yourusername/sqlacodegen``. #. Create a branch for your pull request, like ``git checkout -b myfixname`` #. Make the desired changes to the code base. #. Commit your changes locally. If your changes close an existing issue, add the text ``Fixes #XXX.`` or ``Closes #XXX.`` to the commit message (where XXX is the issue number). #. Push the changeset(s) to your forked repository (``git push``) #. Navigate to Pull requests page on the original repository (not your fork) and click "New pull request" #. Click on the text "compare across forks". #. Select your own fork as the head repository and then select the correct branch name. #. Click on "Create pull request". If you have trouble, consult the `pull request making guide`_ on opensource.com. .. _main sqlacodegen repository: https://github.com/agronholm/sqlacodegen .. _pull request making guide: https://opensource.com/article/19/7/create-pull-request-github agronholm-sqlacodegen-1b321d1/LICENSE000066400000000000000000000021521521515547200173160ustar00rootroot00000000000000This is the MIT license: http://www.opensource.org/licenses/mit-license.php Copyright (c) Alex Grönholm Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. agronholm-sqlacodegen-1b321d1/README.rst000066400000000000000000000235331521515547200200060ustar00rootroot00000000000000.. image:: https://github.com/agronholm/sqlacodegen/actions/workflows/test.yml/badge.svg :target: https://github.com/agronholm/sqlacodegen/actions/workflows/test.yml :alt: Build Status .. image:: https://coveralls.io/repos/github/agronholm/sqlacodegen/badge.svg?branch=master :target: https://coveralls.io/github/agronholm/sqlacodegen?branch=master :alt: Code Coverage .. image:: https://tidelift.com/badges/package/pypi/sqlacodegen :target: https://tidelift.com/subscription/pkg/pypi-sqlacodegen :alt: Tidelift This is a tool that reads the structure of an existing database and generates the appropriate SQLAlchemy model code, using the declarative style if possible. This tool was written as a replacement for `sqlautocode`_, which was suffering from several issues (including, but not limited to, incompatibility with Python 3 and the latest SQLAlchemy version). .. _sqlautocode: http://code.google.com/p/sqlautocode/ Features ======== * Supports SQLAlchemy 2.x * Produces declarative code that almost looks like it was hand written * Produces `PEP 8`_ compliant code * Accurately determines relationships, including many-to-many, one-to-one * Automatically detects joined table inheritance * Excellent test coverage .. _PEP 8: http://www.python.org/dev/peps/pep-0008/ Installation ============ To install, do:: pip install sqlacodegen To include support for the PostgreSQL ``CITEXT`` extension type (which should be considered as tested only under a few environments) specify the ``citext`` extra:: pip install sqlacodegen[citext] To include support for the PostgreSQL ``GEOMETRY``, ``GEOGRAPHY``, and ``RASTER`` types (which should be considered as tested only under a few environments) specify the ``geoalchemy2`` extra: To include support for the PostgreSQL ``PGVECTOR`` extension type, specify the ``pgvector`` extra:: pip install sqlacodegen[pgvector] .. code-block:: bash pip install sqlacodegen[geoalchemy2] Quickstart ========== At the minimum, you have to give sqlacodegen a database URL. The URL is passed directly to SQLAlchemy's `create_engine()`_ method so please refer to `SQLAlchemy's documentation`_ for instructions on how to construct a proper URL. Examples:: sqlacodegen postgresql:///some_local_db sqlacodegen --generator tables mysql+pymysql://user:password@localhost/dbname sqlacodegen --generator dataclasses sqlite:///database.db # --engine-arg values are parsed with ast.literal_eval sqlacodegen oracle+oracledb://user:pass@127.0.0.1:1521/XE --engine-arg thick_mode=True sqlacodegen oracle+oracledb://user:pass@127.0.0.1:1521/XE --engine-arg thick_mode=True --engine-arg connect_args='{"user": "user", "dsn": "..."}' To see the list of generic options:: sqlacodegen --help .. _create_engine(): http://docs.sqlalchemy.org/en/latest/core/engines.html#sqlalchemy.create_engine .. _SQLAlchemy's documentation: http://docs.sqlalchemy.org/en/latest/core/engines.html Available generators ==================== The selection of a generator determines the The following built-in generators are available: * ``tables`` (only generates ``Table`` objects, for those who don't want to use the ORM) * ``declarative`` (the default; generates classes inheriting from ``declarative_base()`` * ``dataclasses`` (generates dataclass-based models; v1.4+ only) * ``sqlmodels`` (generates model classes for SQLModel_) .. _SQLModel: https://sqlmodel.tiangolo.com/ Generator-specific options ========================== The following options can be turned on by passing them using ``--options`` (multiple values must be delimited by commas, e.g. ``--options noconstraints,nobidi``): * ``tables`` * ``noconstraints``: ignore constraints (foreign key, unique etc.) * ``nocomments``: ignore table/column comments * ``noindexes``: ignore indexes * ``nonativeenums``: don't generate Python enum classes for native database ENUM types (e.g., PostgreSQL ENUM); use plain string mapping instead * ``nosyntheticenums``: don't generate Python enum classes from CHECK constraints with IN clauses (e.g., ``column IN ('value1', 'value2', ...)``); preserves CHECK constraints as-is * ``noidsuffix``: prevent the special naming logic for single column many-to-one and one-to-one relationships (see `Relationship naming logic`_ for details) * ``include_dialect_options``: render a table' dialect options, such as ``starrocks_partition`` for StarRocks' specific options. * ``keep_dialect_types``: preserve dialect-specific column types instead of adapting to generic SQLAlchemy types. * ``declarative`` * all the options from ``tables`` * ``use_inflect``: use the ``inflect`` library when naming classes and relationships (turning plural names into singular; see below for details) * ``nojoined``: don't try to detect joined-class inheritance (see below for details) * ``nobidi``: generate relationships in a unidirectional fashion, so only the many-to-one or first side of many-to-many relationships gets a relationship attribute, as on v2.X * ``nofknames``: disable improved relationship naming when multiple FKs or junction tables connect to the same target. By default, uses FK column names for one-to-many (e.g., ``simple_items_parent_container``) and junction table names for many-to-many (e.g., ``students_enrollments``). Reverts to underscore suffixes (``simple_items_``, ``student_``). * ``dataclasses`` * all the options from ``declarative`` * ``sqlmodels`` * all the options from ``declarative`` Model class generators ---------------------- The code generators that generate classes try to generate model classes whenever possible. There are two circumstances in which a ``Table`` is generated instead: * the table has no primary key constraint (which is required by SQLAlchemy for every model class) * the table is an association table between two other tables (see below for the specifics) Model class naming logic ++++++++++++++++++++++++ By default, table names are converted to valid PEP 8 compliant class names by replacing all characters unsuitable for Python identifiers with ``_``. Then, each valid parts (separated by underscores) are title cased and then joined together, eliminating the underscores. So, ``example_name`` becomes ``ExampleName``. If the ``use_inflect`` option is used, the table name (which is assumed to be in English) is converted to singular form using the "inflect" library. For example, ``sales_invoices`` becomes ``SalesInvoice``. Since table names are not always in English, and the inflection process is far from perfect, inflection is disabled by default. Relationship detection logic ++++++++++++++++++++++++++++ Relationships are detected based on existing foreign key constraints as follows: * **many-to-one**: a foreign key constraint exists on the table * **one-to-one**: same as **many-to-one**, but a unique constraint exists on the column(s) involved * **many-to-many**: (not implemented on the ``sqlmodel`` generator) an association table is found to exist between two tables A table is considered an association table if it satisfies all of the following conditions: #. has exactly two foreign key constraints #. all its columns are involved in said constraints Relationship naming logic +++++++++++++++++++++++++ Relationships are typically named based on the table name of the opposite class. For example, if a class has a relationship to another class with the table named ``companies``, the relationship would be named ``companies`` (unless the ``use_inflect`` option was enabled, in which case it would be named ``company`` in the case of a many-to-one or one-to-one relationship). A special case for single column many-to-one and one-to-one relationships, however, is if the column is named like ``employer_id``. Then the relationship is named ``employer`` due to that ``_id`` suffix. For self referential relationships, the reverse side of the relationship will be named with the ``_reverse`` suffix appended to it. When multiple foreign keys or junction tables connect to the same target table, relationships use qualifiers for disambiguation. One-to-many relationships use FK column names (e.g., ``simple_items_parent_container``, ``simple_items_top_container``). Many-to-many relationships use junction table names (e.g., ``students_enrollments``, ``students_waitlist``), except for self-referential cases which use FK column names (e.g., ``parent``, ``child``). The ``nofknames`` option reverts to underscore suffixes (``simple_items_``, ``student_``). Customizing code generation logic ================================= If the built-in generators with all their options don't quite do what you want, you can customize the logic by subclassing one of the existing code generator classes. Override whichever methods you need, and then add an `entry point`_ in the ``sqlacodegen.generators`` namespace that points to your new class. Once the entry point is in place (you typically have to install the project with ``pip install``), you can use ``--generator `` to invoke your custom code generator. For examples, you can look at sqlacodegen's own entry points in its `pyproject.toml`_. .. _entry point: https://setuptools.readthedocs.io/en/latest/userguide/entry_point.html .. _pyproject.toml: https://github.com/agronholm/sqlacodegen/blob/master/pyproject.toml Getting help ============ If you have problems or other questions, you should start a discussion on the `sqlacodegen discussion forum`_. As an alternative, you could also try your luck on the sqlalchemy_ room on Gitter. .. _sqlacodegen discussion forum: https://github.com/agronholm/sqlacodegen/discussions/categories/q-a .. _sqlalchemy: https://app.gitter.im/#/room/#sqlalchemy_community:gitter.im Security contact information ============================ To report a security vulnerability, please use the `Tidelift security contact`_. Tidelift will coordinate the fix and disclosure. .. _Tidelift security contact: https://tidelift.com/security agronholm-sqlacodegen-1b321d1/pyproject.toml000066400000000000000000000053561521515547200212360ustar00rootroot00000000000000[build-system] requires = [ "setuptools >= 64", "setuptools_scm[toml] >= 6.4" ] build-backend = "setuptools.build_meta" [project] name = "sqlacodegen" description = "Automatic model code generator for SQLAlchemy" readme = "README.rst" authors = [{name = "Alex Grönholm", email = "alex.gronholm@nextday.fi"}] maintainers = [{name = "Idan Sheinberg", email = "ishinberg0@gmail.com"}] keywords = ["sqlalchemy"] license = "MIT" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Environment :: Console", "Topic :: Database", "Topic :: Software Development :: Code Generators", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] requires-python = ">=3.10" dependencies = [ "SQLAlchemy >= 2.0.29", "inflect >= 4.0.0", ] dynamic = ["version"] [project.urls] "Bug Tracker" = "https://github.com/agronholm/sqlacodegen/issues" "Source Code" = "https://github.com/agronholm/sqlacodegen" [project.optional-dependencies] sqlmodel = ["sqlmodel >= 0.0.22"] citext = ["sqlalchemy-citext >= 1.7.0"] geoalchemy2 = ["geoalchemy2 >= 0.17.0"] pgvector = ["pgvector >= 0.2.4"] [project.entry-points."sqlacodegen.generators"] tables = "sqlacodegen.generators:TablesGenerator" declarative = "sqlacodegen.generators:DeclarativeGenerator" dataclasses = "sqlacodegen.generators:DataclassGenerator" sqlmodels = "sqlacodegen.generators:SQLModelGenerator" [project.scripts] sqlacodegen = "sqlacodegen.cli:main" [dependency-groups] test = [ "pytest >= 7.4", "coverage >= 7", "psycopg[binary]", "mysql-connector-python", ] [tool.setuptools_scm] version_scheme = "post-release" local_scheme = "dirty-tag" [tool.ruff] src = ["src"] [tool.ruff.lint] extend-select = [ "I", # isort "ISC", # flake8-implicit-str-concat "PGH", # pygrep-hooks "RUF100", # unused noqa (yesqa) "UP", # pyupgrade "W", # pycodestyle warnings ] [tool.mypy] strict = true disable_error_code = "no-untyped-call" [tool.pytest.ini_options] addopts = "-rsfE --tb=short" testpaths = ["tests"] [coverage.run] source = ["sqlacodegen"] relative_files = true [coverage.report] show_missing = true [tool.tox] env_list = ["py310", "py311", "py312", "py313", "py314"] skip_missing_interpreters = true [tool.tox.env_run_base] package = "editable" commands = [["python", "-m", "pytest", { replace = "posargs", extend = true }]] dependency_groups = ["test"] extras = ["sqlmodel", "citext", "geoalchemy2", "pgvector"] agronholm-sqlacodegen-1b321d1/src/000077500000000000000000000000001521515547200171005ustar00rootroot00000000000000agronholm-sqlacodegen-1b321d1/src/sqlacodegen/000077500000000000000000000000001521515547200213655ustar00rootroot00000000000000agronholm-sqlacodegen-1b321d1/src/sqlacodegen/__init__.py000066400000000000000000000000001521515547200234640ustar00rootroot00000000000000agronholm-sqlacodegen-1b321d1/src/sqlacodegen/__main__.py000066400000000000000000000000361521515547200234560ustar00rootroot00000000000000from .cli import main main() agronholm-sqlacodegen-1b321d1/src/sqlacodegen/cli.py000066400000000000000000000103761521515547200225150ustar00rootroot00000000000000from __future__ import annotations import argparse import ast import sys from contextlib import ExitStack from importlib.metadata import entry_points, version from typing import Any, TextIO from sqlalchemy.engine import create_engine from sqlalchemy.schema import MetaData try: import citext except ImportError: citext = None try: import geoalchemy2 except ImportError: geoalchemy2 = None try: import pgvector.sqlalchemy except ImportError: pgvector = None def _parse_engine_arg(arg_str: str) -> tuple[str, Any]: if "=" not in arg_str: raise argparse.ArgumentTypeError("engine-arg must be in key=value format") key, value = arg_str.split("=", 1) try: value = ast.literal_eval(value) except Exception: pass # Leave as string if literal_eval fails return key, value def _parse_engine_args(arg_list: list[str]) -> dict[str, Any]: result = {} for arg in arg_list or []: key, value = _parse_engine_arg(arg) result[key] = value return result def main() -> None: generators = {ep.name: ep for ep in entry_points(group="sqlacodegen.generators")} parser = argparse.ArgumentParser( description="Generates SQLAlchemy model code from an existing database." ) parser.add_argument("url", nargs="?", help="SQLAlchemy url to the database") parser.add_argument( "--options", help="options (comma-delimited) passed to the generator class" ) parser.add_argument( "--version", action="store_true", help="print the version number and exit" ) parser.add_argument( "--schemas", help="load tables from the given schemas (comma-delimited)" ) parser.add_argument( "--generator", choices=generators, default="declarative", help="generator class to use", ) parser.add_argument( "--tables", help="tables to process (comma-delimited, default: all)" ) parser.add_argument( "--noviews", action="store_true", help="ignore views (always true for sqlmodels generator)", ) parser.add_argument( "--engine-arg", action="append", help=( "engine arguments in key=value format, e.g., " '--engine-arg=connect_args=\'{"user": "scott"}\' ' "--engine-arg thick_mode=true or " '--engine-arg thick_mode=\'{"lib_dir": "/path"}\' ' "(values are parsed with ast.literal_eval)" ), ) parser.add_argument("--outfile", help="file to write output to (default: stdout)") args = parser.parse_args() if args.version: print(version("sqlacodegen")) return if not args.url: print("You must supply a url\n", file=sys.stderr) parser.print_help() return if citext: print(f"Using sqlalchemy-citext {version('sqlalchemy-citext')}") if geoalchemy2: print(f"Using geoalchemy2 {version('geoalchemy2')}") if pgvector: print(f"Using pgvector {version('pgvector')}") # Use reflection to fill in the metadata engine_args = _parse_engine_args(args.engine_arg) engine = create_engine(args.url, **engine_args) metadata = MetaData() tables = args.tables.split(",") if args.tables else None schemas = args.schemas.split(",") if args.schemas else [None] options = set(args.options.split(",")) if args.options else set() # Instantiate the generator generator_class = generators[args.generator].load() generator = generator_class(metadata, engine, options) if not generator.views_supported: name = generator_class.__name__ print( f"VIEW models will not be generated when using the '{name}' generator", file=sys.stderr, ) for schema in schemas: metadata.reflect( engine, schema, (generator.views_supported and not args.noviews), tables ) # Open the target file (if given) with ExitStack() as stack: outfile: TextIO if args.outfile: outfile = open(args.outfile, "w", encoding="utf-8") stack.enter_context(outfile) else: outfile = sys.stdout # Write the generated model code to the specified file or standard output outfile.write(generator.generate()) agronholm-sqlacodegen-1b321d1/src/sqlacodegen/generators.py000066400000000000000000002345501521515547200241210ustar00rootroot00000000000000from __future__ import annotations import inspect import re import sys from abc import ABCMeta, abstractmethod from collections import defaultdict from collections.abc import Collection, Iterable, Mapping, Sequence from dataclasses import dataclass from decimal import Decimal from importlib import import_module from inspect import Parameter from itertools import count from keyword import iskeyword from pprint import pformat from textwrap import indent from typing import Any, ClassVar, Literal, cast import inflect import sqlalchemy from sqlalchemy import ( ARRAY, Boolean, CheckConstraint, Column, Computed, Constraint, DefaultClause, Enum, ForeignKey, ForeignKeyConstraint, Identity, Index, MetaData, PrimaryKeyConstraint, String, Table, Text, TypeDecorator, UniqueConstraint, ) from sqlalchemy.dialects.postgresql import DOMAIN, JSON, JSONB from sqlalchemy.engine import Connection, Engine from sqlalchemy.exc import CompileError from sqlalchemy.sql.elements import TextClause from sqlalchemy.sql.type_api import UserDefinedType from sqlalchemy.types import TypeEngine from .models import ( ColumnAttribute, JoinType, Model, ModelClass, RelationshipAttribute, RelationshipType, ) from .utils import ( decode_postgresql_sequence, get_column_names, get_common_fk_constraints, get_compiled_expression, get_constraint_sort_key, get_stdlib_module_names, qualified_table_name, render_callable, uses_default_name, ) _re_boolean_check_constraint = re.compile(r"(?:.*?\.)?(.*?) IN \(0, 1\)") _re_column_name = re.compile(r'(?:(["`]?).*\1\.)?(["`]?)(.*)\2') _re_enum_check_constraint = re.compile(r"(?:.*?\.)?(.*?) IN \((.+)\)") _re_enum_item = re.compile(r"'(.*?)(? bool: pass @abstractmethod def generate(self) -> str: """ Generate the code for the given metadata. .. note:: May modify the metadata. """ @dataclass(eq=False) class TablesGenerator(CodeGenerator): valid_options: ClassVar[set[str]] = { "noindexes", "noconstraints", "nocomments", "nonativeenums", "nosyntheticenums", "include_dialect_options", "keep_dialect_types", } stdlib_module_names: ClassVar[set[str]] = get_stdlib_module_names() def __init__( self, metadata: MetaData, bind: Connection | Engine, options: Sequence[str], *, indentation: str = " ", ): super().__init__(metadata, bind, options) self.indentation: str = indentation self.imports: dict[str, set[str]] = defaultdict(set) self.module_imports: set[str] = set() # Render SchemaItem.info and dialect kwargs (Table/Column) into output self.include_dialect_options_and_info: bool = ( "include_dialect_options" in self.options ) # Keep dialect-specific types instead of adapting to generic SQLAlchemy types self.keep_dialect_types: bool = "keep_dialect_types" in self.options # Track Python enum classes: maps (table_name, column_name) -> enum_class_name self.enum_classes: dict[tuple[str, str], str] = {} # Track enum values: maps enum_class_name -> list of values self.enum_values: dict[str, list[str]] = {} @property def views_supported(self) -> bool: return True def generate_base(self) -> None: self.base = Base( literal_imports=[LiteralImport("sqlalchemy", "MetaData")], declarations=["metadata = MetaData()"], metadata_ref="metadata", ) def generate(self) -> str: self.generate_base() sections: list[str] = [] # Remove unwanted elements from the metadata for table in list(self.metadata.tables.values()): if self.should_ignore_table(table): self.metadata.remove(table) continue if "noindexes" in self.options: table.indexes.clear() if "noconstraints" in self.options: table.constraints.clear() if "nocomments" in self.options: table.comment = None for column in table.columns: if "nocomments" in self.options: column.comment = None # Use information from column constraints to figure out the intended column # types for table in self.metadata.tables.values(): self.fix_column_types(table) # Generate the models models: list[Model] = self.generate_models() # Render module level variables if variables := self.render_module_variables(models): sections.append(variables + "\n") # Render enum classes if enum_classes := self.render_enum_classes(): sections.append(enum_classes + "\n") # Render models if rendered_models := self.render_models(models): sections.append(rendered_models) # Render collected imports groups = self.group_imports() if imports := "\n\n".join( "\n".join(line for line in group) for group in groups ): sections.insert(0, imports) return "\n\n".join(sections) + "\n" def collect_imports(self, models: Iterable[Model]) -> None: for literal_import in self.base.literal_imports: self.add_literal_import(literal_import.pkgname, literal_import.name) for model in models: self.collect_imports_for_model(model) def collect_imports_for_model(self, model: Model) -> None: if model.__class__ is Model: self.add_import(Table) for column in model.table.c: self.collect_imports_for_column(column) for constraint in model.table.constraints: self.collect_imports_for_constraint(constraint) for index in model.table.indexes: self.collect_imports_for_constraint(index) def collect_imports_for_column(self, column: Column[Any]) -> None: self.add_import(column.type) if isinstance(column.type, ARRAY): self.add_import(column.type.item_type.__class__) elif isinstance(column.type, (JSONB, JSON)): if ( not isinstance(column.type.astext_type, Text) or column.type.astext_type.length is not None ): self.add_import(column.type.astext_type) elif isinstance(column.type, DOMAIN): self.add_import(column.type.data_type.__class__) if column.default: self.add_import(column.default) if column.server_default: if isinstance(column.server_default, (Computed, Identity)): self.add_import(column.server_default) elif isinstance(column.server_default, DefaultClause): self.add_literal_import("sqlalchemy", "text") def collect_imports_for_constraint(self, constraint: Constraint | Index) -> None: if isinstance(constraint, Index): if len(constraint.columns) > 1 or not uses_default_name(constraint): self.add_literal_import("sqlalchemy", "Index") elif isinstance(constraint, PrimaryKeyConstraint): if not uses_default_name(constraint): self.add_literal_import("sqlalchemy", "PrimaryKeyConstraint") elif isinstance(constraint, UniqueConstraint): if len(constraint.columns) > 1 or not uses_default_name(constraint): self.add_literal_import("sqlalchemy", "UniqueConstraint") elif isinstance(constraint, ForeignKeyConstraint): if len(constraint.columns) > 1 or not uses_default_name(constraint): self.add_literal_import("sqlalchemy", "ForeignKeyConstraint") else: self.add_import(ForeignKey) else: self.add_import(constraint) def add_import(self, obj: Any) -> None: # Don't store builtin imports if getattr(obj, "__module__", "builtins") == "builtins": return type_ = type(obj) if not isinstance(obj, type) else obj pkgname = type_.__module__ # The column types have already been adapted towards generic types if possible, # so if this is still a vendor specific type (e.g., MySQL INTEGER) be sure to # use that rather than the generic sqlalchemy type as it might have different # constructor parameters. if pkgname.startswith("sqlalchemy.dialects."): dialect_pkgname = ".".join(pkgname.split(".")[0:3]) dialect_pkg = import_module(dialect_pkgname) if type_.__name__ in dialect_pkg.__all__: pkgname = dialect_pkgname elif type_ is getattr(sqlalchemy, type_.__name__, None): pkgname = "sqlalchemy" else: pkgname = type_.__module__ self.add_literal_import(pkgname, type_.__name__) def add_literal_import(self, pkgname: str, name: str) -> None: names = self.imports.setdefault(pkgname, set()) names.add(name) def remove_literal_import(self, pkgname: str, name: str) -> None: names = self.imports.setdefault(pkgname, set()) if name in names: names.remove(name) def add_module_import(self, pgkname: str) -> None: self.module_imports.add(pgkname) def group_imports(self) -> list[list[str]]: future_imports: list[str] = [] stdlib_imports: list[str] = [] thirdparty_imports: list[str] = [] def get_collection(package: str) -> list[str]: collection = thirdparty_imports if package == "__future__": collection = future_imports elif package in self.stdlib_module_names: collection = stdlib_imports elif package in sys.modules: if "site-packages" not in (sys.modules[package].__file__ or ""): collection = stdlib_imports return collection for package in sorted(self.imports): imports = ", ".join(sorted(self.imports[package])) collection = get_collection(package) collection.append(f"from {package} import {imports}") for module in sorted(self.module_imports): collection = get_collection(module) collection.append(f"import {module}") return [ group for group in (future_imports, stdlib_imports, thirdparty_imports) if group ] def generate_models(self) -> list[Model]: models = [Model(table) for table in self.metadata.sorted_tables] # Collect the imports self.collect_imports(models) # Generate names for models global_names = { name for namespace in self.imports.values() for name in namespace } for model in models: self.generate_model_name(model, global_names) global_names.add(model.name) return models def generate_model_name(self, model: Model, global_names: set[str]) -> None: preferred_name = f"t_{model.table.name}" model.name = self.find_free_name(preferred_name, global_names) def render_module_variables(self, models: list[Model]) -> str: declarations = self.base.declarations if any(not isinstance(model, ModelClass) for model in models): if self.base.table_metadata_declaration is not None: declarations.append(self.base.table_metadata_declaration) return "\n".join(declarations) def render_models(self, models: list[Model]) -> str: rendered: list[str] = [] for model in models: rendered_table = self.render_table(model.table) rendered.append(f"{model.name} = {rendered_table}") return "\n\n".join(rendered) def render_table(self, table: Table) -> str: args: list[str] = [f"{table.name!r}, {self.base.metadata_ref}"] kwargs: dict[str, object] = {} for column in table.columns: # Cast is required because of a bug in the SQLAlchemy stubs regarding # Table.columns args.append(self.render_column(column, True, is_table=True)) for constraint in sorted(table.constraints, key=get_constraint_sort_key): if uses_default_name(constraint): if isinstance(constraint, PrimaryKeyConstraint): continue elif isinstance(constraint, (ForeignKeyConstraint, UniqueConstraint)): if len(constraint.columns) == 1: continue args.append(self.render_constraint(constraint)) for index in sorted(table.indexes, key=lambda i: cast(str, i.name)): # One-column indexes should be rendered as index=True on columns if len(index.columns) > 1 or not uses_default_name(index): args.append(self.render_index(index)) if table.schema: kwargs["schema"] = repr(table.schema) table_comment = getattr(table, "comment", None) if table_comment: kwargs["comment"] = repr(table.comment) # add info + dialect kwargs for callable context (opt-in) if self.include_dialect_options_and_info: self._add_dialect_kwargs_and_info(table, kwargs, values_for_dict=False) return render_callable("Table", *args, kwargs=kwargs, indentation=" ") def render_index(self, index: Index) -> str: extra_args = [repr(col.name) for col in index.columns] kwargs = { key: repr(value) if isinstance(value, str) else value for key, value in sorted(index.kwargs.items(), key=lambda item: item[0]) if value not in ([], {}) } if index.unique: kwargs["unique"] = True return render_callable("Index", repr(index.name), *extra_args, kwargs=kwargs) # TODO find better solution for is_table def render_column( self, column: Column[Any], show_name: bool, is_table: bool = False ) -> str: args = [] kwargs: dict[str, Any] = {} kwarg = [] is_part_of_composite_pk = ( column.primary_key and len(column.table.primary_key) > 1 ) dedicated_fks = [ c for c in column.foreign_keys if c.constraint and len(c.constraint.columns) == 1 and uses_default_name(c.constraint) ] is_unique = any( isinstance(c, UniqueConstraint) and set(c.columns) == {column} and uses_default_name(c) for c in column.table.constraints ) is_unique = is_unique or any( i.unique and set(i.columns) == {column} and uses_default_name(i) for i in column.table.indexes ) is_primary = ( any( isinstance(c, PrimaryKeyConstraint) and column.name in c.columns and uses_default_name(c) for c in column.table.constraints ) or column.primary_key ) is_autoincrement = ( isinstance(column.autoincrement, bool) and column.autoincrement ) has_index = any( set(i.columns) == {column} and uses_default_name(i) for i in column.table.indexes ) if show_name: args.append(repr(column.name)) # Render the column type if there are no foreign keys on it or any of them # points back to itself if not dedicated_fks or any(fk.column is column for fk in dedicated_fks): args.append(self.render_column_type(column)) for fk in dedicated_fks: args.append(self.render_constraint(fk)) if column.default: args.append(repr(column.default)) if column.key != column.name: kwargs["key"] = column.key if is_primary: kwargs["primary_key"] = True if is_autoincrement and is_primary: kwargs["autoincrement"] = True if not column.nullable and not column.primary_key: kwargs["nullable"] = False if column.nullable and is_part_of_composite_pk: kwargs["nullable"] = True if is_unique: column.unique = True kwargs["unique"] = True if has_index: column.index = True kwarg.append("index") kwargs["index"] = True if isinstance(column.server_default, DefaultClause): kwargs["server_default"] = render_callable( "text", repr(cast(TextClause, column.server_default.arg).text) ) elif isinstance(column.server_default, Computed): expression = str(column.server_default.sqltext) computed_kwargs = {} if column.server_default.persisted is not None: computed_kwargs["persisted"] = column.server_default.persisted args.append( render_callable("Computed", repr(expression), kwargs=computed_kwargs) ) elif isinstance(column.server_default, Identity): identity = column.server_default identity_kwargs: dict[str, Any] = {} for name, param in inspect.signature(Identity).parameters.items(): if name == "self" or param.kind in ( Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD, ): continue value = getattr(identity, name, None) if value is None: continue if isinstance(value, Decimal): value = int(value) if param.default is not Parameter.empty and value == param.default: continue identity_kwargs[name] = value args.append(render_callable("Identity", kwargs=identity_kwargs)) elif column.server_default: kwargs["server_default"] = repr(column.server_default) comment = getattr(column, "comment", None) if comment: kwargs["comment"] = repr(comment) # add column info + dialect kwargs for callable context (opt-in) if self.include_dialect_options_and_info: self._add_dialect_kwargs_and_info(column, kwargs, values_for_dict=False) return self.render_column_callable(is_table, *args, **kwargs) def render_column_callable(self, is_table: bool, *args: Any, **kwargs: Any) -> str: if is_table: self.add_import(Column) return render_callable("Column", *args, kwargs=kwargs) else: return render_callable("mapped_column", *args, kwargs=kwargs) def _render_column_type_value(self, value: Any) -> str: if isinstance(value, (JSONB, JSON)): # Remove astext_type if it's the default if isinstance(value.astext_type, Text) and value.astext_type.length is None: value.astext_type = None # type: ignore[assignment] else: self.add_import(Text) if isinstance(value, TextClause): self.add_literal_import("sqlalchemy", "text") return render_callable("text", repr(value.text)) return repr(value) def _collect_inherited_init_kwargs( self, column_type: Any, init_sig: inspect.Signature, seen_param_names: set[str], missing: object, ) -> dict[str, str]: has_var_keyword = any( param.kind is Parameter.VAR_KEYWORD for param in init_sig.parameters.values() ) has_var_positional = any( param.kind is Parameter.VAR_POSITIONAL for param in init_sig.parameters.values() ) if not has_var_keyword or has_var_positional: return {} inherited_kwargs: dict[str, str] = {} for supercls in column_type.__class__.__mro__[1:]: if supercls is object: break try: super_sig = inspect.signature(supercls.__init__) except (TypeError, ValueError): continue for super_param in list(super_sig.parameters.values())[1:]: if super_param.name.startswith("_"): continue if super_param.kind in ( Parameter.POSITIONAL_ONLY, Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD, ): continue if super_param.name in seen_param_names: continue seen_param_names.add(super_param.name) value = getattr(column_type, super_param.name, missing) if value is missing: continue default = super_param.default if default is not Parameter.empty and value == default: continue inherited_kwargs[super_param.name] = self._render_column_type_value( value ) return inherited_kwargs def render_column_type(self, column: Column[Any]) -> str: column_type = column.type # Check if this is an enum column with a Python enum class if isinstance(column_type, Enum) and column is not None: if enum_class_name := self.enum_classes.get( (column.table.name, column.name) ): # Import SQLAlchemy Enum (will be handled in collect_imports) self.add_import(Enum) extra_kwargs = "" if column_type.name is not None: extra_kwargs += f", name={column_type.name!r}" if column_type.schema is not None: extra_kwargs += f", schema={column_type.schema!r}" return f"Enum({enum_class_name}, values_callable=lambda cls: [member.value for member in cls]{extra_kwargs})" args = [] kwargs: dict[str, Any] = {} # Check if this is an ARRAY column with an Enum item type mapped to a Python enum class if isinstance(column_type, ARRAY) and isinstance(column_type.item_type, Enum): if enum_class_name := self.enum_classes.get( (column.table.name, column.name) ): self.add_import(ARRAY) self.add_import(Enum) extra_kwargs = "" if column_type.item_type.name is not None: extra_kwargs += f", name={column_type.item_type.name!r}" if column_type.item_type.schema is not None: extra_kwargs += f", schema={column_type.item_type.schema!r}" rendered_enum = f"Enum({enum_class_name}, values_callable=lambda cls: [member.value for member in cls]{extra_kwargs})" if column_type.dimensions is not None: kwargs["dimensions"] = repr(column_type.dimensions) return render_callable("ARRAY", rendered_enum, kwargs=kwargs) sig = inspect.signature(column_type.__class__.__init__) defaults = {param.name: param.default for param in sig.parameters.values()} missing = object() use_kwargs = False seen_param_names: set[str] = set() for param in list(sig.parameters.values())[1:]: # Remove annoyances like _warn_on_bytestring if param.name.startswith("_"): continue elif param.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD): use_kwargs = True continue seen_param_names.add(param.name) value = getattr(column_type, param.name, missing) default = defaults.get(param.name, missing) if value is missing or value == default: use_kwargs = True continue rendered_value = self._render_column_type_value(value) if use_kwargs: kwargs[param.name] = rendered_value else: args.append(rendered_value) kwargs.update( self._collect_inherited_init_kwargs( column_type, sig, seen_param_names, missing ) ) vararg = next( ( param.name for param in sig.parameters.values() if param.kind is Parameter.VAR_POSITIONAL ), None, ) if vararg and hasattr(column_type, vararg): varargs_repr = [repr(arg) for arg in getattr(column_type, vararg)] args.extend(varargs_repr) # These arguments cannot be autodetected from the Enum initializer if isinstance(column_type, Enum): for colname in "name", "schema": if (value := getattr(column_type, colname)) is not None: kwargs[colname] = repr(value) if isinstance(column_type, (JSONB, JSON)): # Remove astext_type if it's the default if ( isinstance(column_type.astext_type, Text) and column_type.astext_type.length is None ): del kwargs["astext_type"] if args or kwargs: return render_callable(column_type.__class__.__name__, *args, kwargs=kwargs) else: return column_type.__class__.__name__ def render_constraint(self, constraint: Constraint | ForeignKey) -> str: def add_fk_options(*opts: Any) -> None: args.extend(repr(opt) for opt in opts) for attr in "ondelete", "onupdate", "deferrable", "initially", "match": value = getattr(constraint, attr, None) if value: kwargs[attr] = repr(value) args: list[str] = [] kwargs: dict[str, Any] = {} if isinstance(constraint, ForeignKey): remote_column = ( f"{constraint.column.table.fullname}.{constraint.column.name}" ) add_fk_options(remote_column) elif isinstance(constraint, ForeignKeyConstraint): local_columns = get_column_names(constraint) remote_columns = [ f"{fk.column.table.fullname}.{fk.column.name}" for fk in constraint.elements ] add_fk_options(local_columns, remote_columns) elif isinstance(constraint, CheckConstraint): args.append(repr(get_compiled_expression(constraint.sqltext, self.bind))) elif isinstance(constraint, (UniqueConstraint, PrimaryKeyConstraint)): args.extend(repr(col.name) for col in constraint.columns) else: raise TypeError( f"Cannot render constraint of type {constraint.__class__.__name__}" ) if isinstance(constraint, Constraint) and not uses_default_name(constraint): kwargs["name"] = repr(constraint.name) return render_callable(constraint.__class__.__name__, *args, kwargs=kwargs) def _add_dialect_kwargs_and_info( self, obj: Any, target_kwargs: dict[str, object], *, values_for_dict: bool ) -> None: """ Merge SchemaItem-like object's .info and .dialect_kwargs into target_kwargs. - values_for_dict=True: keep raw values so pretty-printer emits repr() (for __table_args__ dict) - values_for_dict=False: set values to repr() strings (for callable kwargs) """ info_dict = getattr(obj, "info", None) if info_dict: target_kwargs["info"] = info_dict if values_for_dict else repr(info_dict) dialect_keys: list[str] try: dialect_keys = sorted(getattr(obj, "dialect_kwargs")) except Exception: return dialect_kwargs = getattr(obj, "dialect_kwargs", {}) for key in dialect_keys: try: value = dialect_kwargs[key] except Exception: continue if isinstance(value, list | dict) and not value: continue # Render values: # - callable context (values_for_dict=False): produce a string expression. # primitives use repr(value); custom objects stringify then repr(). # - dict context (values_for_dict=True): pass raw primitives / str; # custom objects become str(value) so pformat quotes them. if values_for_dict: if isinstance(value, type(None) | bool | int | float): target_kwargs[key] = value elif isinstance(value, str | dict | list): target_kwargs[key] = value else: target_kwargs[key] = str(value) else: if isinstance( value, type(None) | bool | int | float | str | dict | list ): target_kwargs[key] = repr(value) else: target_kwargs[key] = repr(str(value)) def should_ignore_table(self, table: Table) -> bool: # Support for Alembic and sqlalchemy-migrate -- never expose the schema version # tables return table.name in ("alembic_version", "migrate_version") def find_free_name( self, name: str, global_names: set[str], local_names: Collection[str] = () ) -> str: """ Generate an attribute name that does not clash with other local or global names. """ name = name.strip() assert name, "Identifier cannot be empty" name = _re_invalid_identifier.sub("_", name) if name[0].isdigit(): name = "_" + name elif iskeyword(name) or name == "metadata": name += "_" original = name for i in count(): if name not in global_names and name not in local_names: break name = original + (str(i) if i else "_") return name def _enum_name_to_class_name(self, enum_name: str) -> str: """Convert a database enum name to a Python class name (PascalCase).""" return "".join(part.capitalize() for part in enum_name.split("_") if part) def _create_enum_class( self, table_name: str, column_name: str, values: list[str] ) -> str: """ Create a Python enum class name and register it. Returns the enum class name to use in generated code. """ # Generate enum class name from table and column names # Convert to PascalCase: user_status -> UserStatus base_name = "".join( part.capitalize() for part in table_name.split("_") + column_name.split("_") if part ) # Ensure uniqueness enum_class_name = base_name for counter in count(1): if enum_class_name not in self.enum_values: break # Check if it's the same enum (same values) if self.enum_values[enum_class_name] == values: # Reuse existing enum class return enum_class_name enum_class_name = f"{base_name}{counter}" # Register the new enum class self.enum_values[enum_class_name] = values return enum_class_name def render_enum_classes(self) -> str: """Render Python enum class definitions.""" if not self.enum_values: return "" self.add_module_import("enum") enum_defs = [] for enum_class_name, values in sorted(self.enum_values.items()): # Create enum members with valid Python identifiers members = [] for value in values: # Unescape SQL escape sequences (e.g., \' -> ') # The value from the CHECK constraint has SQL escaping unescaped_value = value.replace("\\'", "'").replace("\\\\", "\\") # Create a valid identifier from the enum value member_name = _re_invalid_identifier.sub("_", unescaped_value).upper() if not member_name: member_name = "EMPTY" elif member_name[0].isdigit(): member_name = "_" + member_name elif iskeyword(member_name): member_name += "_" # # # Re-escape for Python string literal # python_escaped = unescaped_value.replace("\\", "\\\\").replace( # "'", "\\'" # ) members.append(f" {member_name} = {unescaped_value!r}") enum_def = f"class {enum_class_name}(str, enum.Enum):\n" + "\n".join( members ) enum_defs.append(enum_def) return "\n\n\n".join(enum_defs) def fix_column_types(self, table: Table) -> None: """Adjust the reflected column types.""" def fix_enum_column(col_name: str, enum_type: Enum) -> None: if (table.name, col_name) in self.enum_classes: return if enum_type.name: existing_class = None for (_, _), cls in self.enum_classes.items(): if cls == self._enum_name_to_class_name(enum_type.name): existing_class = cls break if existing_class: enum_class_name = existing_class else: enum_class_name = self._enum_name_to_class_name(enum_type.name) if enum_class_name not in self.enum_values: self.enum_values[enum_class_name] = list(enum_type.enums) else: enum_class_name = self._create_enum_class( table.name, col_name, list(enum_type.enums) ) self.enum_classes[(table.name, col_name)] = enum_class_name # Detect check constraints for boolean and enum columns for constraint in table.constraints.copy(): if isinstance(constraint, CheckConstraint): sqltext = get_compiled_expression(constraint.sqltext, self.bind) # Turn any integer-like column with a CheckConstraint like # "column IN (0, 1)" into a Boolean if match := _re_boolean_check_constraint.match(sqltext): if colname_match := _re_column_name.match(match.group(1)): colname = colname_match.group(3) table.constraints.remove(constraint) table.c[colname].type = Boolean() continue # Turn VARCHAR columns with CHECK constraints like "column IN ('a', 'b')" # into synthetic Enum types with Python enum classes if ( "nosyntheticenums" not in self.options and (match := _re_enum_check_constraint.match(sqltext)) and (colname_match := _re_column_name.match(match.group(1))) ): colname = colname_match.group(3) items = match.group(2) if isinstance(table.c[colname].type, String) and not isinstance( table.c[colname].type, Enum ): options = _re_enum_item.findall(items) # Create Python enum class enum_class_name = self._create_enum_class( table.name, colname, options ) self.enum_classes[(table.name, colname)] = enum_class_name # Convert to Enum type but KEEP the constraint table.c[colname].type = Enum(*options, native_enum=False) continue for column in table.c: # Handle native database Enum types (e.g., PostgreSQL ENUM) if ( "nonativeenums" not in self.options and isinstance(column.type, Enum) and column.type.enums ): fix_enum_column(column.name, column.type) # Handle ARRAY columns with Enum item types (e.g., PostgreSQL ARRAY(ENUM)) elif ( "nonativeenums" not in self.options and isinstance(column.type, ARRAY) and isinstance(column.type.item_type, Enum) and column.type.item_type.enums ): fix_enum_column(column.name, column.type.item_type) if not self.keep_dialect_types: try: column.type = self.get_adapted_type(column.type) except CompileError: continue # PostgreSQL specific fix: detect sequences from server_default if column.server_default and self.bind.dialect.name == "postgresql": if isinstance(column.server_default, DefaultClause) and isinstance( column.server_default.arg, TextClause ): schema, seqname = decode_postgresql_sequence( column.server_default.arg ) if seqname: # Add an explicit sequence if seqname != f"{column.table.name}_{column.name}_seq": column.default = sqlalchemy.Sequence(seqname, schema=schema) column.server_default = None def get_adapted_type(self, coltype: Any) -> Any: # Keep dialect-specific ARRAY subclasses; the generic sqlalchemy.ARRAY # is missing operators like .contains() (GH-441). if isinstance(coltype, ARRAY) and type(coltype) is not ARRAY: coltype.item_type = self.get_adapted_type(coltype.item_type) return coltype compiled_type = coltype.compile(self.bind.engine.dialect) for supercls in coltype.__class__.__mro__: if not supercls.__name__.startswith("_") and hasattr( supercls, "__visit_name__" ): # Don't try to adapt UserDefinedType as it's not a proper column type if supercls is UserDefinedType or issubclass(supercls, TypeDecorator): return coltype # Hack to fix adaptation of the Enum class which is broken since # SQLAlchemy 1.2 kw = {} if supercls is Enum: kw["name"] = coltype.name if coltype.schema: kw["schema"] = coltype.schema # Hack to fix Postgres DOMAIN type adaptation, broken as of SQLAlchemy 2.0.42 # For additional information - https://github.com/agronholm/sqlacodegen/issues/416#issuecomment-3417480599 if supercls is DOMAIN: if coltype.default: kw["default"] = coltype.default if coltype.constraint_name is not None: kw["constraint_name"] = coltype.constraint_name if coltype.not_null: kw["not_null"] = coltype.not_null if coltype.check is not None: kw["check"] = coltype.check if coltype.create_type: kw["create_type"] = coltype.create_type try: new_coltype = coltype.adapt(supercls) except TypeError: # If the adaptation fails, don't try again break for key, value in kw.items(): setattr(new_coltype, key, value) if isinstance(coltype, ARRAY): new_coltype.item_type = self.get_adapted_type(new_coltype.item_type) try: # If the adapted column type does not render the same as the # original, don't substitute it if new_coltype.compile(self.bind.engine.dialect) != compiled_type: break except CompileError: # If the adapted column type can't be compiled, don't substitute it break # Stop on the first valid non-uppercase column type class coltype = new_coltype if supercls.__name__ != supercls.__name__.upper(): break return coltype class DeclarativeGenerator(TablesGenerator): valid_options: ClassVar[set[str]] = TablesGenerator.valid_options | { "use_inflect", "nojoined", "nobidi", "noidsuffix", "nofknames", } def __init__( self, metadata: MetaData, bind: Connection | Engine, options: Sequence[str], *, indentation: str = " ", base_class_name: str = "Base", explicit_foreign_keys: bool = False, ): super().__init__(metadata, bind, options, indentation=indentation) self.base_class_name: str = base_class_name self.inflect_engine = inflect.engine() self.explicit_foreign_keys = explicit_foreign_keys def generate_base(self) -> None: self.base = Base( literal_imports=[LiteralImport("sqlalchemy.orm", "DeclarativeBase")], declarations=[ f"class {self.base_class_name}(DeclarativeBase):", f"{self.indentation}pass", ], metadata_ref=f"{self.base_class_name}.metadata", ) def collect_imports(self, models: Iterable[Model]) -> None: super().collect_imports(models) if any(isinstance(model, ModelClass) for model in models): self.add_literal_import("sqlalchemy.orm", "Mapped") self.add_literal_import("sqlalchemy.orm", "mapped_column") def collect_imports_for_model(self, model: Model) -> None: super().collect_imports_for_model(model) if isinstance(model, ModelClass): if model.relationships: self.add_literal_import("sqlalchemy.orm", "relationship") def generate_models(self) -> list[Model]: models_by_table_name: dict[str, Model] = {} # Pick association tables from the metadata into their own set, don't process # them normally links: defaultdict[str, list[Model]] = defaultdict(lambda: []) for table in self.metadata.sorted_tables: qualified_name = qualified_table_name(table) # Link tables have exactly two foreign key constraints and all columns are # involved in them fk_constraints = sorted( table.foreign_key_constraints, key=get_constraint_sort_key ) if len(fk_constraints) == 2 and all( col.foreign_keys for col in table.columns ): model = models_by_table_name[qualified_name] = Model(table) tablename = fk_constraints[0].elements[0].column.table.name links[tablename].append(model) continue # Only form model classes for tables that have a primary key and are not # association tables if not table.primary_key: models_by_table_name[qualified_name] = Model(table) else: model = ModelClass(table) models_by_table_name[qualified_name] = model # Fill in the columns for column in table.c: column_attr = ColumnAttribute(model, column) model.columns.append(column_attr) # Add relationships for model in models_by_table_name.values(): if isinstance(model, ModelClass): self.generate_relationships( model, models_by_table_name, links[model.table.name] ) # Nest inherited classes in their superclasses to ensure proper ordering if "nojoined" not in self.options: for model in list(models_by_table_name.values()): if not isinstance(model, ModelClass): continue pk_column_names = {col.name for col in model.table.primary_key.columns} for constraint in model.table.foreign_key_constraints: if set(get_column_names(constraint)) == pk_column_names: target = models_by_table_name[ qualified_table_name(constraint.elements[0].column.table) ] if isinstance(target, ModelClass): model.parent_class = target target.children.append(model) # Change base if we only have tables if not any( isinstance(model, ModelClass) for model in models_by_table_name.values() ): super().generate_base() # Collect the imports self.collect_imports(models_by_table_name.values()) # Rename models and their attributes that conflict with imports or other # attributes global_names = { name for namespace in self.imports.values() for name in namespace } for model in models_by_table_name.values(): self.generate_model_name(model, global_names) global_names.add(model.name) return list(models_by_table_name.values()) def generate_relationships( self, source: ModelClass, models_by_table_name: dict[str, Model], association_tables: list[Model], ) -> list[RelationshipAttribute]: relationships: list[RelationshipAttribute] = [] reverse_relationship: RelationshipAttribute | None # Add many-to-one (and one-to-many) relationships pk_column_names = {col.name for col in source.table.primary_key.columns} for constraint in sorted( source.table.foreign_key_constraints, key=get_constraint_sort_key ): target = models_by_table_name[ qualified_table_name(constraint.elements[0].column.table) ] if isinstance(target, ModelClass): if "nojoined" not in self.options: if set(get_column_names(constraint)) == pk_column_names: parent = models_by_table_name[ qualified_table_name(constraint.elements[0].column.table) ] if isinstance(parent, ModelClass): source.parent_class = parent parent.children.append(source) continue # Add uselist=False to One-to-One relationships column_names = get_column_names(constraint) if any( isinstance(c, (PrimaryKeyConstraint, UniqueConstraint)) and {col.name for col in c.columns} == set(column_names) for c in constraint.table.constraints ): r_type = RelationshipType.ONE_TO_ONE else: r_type = RelationshipType.MANY_TO_ONE relationship = RelationshipAttribute(r_type, source, target, constraint) source.relationships.append(relationship) # For self referential relationships, remote_side needs to be set if source is target: relationship.remote_side = [ source.get_column_attribute(col.name) for col in constraint.referred_table.primary_key ] # If the two tables share more than one foreign key constraint, # SQLAlchemy needs an explicit primaryjoin to figure out which column(s) # it needs common_fk_constraints = get_common_fk_constraints( source.table, target.table ) if len(common_fk_constraints) > 1: relationship.foreign_keys = [ source.get_column_attribute(key) for key in constraint.column_keys ] # Generate the opposite end of the relationship in the target class if "nobidi" not in self.options: if r_type is RelationshipType.MANY_TO_ONE: r_type = RelationshipType.ONE_TO_MANY reverse_relationship = RelationshipAttribute( r_type, target, source, constraint, foreign_keys=relationship.foreign_keys, backref=relationship, ) relationship.backref = reverse_relationship target.relationships.append(reverse_relationship) # For self referential relationships, remote_side needs to be set if source is target: reverse_relationship.remote_side = [ source.get_column_attribute(colname) for colname in constraint.column_keys ] # Add many-to-many relationships for association_table in association_tables: fk_constraints = sorted( association_table.table.foreign_key_constraints, key=get_constraint_sort_key, ) target = models_by_table_name[ qualified_table_name(fk_constraints[1].elements[0].column.table) ] if isinstance(target, ModelClass): relationship = RelationshipAttribute( RelationshipType.MANY_TO_MANY, source, target, fk_constraints[1], association_table, ) source.relationships.append(relationship) # Generate the opposite end of the relationship in the target class reverse_relationship = None if "nobidi" not in self.options: reverse_relationship = RelationshipAttribute( RelationshipType.MANY_TO_MANY, target, source, fk_constraints[0], association_table, relationship, ) relationship.backref = reverse_relationship target.relationships.append(reverse_relationship) # Add a primary/secondary join for self-referential many-to-many # relationships if source is target: both_relationships = [relationship] reverse_flags = [False, True] if reverse_relationship: both_relationships.append(reverse_relationship) for relationship, reverse in zip(both_relationships, reverse_flags): if ( not relationship.association_table or not relationship.constraint ): continue constraints = sorted( relationship.constraint.table.foreign_key_constraints, key=get_constraint_sort_key, reverse=reverse, ) pri_pairs = zip( get_column_names(constraints[0]), constraints[0].elements ) sec_pairs = zip( get_column_names(constraints[1]), constraints[1].elements ) relationship.primaryjoin = [ ( relationship.source, elem.column.name, relationship.association_table, col, ) for col, elem in pri_pairs ] relationship.secondaryjoin = [ ( relationship.target, elem.column.name, relationship.association_table, col, ) for col, elem in sec_pairs ] return relationships def generate_model_name(self, model: Model, global_names: set[str]) -> None: if isinstance(model, ModelClass): preferred_name = _re_invalid_identifier.sub("_", model.table.name) preferred_name = "".join( part[:1].upper() + part[1:] for part in preferred_name.split("_") ) if "use_inflect" in self.options: singular_name = self.inflect_engine.singular_noun(preferred_name) if singular_name: preferred_name = singular_name model.name = self.find_free_name(preferred_name, global_names) # Fill in the names for column attributes local_names: set[str] = set() for column_attr in model.columns: self.generate_column_attr_name(column_attr, global_names, local_names) local_names.add(column_attr.name) # Fill in the names for relationship attributes for relationship in model.relationships: self.generate_relationship_name(relationship, global_names, local_names) local_names.add(relationship.name) else: super().generate_model_name(model, global_names) def generate_column_attr_name( self, column_attr: ColumnAttribute, global_names: set[str], local_names: set[str], ) -> None: column_attr.name = self.find_free_name( column_attr.column.name, global_names, local_names ) def generate_relationship_name( self, relationship: RelationshipAttribute, global_names: set[str], local_names: set[str], ) -> None: def strip_id_suffix(name: str) -> str: # Strip _id only if at the end or followed by underscore (e.g., "course_id" -> "course", "course_id_1" -> "course_1") # But don't strip from "parent_id1" (where id is followed by a digit without underscore) return re.sub(r"_id(?=_|$)", "", name) def get_m2m_qualified_name(default_name: str) -> str: """Generate qualified name for many-to-many relationship when multiple junction tables exist.""" # Check if there are multiple M2M relationships to the same target target_m2m_relationships = [ r for r in relationship.source.relationships if r.target is relationship.target and r.type == RelationshipType.MANY_TO_MANY ] # Only use junction-based naming when there are multiple M2M to same target if len(target_m2m_relationships) > 1: if relationship.source is relationship.target: # Self-referential: use FK column name from junction table # (e.g., "parent_id" -> "parent", "child_id" -> "child") if relationship.constraint: column_names = [c.name for c in relationship.constraint.columns] if len(column_names) == 1: fk_qualifier = strip_id_suffix(column_names[0]) else: fk_qualifier = "_".join( strip_id_suffix(col_name) for col_name in column_names ) return fk_qualifier elif relationship.association_table: # Normal: use junction table name as qualifier junction_name = relationship.association_table.table.name fk_qualifier = strip_id_suffix(junction_name) return f"{relationship.target.table.name}_{fk_qualifier}" else: # Single M2M: use simple name from junction table FK column # (e.g., "right_id" -> "right" instead of "right_table") if relationship.constraint and "noidsuffix" not in self.options: column_names = [c.name for c in relationship.constraint.columns] if len(column_names) == 1: stripped_name = strip_id_suffix(column_names[0]) if stripped_name != column_names[0]: return stripped_name return default_name def get_fk_qualified_name(constraint: ForeignKeyConstraint) -> str: """Generate qualified name for one-to-many/one-to-one relationship using FK column names.""" column_names = [c.name for c in constraint.columns] if len(column_names) == 1: # Single column FK: strip _id suffix if present fk_qualifier = strip_id_suffix(column_names[0]) else: # Multi-column FK: concatenate all column names (strip _id from each) fk_qualifier = "_".join( strip_id_suffix(col_name) for col_name in column_names ) # For self-referential relationships, don't prepend the table name if relationship.source is relationship.target: return fk_qualifier else: return f"{relationship.target.table.name}_{fk_qualifier}" def resolve_preferred_name() -> str: resolved_name = relationship.target.table.name # For reverse relationships with multiple FKs to the same table, use the FK # column name to create a more descriptive relationship name # For M2M relationships with multiple junction tables, use the junction table name use_fk_based_naming = "nofknames" not in self.options and ( ( relationship.constraint and relationship.type in (RelationshipType.ONE_TO_MANY, RelationshipType.ONE_TO_ONE) and relationship.foreign_keys ) or ( relationship.type == RelationshipType.MANY_TO_MANY and relationship.association_table ) ) if use_fk_based_naming: if relationship.type == RelationshipType.MANY_TO_MANY: resolved_name = get_m2m_qualified_name(resolved_name) elif relationship.constraint: resolved_name = get_fk_qualified_name(relationship.constraint) # If there's a constraint with a single column that contains "_id", use the # stripped version as the relationship name elif relationship.constraint and "noidsuffix" not in self.options: is_source = relationship.source.table is relationship.constraint.table if is_source or relationship.type not in ( RelationshipType.ONE_TO_ONE, RelationshipType.ONE_TO_MANY, ): column_names = [c.name for c in relationship.constraint.columns] if len(column_names) == 1: stripped_name = strip_id_suffix(column_names[0]) # Only use the stripped name if it actually changed (had _id in it) if stripped_name != column_names[0]: resolved_name = stripped_name else: # For composite FKs, check if there are multiple FKs to the same target target_relationships = [ r for r in relationship.source.relationships if r.target is relationship.target and r.type == relationship.type ] if len(target_relationships) > 1: # Multiple FKs to same table - use concatenated column names resolved_name = "_".join( strip_id_suffix(col_name) for col_name in column_names ) if "use_inflect" in self.options: inflected_name: str | Literal[False] if relationship.type in ( RelationshipType.ONE_TO_MANY, RelationshipType.MANY_TO_MANY, ): if not self.inflect_engine.singular_noun(resolved_name): resolved_name = self.inflect_engine.plural_noun(resolved_name) else: inflected_name = self.inflect_engine.singular_noun(resolved_name) if inflected_name: resolved_name = inflected_name return resolved_name if ( relationship.type in (RelationshipType.ONE_TO_MANY, RelationshipType.ONE_TO_ONE) and relationship.source is relationship.target and relationship.backref and relationship.backref.name ): preferred_name = relationship.backref.name + "_reverse" else: preferred_name = resolve_preferred_name() relationship.name = self.find_free_name( preferred_name, global_names, local_names ) def render_models(self, models: list[Model]) -> str: rendered: list[str] = [] for model in models: if isinstance(model, ModelClass): rendered.append(self.render_class(model)) else: rendered.append(f"{model.name} = {self.render_table(model.table)}") return "\n\n\n".join(rendered) def render_class(self, model: ModelClass) -> str: sections: list[str] = [] # Render class variables / special declarations class_vars: str = self.render_class_variables(model) if class_vars: sections.append(class_vars) # Render column attributes rendered_column_attributes: list[str] = [] for nullable in (False, True): for column_attr in model.columns: if column_attr.column.nullable is nullable: rendered_column_attributes.append( self.render_column_attribute(column_attr) ) if rendered_column_attributes: sections.append("\n".join(rendered_column_attributes)) # Render relationship attributes rendered_relationship_attributes: list[str] = [ self.render_relationship(relationship) for relationship in model.relationships ] if rendered_relationship_attributes: sections.append("\n".join(rendered_relationship_attributes)) declaration = self.render_class_declaration(model) rendered_sections = "\n\n".join( indent(section, self.indentation) for section in sections ) return f"{declaration}\n{rendered_sections}" def render_class_declaration(self, model: ModelClass) -> str: parent_class_name = ( model.parent_class.name if model.parent_class else self.base_class_name ) return f"class {model.name}({parent_class_name}):" def render_class_variables(self, model: ModelClass) -> str: variables = [f"__tablename__ = {model.table.name!r}"] # Render constraints and indexes as __table_args__ table_args = self.render_table_args(model.table) if table_args: variables.append(f"__table_args__ = {table_args}") return "\n".join(variables) def render_table_args(self, table: Table) -> str: args: list[str] = [] kwargs: dict[str, object] = {} # Render constraints for constraint in sorted(table.constraints, key=get_constraint_sort_key): if uses_default_name(constraint): if isinstance(constraint, PrimaryKeyConstraint): continue if ( isinstance(constraint, (ForeignKeyConstraint, UniqueConstraint)) and len(constraint.columns) == 1 ): continue args.append(self.render_constraint(constraint)) # Render indexes for index in sorted(table.indexes, key=lambda i: cast(str, i.name)): if len(index.columns) > 1 or not uses_default_name(index): args.append(self.render_index(index)) if table.schema: kwargs["schema"] = table.schema if table.comment: kwargs["comment"] = table.comment # add info + dialect kwargs for dict context (__table_args__) (opt-in) if self.include_dialect_options_and_info: self._add_dialect_kwargs_and_info(table, kwargs, values_for_dict=True) if kwargs: formatted_kwargs = pformat(kwargs) if not args: return formatted_kwargs else: args.append(formatted_kwargs) if args: rendered_args = f",\n{self.indentation}".join(args) if len(args) == 1: rendered_args += "," return f"(\n{self.indentation}{rendered_args}\n)" else: return "" def render_column_python_type(self, column: Column[Any]) -> str: def get_type_qualifiers() -> tuple[str, TypeEngine[Any], str]: column_type = column.type pre: list[str] = [] post_size = 0 if column.nullable: self.add_literal_import("typing", "Optional") pre.append("Optional[") post_size += 1 if isinstance(column_type, ARRAY): dim = getattr(column_type, "dimensions", None) or 1 pre.extend("list[" for _ in range(dim)) post_size += dim column_type = column_type.item_type return "".join(pre), column_type, "]" * post_size def render_python_type(column_type: TypeEngine[Any]) -> str: # Check if this is an enum column with a Python enum class if isinstance(column_type, Enum): table_name = column.table.name column_name = column.name if (table_name, column_name) in self.enum_classes: enum_class_name = self.enum_classes[(table_name, column_name)] return enum_class_name if isinstance(column_type, DOMAIN): column_type = column_type.data_type try: python_type = column_type.python_type python_type_module = python_type.__module__ python_type_name = python_type.__name__ except NotImplementedError: self.add_literal_import("typing", "Any") return "Any" if python_type_module == "builtins": return python_type_name self.add_module_import(python_type_module) return f"{python_type_module}.{python_type_name}" pre, col_type, post = get_type_qualifiers() column_python_type = f"{pre}{render_python_type(col_type)}{post}" return column_python_type def render_column_attribute(self, column_attr: ColumnAttribute) -> str: column = column_attr.column rendered_column = self.render_column(column, column_attr.name != column.name) rendered_column_python_type = self.render_column_python_type(column) return f"{column_attr.name}: Mapped[{rendered_column_python_type}] = {rendered_column}" def render_relationship(self, relationship: RelationshipAttribute) -> str: kwargs = self.render_relationship_arguments(relationship) annotation = self.render_relationship_annotation(relationship) rendered_relationship = render_callable( "relationship", repr(relationship.target.name), kwargs=kwargs ) return f"{relationship.name}: Mapped[{annotation}] = {rendered_relationship}" def render_relationship_annotation( self, relationship: RelationshipAttribute ) -> str: match relationship.type: case RelationshipType.ONE_TO_MANY: return f"list[{relationship.target.name!r}]" case RelationshipType.ONE_TO_ONE | RelationshipType.MANY_TO_ONE: if relationship.constraint and any( col.nullable for col in relationship.constraint.columns ): self.add_literal_import("typing", "Optional") return f"Optional[{relationship.target.name!r}]" else: return f"'{relationship.target.name}'" case RelationshipType.MANY_TO_MANY: return f"list[{relationship.target.name!r}]" def render_relationship_arguments( self, relationship: RelationshipAttribute ) -> Mapping[str, Any]: def render_column_attrs(column_attrs: list[ColumnAttribute]) -> str: rendered = [] render_as_string = False for attr in column_attrs: if not self.explicit_foreign_keys and attr.model is relationship.source: rendered.append(attr.name) else: rendered.append(f"{attr.model.name}.{attr.name}") render_as_string = True joined = "[" + ", ".join(rendered) + "]" return repr(joined) if render_as_string else joined def render_foreign_keys(column_attrs: list[ColumnAttribute]) -> str: rendered = [] render_as_string = False # Assume that column_attrs are all in relationship.source or none for attr in column_attrs: if not self.explicit_foreign_keys and attr.model is relationship.source: rendered.append(attr.name) else: rendered.append(f"{attr.model.name}.{attr.name}") render_as_string = True if render_as_string: return "'[" + ", ".join(rendered) + "]'" else: return "[" + ", ".join(rendered) + "]" def render_join(terms: list[JoinType]) -> str: rendered_joins = [] for source, source_col, target, target_col in terms: rendered = f"lambda: {source.name}.{source_col} == {target.name}." if target.__class__ is Model: rendered += "c." rendered += str(target_col) rendered_joins.append(rendered) if len(rendered_joins) > 1: rendered = ", ".join(rendered_joins) return f"and_({rendered})" else: return rendered_joins[0] # Render keyword arguments kwargs: dict[str, Any] = {} if relationship.type is RelationshipType.ONE_TO_ONE and relationship.constraint: if relationship.constraint.referred_table is relationship.source.table: kwargs["uselist"] = False # Add the "secondary" keyword for many-to-many relationships if relationship.association_table: table_ref = relationship.association_table.table.name if relationship.association_table.schema: table_ref = f"{relationship.association_table.schema}.{table_ref}" kwargs["secondary"] = repr(table_ref) if relationship.remote_side: kwargs["remote_side"] = render_column_attrs(relationship.remote_side) if relationship.foreign_keys: kwargs["foreign_keys"] = render_foreign_keys(relationship.foreign_keys) if relationship.primaryjoin: kwargs["primaryjoin"] = render_join(relationship.primaryjoin) if relationship.secondaryjoin: kwargs["secondaryjoin"] = render_join(relationship.secondaryjoin) if relationship.backref: kwargs["back_populates"] = repr(relationship.backref.name) return kwargs class DataclassGenerator(DeclarativeGenerator): def __init__( self, metadata: MetaData, bind: Connection | Engine, options: Sequence[str], *, indentation: str = " ", base_class_name: str = "Base", quote_annotations: bool = False, metadata_key: str = "sa", ): super().__init__( metadata, bind, options, indentation=indentation, base_class_name=base_class_name, ) self.metadata_key: str = metadata_key self.quote_annotations: bool = quote_annotations def generate_base(self) -> None: self.base = Base( literal_imports=[ LiteralImport("sqlalchemy.orm", "DeclarativeBase"), LiteralImport("sqlalchemy.orm", "MappedAsDataclass"), ], declarations=[ (f"class {self.base_class_name}(MappedAsDataclass, DeclarativeBase):"), f"{self.indentation}pass", ], metadata_ref=f"{self.base_class_name}.metadata", ) class SQLModelGenerator(DeclarativeGenerator): def __init__( self, metadata: MetaData, bind: Connection | Engine, options: Sequence[str], *, indentation: str = " ", base_class_name: str = "SQLModel", ): super().__init__( metadata, bind, options, indentation=indentation, base_class_name=base_class_name, explicit_foreign_keys=True, ) @property def views_supported(self) -> bool: return False def render_column_callable(self, is_table: bool, *args: Any, **kwargs: Any) -> str: self.add_import(Column) return render_callable("Column", *args, kwargs=kwargs) def render_table(self, table: Table) -> str: # Hack to fix #465 without breaking backwards compatibility self.base.metadata_ref = "SQLModel.metadata" return super().render_table(table) def generate_base(self) -> None: self.base = Base( literal_imports=[], declarations=[], metadata_ref="SQLModel.metadata", ) def collect_imports(self, models: Iterable[Model]) -> None: super(DeclarativeGenerator, self).collect_imports(models) if any(isinstance(model, ModelClass) for model in models): self.add_literal_import("sqlmodel", "Field") if models: self.remove_literal_import("sqlalchemy", "MetaData") self.add_literal_import("sqlmodel", "SQLModel") def collect_imports_for_model(self, model: Model) -> None: super(DeclarativeGenerator, self).collect_imports_for_model(model) if isinstance(model, ModelClass): for column_attr in model.columns: if column_attr.column.nullable: self.add_literal_import("typing", "Optional") break if model.relationships: self.add_literal_import("sqlmodel", "Relationship") def render_module_variables(self, models: list[Model]) -> str: declarations: list[str] = [] if any(not isinstance(model, ModelClass) for model in models): if self.base.table_metadata_declaration is not None: declarations.append(self.base.table_metadata_declaration) return "\n".join(declarations) def render_class_declaration(self, model: ModelClass) -> str: if model.parent_class: parent = model.parent_class.name else: parent = self.base_class_name superclass_part = f"({parent}, table=True)" return f"class {model.name}{superclass_part}:" def render_class_variables(self, model: ModelClass) -> str: variables = [] if model.table.name != model.name.lower(): variables.append(f"__tablename__ = {model.table.name!r}") # Render constraints and indexes as __table_args__ table_args = self.render_table_args(model.table) if table_args: variables.append(f"__table_args__ = {table_args}") return "\n".join(variables) def render_column_attribute(self, column_attr: ColumnAttribute) -> str: column = column_attr.column rendered_column = self.render_column(column, True) rendered_column_python_type = self.render_column_python_type(column) kwargs: dict[str, Any] = {} if column.nullable: kwargs["default"] = None kwargs["sa_column"] = f"{rendered_column}" rendered_field = render_callable("Field", kwargs=kwargs) return f"{column_attr.name}: {rendered_column_python_type} = {rendered_field}" def render_relationship(self, relationship: RelationshipAttribute) -> str: kwargs = self.render_relationship_arguments(relationship) annotation = self.render_relationship_annotation(relationship) native_kwargs: dict[str, Any] = {} non_native_kwargs: dict[str, Any] = {} for key, value in kwargs.items(): # The following keyword arguments are natively supported in Relationship if key in ("back_populates", "cascade_delete", "passive_deletes"): native_kwargs[key] = value else: non_native_kwargs[key] = value if non_native_kwargs: native_kwargs["sa_relationship_kwargs"] = ( "{" + ", ".join( f"{key!r}: {value}" for key, value in non_native_kwargs.items() ) + "}" ) rendered_field = render_callable("Relationship", kwargs=native_kwargs) return f"{relationship.name}: {annotation} = {rendered_field}" agronholm-sqlacodegen-1b321d1/src/sqlacodegen/models.py000066400000000000000000000043101521515547200232200ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass, field from enum import Enum, auto from typing import Any from sqlalchemy.sql.schema import Column, ForeignKeyConstraint, Table @dataclass class Model: table: Table name: str = field(init=False, default="") @property def schema(self) -> str | None: return self.table.schema @dataclass class ModelClass(Model): columns: list[ColumnAttribute] = field(default_factory=list) relationships: list[RelationshipAttribute] = field(default_factory=list) parent_class: ModelClass | None = None children: list[ModelClass] = field(default_factory=list) def get_column_attribute(self, column_name: str) -> ColumnAttribute: for column in self.columns: if column.column.name == column_name: return column raise LookupError(f"Cannot find column attribute for {column_name!r}") class RelationshipType(Enum): ONE_TO_ONE = auto() ONE_TO_MANY = auto() MANY_TO_ONE = auto() MANY_TO_MANY = auto() @dataclass class ColumnAttribute: model: ModelClass column: Column[Any] name: str = field(init=False, default="") def __repr__(self) -> str: return f"{self.__class__.__name__}(name={self.name!r}, type={self.column.type})" def __str__(self) -> str: return self.name JoinType = tuple[Model, ColumnAttribute | str, Model, ColumnAttribute | str] @dataclass class RelationshipAttribute: type: RelationshipType source: ModelClass target: ModelClass constraint: ForeignKeyConstraint | None = None association_table: Model | None = None backref: RelationshipAttribute | None = None remote_side: list[ColumnAttribute] = field(default_factory=list) foreign_keys: list[ColumnAttribute] = field(default_factory=list) primaryjoin: list[JoinType] = field(default_factory=list) secondaryjoin: list[JoinType] = field(default_factory=list) name: str = field(init=False, default="") def __repr__(self) -> str: return ( f"{self.__class__.__name__}(name={self.name!r}, type={self.type}, " f"target={self.target.name})" ) def __str__(self) -> str: return self.name agronholm-sqlacodegen-1b321d1/src/sqlacodegen/py.typed000066400000000000000000000000001521515547200230520ustar00rootroot00000000000000agronholm-sqlacodegen-1b321d1/src/sqlacodegen/utils.py000066400000000000000000000157351521515547200231120ustar00rootroot00000000000000from __future__ import annotations import re import sys from collections.abc import Mapping from typing import Any, Literal, cast from sqlalchemy import PrimaryKeyConstraint, UniqueConstraint from sqlalchemy.engine import Connection, Engine from sqlalchemy.sql import ClauseElement from sqlalchemy.sql.elements import TextClause from sqlalchemy.sql.schema import ( CheckConstraint, ColumnCollectionConstraint, Constraint, ForeignKeyConstraint, Index, Table, ) _re_postgresql_nextval_sequence = re.compile(r"nextval\('(.+)'::regclass\)") _re_postgresql_sequence_delimiter = re.compile(r'(.*?)([."]|$)') def get_column_names(constraint: ColumnCollectionConstraint) -> list[str]: return list(constraint.columns.keys()) def get_constraint_sort_key(constraint: Constraint) -> str: if isinstance(constraint, CheckConstraint): return f"C{constraint.sqltext}" elif isinstance(constraint, ColumnCollectionConstraint): return constraint.__class__.__name__[0] + repr(get_column_names(constraint)) else: return str(constraint) def get_compiled_expression(statement: ClauseElement, bind: Engine | Connection) -> str: """Return the statement in a form where any placeholders have been filled in.""" return str(statement.compile(bind, compile_kwargs={"literal_binds": True})) def get_common_fk_constraints( table1: Table, table2: Table ) -> set[ForeignKeyConstraint]: """ Return a set of foreign key constraints the two tables have against each other. """ c1 = { c for c in table1.constraints if isinstance(c, ForeignKeyConstraint) and c.elements[0].column.table == table2 } c2 = { c for c in table2.constraints if isinstance(c, ForeignKeyConstraint) and c.elements[0].column.table == table1 } return c1.union(c2) def uses_default_name(constraint: Constraint | Index) -> bool: if not constraint.name or constraint.table is None: return True table = constraint.table values: dict[str, Any] = { "table_name": table.name, "constraint_name": constraint.name, } if isinstance(constraint, (Index, ColumnCollectionConstraint)): values.update( { "column_0N_name": "".join(col.name for col in constraint.columns), "column_0_N_name": "_".join(col.name for col in constraint.columns), "column_0N_label": "".join( col.label(col.name).name for col in constraint.columns ), "column_0_N_label": "_".join( col.label(col.name).name for col in constraint.columns ), "column_0N_key": "".join( col.key for col in constraint.columns if col.key ), "column_0_N_key": "_".join( col.key for col in constraint.columns if col.key ), } ) if constraint.columns: columns = constraint.columns.values() values.update( { "column_0_name": columns[0].name, "column_0_label": columns[0].label(columns[0].name).name, "column_0_key": columns[0].key, } ) key: Literal["fk", "pk", "ix", "ck", "uq"] if isinstance(constraint, Index): key = "ix" elif isinstance(constraint, CheckConstraint): key = "ck" elif isinstance(constraint, UniqueConstraint): key = "uq" elif isinstance(constraint, PrimaryKeyConstraint): key = "pk" elif isinstance(constraint, ForeignKeyConstraint): key = "fk" values.update( { "referred_table_name": constraint.referred_table, "referred_column_0_name": constraint.elements[0].column.name, "referred_column_0N_name": "".join( fk.column.name for fk in constraint.elements ), "referred_column_0_N_name": "_".join( fk.column.name for fk in constraint.elements ), "referred_column_0_label": constraint.elements[0] .column.label(constraint.elements[0].column.name) .name, "referred_fk.column_0N_label": "".join( fk.column.label(fk.column.name).name for fk in constraint.elements ), "referred_fk.column_0_N_label": "_".join( fk.column.label(fk.column.name).name for fk in constraint.elements ), "referred_fk.column_0_key": constraint.elements[0].column.key, "referred_fk.column_0N_key": "".join( fk.column.key for fk in constraint.elements if fk.column.key ), "referred_fk.column_0_N_key": "_".join( fk.column.key for fk in constraint.elements if fk.column.key ), } ) else: raise TypeError(f"Unknown constraint type: {constraint.__class__.__qualname__}") try: convention = cast( Mapping[str, str], table.metadata.naming_convention, )[key] return constraint.name == (convention % values) except KeyError: return False def render_callable( name: str, *args: object, kwargs: Mapping[str, object] | None = None, indentation: str = "", ) -> str: """ Render a function call. :param name: name of the callable :param args: positional arguments :param kwargs: keyword arguments :param indentation: if given, each argument will be rendered on its own line with this value used as the indentation """ if kwargs: args += tuple(f"{key}={value}" for key, value in kwargs.items()) if indentation: prefix = f"\n{indentation}" suffix = "\n" delimiter = f",\n{indentation}" else: prefix = suffix = "" delimiter = ", " rendered_args = delimiter.join(str(arg) for arg in args) return f"{name}({prefix}{rendered_args}{suffix})" def qualified_table_name(table: Table) -> str: if table.schema: return f"{table.schema}.{table.name}" else: return str(table.name) def decode_postgresql_sequence(clause: TextClause) -> tuple[str | None, str | None]: match = _re_postgresql_nextval_sequence.match(clause.text) if not match: return None, None schema: str | None = None sequence: str = "" in_quotes = False for match in _re_postgresql_sequence_delimiter.finditer(match.group(1)): sequence += match.group(1) if match.group(2) == '"': in_quotes = not in_quotes elif match.group(2) == ".": if in_quotes: sequence += "." else: schema, sequence = sequence, "" return schema, sequence def get_stdlib_module_names() -> set[str]: return set(sys.builtin_module_names) | set(sys.stdlib_module_names) agronholm-sqlacodegen-1b321d1/tests/000077500000000000000000000000001521515547200174535ustar00rootroot00000000000000agronholm-sqlacodegen-1b321d1/tests/__init__.py000066400000000000000000000000001521515547200215520ustar00rootroot00000000000000agronholm-sqlacodegen-1b321d1/tests/conftest.py000066400000000000000000000016221521515547200216530ustar00rootroot00000000000000from textwrap import dedent import pytest from pytest import FixtureRequest from sqlalchemy.engine import Engine, create_engine from sqlalchemy.orm import clear_mappers, configure_mappers from sqlalchemy.schema import MetaData @pytest.fixture def engine(request: FixtureRequest) -> Engine: dialect = getattr(request, "param", None) if dialect == "postgresql": return create_engine("postgresql+psycopg:///testdb") elif dialect == "mysql": return create_engine("mysql+mysqlconnector://testdb") else: return create_engine("sqlite:///:memory:") @pytest.fixture def metadata() -> MetaData: return MetaData() def validate_code(generated_code: str, expected_code: str) -> None: expected_code = dedent(expected_code) assert generated_code == expected_code try: exec(generated_code, {}) configure_mappers() finally: clear_mappers() agronholm-sqlacodegen-1b321d1/tests/test_cli.py000066400000000000000000000123101521515547200216300ustar00rootroot00000000000000from __future__ import annotations import sqlite3 import subprocess import sys from importlib.metadata import version from pathlib import Path import pytest future_imports = "from __future__ import annotations\n\n" @pytest.fixture def db_path(tmp_path: Path) -> Path: path = tmp_path / "test.db" with sqlite3.connect(str(path)) as conn: cursor = conn.cursor() cursor.execute( "CREATE TABLE foo (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL)" ) return path def test_cli_tables(db_path: Path, tmp_path: Path) -> None: output_path = tmp_path / "outfile" subprocess.run( [ "sqlacodegen", f"sqlite:///{db_path}", "--generator", "tables", "--outfile", str(output_path), ], check=True, ) assert ( output_path.read_text() == """\ from sqlalchemy import Column, Integer, MetaData, Table, Text metadata = MetaData() t_foo = Table( 'foo', metadata, Column('id', Integer, primary_key=True), Column('name', Text, nullable=False) ) """ ) def test_cli_declarative(db_path: Path, tmp_path: Path) -> None: output_path = tmp_path / "outfile" subprocess.run( [ "sqlacodegen", f"sqlite:///{db_path}", "--generator", "declarative", "--outfile", str(output_path), ], check=True, ) assert ( output_path.read_text() == """\ from sqlalchemy import Integer, Text from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Foo(Base): __tablename__ = 'foo' id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(Text, nullable=False) """ ) def test_cli_dataclass(db_path: Path, tmp_path: Path) -> None: output_path = tmp_path / "outfile" subprocess.run( [ "sqlacodegen", f"sqlite:///{db_path}", "--generator", "dataclasses", "--outfile", str(output_path), ], check=True, ) assert ( output_path.read_text() == """\ from sqlalchemy import Integer, Text from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column class Base(MappedAsDataclass, DeclarativeBase): pass class Foo(Base): __tablename__ = 'foo' id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(Text, nullable=False) """ ) def test_cli_sqlmodels(db_path: Path, tmp_path: Path) -> None: output_path = tmp_path / "outfile" subprocess.run( [ "sqlacodegen", f"sqlite:///{db_path}", "--generator", "sqlmodels", "--outfile", str(output_path), ], check=True, ) assert ( output_path.read_text() == """\ from sqlalchemy import Column, Integer, Text from sqlmodel import Field, SQLModel class Foo(SQLModel, table=True): id: int = Field(sa_column=Column('id', Integer, primary_key=True)) name: str = Field(sa_column=Column('name', Text, nullable=False)) """ ) def test_cli_engine_arg(db_path: Path, tmp_path: Path) -> None: output_path = tmp_path / "outfile" subprocess.run( [ "sqlacodegen", f"sqlite:///{db_path}", "--generator", "tables", "--engine-arg", 'connect_args={"timeout": 10}', "--outfile", str(output_path), ], check=True, ) assert ( output_path.read_text() == """\ from sqlalchemy import Column, Integer, MetaData, Table, Text metadata = MetaData() t_foo = Table( 'foo', metadata, Column('id', Integer, primary_key=True), Column('name', Text, nullable=False) ) """ ) def test_cli_invalid_engine_arg(db_path: Path, tmp_path: Path) -> None: output_path = tmp_path / "outfile" # Expect exception: # TypeError: 'this_arg_does_not_exist' is an invalid keyword argument for Connection() with pytest.raises(subprocess.CalledProcessError) as exc_info: subprocess.run( [ "sqlacodegen", f"sqlite:///{db_path}", "--generator", "tables", "--engine-arg", 'connect_args={"this_arg_does_not_exist": 10}', "--outfile", str(output_path), ], check=True, capture_output=True, ) if sys.version_info < (3, 13): assert ( "'this_arg_does_not_exist' is an invalid keyword argument" in exc_info.value.stderr.decode() ) else: assert ( "got an unexpected keyword argument 'this_arg_does_not_exist'" in exc_info.value.stderr.decode() ) def test_main() -> None: expected_version = version("sqlacodegen") completed = subprocess.run( [sys.executable, "-m", "sqlacodegen", "--version"], stdout=subprocess.PIPE, check=True, ) assert completed.stdout.decode().strip() == expected_version agronholm-sqlacodegen-1b321d1/tests/test_generator_dataclass.py000066400000000000000000000170701521515547200250760ustar00rootroot00000000000000from __future__ import annotations import pytest from _pytest.fixtures import FixtureRequest from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.engine import Engine from sqlalchemy.schema import Column, ForeignKeyConstraint, MetaData, Table from sqlalchemy.sql.expression import text from sqlalchemy.types import INTEGER, VARCHAR from sqlacodegen.generators import CodeGenerator, DataclassGenerator from .conftest import validate_code @pytest.fixture def generator( request: FixtureRequest, metadata: MetaData, engine: Engine ) -> CodeGenerator: options = getattr(request, "param", []) return DataclassGenerator(metadata, engine, options) def test_basic_class(generator: CodeGenerator) -> None: Table( "simple", generator.metadata, Column("id", INTEGER, primary_key=True), Column("name", VARCHAR(20)), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Integer, String from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, \ mapped_column class Base(MappedAsDataclass, DeclarativeBase): pass class Simple(Base): __tablename__ = 'simple' id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[Optional[str]] = mapped_column(String(20)) """, ) def test_mandatory_field_last(generator: CodeGenerator) -> None: Table( "simple", generator.metadata, Column("id", INTEGER, primary_key=True), Column("name", VARCHAR(20), server_default=text("foo")), Column("age", INTEGER, nullable=False), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Integer, String, text from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, \ mapped_column class Base(MappedAsDataclass, DeclarativeBase): pass class Simple(Base): __tablename__ = 'simple' id: Mapped[int] = mapped_column(Integer, primary_key=True) age: Mapped[int] = mapped_column(Integer, nullable=False) name: Mapped[Optional[str]] = mapped_column(String(20), \ server_default=text('foo')) """, ) def test_onetomany_optional(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("container_id", INTEGER), ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), ) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, \ mapped_column, relationship class Base(MappedAsDataclass, DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ back_populates='container') class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) container_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('simple_containers.id')) container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ back_populates='simple_items') """, ) def test_manytomany(generator: CodeGenerator) -> None: Table("simple_items", generator.metadata, Column("id", INTEGER, primary_key=True)) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) Table( "container_items", generator.metadata, Column("item_id", INTEGER), Column("container_id", INTEGER), ForeignKeyConstraint(["item_id"], ["simple_items.id"]), ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, ForeignKey, Integer, Table from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, \ mapped_column, relationship class Base(MappedAsDataclass, DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) item: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ secondary='container_items', back_populates='container') class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) container: Mapped[list['SimpleContainers']] = \ relationship('SimpleContainers', secondary='container_items', back_populates='item') t_container_items = Table( 'container_items', Base.metadata, Column('item_id', ForeignKey('simple_items.id')), Column('container_id', ForeignKey('simple_containers.id')) ) """, ) def test_named_foreign_key_constraints(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("container_id", INTEGER), ForeignKeyConstraint( ["container_id"], ["simple_containers.id"], name="foreignkeytest" ), ) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKeyConstraint, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, \ mapped_column, relationship class Base(MappedAsDataclass, DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ back_populates='container') class SimpleItems(Base): __tablename__ = 'simple_items' __table_args__ = ( ForeignKeyConstraint(['container_id'], ['simple_containers.id'], \ name='foreignkeytest'), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) container_id: Mapped[Optional[int]] = mapped_column(Integer) container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ back_populates='simple_items') """, ) def test_uuid_type_annotation(generator: CodeGenerator) -> None: Table( "simple", generator.metadata, Column("id", UUID, primary_key=True), ) validate_code( generator.generate(), """\ import uuid from sqlalchemy import UUID from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, \ mapped_column class Base(MappedAsDataclass, DeclarativeBase): pass class Simple(Base): __tablename__ = 'simple' id: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True) """, ) agronholm-sqlacodegen-1b321d1/tests/test_generator_declarative.py000066400000000000000000002650131521515547200254240ustar00rootroot00000000000000from __future__ import annotations import sys import pytest from _pytest.fixtures import FixtureRequest from geoalchemy2 import Geography, Geometry from sqlalchemy import BIGINT, PrimaryKeyConstraint from sqlalchemy import Enum as SAEnum from sqlalchemy.dialects import postgresql from sqlalchemy.dialects.postgresql import JSON, JSONB from sqlalchemy.engine import Engine from sqlalchemy.schema import ( CheckConstraint, Column, ForeignKey, ForeignKeyConstraint, Index, MetaData, Table, UniqueConstraint, ) from sqlalchemy.sql.expression import text from sqlalchemy.types import ARRAY, INTEGER, VARCHAR, Text from sqlacodegen.generators import CodeGenerator, DeclarativeGenerator from .conftest import validate_code @pytest.fixture def generator( request: FixtureRequest, metadata: MetaData, engine: Engine ) -> CodeGenerator: options = getattr(request, "param", []) return DeclarativeGenerator(metadata, engine, options) def test_indexes(generator: CodeGenerator) -> None: simple_items = Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("number", INTEGER), Column("text", VARCHAR), ) simple_items.indexes.add(Index("idx_number", simple_items.c.number)) simple_items.indexes.add( Index("idx_text_number", simple_items.c.text, simple_items.c.number) ) simple_items.indexes.add(Index("idx_text", simple_items.c.text, unique=True)) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Index, Integer, String from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class SimpleItems(Base): __tablename__ = 'simple_items' __table_args__ = ( Index('idx_number', 'number'), Index('idx_text', 'text', unique=True), Index('idx_text_number', 'text', 'number') ) id: Mapped[int] = mapped_column(Integer, primary_key=True) number: Mapped[Optional[int]] = mapped_column(Integer) text: Mapped[Optional[str]] = mapped_column(String) """, ) def test_index_with_kwargs(generator: CodeGenerator) -> None: simple_items = Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("name", VARCHAR), ) simple_items.indexes.add( Index("idx_name", simple_items.c.name, postgresql_using="gist", mysql_length=10) ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Index, Integer, String from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class SimpleItems(Base): __tablename__ = 'simple_items' __table_args__ = ( Index('idx_name', 'name', mysql_length=10, postgresql_using='gist'), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[Optional[str]] = mapped_column(String) """, ) def test_constraints(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("number", INTEGER), CheckConstraint("number > 0"), UniqueConstraint("id", "number"), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import CheckConstraint, Integer, UniqueConstraint from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class SimpleItems(Base): __tablename__ = 'simple_items' __table_args__ = ( CheckConstraint('number > 0'), UniqueConstraint('id', 'number') ) id: Mapped[int] = mapped_column(Integer, primary_key=True) number: Mapped[Optional[int]] = mapped_column(Integer) """, ) @pytest.mark.parametrize("generator", [["include_dialect_options"]], indirect=True) def test_include_dialect_options_and_info_table_and_column( generator: CodeGenerator, ) -> None: from .test_generator_tables import _PartitionInfo Table( "t_opts", generator.metadata, Column("id", INTEGER, primary_key=True, starrocks_is_agg_key=True), Column("name", VARCHAR, starrocks_agg_type="REPLACE"), starrocks_aggregate_key="id", starrocks_partition_by=_PartitionInfo("RANGE(id)"), starrocks_security="DEFINER", starrocks_PROPERTIES={"replication_num": "3", "storage_medium": "SSD"}, info={ "table_kind": "MATERIALIZED VIEW", "definition": "SELECT id, name FROM t_opts_base_table", }, ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Integer, String from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class TOpts(Base): __tablename__ = 't_opts' __table_args__ = {'info': {'definition': 'SELECT id, name FROM t_opts_base_table', 'table_kind': 'MATERIALIZED VIEW'}, 'starrocks_PROPERTIES': {'replication_num': '3', 'storage_medium': 'SSD'}, 'starrocks_aggregate_key': 'id', 'starrocks_partition_by': 'RANGE(id)', 'starrocks_security': 'DEFINER'} id: Mapped[int] = mapped_column(Integer, primary_key=True, starrocks_is_agg_key=True) name: Mapped[Optional[str]] = mapped_column(String, starrocks_agg_type='REPLACE') """, ) @pytest.mark.parametrize("generator", [["include_dialect_options"]], indirect=True) def test_include_dialect_options_and_info_with_hyphen(generator: CodeGenerator) -> None: Table( "t_opts2", generator.metadata, Column("id", INTEGER, primary_key=True), mysql_engine="InnoDB", info={"table_kind": "View"}, ) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class TOpts2(Base): __tablename__ = 't_opts2' __table_args__ = {'info': {'table_kind': 'View'}, 'mysql_engine': 'InnoDB'} id: Mapped[int] = mapped_column(Integer, primary_key=True) """, ) def test_include_dialect_options_not_enabled_skips(generator: CodeGenerator) -> None: from .test_generator_tables import _PartitionInfo Table( "t_plain", generator.metadata, Column( "id", INTEGER, primary_key=True, info={"abc": True}, starrocks_is_agg_key=True, ), starrocks_engine="OLAP", starrocks_partition_by=_PartitionInfo("RANGE(id)"), ) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class TPlain(Base): __tablename__ = 't_plain' id: Mapped[int] = mapped_column(Integer, primary_key=True) """, ) def test_keep_dialect_types_adapts_mysql_integer_default( generator: CodeGenerator, ) -> None: from sqlalchemy.dialects.mysql import INTEGER as MYSQL_INTEGER Table( "num", generator.metadata, Column("id", INTEGER, primary_key=True), Column("val", MYSQL_INTEGER(), nullable=False), ) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Num(Base): __tablename__ = 'num' id: Mapped[int] = mapped_column(Integer, primary_key=True) val: Mapped[int] = mapped_column(Integer, nullable=False) """, ) @pytest.mark.parametrize("generator", [["keep_dialect_types"]], indirect=True) def test_keep_dialect_types_keeps_mysql_integer(generator: CodeGenerator) -> None: from sqlalchemy.dialects.mysql import INTEGER as MYSQL_INTEGER Table( "num2", generator.metadata, Column("id", INTEGER, primary_key=True), Column("val", MYSQL_INTEGER(), nullable=False), ) validate_code( generator.generate(), """\ from sqlalchemy import INTEGER from sqlalchemy.dialects.mysql import INTEGER from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Num2(Base): __tablename__ = 'num2' id: Mapped[int] = mapped_column(INTEGER, primary_key=True) val: Mapped[int] = mapped_column(INTEGER, nullable=False) """, ) @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) @pytest.mark.parametrize("generator", [["keep_dialect_types"]], indirect=True) def test_keep_dialect_types_keeps_mysql_char_collation( generator: CodeGenerator, ) -> None: from sqlalchemy.dialects.mysql import CHAR as MYSQL_CHAR from sqlalchemy.dialects.mysql import INTEGER as MYSQL_INTEGER Table( "result_logs", generator.metadata, Column("id", MYSQL_INTEGER, primary_key=True), Column("result_code", MYSQL_CHAR(1, collation="utf8mb3_bin"), nullable=False), ) validate_code( generator.generate(), """\ from sqlalchemy.dialects.mysql import CHAR, INTEGER from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class ResultLogs(Base): __tablename__ = 'result_logs' id: Mapped[int] = mapped_column(INTEGER, primary_key=True) result_code: Mapped[str] = mapped_column(CHAR(1, collation='utf8mb3_bin'), nullable=False) """, ) def test_onetomany(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("container_id", INTEGER), ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), ) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ back_populates='container') class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) container_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('simple_containers.id')) container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ back_populates='simple_items') """, ) def test_onetomany_selfref(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("parent_item_id", INTEGER), ForeignKeyConstraint(["parent_item_id"], ["simple_items.id"]), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) parent_item_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('simple_items.id')) parent_item: Mapped[Optional['SimpleItems']] = relationship('SimpleItems', \ remote_side=[id], back_populates='parent_item_reverse') parent_item_reverse: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ remote_side=[parent_item_id], back_populates='parent_item') """, ) def test_onetomany_selfref_multi(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("parent_item_id", INTEGER), Column("top_item_id", INTEGER), ForeignKeyConstraint(["parent_item_id"], ["simple_items.id"]), ForeignKeyConstraint(["top_item_id"], ["simple_items.id"]), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) parent_item_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('simple_items.id')) top_item_id: Mapped[Optional[int]] = mapped_column(ForeignKey('simple_items.id')) parent_item: Mapped[Optional['SimpleItems']] = relationship('SimpleItems', \ remote_side=[id], foreign_keys=[parent_item_id], back_populates='parent_item_reverse') parent_item_reverse: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ remote_side=[parent_item_id], foreign_keys=[parent_item_id], \ back_populates='parent_item') top_item: Mapped[Optional['SimpleItems']] = relationship('SimpleItems', remote_side=[id], \ foreign_keys=[top_item_id], back_populates='top_item_reverse') top_item_reverse: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ remote_side=[top_item_id], foreign_keys=[top_item_id], back_populates='top_item') """, ) def test_onetomany_composite(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("container_id1", INTEGER), Column("container_id2", INTEGER), ForeignKeyConstraint( ["container_id1", "container_id2"], ["simple_containers.id1", "simple_containers.id2"], ondelete="CASCADE", onupdate="CASCADE", ), ) Table( "simple_containers", generator.metadata, Column("id1", INTEGER, primary_key=True), Column("id2", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKeyConstraint, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id1: Mapped[int] = mapped_column(Integer, primary_key=True) id2: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ back_populates='simple_containers') class SimpleItems(Base): __tablename__ = 'simple_items' __table_args__ = ( ForeignKeyConstraint(['container_id1', 'container_id2'], \ ['simple_containers.id1', 'simple_containers.id2'], ondelete='CASCADE', \ onupdate='CASCADE'), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) container_id1: Mapped[Optional[int]] = mapped_column(Integer) container_id2: Mapped[Optional[int]] = mapped_column(Integer) simple_containers: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ back_populates='simple_items') """, ) def test_onetomany_multiref(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("parent_container_id", INTEGER), Column("top_container_id", INTEGER, nullable=False), ForeignKeyConstraint(["parent_container_id"], ["simple_containers.id"]), ForeignKeyConstraint(["top_container_id"], ["simple_containers.id"]), ) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items_parent_container: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ foreign_keys='[SimpleItems.parent_container_id]', back_populates='parent_container') simple_items_top_container: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ foreign_keys='[SimpleItems.top_container_id]', back_populates='top_container') class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) top_container_id: Mapped[int] = \ mapped_column(ForeignKey('simple_containers.id'), nullable=False) parent_container_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('simple_containers.id')) parent_container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ foreign_keys=[parent_container_id], back_populates='simple_items_parent_container') top_container: Mapped['SimpleContainers'] = relationship('SimpleContainers', \ foreign_keys=[top_container_id], back_populates='simple_items_top_container') """, ) @pytest.mark.parametrize("generator", [["nofknames"]], indirect=True) def test_onetomany_multiref_with_nofknames(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("parent_container_id", INTEGER), Column("top_container_id", INTEGER, nullable=False), ForeignKeyConstraint(["parent_container_id"], ["simple_containers.id"]), ForeignKeyConstraint(["top_container_id"], ["simple_containers.id"]), ) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ foreign_keys='[SimpleItems.parent_container_id]', back_populates='parent_container') simple_items_: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ foreign_keys='[SimpleItems.top_container_id]', back_populates='top_container') class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) top_container_id: Mapped[int] = \ mapped_column(ForeignKey('simple_containers.id'), nullable=False) parent_container_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('simple_containers.id')) parent_container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ foreign_keys=[parent_container_id], back_populates='simple_items') top_container: Mapped['SimpleContainers'] = relationship('SimpleContainers', \ foreign_keys=[top_container_id], back_populates='simple_items_') """, ) def test_onetomany_multiref_no_id_suffix(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("parent_container", INTEGER), Column("top_container", INTEGER, nullable=False), ForeignKeyConstraint(["parent_container"], ["simple_containers.id"]), ForeignKeyConstraint(["top_container"], ["simple_containers.id"]), ) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items_parent_container: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ foreign_keys='[SimpleItems.parent_container]', back_populates='simple_containers') simple_items_top_container: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ foreign_keys='[SimpleItems.top_container]', back_populates='simple_containers_') class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) top_container: Mapped[int] = mapped_column(ForeignKey('simple_containers.id'), nullable=False) parent_container: Mapped[Optional[int]] = mapped_column(ForeignKey('simple_containers.id')) simple_containers: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ foreign_keys=[parent_container], back_populates='simple_items_parent_container') simple_containers_: Mapped['SimpleContainers'] = relationship('SimpleContainers', \ foreign_keys=[top_container], back_populates='simple_items_top_container') """, ) def test_onetomany_multiref_composite(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("parent_id1", INTEGER), Column("parent_id2", INTEGER), Column("top_id1", INTEGER), Column("top_id2", INTEGER), ForeignKeyConstraint( ["parent_id1", "parent_id2"], ["simple_containers.id1", "simple_containers.id2"], ), ForeignKeyConstraint( ["top_id1", "top_id2"], ["simple_containers.id1", "simple_containers.id2"] ), ) Table( "simple_containers", generator.metadata, Column("id1", INTEGER, primary_key=True), Column("id2", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKeyConstraint, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id1: Mapped[int] = mapped_column(Integer, primary_key=True) id2: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items_parent_id1_parent_id2: Mapped[list['SimpleItems']] = \ relationship('SimpleItems', foreign_keys='[SimpleItems.parent_id1, SimpleItems.parent_id2]', \ back_populates='parent_id1_parent_id2') simple_items_top_id1_top_id2: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ foreign_keys='[SimpleItems.top_id1, SimpleItems.top_id2]', \ back_populates='top_id1_top_id2') class SimpleItems(Base): __tablename__ = 'simple_items' __table_args__ = ( ForeignKeyConstraint(['parent_id1', 'parent_id2'], \ ['simple_containers.id1', 'simple_containers.id2']), ForeignKeyConstraint(['top_id1', 'top_id2'], \ ['simple_containers.id1', 'simple_containers.id2']) ) id: Mapped[int] = mapped_column(Integer, primary_key=True) parent_id1: Mapped[Optional[int]] = mapped_column(Integer) parent_id2: Mapped[Optional[int]] = mapped_column(Integer) top_id1: Mapped[Optional[int]] = mapped_column(Integer) top_id2: Mapped[Optional[int]] = mapped_column(Integer) parent_id1_parent_id2: Mapped[Optional['SimpleContainers']] = \ relationship('SimpleContainers', foreign_keys=[parent_id1, parent_id2], \ back_populates='simple_items_parent_id1_parent_id2') top_id1_top_id2: Mapped[Optional['SimpleContainers']] = \ relationship('SimpleContainers', foreign_keys=[top_id1, top_id2], \ back_populates='simple_items_top_id1_top_id2') """, ) @pytest.mark.parametrize("generator", [["use_inflect"]], indirect=True) def test_onetomany_multiref_with_inflect(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("parent_container_id", INTEGER), Column("top_container_id", INTEGER, nullable=False), ForeignKeyConstraint(["parent_container_id"], ["simple_containers.id"]), ForeignKeyConstraint(["top_container_id"], ["simple_containers.id"]), ) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainer(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items_parent_containers: Mapped[list['SimpleItem']] = relationship('SimpleItem', \ foreign_keys='[SimpleItem.parent_container_id]', back_populates='parent_container') simple_items_top_containers: Mapped[list['SimpleItem']] = relationship('SimpleItem', \ foreign_keys='[SimpleItem.top_container_id]', back_populates='top_container') class SimpleItem(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) top_container_id: Mapped[int] = \ mapped_column(ForeignKey('simple_containers.id'), nullable=False) parent_container_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('simple_containers.id')) parent_container: Mapped[Optional['SimpleContainer']] = relationship('SimpleContainer', \ foreign_keys=[parent_container_id], back_populates='simple_items_parent_containers') top_container: Mapped['SimpleContainer'] = relationship('SimpleContainer', \ foreign_keys=[top_container_id], back_populates='simple_items_top_containers') """, ) def test_onetoone(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("other_item_id", INTEGER), ForeignKeyConstraint(["other_item_id"], ["other_items.id"]), UniqueConstraint("other_item_id"), ) Table( "other_items", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class OtherItems(Base): __tablename__ = 'other_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items: Mapped[Optional['SimpleItems']] = relationship('SimpleItems', uselist=False, \ back_populates='other_item') class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) other_item_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('other_items.id'), unique=True) other_item: Mapped[Optional['OtherItems']] = relationship('OtherItems', \ back_populates='simple_items') """, ) def test_onetomany_noinflect(generator: CodeGenerator) -> None: Table( "oglkrogk", generator.metadata, Column("id", INTEGER, primary_key=True), Column("fehwiuhfiwID", INTEGER), ForeignKeyConstraint(["fehwiuhfiwID"], ["fehwiuhfiw.id"]), ) Table("fehwiuhfiw", generator.metadata, Column("id", INTEGER, primary_key=True)) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class Fehwiuhfiw(Base): __tablename__ = 'fehwiuhfiw' id: Mapped[int] = mapped_column(Integer, primary_key=True) oglkrogk: Mapped[list['Oglkrogk']] = relationship('Oglkrogk', \ back_populates='fehwiuhfiw') class Oglkrogk(Base): __tablename__ = 'oglkrogk' id: Mapped[int] = mapped_column(Integer, primary_key=True) fehwiuhfiwID: Mapped[Optional[int]] = mapped_column(ForeignKey('fehwiuhfiw.id')) fehwiuhfiw: Mapped[Optional['Fehwiuhfiw']] = \ relationship('Fehwiuhfiw', back_populates='oglkrogk') """, ) def test_onetomany_conflicting_column(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("container_id", INTEGER), ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), ) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), Column("relationship", Text), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer, Text from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) relationship_: Mapped[Optional[str]] = mapped_column('relationship', Text) simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ back_populates='container') class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) container_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('simple_containers.id')) container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ back_populates='simple_items') """, ) def test_onetomany_conflicting_relationship(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("relationship_id", INTEGER), ForeignKeyConstraint(["relationship_id"], ["relationship.id"]), ) Table("relationship", generator.metadata, Column("id", INTEGER, primary_key=True)) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class Relationship(Base): __tablename__ = 'relationship' id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ back_populates='relationship_') class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) relationship_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('relationship.id')) relationship_: Mapped[Optional['Relationship']] = relationship('Relationship', \ back_populates='simple_items') """, ) @pytest.mark.parametrize("generator", [["nobidi"]], indirect=True) def test_manytoone_nobidi(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("container_id", INTEGER), ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), ) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) container_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('simple_containers.id')) container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers') """, ) def test_manytomany_multi(generator: CodeGenerator) -> None: Table( "students", generator.metadata, Column("student_id", INTEGER, primary_key=True), Column("name", VARCHAR), ) Table( "courses", generator.metadata, Column("course_id", INTEGER, primary_key=True), Column("title", VARCHAR), ) # First many-to-many relationship (enrollments) Table( "enrollments", generator.metadata, Column("student_id", INTEGER, ForeignKey("students.student_id")), Column("course_id", INTEGER, ForeignKey("courses.course_id")), ) # Second many-to-many relationship (waitlist) Table( "waitlist", generator.metadata, Column("student_id", INTEGER, ForeignKey("students.student_id")), Column("course_id", INTEGER, ForeignKey("courses.course_id")), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Column, ForeignKey, Integer, String, Table from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class Courses(Base): __tablename__ = 'courses' course_id: Mapped[int] = mapped_column(Integer, primary_key=True) title: Mapped[Optional[str]] = mapped_column(String) students_enrollments: Mapped[list['Students']] = relationship('Students', \ secondary='enrollments', back_populates='courses_enrollments') students_waitlist: Mapped[list['Students']] = relationship('Students', \ secondary='waitlist', back_populates='courses_waitlist') class Students(Base): __tablename__ = 'students' student_id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[Optional[str]] = mapped_column(String) courses_enrollments: Mapped[list['Courses']] = relationship('Courses', \ secondary='enrollments', back_populates='students_enrollments') courses_waitlist: Mapped[list['Courses']] = relationship('Courses', \ secondary='waitlist', back_populates='students_waitlist') t_enrollments = Table( 'enrollments', Base.metadata, Column('student_id', ForeignKey('students.student_id')), Column('course_id', ForeignKey('courses.course_id')) ) t_waitlist = Table( 'waitlist', Base.metadata, Column('student_id', ForeignKey('students.student_id')), Column('course_id', ForeignKey('courses.course_id')) ) """, ) @pytest.mark.parametrize("generator", [["nofknames"]], indirect=True) def test_manytomany_multi_with_nofknames(generator: CodeGenerator) -> None: Table( "students", generator.metadata, Column("student_id", INTEGER, primary_key=True), Column("name", VARCHAR), ) Table( "courses", generator.metadata, Column("course_id", INTEGER, primary_key=True), Column("title", VARCHAR), ) # First many-to-many relationship (enrollments) Table( "enrollments", generator.metadata, Column("student_id", INTEGER, ForeignKey("students.student_id")), Column("course_id", INTEGER, ForeignKey("courses.course_id")), ) # Second many-to-many relationship (waitlist) Table( "waitlist", generator.metadata, Column("student_id", INTEGER, ForeignKey("students.student_id")), Column("course_id", INTEGER, ForeignKey("courses.course_id")), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Column, ForeignKey, Integer, String, Table from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class Courses(Base): __tablename__ = 'courses' course_id: Mapped[int] = mapped_column(Integer, primary_key=True) title: Mapped[Optional[str]] = mapped_column(String) student: Mapped[list['Students']] = relationship('Students', secondary='enrollments', \ back_populates='course') student_: Mapped[list['Students']] = relationship('Students', secondary='waitlist', \ back_populates='course_') class Students(Base): __tablename__ = 'students' student_id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[Optional[str]] = mapped_column(String) course: Mapped[list['Courses']] = relationship('Courses', secondary='enrollments', \ back_populates='student') course_: Mapped[list['Courses']] = relationship('Courses', secondary='waitlist', \ back_populates='student_') t_enrollments = Table( 'enrollments', Base.metadata, Column('student_id', ForeignKey('students.student_id')), Column('course_id', ForeignKey('courses.course_id')) ) t_waitlist = Table( 'waitlist', Base.metadata, Column('student_id', ForeignKey('students.student_id')), Column('course_id', ForeignKey('courses.course_id')) ) """, ) def test_manytomany(generator: CodeGenerator) -> None: Table("left_table", generator.metadata, Column("id", INTEGER, primary_key=True)) Table( "right_table", generator.metadata, Column("id", INTEGER, primary_key=True), ) Table( "association_table", generator.metadata, Column("left_id", INTEGER), Column("right_id", INTEGER), ForeignKeyConstraint(["left_id"], ["left_table.id"]), ForeignKeyConstraint(["right_id"], ["right_table.id"]), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, ForeignKey, Integer, Table from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class LeftTable(Base): __tablename__ = 'left_table' id: Mapped[int] = mapped_column(Integer, primary_key=True) right: Mapped[list['RightTable']] = relationship('RightTable', \ secondary='association_table', back_populates='left') class RightTable(Base): __tablename__ = 'right_table' id: Mapped[int] = mapped_column(Integer, primary_key=True) left: Mapped[list['LeftTable']] = relationship('LeftTable', \ secondary='association_table', back_populates='right') t_association_table = Table( 'association_table', Base.metadata, Column('left_id', ForeignKey('left_table.id')), Column('right_id', ForeignKey('right_table.id')) ) """, ) @pytest.mark.parametrize("generator", [["nobidi"]], indirect=True) def test_manytomany_nobidi(generator: CodeGenerator) -> None: Table("simple_items", generator.metadata, Column("id", INTEGER, primary_key=True)) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) Table( "container_items", generator.metadata, Column("item_id", INTEGER), Column("container_id", INTEGER), ForeignKeyConstraint(["item_id"], ["simple_items.id"]), ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, ForeignKey, Integer, Table from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) item: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ secondary='container_items') class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) t_container_items = Table( 'container_items', Base.metadata, Column('item_id', ForeignKey('simple_items.id')), Column('container_id', ForeignKey('simple_containers.id')) ) """, ) def test_manytomany_selfref(generator: CodeGenerator) -> None: Table("simple_items", generator.metadata, Column("id", INTEGER, primary_key=True)) Table( "child_items", generator.metadata, Column("parent_id", INTEGER), Column("child_id", INTEGER), ForeignKeyConstraint(["parent_id"], ["simple_items.id"]), ForeignKeyConstraint(["child_id"], ["simple_items.id"]), schema="otherschema", ) validate_code( generator.generate(), """\ from sqlalchemy import Column, ForeignKey, Integer, Table from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) parent: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ secondary='otherschema.child_items', primaryjoin=lambda: SimpleItems.id \ == t_child_items.c.child_id, \ secondaryjoin=lambda: SimpleItems.id == \ t_child_items.c.parent_id, back_populates='child') child: Mapped[list['SimpleItems']] = \ relationship('SimpleItems', secondary='otherschema.child_items', \ primaryjoin=lambda: SimpleItems.id == t_child_items.c.parent_id, \ secondaryjoin=lambda: SimpleItems.id == t_child_items.c.child_id, \ back_populates='parent') t_child_items = Table( 'child_items', Base.metadata, Column('parent_id', ForeignKey('simple_items.id')), Column('child_id', ForeignKey('simple_items.id')), schema='otherschema' ) """, ) def test_manytomany_composite(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id1", INTEGER, primary_key=True), Column("id2", INTEGER, primary_key=True), ) Table( "simple_containers", generator.metadata, Column("id1", INTEGER, primary_key=True), Column("id2", INTEGER, primary_key=True), ) Table( "container_items", generator.metadata, Column("item_id1", INTEGER), Column("item_id2", INTEGER), Column("container_id1", INTEGER), Column("container_id2", INTEGER), ForeignKeyConstraint( ["item_id1", "item_id2"], ["simple_items.id1", "simple_items.id2"] ), ForeignKeyConstraint( ["container_id1", "container_id2"], ["simple_containers.id1", "simple_containers.id2"], ), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, ForeignKeyConstraint, Integer, Table from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id1: Mapped[int] = mapped_column(Integer, primary_key=True) id2: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ secondary='container_items', back_populates='simple_containers') class SimpleItems(Base): __tablename__ = 'simple_items' id1: Mapped[int] = mapped_column(Integer, primary_key=True) id2: Mapped[int] = mapped_column(Integer, primary_key=True) simple_containers: Mapped[list['SimpleContainers']] = \ relationship('SimpleContainers', secondary='container_items', \ back_populates='simple_items') t_container_items = Table( 'container_items', Base.metadata, Column('item_id1', Integer), Column('item_id2', Integer), Column('container_id1', Integer), Column('container_id2', Integer), ForeignKeyConstraint(['container_id1', 'container_id2'], \ ['simple_containers.id1', 'simple_containers.id2']), ForeignKeyConstraint(['item_id1', 'item_id2'], \ ['simple_items.id1', 'simple_items.id2']) ) """, ) def test_composite_nullable_pk(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id1", INTEGER, primary_key=True), Column("id2", INTEGER, primary_key=True, nullable=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class SimpleItems(Base): __tablename__ = 'simple_items' id1: Mapped[int] = mapped_column(Integer, primary_key=True) id2: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True, nullable=True) """, ) def test_composite_autoincrement_pk(generator: CodeGenerator) -> None: Table( "simple_autoincrement_items", generator.metadata, Column("id1", INTEGER, primary_key=True, autoincrement=True), Column("id2", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class SimpleAutoincrementItems(Base): __tablename__ = 'simple_autoincrement_items' id1: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id2: Mapped[int] = mapped_column(Integer, primary_key=True) """, ) def test_joined_inheritance(generator: CodeGenerator) -> None: Table( "simple_sub_items", generator.metadata, Column("simple_items_id", INTEGER, primary_key=True), Column("data3", INTEGER), ForeignKeyConstraint(["simple_items_id"], ["simple_items.super_item_id"]), ) Table( "simple_super_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("data1", INTEGER), ) Table( "simple_items", generator.metadata, Column("super_item_id", INTEGER, primary_key=True), Column("data2", INTEGER), ForeignKeyConstraint(["super_item_id"], ["simple_super_items.id"]), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class SimpleSuperItems(Base): __tablename__ = 'simple_super_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) data1: Mapped[Optional[int]] = mapped_column(Integer) class SimpleItems(SimpleSuperItems): __tablename__ = 'simple_items' super_item_id: Mapped[int] = mapped_column(ForeignKey('simple_super_items.id'), \ primary_key=True) data2: Mapped[Optional[int]] = mapped_column(Integer) class SimpleSubItems(SimpleItems): __tablename__ = 'simple_sub_items' simple_items_id: Mapped[int] = \ mapped_column(ForeignKey('simple_items.super_item_id'), primary_key=True) data3: Mapped[Optional[int]] = mapped_column(Integer) """, ) def test_joined_inheritance_same_table_name(generator: CodeGenerator) -> None: Table( "simple", generator.metadata, Column("id", INTEGER, primary_key=True), ) Table( "simple", generator.metadata, Column("id", INTEGER, ForeignKey("simple.id"), primary_key=True), schema="altschema", ) validate_code( generator.generate(), """\ from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Simple(Base): __tablename__ = 'simple' id: Mapped[int] = mapped_column(Integer, primary_key=True) class Simple_(Simple): __tablename__ = 'simple' __table_args__ = {'schema': 'altschema'} id: Mapped[int] = mapped_column(ForeignKey('simple.id'), primary_key=True) """, ) @pytest.mark.parametrize("generator", [["use_inflect"]], indirect=True) def test_use_inflect(generator: CodeGenerator) -> None: Table("simple_items", generator.metadata, Column("id", INTEGER, primary_key=True)) Table("singular", generator.metadata, Column("id", INTEGER, primary_key=True)) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class SimpleItem(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) class Singular(Base): __tablename__ = 'singular' id: Mapped[int] = mapped_column(Integer, primary_key=True) """, ) @pytest.mark.parametrize("generator", [["use_inflect"]], indirect=True) @pytest.mark.parametrize( argnames=("table_name", "class_name", "relationship_name"), argvalues=[ ("manufacturers", "manufacturer", "manufacturer"), ("statuses", "status", "status"), ("studies", "study", "study"), ("moose", "moose", "moose"), ], ids=[ "test_inflect_manufacturer", "test_inflect_status", "test_inflect_study", "test_inflect_moose", ], ) def test_use_inflect_plural( generator: CodeGenerator, table_name: str, class_name: str, relationship_name: str, ) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column(f"{relationship_name}_id", INTEGER), ForeignKeyConstraint([f"{relationship_name}_id"], [f"{table_name}.id"]), UniqueConstraint(f"{relationship_name}_id"), ) Table(table_name, generator.metadata, Column("id", INTEGER, primary_key=True)) validate_code( generator.generate(), f"""\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class {class_name.capitalize()}(Base): __tablename__ = '{table_name}' id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_item: Mapped[Optional['SimpleItem']] = relationship('SimpleItem', uselist=False, \ back_populates='{relationship_name}') class SimpleItem(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) {relationship_name}_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('{table_name}.id'), unique=True) {relationship_name}: Mapped[Optional['{class_name.capitalize()}']] = \ relationship('{class_name.capitalize()}', back_populates='simple_item') """, ) @pytest.mark.parametrize("generator", [["use_inflect"]], indirect=True) def test_use_inflect_plural_double_pluralize(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("users_id", INTEGER), Column("groups_id", INTEGER), ForeignKeyConstraint( ["groups_id"], ["groups.groups_id"], name="fk_users_groups_id" ), PrimaryKeyConstraint("users_id", name="users_pkey"), ) Table( "groups", generator.metadata, Column("groups_id", INTEGER), Column("group_name", Text(50), nullable=False), PrimaryKeyConstraint("groups_id", name="groups_pkey"), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKeyConstraint, Integer, PrimaryKeyConstraint, Text from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class Group(Base): __tablename__ = 'groups' __table_args__ = ( PrimaryKeyConstraint('groups_id', name='groups_pkey'), ) groups_id: Mapped[int] = mapped_column(Integer, primary_key=True) group_name: Mapped[str] = mapped_column(Text(50), nullable=False) users: Mapped[list['User']] = relationship('User', back_populates='group') class User(Base): __tablename__ = 'users' __table_args__ = ( ForeignKeyConstraint(['groups_id'], ['groups.groups_id'], name='fk_users_groups_id'), PrimaryKeyConstraint('users_id', name='users_pkey') ) users_id: Mapped[int] = mapped_column(Integer, primary_key=True) groups_id: Mapped[Optional[int]] = mapped_column(Integer) group: Mapped[Optional['Group']] = relationship('Group', back_populates='users') """, ) def test_table_kwargs(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), schema="testschema", ) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class SimpleItems(Base): __tablename__ = 'simple_items' __table_args__ = {'schema': 'testschema'} id: Mapped[int] = mapped_column(Integer, primary_key=True) """, ) def test_table_args_kwargs(generator: CodeGenerator) -> None: simple_items = Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("name", VARCHAR), schema="testschema", ) simple_items.indexes.add(Index("testidx", simple_items.c.id, simple_items.c.name)) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Index, Integer, String from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class SimpleItems(Base): __tablename__ = 'simple_items' __table_args__ = ( Index('testidx', 'id', 'name'), {'schema': 'testschema'} ) id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[Optional[str]] = mapped_column(String) """, ) def test_foreign_key_schema(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("other_item_id", INTEGER), ForeignKeyConstraint(["other_item_id"], ["otherschema.other_items.id"]), ) Table( "other_items", generator.metadata, Column("id", INTEGER, primary_key=True), schema="otherschema", ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKey, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class OtherItems(Base): __tablename__ = 'other_items' __table_args__ = {'schema': 'otherschema'} id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ back_populates='other_item') class SimpleItems(Base): __tablename__ = 'simple_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) other_item_id: Mapped[Optional[int]] = \ mapped_column(ForeignKey('otherschema.other_items.id')) other_item: Mapped[Optional['OtherItems']] = relationship('OtherItems', \ back_populates='simple_items') """, ) def test_invalid_attribute_names(generator: CodeGenerator) -> None: Table( "simple-items", generator.metadata, Column("id-test", INTEGER, primary_key=True), Column("4test", INTEGER), Column("_4test", INTEGER), Column("def", INTEGER), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class SimpleItems(Base): __tablename__ = 'simple-items' id_test: Mapped[int] = mapped_column('id-test', Integer, primary_key=True) _4test: Mapped[Optional[int]] = mapped_column('4test', Integer) _4test_: Mapped[Optional[int]] = mapped_column('_4test', Integer) def_: Mapped[Optional[int]] = mapped_column('def', Integer) """, ) def test_pascal(generator: CodeGenerator) -> None: Table( "CustomerAPIPreference", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class CustomerAPIPreference(Base): __tablename__ = 'CustomerAPIPreference' id: Mapped[int] = mapped_column(Integer, primary_key=True) """, ) def test_underscore(generator: CodeGenerator) -> None: Table( "customer_api_preference", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class CustomerApiPreference(Base): __tablename__ = 'customer_api_preference' id: Mapped[int] = mapped_column(Integer, primary_key=True) """, ) def test_pascal_underscore(generator: CodeGenerator) -> None: Table( "customer_API_Preference", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class CustomerAPIPreference(Base): __tablename__ = 'customer_API_Preference' id: Mapped[int] = mapped_column(Integer, primary_key=True) """, ) def test_pascal_multiple_underscore(generator: CodeGenerator) -> None: Table( "customer_API__Preference", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class CustomerAPIPreference(Base): __tablename__ = 'customer_API__Preference' id: Mapped[int] = mapped_column(Integer, primary_key=True) """, ) @pytest.mark.parametrize( "generator, nocomments", [([], False), (["nocomments"], True)], indirect=["generator"], ) def test_column_comment(generator: CodeGenerator, nocomments: bool) -> None: Table( "simple", generator.metadata, Column("id", INTEGER, primary_key=True, comment="this is a 'comment'"), ) comment_part = "" if nocomments else ", comment=\"this is a 'comment'\"" validate_code( generator.generate(), f"""\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Simple(Base): __tablename__ = 'simple' id: Mapped[int] = mapped_column(Integer, primary_key=True{comment_part}) """, ) def test_table_comment(generator: CodeGenerator) -> None: Table( "simple", generator.metadata, Column("id", INTEGER, primary_key=True), comment="this is a 'comment'", ) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Simple(Base): __tablename__ = 'simple' __table_args__ = {'comment': "this is a 'comment'"} id: Mapped[int] = mapped_column(Integer, primary_key=True) """, ) def test_metadata_column(generator: CodeGenerator) -> None: Table( "simple", generator.metadata, Column("id", INTEGER, primary_key=True), Column("metadata", VARCHAR), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Integer, String from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Simple(Base): __tablename__ = 'simple' id: Mapped[int] = mapped_column(Integer, primary_key=True) metadata_: Mapped[Optional[str]] = mapped_column('metadata', String) """, ) def test_invalid_variable_name_from_column(generator: CodeGenerator) -> None: Table( "simple", generator.metadata, Column(" id ", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Simple(Base): __tablename__ = 'simple' id: Mapped[int] = mapped_column(' id ', Integer, primary_key=True) """, ) def test_only_tables(generator: CodeGenerator) -> None: Table("simple", generator.metadata, Column("id", INTEGER)) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table metadata = MetaData() t_simple = Table( 'simple', metadata, Column('id', Integer) ) """, ) def test_named_constraints(generator: CodeGenerator) -> None: Table( "simple", generator.metadata, Column("id", INTEGER), Column("text", VARCHAR), CheckConstraint("id > 0", name="checktest"), PrimaryKeyConstraint("id", name="primarytest"), UniqueConstraint("text", name="uniquetest"), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import CheckConstraint, Integer, PrimaryKeyConstraint, \ String, UniqueConstraint from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Simple(Base): __tablename__ = 'simple' __table_args__ = ( CheckConstraint('id > 0', name='checktest'), PrimaryKeyConstraint('id', name='primarytest'), UniqueConstraint('text', name='uniquetest') ) id: Mapped[int] = mapped_column(Integer, primary_key=True) text: Mapped[Optional[str]] = mapped_column(String) """, ) def test_named_foreign_key_constraints(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("container_id", INTEGER), ForeignKeyConstraint( ["container_id"], ["simple_containers.id"], name="foreignkeytest" ), ) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKeyConstraint, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ back_populates='container') class SimpleItems(Base): __tablename__ = 'simple_items' __table_args__ = ( ForeignKeyConstraint(['container_id'], ['simple_containers.id'], \ name='foreignkeytest'), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) container_id: Mapped[Optional[int]] = mapped_column(Integer) container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ back_populates='simple_items') """, ) @pytest.mark.parametrize("generator", [["noidsuffix"]], indirect=True) def test_named_foreign_key_constraints_with_noidsuffix( generator: CodeGenerator, ) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("container_id", INTEGER), ForeignKeyConstraint( ["container_id"], ["simple_containers.id"], name="foreignkeytest" ), ) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ForeignKeyConstraint, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass class SimpleContainers(Base): __tablename__ = 'simple_containers' id: Mapped[int] = mapped_column(Integer, primary_key=True) simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ back_populates='simple_containers') class SimpleItems(Base): __tablename__ = 'simple_items' __table_args__ = ( ForeignKeyConstraint(['container_id'], ['simple_containers.id'], \ name='foreignkeytest'), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) container_id: Mapped[Optional[int]] = mapped_column(Integer) simple_containers: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ back_populates='simple_items') """, ) # @pytest.mark.xfail(strict=True) def test_colname_import_conflict(generator: CodeGenerator) -> None: Table( "simple", generator.metadata, Column("id", INTEGER, primary_key=True), Column("text", VARCHAR), Column("textwithdefault", VARCHAR, server_default=text("'test'")), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Integer, String, text from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Simple(Base): __tablename__ = 'simple' id: Mapped[int] = mapped_column(Integer, primary_key=True) text_: Mapped[Optional[str]] = mapped_column('text', String) textwithdefault: Mapped[Optional[str]] = mapped_column(String, \ server_default=text("'test'")) """, ) def test_table_with_arrays(generator: CodeGenerator) -> None: Table( "with_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("int_items_not_optional", ARRAY(INTEGER()), nullable=False), Column("str_matrix", ARRAY(VARCHAR(), dimensions=2)), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import ARRAY, INTEGER, Integer, VARCHAR from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class WithItems(Base): __tablename__ = 'with_items' id: Mapped[int] = mapped_column(Integer, primary_key=True) int_items_not_optional: Mapped[list[int]] = mapped_column(ARRAY(INTEGER()), nullable=False) str_matrix: Mapped[Optional[list[list[str]]]] = mapped_column(ARRAY(VARCHAR(), dimensions=2)) """, ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_domain_json(generator: CodeGenerator) -> None: Table( "test_domain_json", generator.metadata, Column("id", BIGINT, primary_key=True), Column( "foo", postgresql.DOMAIN( "domain_json", JSON, not_null=False, ), nullable=True, ), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import BigInteger from sqlalchemy.dialects.postgresql import DOMAIN, JSON from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class TestDomainJson(Base): __tablename__ = 'test_domain_json' id: Mapped[int] = mapped_column(BigInteger, primary_key=True) foo: Mapped[Optional[dict]] = mapped_column(DOMAIN('domain_json', JSON(), not_null=False)) """, ) @pytest.mark.parametrize( "domain_type", [JSONB, JSON], ) def test_domain_non_default_json( generator: CodeGenerator, domain_type: type[JSON] | type[JSONB], ) -> None: Table( "test_domain_json", generator.metadata, Column("id", BIGINT, primary_key=True), Column( "foo", postgresql.DOMAIN( "domain_json", domain_type(astext_type=Text(128)), not_null=False, ), nullable=True, ), ) validate_code( generator.generate(), f"""\ from typing import Optional from sqlalchemy import BigInteger, Text from sqlalchemy.dialects.postgresql import DOMAIN, {domain_type.__name__} from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class TestDomainJson(Base): __tablename__ = 'test_domain_json' id: Mapped[int] = mapped_column(BigInteger, primary_key=True) foo: Mapped[Optional[dict]] = mapped_column(DOMAIN('domain_json', {domain_type.__name__}(astext_type=Text(length=128)), not_null=False)) """, ) @pytest.mark.skipif( sys.version_info < (3, 10), reason="This test assumes GeoAlchemy2 0.18.x and above, which does not support python 3.9", ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_geoalchemy2_types(generator: CodeGenerator) -> None: Table( "spatial_table", generator.metadata, Column("id", INTEGER, primary_key=True), Column("geom", Geometry("POINT", srid=4326, dimension=2), nullable=False), Column("geog", Geography("POLYGON", dimension=2)), ) validate_code( generator.generate(), """\ from typing import Any, Optional from geoalchemy2.types import Geography, Geometry from sqlalchemy import Index, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class SpatialTable(Base): __tablename__ = 'spatial_table' __table_args__ = ( Index('idx_spatial_table_geog', 'geog', postgresql_using='gist'), Index('idx_spatial_table_geom', 'geom', postgresql_using='gist') ) id: Mapped[int] = mapped_column(Integer, primary_key=True) geom: Mapped[Any] = mapped_column(Geometry('POINT', 4326, 2, from_text='ST_GeomFromEWKT', name='geometry', nullable=False), nullable=False) geog: Mapped[Optional[Any]] = mapped_column(Geography('POLYGON', dimension=2, from_text='ST_GeogFromText', name='geography')) """, ) def test_enum_nonativeenums_option(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "status", SAEnum("active", "inactive", "pending", name="status_enum"), nullable=False, ), ) # Recreate generator with nonativeenums option generator = DeclarativeGenerator( generator.metadata, generator.bind, ["nonativeenums"] ) validate_code( generator.generate(), """\ from sqlalchemy import Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Users(Base): __tablename__ = 'users' id: Mapped[int] = mapped_column(Integer, primary_key=True) status: Mapped[str] = mapped_column(Enum('active', 'inactive', 'pending', name='status_enum'), nullable=False) """, ) def test_enum_shared_values(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "status", SAEnum("active", "inactive", "pending", name="status_enum"), nullable=False, ), ) Table( "accounts", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "status", SAEnum("active", "inactive", "pending", name="status_enum"), nullable=False, ), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class StatusEnum(str, enum.Enum): ACTIVE = 'active' INACTIVE = 'inactive' PENDING = 'pending' class Accounts(Base): __tablename__ = 'accounts' id: Mapped[int] = mapped_column(Integer, primary_key=True) status: Mapped[StatusEnum] = mapped_column(Enum(StatusEnum, values_callable=lambda cls: [member.value for member in cls], name='status_enum'), nullable=False) class Users(Base): __tablename__ = 'users' id: Mapped[int] = mapped_column(Integer, primary_key=True) status: Mapped[StatusEnum] = mapped_column(Enum(StatusEnum, values_callable=lambda cls: [member.value for member in cls], name='status_enum'), nullable=False) """, ) def test_enum_unnamed(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "status", SAEnum("active", "inactive", "pending"), nullable=False, ), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class UsersStatus(str, enum.Enum): ACTIVE = 'active' INACTIVE = 'inactive' PENDING = 'pending' class Users(Base): __tablename__ = 'users' id: Mapped[int] = mapped_column(Integer, primary_key=True) status: Mapped[UsersStatus] = mapped_column(Enum(UsersStatus, values_callable=lambda cls: [member.value for member in cls]), nullable=False) """, ) def test_enum_unnamed_reuse_same_values(generator: CodeGenerator) -> None: # table "a_b", column "c" -> A + B + C = ABC # table "a", column "b_c" -> A + B + C = ABC # Both generate same name with same values, so reuse Table( "a_b", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "c", SAEnum("active", "inactive"), nullable=False, ), ) Table( "a", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "b_c", SAEnum("active", "inactive"), nullable=False, ), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class ABC(str, enum.Enum): ACTIVE = 'active' INACTIVE = 'inactive' class A(Base): __tablename__ = 'a' id: Mapped[int] = mapped_column(Integer, primary_key=True) b_c: Mapped[ABC] = mapped_column(Enum(ABC, values_callable=lambda cls: [member.value for member in cls]), nullable=False) class AB(Base): __tablename__ = 'a_b' id: Mapped[int] = mapped_column(Integer, primary_key=True) c: Mapped[ABC] = mapped_column(Enum(ABC, values_callable=lambda cls: [member.value for member in cls]), nullable=False) """, ) def test_enum_unnamed_name_collision_different_values(generator: CodeGenerator) -> None: # table "a_b", column "c" -> A + B + C = ABC # table "a", column "b_c" -> A + B + C = ABC # Same name but different values, so append counter Table( "a_b", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "c", SAEnum("active", "inactive"), nullable=False, ), ) Table( "a", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "b_c", SAEnum("pending", "complete"), nullable=False, ), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class ABC(str, enum.Enum): ACTIVE = 'active' INACTIVE = 'inactive' class ABC1(str, enum.Enum): PENDING = 'pending' COMPLETE = 'complete' class A(Base): __tablename__ = 'a' id: Mapped[int] = mapped_column(Integer, primary_key=True) b_c: Mapped[ABC1] = mapped_column(Enum(ABC1, values_callable=lambda cls: [member.value for member in cls]), nullable=False) class AB(Base): __tablename__ = 'a_b' id: Mapped[int] = mapped_column(Integer, primary_key=True) c: Mapped[ABC] = mapped_column(Enum(ABC, values_callable=lambda cls: [member.value for member in cls]), nullable=False) """, ) def test_synthetic_enum_generation(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column("status", VARCHAR(20), nullable=False), CheckConstraint("users.status IN ('active', 'inactive', 'pending')"), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import CheckConstraint, Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class UsersStatus(str, enum.Enum): ACTIVE = 'active' INACTIVE = 'inactive' PENDING = 'pending' class Users(Base): __tablename__ = 'users' __table_args__ = ( CheckConstraint("users.status IN ('active', 'inactive', 'pending')"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) status: Mapped[UsersStatus] = mapped_column(Enum(UsersStatus, values_callable=lambda cls: [member.value for member in cls]), nullable=False) """, ) def test_synthetic_enum_nosyntheticenums_option(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column("status", VARCHAR(20), nullable=False), CheckConstraint("users.status IN ('active', 'inactive', 'pending')"), ) # Recreate generator with nosyntheticenums option generator = DeclarativeGenerator( generator.metadata, generator.bind, ["nosyntheticenums"] ) validate_code( generator.generate(), """\ from sqlalchemy import CheckConstraint, Integer, String from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Users(Base): __tablename__ = 'users' __table_args__ = ( CheckConstraint("users.status IN ('active', 'inactive', 'pending')"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) status: Mapped[str] = mapped_column(String(20), nullable=False) """, ) def test_synthetic_enum_shared_values(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column("status", VARCHAR(20), nullable=False), CheckConstraint("users.status IN ('active', 'inactive', 'pending')"), ) Table( "accounts", generator.metadata, Column("id", INTEGER, primary_key=True), Column("status", VARCHAR(20), nullable=False), CheckConstraint("accounts.status IN ('active', 'inactive', 'pending')"), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import CheckConstraint, Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class AccountsStatus(str, enum.Enum): ACTIVE = 'active' INACTIVE = 'inactive' PENDING = 'pending' class UsersStatus(str, enum.Enum): ACTIVE = 'active' INACTIVE = 'inactive' PENDING = 'pending' class Accounts(Base): __tablename__ = 'accounts' __table_args__ = ( CheckConstraint("accounts.status IN ('active', 'inactive', 'pending')"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) status: Mapped[AccountsStatus] = mapped_column(Enum(AccountsStatus, values_callable=lambda cls: [member.value for member in cls]), nullable=False) class Users(Base): __tablename__ = 'users' __table_args__ = ( CheckConstraint("users.status IN ('active', 'inactive', 'pending')"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) status: Mapped[UsersStatus] = mapped_column(Enum(UsersStatus, values_callable=lambda cls: [member.value for member in cls]), nullable=False) """, ) def test_array_enum_named(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "roles", ARRAY(SAEnum("admin", "user", "moderator", name="role_enum")), nullable=False, ), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import ARRAY, Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class RoleEnum(str, enum.Enum): ADMIN = 'admin' USER = 'user' MODERATOR = 'moderator' class Users(Base): __tablename__ = 'users' id: Mapped[int] = mapped_column(Integer, primary_key=True) roles: Mapped[list[RoleEnum]] = mapped_column(ARRAY(Enum(RoleEnum, values_callable=lambda cls: [member.value for member in cls], name='role_enum')), nullable=False) """, ) def test_array_enum_unnamed(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "roles", ARRAY(SAEnum("admin", "user")), nullable=False, ), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import ARRAY, Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class UsersRoles(str, enum.Enum): ADMIN = 'admin' USER = 'user' class Users(Base): __tablename__ = 'users' id: Mapped[int] = mapped_column(Integer, primary_key=True) roles: Mapped[list[UsersRoles]] = mapped_column(ARRAY(Enum(UsersRoles, values_callable=lambda cls: [member.value for member in cls])), nullable=False) """, ) def test_array_enum_nullable(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "roles", ARRAY(SAEnum("admin", "user", name="role_enum")), ), ) validate_code( generator.generate(), """\ from typing import Optional import enum from sqlalchemy import ARRAY, Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class RoleEnum(str, enum.Enum): ADMIN = 'admin' USER = 'user' class Users(Base): __tablename__ = 'users' id: Mapped[int] = mapped_column(Integer, primary_key=True) roles: Mapped[Optional[list[RoleEnum]]] = mapped_column(ARRAY(Enum(RoleEnum, values_callable=lambda cls: [member.value for member in cls], name='role_enum'))) """, ) def test_array_enum_with_dimensions(generator: CodeGenerator) -> None: Table( "items", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "tag_matrix", ARRAY(SAEnum("a", "b", name="tag_enum"), dimensions=2), nullable=False, ), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import ARRAY, Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class TagEnum(str, enum.Enum): A = 'a' B = 'b' class Items(Base): __tablename__ = 'items' id: Mapped[int] = mapped_column(Integer, primary_key=True) tag_matrix: Mapped[list[list[TagEnum]]] = mapped_column(ARRAY(Enum(TagEnum, values_callable=lambda cls: [member.value for member in cls], name='tag_enum'), dimensions=2), nullable=False) """, ) def test_array_enum_nonativeenums_option(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "roles", ARRAY(SAEnum("admin", "user", name="role_enum")), nullable=False, ), ) generator = DeclarativeGenerator( generator.metadata, generator.bind, ["nonativeenums"] ) validate_code( generator.generate(), """\ from sqlalchemy import ARRAY, Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Users(Base): __tablename__ = 'users' id: Mapped[int] = mapped_column(Integer, primary_key=True) roles: Mapped[list[str]] = mapped_column(ARRAY(Enum('admin', 'user', name='role_enum')), nullable=False) """, ) def test_array_enum_shared_with_regular_enum(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "primary_role", SAEnum("admin", "user", name="role_enum"), nullable=False, ), Column( "all_roles", ARRAY(SAEnum("admin", "user", name="role_enum")), nullable=False, ), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import ARRAY, Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class RoleEnum(str, enum.Enum): ADMIN = 'admin' USER = 'user' class Users(Base): __tablename__ = 'users' id: Mapped[int] = mapped_column(Integer, primary_key=True) primary_role: Mapped[RoleEnum] = mapped_column(Enum(RoleEnum, values_callable=lambda cls: [member.value for member in cls], name='role_enum'), nullable=False) all_roles: Mapped[list[RoleEnum]] = mapped_column(ARRAY(Enum(RoleEnum, values_callable=lambda cls: [member.value for member in cls], name='role_enum')), nullable=False) """, ) def test_enum_named_with_schema(generator: CodeGenerator) -> None: Table( "my_table", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "status", SAEnum("active", "inactive", name="status_enum", schema="custom_schema"), nullable=False, ), schema="custom_schema", ) validate_code( generator.generate(), """\ import enum from sqlalchemy import Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class StatusEnum(str, enum.Enum): ACTIVE = 'active' INACTIVE = 'inactive' class MyTable(Base): __tablename__ = 'my_table' __table_args__ = {'schema': 'custom_schema'} id: Mapped[int] = mapped_column(Integer, primary_key=True) status: Mapped[StatusEnum] = mapped_column(Enum(StatusEnum, values_callable=lambda cls: [member.value for member in cls], name='status_enum', schema='custom_schema'), nullable=False) """, ) def test_array_enum_named_with_schema(generator: CodeGenerator) -> None: Table( "my_table", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "tags", ARRAY(SAEnum("a", "b", name="tag_enum", schema="custom_schema")), nullable=False, ), schema="custom_schema", ) validate_code( generator.generate(), """\ import enum from sqlalchemy import ARRAY, Enum, Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class TagEnum(str, enum.Enum): A = 'a' B = 'b' class MyTable(Base): __tablename__ = 'my_table' __table_args__ = {'schema': 'custom_schema'} id: Mapped[int] = mapped_column(Integer, primary_key=True) tags: Mapped[list[TagEnum]] = mapped_column(ARRAY(Enum(TagEnum, values_callable=lambda cls: [member.value for member in cls], name='tag_enum', schema='custom_schema')), nullable=False) """, ) def test_index_with_empty_kwargs(generator: CodeGenerator) -> None: simple_items = Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("name", VARCHAR), ) simple_items.indexes.add( Index( "idx_name", simple_items.c.name, postgresql_using="gist", postgresql_include=[], ) ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Index, Integer, String from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class SimpleItems(Base): __tablename__ = 'simple_items' __table_args__ = ( Index('idx_name', 'name', postgresql_using='gist'), ) id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[Optional[str]] = mapped_column(String) """, ) @pytest.mark.parametrize("generator", [["include_dialect_options"]], indirect=True) def test_include_dialect_options_empty_values_skipped( generator: CodeGenerator, ) -> None: Table( "t_opts3", generator.metadata, Column("id", INTEGER, primary_key=True), mysql_engine="InnoDB", mysql_partition_by=[], mysql_PROPERTIES={}, ) validate_code( generator.generate(), """\ from sqlalchemy import Integer from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class TOpts3(Base): __tablename__ = 't_opts3' __table_args__ = {'mysql_engine': 'InnoDB'} id: Mapped[int] = mapped_column(Integer, primary_key=True) """, ) agronholm-sqlacodegen-1b321d1/tests/test_generator_sqlmodel.py000066400000000000000000000375001521515547200247570ustar00rootroot00000000000000from __future__ import annotations import pytest from _pytest.fixtures import FixtureRequest from sqlalchemy import Enum as SAEnum from sqlalchemy import Uuid from sqlalchemy.engine import Engine from sqlalchemy.schema import ( CheckConstraint, Column, ForeignKeyConstraint, Index, MetaData, Table, UniqueConstraint, ) from sqlalchemy.types import ARRAY, INTEGER, VARCHAR from sqlacodegen.generators import CodeGenerator, SQLModelGenerator from .conftest import validate_code @pytest.fixture def generator( request: FixtureRequest, metadata: MetaData, engine: Engine ) -> CodeGenerator: options = getattr(request, "param", []) return SQLModelGenerator(metadata, engine, options) def test_indexes(generator: CodeGenerator) -> None: simple_items = Table( "item", generator.metadata, Column("id", INTEGER, primary_key=True), Column("number", INTEGER, nullable=False), Column("text", VARCHAR), ) simple_items.indexes.add(Index("idx_number", simple_items.c.number)) simple_items.indexes.add( Index("idx_text_number", simple_items.c.text, simple_items.c.number) ) simple_items.indexes.add(Index("idx_text", simple_items.c.text, unique=True)) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Column, Index, Integer, String from sqlmodel import Field, SQLModel class Item(SQLModel, table=True): __table_args__ = ( Index('idx_number', 'number'), Index('idx_text', 'text', unique=True), Index('idx_text_number', 'text', 'number') ) id: int = Field(sa_column=Column('id', Integer, primary_key=True)) number: int = Field(sa_column=Column(\ 'number', Integer, nullable=False)) text: Optional[str] = Field(default=None, sa_column=Column(\ 'text', String)) """, ) def test_constraints(generator: CodeGenerator) -> None: Table( "simple_constraints", generator.metadata, Column("id", INTEGER, primary_key=True), Column("number", INTEGER), CheckConstraint("number > 0"), UniqueConstraint("id", "number"), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import CheckConstraint, Column, Integer, UniqueConstraint from sqlmodel import Field, SQLModel class SimpleConstraints(SQLModel, table=True): __tablename__ = 'simple_constraints' __table_args__ = ( CheckConstraint('number > 0'), UniqueConstraint('id', 'number') ) id: int = Field(sa_column=Column('id', Integer, primary_key=True)) number: Optional[int] = Field(default=None, sa_column=Column(\ 'number', Integer)) """, ) def test_onetomany(generator: CodeGenerator) -> None: Table( "simple_goods", generator.metadata, Column("id", INTEGER, primary_key=True), Column("container_id", INTEGER), ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), ) Table( "simple_containers", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Column, ForeignKey, Integer from sqlmodel import Field, Relationship, SQLModel class SimpleContainers(SQLModel, table=True): __tablename__ = 'simple_containers' id: int = Field(sa_column=Column('id', Integer, primary_key=True)) simple_goods: list['SimpleGoods'] = Relationship(\ back_populates='container') class SimpleGoods(SQLModel, table=True): __tablename__ = 'simple_goods' id: int = Field(sa_column=Column('id', Integer, primary_key=True)) container_id: Optional[int] = Field(default=None, sa_column=Column(\ 'container_id', ForeignKey('simple_containers.id'))) container: Optional['SimpleContainers'] = Relationship(\ back_populates='simple_goods') """, ) def test_onetomany_multiref(generator: CodeGenerator) -> None: Table( "simple_items_multiref", generator.metadata, Column("id", INTEGER, primary_key=True), Column("parent_container_id", INTEGER), Column("top_container_id", INTEGER, nullable=False), ForeignKeyConstraint( ["parent_container_id"], ["simple_containers_multiref.id"] ), ForeignKeyConstraint(["top_container_id"], ["simple_containers_multiref.id"]), ) Table( "simple_containers_multiref", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Column, ForeignKey, Integer from sqlmodel import Field, Relationship, SQLModel class SimpleContainersMultiref(SQLModel, table=True): __tablename__ = 'simple_containers_multiref' id: int = Field(sa_column=Column('id', Integer, primary_key=True)) simple_items_multiref_parent_container: list['SimpleItemsMultiref'] = \ Relationship(back_populates='parent_container', sa_relationship_kwargs={\ 'foreign_keys': '[SimpleItemsMultiref.parent_container_id]'}) simple_items_multiref_top_container: list['SimpleItemsMultiref'] = \ Relationship(back_populates='top_container', sa_relationship_kwargs={'foreign_keys': \ '[SimpleItemsMultiref.top_container_id]'}) class SimpleItemsMultiref(SQLModel, table=True): __tablename__ = 'simple_items_multiref' id: int = Field(sa_column=Column('id', Integer, primary_key=True)) top_container_id: int = \ Field(sa_column=Column('top_container_id', \ ForeignKey('simple_containers_multiref.id'), nullable=False)) parent_container_id: Optional[int] = \ Field(default=None, sa_column=Column('parent_container_id', \ ForeignKey('simple_containers_multiref.id'))) parent_container: Optional['SimpleContainersMultiref'] = Relationship(\ back_populates='simple_items_multiref_parent_container', sa_relationship_kwargs={\ 'foreign_keys': '[SimpleItemsMultiref.parent_container_id]'}) top_container: 'SimpleContainersMultiref' = Relationship(\ back_populates='simple_items_multiref_top_container', sa_relationship_kwargs={\ 'foreign_keys': '[SimpleItemsMultiref.top_container_id]'}) """, ) def test_onetoone(generator: CodeGenerator) -> None: Table( "simple_onetoone", generator.metadata, Column("id", INTEGER, primary_key=True), Column("other_item_id", INTEGER), ForeignKeyConstraint(["other_item_id"], ["other_items.id"]), UniqueConstraint("other_item_id"), ) Table("other_items", generator.metadata, Column("id", INTEGER, primary_key=True)) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Column, ForeignKey, Integer from sqlmodel import Field, Relationship, SQLModel class OtherItems(SQLModel, table=True): __tablename__ = 'other_items' id: int = Field(sa_column=Column('id', Integer, primary_key=True)) simple_onetoone: Optional['SimpleOnetoone'] = Relationship(\ back_populates='other_item', sa_relationship_kwargs={'uselist': False}) class SimpleOnetoone(SQLModel, table=True): __tablename__ = 'simple_onetoone' id: int = Field(sa_column=Column('id', Integer, primary_key=True)) other_item_id: Optional[int] = Field(default=None, sa_column=Column(\ 'other_item_id', ForeignKey('other_items.id'), unique=True)) other_item: Optional['OtherItems'] = Relationship(\ back_populates='simple_onetoone') """, ) def test_uuid(generator: CodeGenerator) -> None: Table( "simple_uuid", generator.metadata, Column("id", Uuid, primary_key=True), ) validate_code( generator.generate(), """\ import uuid from sqlalchemy import Column, Uuid from sqlmodel import Field, SQLModel class SimpleUuid(SQLModel, table=True): __tablename__ = 'simple_uuid' id: uuid.UUID = Field(sa_column=Column('id', Uuid, primary_key=True)) """, ) def test_check_constraint_not_converted_to_enum(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column("status", VARCHAR(20), nullable=False), CheckConstraint("users.status IN ('active', 'inactive', 'pending')"), ) # Recreate generator with nosyntheticenums option to preserve constraints generator = SQLModelGenerator( generator.metadata, generator.bind, ["nosyntheticenums"] ) validate_code( generator.generate(), """\ from sqlalchemy import CheckConstraint, Column, Integer, String from sqlmodel import Field, SQLModel class Users(SQLModel, table=True): __table_args__ = ( CheckConstraint("users.status IN ('active', 'inactive', 'pending')"), ) id: int = Field(sa_column=Column('id', Integer, primary_key=True)) status: str = Field(sa_column=Column('status', String(20), nullable=False)) """, ) def test_synthetic_enum_generation(generator: CodeGenerator) -> None: Table( "accounts", generator.metadata, Column("id", INTEGER, primary_key=True), Column("status", VARCHAR(20), nullable=False), CheckConstraint("accounts.status IN ('active', 'inactive', 'pending')"), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import CheckConstraint, Column, Enum, Integer from sqlmodel import Field, SQLModel class AccountsStatus(str, enum.Enum): ACTIVE = 'active' INACTIVE = 'inactive' PENDING = 'pending' class Accounts(SQLModel, table=True): __table_args__ = ( CheckConstraint("accounts.status IN ('active', 'inactive', 'pending')"), ) id: int = Field(sa_column=Column('id', Integer, primary_key=True)) status: AccountsStatus = Field(sa_column=Column('status', Enum(AccountsStatus, values_callable=lambda cls: [member.value for member in cls]), nullable=False)) """, ) def test_array_enum_named_with_schema(generator: CodeGenerator) -> None: Table( "my_table", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "tags", ARRAY(SAEnum("a", "b", name="tag_enum", schema="custom_schema")), nullable=False, ), schema="custom_schema", ) validate_code( generator.generate(), """\ import enum from sqlalchemy import ARRAY, Column, Enum, Integer from sqlmodel import Field, SQLModel class TagEnum(str, enum.Enum): A = 'a' B = 'b' class MyTable(SQLModel, table=True): __tablename__ = 'my_table' __table_args__ = {'schema': 'custom_schema'} id: int = Field(sa_column=Column('id', Integer, primary_key=True)) tags: list[TagEnum] = Field(sa_column=Column('tags', ARRAY(Enum(TagEnum, values_callable=lambda cls: [member.value for member in cls], name='tag_enum', schema='custom_schema')), nullable=False)) """, ) def test_fallback_table(generator: CodeGenerator) -> None: Table( "simple_fallback", generator.metadata, Column("field", VARCHAR(20), nullable=False), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, String, Table from sqlmodel import SQLModel t_simple_fallback = Table( 'simple_fallback', SQLModel.metadata, Column('field', String(20), nullable=False) ) """, ) def test_onetomany_selfref(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("parent_item_id", INTEGER), ForeignKeyConstraint(["parent_item_id"], ["simple_items.id"]), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Column, ForeignKey, Integer from sqlmodel import Field, Relationship, SQLModel class SimpleItems(SQLModel, table=True): __tablename__ = 'simple_items' id: int = Field(sa_column=Column('id', Integer, primary_key=True)) parent_item_id: Optional[int] = Field(default=None, sa_column=Column(\ 'parent_item_id', ForeignKey('simple_items.id'))) parent_item: Optional['SimpleItems'] = Relationship(\ back_populates='parent_item_reverse', sa_relationship_kwargs={\ 'remote_side': '[SimpleItems.id]'}) parent_item_reverse: list['SimpleItems'] = Relationship(\ back_populates='parent_item', sa_relationship_kwargs={\ 'remote_side': '[SimpleItems.parent_item_id]'}) """, ) def test_onetomany_selfref_multi(generator: CodeGenerator) -> None: Table( "simple_items_selfref", generator.metadata, Column("id", INTEGER, primary_key=True), Column("parent_item_id", INTEGER), Column("top_item_id", INTEGER), ForeignKeyConstraint(["parent_item_id"], ["simple_items_selfref.id"]), ForeignKeyConstraint(["top_item_id"], ["simple_items_selfref.id"]), ) validate_code( generator.generate(), """\ from typing import Optional from sqlalchemy import Column, ForeignKey, Integer from sqlmodel import Field, Relationship, SQLModel class SimpleItemsSelfref(SQLModel, table=True): __tablename__ = 'simple_items_selfref' id: int = Field(sa_column=Column('id', Integer, primary_key=True)) parent_item_id: Optional[int] = Field(default=None, sa_column=Column(\ 'parent_item_id', ForeignKey('simple_items_selfref.id'))) top_item_id: Optional[int] = Field(default=None, sa_column=Column(\ 'top_item_id', ForeignKey('simple_items_selfref.id'))) parent_item: Optional['SimpleItemsSelfref'] = Relationship(\ back_populates='parent_item_reverse', sa_relationship_kwargs={\ 'remote_side': '[SimpleItemsSelfref.id]', \ 'foreign_keys': '[SimpleItemsSelfref.parent_item_id]'}) parent_item_reverse: list['SimpleItemsSelfref'] = Relationship(\ back_populates='parent_item', sa_relationship_kwargs={\ 'remote_side': '[SimpleItemsSelfref.parent_item_id]', \ 'foreign_keys': '[SimpleItemsSelfref.parent_item_id]'}) top_item: Optional['SimpleItemsSelfref'] = Relationship(\ back_populates='top_item_reverse', sa_relationship_kwargs={\ 'remote_side': '[SimpleItemsSelfref.id]', \ 'foreign_keys': '[SimpleItemsSelfref.top_item_id]'}) top_item_reverse: list['SimpleItemsSelfref'] = Relationship(\ back_populates='top_item', sa_relationship_kwargs={\ 'remote_side': '[SimpleItemsSelfref.top_item_id]', \ 'foreign_keys': '[SimpleItemsSelfref.top_item_id]'}) """, ) agronholm-sqlacodegen-1b321d1/tests/test_generator_tables.py000066400000000000000000001155651521515547200244210ustar00rootroot00000000000000from __future__ import annotations from decimal import Decimal from textwrap import dedent import pytest from _pytest.fixtures import FixtureRequest from sqlalchemy import Enum as SAEnum from sqlalchemy import TypeDecorator from sqlalchemy.dialects import mysql, postgresql, registry from sqlalchemy.dialects.mysql.pymysql import MySQLDialect_pymysql from sqlalchemy.engine import Engine from sqlalchemy.schema import ( CheckConstraint, Column, Computed, ForeignKey, Identity, Index, MetaData, Table, UniqueConstraint, ) from sqlalchemy.sql.expression import text from sqlalchemy.sql.sqltypes import DateTime, NullType from sqlalchemy.types import ARRAY, INTEGER, NUMERIC, SMALLINT, VARCHAR, Text from sqlacodegen.generators import CodeGenerator, TablesGenerator from .conftest import validate_code # This needs to be uppercased to trigger #315 class TIMESTAMP_DECORATOR(TypeDecorator[DateTime]): impl = DateTime @pytest.fixture def generator( request: FixtureRequest, metadata: MetaData, engine: Engine ) -> CodeGenerator: options = getattr(request, "param", []) return TablesGenerator(metadata, engine, options) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_fancy_coltypes(generator: CodeGenerator) -> None: from pgvector.sqlalchemy.vector import VECTOR Table( "simple_items", generator.metadata, Column("enum", postgresql.ENUM("A", "B", name="blah", schema="someschema")), Column("bool", postgresql.BOOLEAN), Column("vector", VECTOR(3)), Column("number", NUMERIC(10, asdecimal=False)), Column("timestamp", TIMESTAMP_DECORATOR()), schema="someschema", ) validate_code( generator.generate(), """\ from tests.test_generator_tables import TIMESTAMP_DECORATOR import enum from pgvector.sqlalchemy.vector import VECTOR from sqlalchemy import Boolean, Column, Enum, MetaData, Numeric, Table metadata = MetaData() class Blah(str, enum.Enum): A = 'A' B = 'B' t_simple_items = Table( 'simple_items', metadata, Column('enum', Enum(Blah, values_callable=lambda cls: [member.value for member in cls], name='blah', schema='someschema')), Column('bool', Boolean), Column('vector', VECTOR(3)), Column('number', Numeric(10, asdecimal=False)), Column('timestamp', TIMESTAMP_DECORATOR), schema='someschema' ) """, ) def test_boolean_detection(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("bool1", INTEGER), Column("bool2", SMALLINT), Column("bool3", mysql.TINYINT), CheckConstraint("simple_items.bool1 IN (0, 1)"), CheckConstraint("simple_items.bool2 IN (0, 1)"), CheckConstraint("simple_items.bool3 IN (0, 1)"), ) validate_code( generator.generate(), """\ from sqlalchemy import Boolean, Column, MetaData, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('bool1', Boolean), Column('bool2', Boolean), Column('bool3', Boolean) ) """, ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_arrays(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("dp_array", postgresql.ARRAY(postgresql.DOUBLE_PRECISION(precision=53))), Column("int_array", postgresql.ARRAY(INTEGER)), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Double, Integer, MetaData, Table from sqlalchemy.dialects.postgresql import ARRAY metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('dp_array', ARRAY(Double(precision=53))), Column('int_array', ARRAY(Integer())) ) """, ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_array_preserves_dialect_for_runtime_operators( generator: CodeGenerator, ) -> None: """Regression test for GH-441.""" Table( "simple_items", generator.metadata, Column("id", postgresql.TEXT, primary_key=True), Column("tags", postgresql.ARRAY(postgresql.TEXT)), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, MetaData, Table, Text from sqlalchemy.dialects.postgresql import ARRAY metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Text, primary_key=True), Column('tags', ARRAY(Text())) ) """, ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_jsonb(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("jsonb", postgresql.JSONB(astext_type=Text(50))), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, MetaData, Table, Text from sqlalchemy.dialects.postgresql import JSONB metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('jsonb', JSONB(astext_type=Text(length=50))) ) """, ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_jsonb_default(generator: CodeGenerator) -> None: Table("simple_items", generator.metadata, Column("jsonb", postgresql.JSONB)) validate_code( generator.generate(), """\ from sqlalchemy import Column, MetaData, Table from sqlalchemy.dialects.postgresql import JSONB metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('jsonb', JSONB) ) """, ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_json_default(generator: CodeGenerator) -> None: Table("simple_items", generator.metadata, Column("json", postgresql.JSON)) validate_code( generator.generate(), """\ from sqlalchemy import Column, JSON, MetaData, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('json', JSON) ) """, ) def test_check_constraint_preserved(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("enum", VARCHAR(255)), CheckConstraint(r"simple_items.enum IN ('A', '\'B', 'C')"), ) # Recreate generator with nosyntheticenums option to preserve constraints generator = TablesGenerator( generator.metadata, generator.bind, ["nosyntheticenums"] ) validate_code( generator.generate(), """\ from sqlalchemy import CheckConstraint, Column, MetaData, String, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('enum', String(255)), CheckConstraint("simple_items.enum IN ('A', '\\\\'B', 'C')") ) """, ) def test_synthetic_enum_generation(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("status", VARCHAR(20)), CheckConstraint("simple_items.status IN ('active', 'inactive', 'pending')"), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import CheckConstraint, Column, Enum, Integer, MetaData, Table metadata = MetaData() class SimpleItemsStatus(str, enum.Enum): ACTIVE = 'active' INACTIVE = 'inactive' PENDING = 'pending' t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, primary_key=True), Column('status', Enum(SimpleItemsStatus, values_callable=lambda cls: [member.value for member in cls])), CheckConstraint("simple_items.status IN ('active', 'inactive', 'pending')") ) """, ) def test_enum_shared_values(generator: CodeGenerator) -> None: from sqlalchemy import Enum as SAEnum Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column("status", SAEnum("active", "inactive", "pending", name="status_enum")), ) Table( "accounts", generator.metadata, Column("id", INTEGER, primary_key=True), Column("status", SAEnum("active", "inactive", "pending", name="status_enum")), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import Column, Enum, Integer, MetaData, Table metadata = MetaData() class StatusEnum(str, enum.Enum): ACTIVE = 'active' INACTIVE = 'inactive' PENDING = 'pending' t_accounts = Table( 'accounts', metadata, Column('id', Integer, primary_key=True), Column('status', Enum(StatusEnum, values_callable=lambda cls: [member.value for member in cls], name='status_enum')) ) t_users = Table( 'users', metadata, Column('id', Integer, primary_key=True), Column('status', Enum(StatusEnum, values_callable=lambda cls: [member.value for member in cls], name='status_enum')) ) """, ) def test_array_enum_named(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column("roles", ARRAY(SAEnum("admin", "user", "moderator", name="role_enum"))), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import ARRAY, Column, Enum, Integer, MetaData, Table metadata = MetaData() class RoleEnum(str, enum.Enum): ADMIN = 'admin' USER = 'user' MODERATOR = 'moderator' t_users = Table( 'users', metadata, Column('id', Integer, primary_key=True), Column('roles', ARRAY(Enum(RoleEnum, values_callable=lambda cls: [member.value for member in cls], name='role_enum'))) ) """, ) def test_array_enum_shared(generator: CodeGenerator) -> None: Table( "users", generator.metadata, Column("id", INTEGER, primary_key=True), Column("roles", ARRAY(SAEnum("admin", "user", name="role_enum"))), ) Table( "groups", generator.metadata, Column("id", INTEGER, primary_key=True), Column("allowed_roles", ARRAY(SAEnum("admin", "user", name="role_enum"))), ) validate_code( generator.generate(), """\ import enum from sqlalchemy import ARRAY, Column, Enum, Integer, MetaData, Table metadata = MetaData() class RoleEnum(str, enum.Enum): ADMIN = 'admin' USER = 'user' t_groups = Table( 'groups', metadata, Column('id', Integer, primary_key=True), Column('allowed_roles', ARRAY(Enum(RoleEnum, values_callable=lambda cls: [member.value for member in cls], name='role_enum'))) ) t_users = Table( 'users', metadata, Column('id', Integer, primary_key=True), Column('roles', ARRAY(Enum(RoleEnum, values_callable=lambda cls: [member.value for member in cls], name='role_enum'))) ) """, ) def test_enum_named_with_schema(generator: CodeGenerator) -> None: Table( "my_table", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "status", SAEnum("active", "inactive", name="status_enum", schema="custom_schema"), ), schema="custom_schema", ) validate_code( generator.generate(), """\ import enum from sqlalchemy import Column, Enum, Integer, MetaData, Table metadata = MetaData() class StatusEnum(str, enum.Enum): ACTIVE = 'active' INACTIVE = 'inactive' t_my_table = Table( 'my_table', metadata, Column('id', Integer, primary_key=True), Column('status', Enum(StatusEnum, values_callable=lambda cls: [member.value for member in cls], name='status_enum', schema='custom_schema')), schema='custom_schema' ) """, ) def test_array_enum_named_with_schema(generator: CodeGenerator) -> None: Table( "my_table", generator.metadata, Column("id", INTEGER, primary_key=True), Column( "tags", ARRAY(SAEnum("a", "b", name="tag_enum", schema="custom_schema")), ), schema="custom_schema", ) validate_code( generator.generate(), """\ import enum from sqlalchemy import ARRAY, Column, Enum, Integer, MetaData, Table metadata = MetaData() class TagEnum(str, enum.Enum): A = 'a' B = 'b' t_my_table = Table( 'my_table', metadata, Column('id', Integer, primary_key=True), Column('tags', ARRAY(Enum(TagEnum, values_callable=lambda cls: [member.value for member in cls], name='tag_enum', schema='custom_schema'))), schema='custom_schema' ) """, ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_domain_text(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column( "postal_code", postgresql.DOMAIN( "us_postal_code", Text, constraint_name="valid_us_postal_code", not_null=False, check=text("VALUE ~ '^\\d{5}$' OR VALUE ~ '^\\d{5}-\\d{4}$'"), ), nullable=False, ), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, MetaData, Table, Text, text from sqlalchemy.dialects.postgresql import DOMAIN metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('postal_code', DOMAIN('us_postal_code', Text(), \ constraint_name='valid_us_postal_code', not_null=False, \ check=text("VALUE ~ '^\\\\d{5}$' OR VALUE ~ '^\\\\d{5}-\\\\d{4}$'")), nullable=False) ) """, ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_domain_int(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column( "n", postgresql.DOMAIN( "positive_int", INTEGER, constraint_name="positive", not_null=False, check=text("VALUE > 0"), ), nullable=False, ), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, INTEGER, MetaData, Table, text from sqlalchemy.dialects.postgresql import DOMAIN metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('n', DOMAIN('positive_int', INTEGER(), \ constraint_name='positive', not_null=False, \ check=text('VALUE > 0')), nullable=False) ) """, ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_column_adaptation(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", postgresql.BIGINT), Column("length", postgresql.DOUBLE_PRECISION), ) validate_code( generator.generate(), """\ from sqlalchemy import BigInteger, Column, Double, MetaData, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', BigInteger), Column('length', Double) ) """, ) @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) def test_mysql_column_types(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", mysql.INTEGER), Column("name", mysql.VARCHAR(255)), Column("double", mysql.DOUBLE(1, 2)), Column("set", mysql.SET("one", "two")), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, String, Table from sqlalchemy.dialects.mysql import DOUBLE, SET metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer), Column('name', String(255)), Column('double', DOUBLE(1, 2)), Column('set', SET('one', 'two')) ) """, ) @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) @pytest.mark.parametrize("generator", [["keep_dialect_types"]], indirect=True) def test_mysql_char_collation_keep_dialect_types(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", mysql.INTEGER, primary_key=True), Column("result_code", mysql.CHAR(1, collation="utf8mb3_bin"), nullable=False), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, MetaData, Table from sqlalchemy.dialects.mysql import CHAR, INTEGER metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', INTEGER, primary_key=True), Column('result_code', CHAR(1, collation='utf8mb3_bin'), nullable=False) ) """, ) def test_constraints(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER), Column("number", INTEGER), CheckConstraint("number > 0"), UniqueConstraint("id", "number"), ) validate_code( generator.generate(), """\ from sqlalchemy import CheckConstraint, Column, Integer, MetaData, Table, \ UniqueConstraint metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer), Column('number', Integer), CheckConstraint('number > 0'), UniqueConstraint('id', 'number') ) """, ) def test_indexes(generator: CodeGenerator) -> None: simple_items = Table( "simple_items", generator.metadata, Column("id", INTEGER), Column("number", INTEGER), Column("text", VARCHAR), Index("ix_empty"), ) simple_items.indexes.add(Index("ix_number", simple_items.c.number)) simple_items.indexes.add( Index( "ix_text_number", simple_items.c.text, simple_items.c.number, unique=True, ) ) simple_items.indexes.add(Index("ix_text", simple_items.c.text, unique=True)) validate_code( generator.generate(), """\ from sqlalchemy import Column, Index, Integer, MetaData, String, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer), Column('number', Integer, index=True), Column('text', String, unique=True, index=True), Index('ix_empty'), Index('ix_text_number', 'text', 'number', unique=True) ) """, ) def test_table_comment(generator: CodeGenerator) -> None: Table( "simple", generator.metadata, Column("id", INTEGER, primary_key=True), comment="this is a 'comment'", ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table metadata = MetaData() t_simple = Table( 'simple', metadata, Column('id', Integer, primary_key=True), comment="this is a 'comment'" ) """, ) def test_table_name_identifiers(generator: CodeGenerator) -> None: Table( "simple-items table", generator.metadata, Column("id", INTEGER, primary_key=True), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table metadata = MetaData() t_simple_items_table = Table( 'simple-items table', metadata, Column('id', Integer, primary_key=True) ) """, ) @pytest.mark.parametrize("generator", [["noindexes"]], indirect=True) def test_option_noindexes(generator: CodeGenerator) -> None: simple_items = Table( "simple_items", generator.metadata, Column("number", INTEGER), CheckConstraint("number > 2"), ) simple_items.indexes.add(Index("idx_number", simple_items.c.number)) validate_code( generator.generate(), """\ from sqlalchemy import CheckConstraint, Column, Integer, MetaData, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('number', Integer), CheckConstraint('number > 2') ) """, ) @pytest.mark.parametrize("generator", [["noconstraints"]], indirect=True) def test_option_noconstraints(generator: CodeGenerator) -> None: simple_items = Table( "simple_items", generator.metadata, Column("number", INTEGER), CheckConstraint("number > 2"), ) simple_items.indexes.add(Index("ix_number", simple_items.c.number)) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('number', Integer, index=True) ) """, ) @pytest.mark.parametrize("generator", [["nocomments"]], indirect=True) def test_option_nocomments(generator: CodeGenerator) -> None: Table( "simple", generator.metadata, Column("id", INTEGER, primary_key=True, comment="pk column comment"), comment="this is a 'comment'", ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table metadata = MetaData() t_simple = Table( 'simple', metadata, Column('id', Integer, primary_key=True) ) """, ) @pytest.mark.parametrize( "persisted, extra_args", [(None, ""), (False, ", persisted=False"), (True, ", persisted=True")], ) def test_computed_column( generator: CodeGenerator, persisted: bool | None, extra_args: str ) -> None: Table( "computed", generator.metadata, Column("id", INTEGER, primary_key=True), Column("computed", INTEGER, Computed("1 + 2", persisted=persisted)), ) validate_code( generator.generate(), f"""\ from sqlalchemy import Column, Computed, Integer, MetaData, Table metadata = MetaData() t_computed = Table( 'computed', metadata, Column('id', Integer, primary_key=True), Column('computed', Integer, Computed('1 + 2'{extra_args})) ) """, ) def test_schema(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("name", VARCHAR), schema="testschema", ) validate_code( generator.generate(), """\ from sqlalchemy import Column, MetaData, String, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('name', String), schema='testschema' ) """, ) def test_foreign_key_options(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column( "name", VARCHAR, ForeignKey( "simple_items.name", ondelete="CASCADE", onupdate="CASCADE", deferrable=True, initially="DEFERRED", ), ), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, ForeignKey, MetaData, String, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('name', String, ForeignKey('simple_items.name', \ ondelete='CASCADE', onupdate='CASCADE', deferrable=True, initially='DEFERRED')) ) """, ) def test_pk_default(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column( "id", INTEGER, primary_key=True, server_default=text("uuid_generate_v4()"), ), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table, text metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, primary_key=True, \ server_default=text('uuid_generate_v4()')) ) """, ) @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) def test_mysql_timestamp(generator: CodeGenerator) -> None: Table( "simple", generator.metadata, Column("id", INTEGER, primary_key=True), Column("timestamp", mysql.TIMESTAMP), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, TIMESTAMP, Table metadata = MetaData() t_simple = Table( 'simple', metadata, Column('id', Integer, primary_key=True), Column('timestamp', TIMESTAMP) ) """, ) @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) def test_mysql_integer_display_width(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("number", mysql.INTEGER(11)), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table from sqlalchemy.dialects.mysql import INTEGER metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, primary_key=True), Column('number', INTEGER(11)) ) """, ) @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) def test_mysql_tinytext(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("my_tinytext", mysql.TINYTEXT), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table from sqlalchemy.dialects.mysql import TINYTEXT metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, primary_key=True), Column('my_tinytext', TINYTEXT) ) """, ) @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) def test_mysql_mediumtext(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("my_mediumtext", mysql.MEDIUMTEXT), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table from sqlalchemy.dialects.mysql import MEDIUMTEXT metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, primary_key=True), Column('my_mediumtext', MEDIUMTEXT) ) """, ) @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) def test_mysql_longtext(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, primary_key=True), Column("my_longtext", mysql.LONGTEXT), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table from sqlalchemy.dialects.mysql import LONGTEXT metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, primary_key=True), Column('my_longtext', LONGTEXT) ) """, ) def test_schema_boolean(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("bool1", INTEGER), CheckConstraint("testschema.simple_items.bool1 IN (0, 1)"), schema="testschema", ) validate_code( generator.generate(), """\ from sqlalchemy import Boolean, Column, MetaData, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('bool1', Boolean), schema='testschema' ) """, ) def test_server_default_multiline(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column( "id", INTEGER, primary_key=True, server_default=text( dedent( """\ /*Comment*/ /*Next line*/ something()""" ) ), ), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table, text metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, primary_key=True, server_default=\ text('/*Comment*/\\n/*Next line*/\\nsomething()')) ) """, ) def test_server_default_colon(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("problem", VARCHAR, server_default=text("':001'")), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, MetaData, String, Table, text metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('problem', String, server_default=text("':001'")) ) """, ) def test_null_type(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("problem", NullType), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, MetaData, Table from sqlalchemy.sql.sqltypes import NullType metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('problem', NullType) ) """, ) def test_identity_column(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column( "id", INTEGER, primary_key=True, server_default=Identity(start=1, increment=2), ), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Identity, Integer, MetaData, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, Identity(start=1, increment=2), primary_key=True) ) """, ) def test_identity_column_decimal_values(generator: CodeGenerator) -> None: # MSSQL reflects Identity column parameters (start, increment) as Decimal # values instead of integers. This test ensures those are serialized correctly. identity = Identity(start=1, increment=2) # Simulate database reflection returning Decimal values (as MSSQL does) identity.start = Decimal("1") # type: ignore[assignment] identity.increment = Decimal("2") # type: ignore[assignment] Table( "simple_items", generator.metadata, Column( "id", INTEGER, primary_key=True, server_default=identity, ), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Identity, Integer, MetaData, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, Identity(start=1, increment=2), primary_key=True) ) """, ) def test_multiline_column_comment(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER, comment="This\nis a multi-line\ncomment"), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, comment='This\\nis a multi-line\\ncomment') ) """, ) def test_multiline_table_comment(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column("id", INTEGER), comment="This\nis a multi-line\ncomment", ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer), comment='This\\nis a multi-line\\ncomment' ) """, ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_postgresql_sequence_standard_name(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column( "id", INTEGER, primary_key=True, server_default=text("nextval('simple_items_id_seq'::regclass)"), ), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, primary_key=True) ) """, ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_postgresql_sequence_nonstandard_name(generator: CodeGenerator) -> None: Table( "simple_items", generator.metadata, Column( "id", INTEGER, primary_key=True, server_default=text("nextval('test_seq'::regclass)"), ), ) validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Sequence, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, Sequence('test_seq'), primary_key=True) ) """, ) @pytest.mark.parametrize( "schemaname, seqname", [ pytest.param("myschema", "test_seq"), pytest.param("myschema", '"test_seq"'), pytest.param('"my.schema"', "test_seq"), pytest.param('"my.schema"', '"test_seq"'), ], ) @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) def test_postgresql_sequence_with_schema( generator: CodeGenerator, schemaname: str, seqname: str ) -> None: expected_schema = schemaname.strip('"') Table( "simple_items", generator.metadata, Column( "id", INTEGER, primary_key=True, server_default=text(f"nextval('{schemaname}.{seqname}'::regclass)"), ), schema=expected_schema, ) validate_code( generator.generate(), f"""\ from sqlalchemy import Column, Integer, MetaData, Sequence, Table metadata = MetaData() t_simple_items = Table( 'simple_items', metadata, Column('id', Integer, Sequence('test_seq', \ schema='{expected_schema}'), primary_key=True), schema='{expected_schema}' ) """, ) class MockStarRocksDialect(MySQLDialect_pymysql): name = "starrocks" construct_arguments = [ ( Column, { "is_agg_key": None, "agg_type": None, "IS_AGG_KEY": None, "AGG_TYPE": None, }, ), ( Table, { "primary_key": None, "aggregate_key": None, "unique_key": None, "duplicate_key": None, "engine": "OLAP", "partition_by": None, "order_by": None, "security": None, "properties": {}, "ENGINE": "OLAP", "PARTITION_BY": None, "ORDER_BY": None, "SECURITY": None, "PROPERTIES": {}, }, ), ] # Register StarRocksDialect registry.register("starrocks", __name__, "MockStarRocksDialect") class _PartitionInfo: def __init__(self, partition_by: str) -> None: self.partition_by = partition_by def __str__(self) -> str: return self.partition_by def __repr__(self) -> str: return repr(self.partition_by) @pytest.mark.parametrize("generator", [["include_dialect_options"]], indirect=True) def test_include_dialect_options_starrocks_tables(generator: CodeGenerator) -> None: Table( "t_starrocks", generator.metadata, Column("id", INTEGER, primary_key=True, starrocks_is_agg_key=True), starrocks_ENGINE="OLAP", starrocks_PARTITION_BY=_PartitionInfo("RANGE(id)"), starrocks_ORDER_BY="id, name", starrocks_PROPERTIES={"replication_num": "3", "storage_medium": "SSD"}, ).info = {"table_kind": "TABLE"} validate_code( generator.generate(), """\ from sqlalchemy import Column, Integer, MetaData, Table metadata = MetaData() t_t_starrocks = Table( 't_starrocks', metadata, Column('id', Integer, primary_key=True, starrocks_is_agg_key=True), info={'table_kind': 'TABLE'}, starrocks_ENGINE='OLAP', starrocks_ORDER_BY='id, name', starrocks_PARTITION_BY='RANGE(id)', starrocks_PROPERTIES={'replication_num': '3', 'storage_medium': 'SSD'} ) """, )