├── tests ├── __init__.py ├── conftest.py ├── test_cli.py ├── test_generator_sqlmodel.py ├── test_generator_dataclass.py ├── test_generator_tables.py └── test_generator_declarative.py ├── src └── sqlacodegen │ ├── __init__.py │ ├── py.typed │ ├── __main__.py │ ├── models.py │ ├── cli.py │ └── utils.py ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── features_request.yaml │ └── bug_report.yaml ├── workflows │ ├── test.yml │ └── publish.yml └── pull_request_template.md ├── .gitignore ├── LICENSE ├── .pre-commit-config.yaml ├── CONTRIBUTING.rst ├── pyproject.toml ├── README.rst └── CHANGES.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/sqlacodegen/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/sqlacodegen/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /src/sqlacodegen/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .project 4 | .pydevproject 5 | .coverage 6 | .settings 7 | .tox 8 | .idea 9 | .vscode 10 | .cache 11 | .pytest_cache 12 | .mypy_cache 13 | dist 14 | build 15 | venv* 16 | docker-compose.yaml 17 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | import pytest 4 | from pytest import FixtureRequest 5 | from sqlalchemy.engine import Engine, create_engine 6 | from sqlalchemy.orm import clear_mappers, configure_mappers 7 | from sqlalchemy.schema import MetaData 8 | 9 | 10 | @pytest.fixture 11 | def engine(request: FixtureRequest) -> Engine: 12 | dialect = getattr(request, "param", None) 13 | if dialect == "postgresql": 14 | return create_engine("postgresql+psycopg:///testdb") 15 | elif dialect == "mysql": 16 | return create_engine("mysql+mysqlconnector://testdb") 17 | else: 18 | return create_engine("sqlite:///:memory:") 19 | 20 | 21 | @pytest.fixture 22 | def metadata() -> MetaData: 23 | return MetaData() 24 | 25 | 26 | def validate_code(generated_code: str, expected_code: str) -> None: 27 | expected_code = dedent(expected_code) 28 | assert generated_code == expected_code 29 | try: 30 | exec(generated_code, {}) 31 | configure_mappers() 32 | finally: 33 | clear_mappers() 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test suite 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | allow-prereleases: true 22 | cache: pip 23 | cache-dependency-path: pyproject.toml 24 | - name: Install dependencies 25 | run: pip install -e .[test] 26 | - name: Test with pytest 27 | run: coverage run -m pytest 28 | - name: Upload Coverage 29 | uses: coverallsapp/github-action@v2 30 | with: 31 | parallel: true 32 | 33 | coveralls: 34 | name: Finish Coveralls 35 | needs: test 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: Finished 39 | uses: coverallsapp/github-action@v2 40 | with: 41 | parallel-finished: true 42 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | ## Changes 3 | 4 | Fixes #. 5 | 6 | 7 | 8 | ## Checklist 9 | 10 | If this is a user-facing code change, like a bugfix or a new feature, please ensure that 11 | you've fulfilled the following conditions (where applicable): 12 | 13 | - [ ] You've added tests (in `tests/`) which would fail without your patch 14 | - [ ] You've added a new changelog entry (in `CHANGES.rst`). 15 | 16 | If this is a trivial change, like a typo fix or a code reformatting, then you can ignore 17 | these instructions. 18 | 19 | ### Updating the changelog 20 | 21 | If there are no entries after the last release, use `**UNRELEASED**` as the version. 22 | If, say, your patch fixes issue #123, the entry should look like this: 23 | 24 | ``` 25 | - Fix big bad boo-boo in task groups 26 | (`#123 `_; PR by @yourgithubaccount) 27 | ``` 28 | 29 | If there's no issue linked, just link to your pull request instead by updating the 30 | changelog after you've created the PR. 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is the MIT license: http://www.opensource.org/licenses/mit-license.php 2 | 3 | Copyright (c) Alex Grönholm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons 9 | to whom the Software is furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or 12 | substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 15 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 16 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE 17 | FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/features_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest a new feature 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: > 8 | If you have thought of a new feature that would increase the usefulness of this 9 | project, please use this form to send us your idea. 10 | - type: checkboxes 11 | attributes: 12 | label: Things to check first 13 | options: 14 | - label: > 15 | I have searched the existing issues and didn't find my feature already 16 | requested there 17 | required: true 18 | - type: textarea 19 | id: feature 20 | attributes: 21 | label: Feature description 22 | description: > 23 | Describe the feature in detail. The more specific the description you can give, 24 | the easier it should be to implement this feature. 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: usecase 29 | attributes: 30 | label: Use case 31 | description: > 32 | Explain why you need this feature, and why you think it would be useful to 33 | others too. 34 | validations: 35 | required: true 36 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # This is the configuration file for pre-commit (https://pre-commit.com/). 2 | # To use: 3 | # * Install pre-commit (https://pre-commit.com/#installation) 4 | # * Copy this file as ".pre-commit-config.yaml" 5 | # * Run "pre-commit install". 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v6.0.0 9 | hooks: 10 | - id: check-toml 11 | - id: check-yaml 12 | - id: debug-statements 13 | - id: end-of-file-fixer 14 | - id: mixed-line-ending 15 | args: [ "--fix=lf" ] 16 | - id: trailing-whitespace 17 | 18 | - repo: https://github.com/astral-sh/ruff-pre-commit 19 | rev: v0.13.3 20 | hooks: 21 | - id: ruff 22 | args: [--fix, --show-fixes] 23 | - id: ruff-format 24 | 25 | - repo: https://github.com/pre-commit/mirrors-mypy 26 | rev: v1.18.2 27 | hooks: 28 | - id: mypy 29 | additional_dependencies: 30 | - pytest 31 | - "SQLAlchemy >= 2.0.29" 32 | 33 | - repo: https://github.com/pre-commit/pygrep-hooks 34 | rev: v1.10.0 35 | hooks: 36 | - id: rst-backticks 37 | - id: rst-directive-colons 38 | - id: rst-inline-touching-normal 39 | 40 | ci: 41 | autoupdate_schedule: quarterly 42 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish packages to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | - "[0-9]+.[0-9]+.[0-9]+.post[0-9]+" 8 | - "[0-9]+.[0-9]+.[0-9]+[a-b][0-9]+" 9 | - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" 10 | 11 | jobs: 12 | build: 13 | name: Build the source tarball and the wheel 14 | runs-on: ubuntu-latest 15 | environment: release 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.x 22 | - name: Install dependencies 23 | run: pip install build 24 | - name: Create packages 25 | run: python -m build 26 | - name: Archive packages 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: dist 30 | path: dist 31 | 32 | publish: 33 | name: Publish build artifacts to the PyPI 34 | needs: build 35 | runs-on: ubuntu-latest 36 | environment: release 37 | permissions: 38 | id-token: write 39 | steps: 40 | - name: Retrieve packages 41 | uses: actions/download-artifact@v4 42 | - name: Upload packages 43 | uses: pypa/gh-action-pypi-publish@release/v1 44 | 45 | release: 46 | name: Create a GitHub release 47 | needs: build 48 | runs-on: ubuntu-latest 49 | permissions: 50 | contents: write 51 | steps: 52 | - uses: actions/checkout@v4 53 | - id: changelog 54 | uses: agronholm/release-notes@v1 55 | with: 56 | path: CHANGES.rst 57 | - uses: ncipollo/release-action@v1 58 | with: 59 | body: ${{ steps.changelog.outputs.changelog }} 60 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: > 8 | If you observed a crash in the project, or saw unexpected behavior in it, report 9 | your findings here. 10 | - type: checkboxes 11 | attributes: 12 | label: Things to check first 13 | options: 14 | - label: > 15 | I have searched the existing issues and didn't find my bug already reported 16 | there 17 | required: true 18 | - label: > 19 | I have checked that my bug is still present in the latest release 20 | required: true 21 | - type: input 22 | id: project-version 23 | attributes: 24 | label: Sqlacodegen version 25 | description: What version of Sqlacodegen were you running? 26 | validations: 27 | required: true 28 | - type: input 29 | id: sqlalchemy-version 30 | attributes: 31 | label: SQLAlchemy version 32 | description: What version of SQLAlchemy were you running? 33 | validations: 34 | required: true 35 | - type: dropdown 36 | id: rdbms 37 | attributes: 38 | label: RDBMS vendor 39 | description: > 40 | What RDBMS (relational database management system) did you run the tool against? 41 | options: 42 | - PostgreSQL 43 | - MySQL (or compatible) 44 | - SQLite 45 | - MSSQL 46 | - Oracle 47 | - DB2 48 | - Other 49 | - N/A 50 | validations: 51 | required: true 52 | - type: textarea 53 | id: what-happened 54 | attributes: 55 | label: What happened? 56 | description: > 57 | Unless you are reporting a crash, tell us what you expected to happen instead. 58 | validations: 59 | required: true 60 | - type: textarea 61 | id: schema 62 | attributes: 63 | label: Database schema for reproducing the bug 64 | description: > 65 | If applicable, paste the database schema (as a series of `CREATE TABLE` and 66 | other SQL commands) here. 67 | -------------------------------------------------------------------------------- /src/sqlacodegen/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from enum import Enum, auto 5 | from typing import Any 6 | 7 | from sqlalchemy.sql.schema import Column, ForeignKeyConstraint, Table 8 | 9 | 10 | @dataclass 11 | class Model: 12 | table: Table 13 | name: str = field(init=False, default="") 14 | 15 | @property 16 | def schema(self) -> str | None: 17 | return self.table.schema 18 | 19 | 20 | @dataclass 21 | class ModelClass(Model): 22 | columns: list[ColumnAttribute] = field(default_factory=list) 23 | relationships: list[RelationshipAttribute] = field(default_factory=list) 24 | parent_class: ModelClass | None = None 25 | children: list[ModelClass] = field(default_factory=list) 26 | 27 | def get_column_attribute(self, column_name: str) -> ColumnAttribute: 28 | for column in self.columns: 29 | if column.column.name == column_name: 30 | return column 31 | 32 | raise LookupError(f"Cannot find column attribute for {column_name!r}") 33 | 34 | 35 | class RelationshipType(Enum): 36 | ONE_TO_ONE = auto() 37 | ONE_TO_MANY = auto() 38 | MANY_TO_ONE = auto() 39 | MANY_TO_MANY = auto() 40 | 41 | 42 | @dataclass 43 | class ColumnAttribute: 44 | model: ModelClass 45 | column: Column[Any] 46 | name: str = field(init=False, default="") 47 | 48 | def __repr__(self) -> str: 49 | return f"{self.__class__.__name__}(name={self.name!r}, type={self.column.type})" 50 | 51 | def __str__(self) -> str: 52 | return self.name 53 | 54 | 55 | JoinType = tuple[Model, ColumnAttribute | str, Model, ColumnAttribute | str] 56 | 57 | 58 | @dataclass 59 | class RelationshipAttribute: 60 | type: RelationshipType 61 | source: ModelClass 62 | target: ModelClass 63 | constraint: ForeignKeyConstraint | None = None 64 | association_table: Model | None = None 65 | backref: RelationshipAttribute | None = None 66 | remote_side: list[ColumnAttribute] = field(default_factory=list) 67 | foreign_keys: list[ColumnAttribute] = field(default_factory=list) 68 | primaryjoin: list[JoinType] = field(default_factory=list) 69 | secondaryjoin: list[JoinType] = field(default_factory=list) 70 | name: str = field(init=False, default="") 71 | 72 | def __repr__(self) -> str: 73 | return ( 74 | f"{self.__class__.__name__}(name={self.name!r}, type={self.type}, " 75 | f"target={self.target.name})" 76 | ) 77 | 78 | def __str__(self) -> str: 79 | return self.name 80 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing to sqlacodegen 2 | =========================== 3 | 4 | If you wish to contribute a fix or feature to sqlacodegen, please follow the following 5 | guidelines. 6 | 7 | When you make a pull request against the main sqlacodegen codebase, Github runs the 8 | sqlacodegen test suite against your modified code. Before making a pull request, you 9 | should ensure that the modified code passes tests locally. To that end, the use of tox_ 10 | is recommended. The default tox run first runs ``pre-commit`` and then the actual test 11 | suite. To run the checks on all environments in parallel, invoke tox with ``tox -p``. 12 | 13 | To build the documentation, run ``tox -e docs`` which will generate a directory named 14 | ``build`` in which you may view the formatted HTML documentation. 15 | 16 | sqlacodegen uses pre-commit_ to perform several code style/quality checks. It is 17 | recommended to activate pre-commit_ on your local clone of the repository (using 18 | ``pre-commit install``) to ensure that your changes will pass the same checks on GitHub. 19 | 20 | .. _tox: https://tox.readthedocs.io/en/latest/install.html 21 | .. _pre-commit: https://pre-commit.com/#installation 22 | 23 | Making a pull request on Github 24 | ------------------------------- 25 | 26 | To get your changes merged to the main codebase, you need a Github account. 27 | 28 | #. Fork the repository (if you don't have your own fork of it yet) by navigating to the 29 | `main sqlacodegen repository`_ and clicking on "Fork" near the top right corner. 30 | #. Clone the forked repository to your local machine with 31 | ``git clone git@github.com/yourusername/sqlacodegen``. 32 | #. Create a branch for your pull request, like ``git checkout -b myfixname`` 33 | #. Make the desired changes to the code base. 34 | #. Commit your changes locally. If your changes close an existing issue, add the text 35 | ``Fixes #XXX.`` or ``Closes #XXX.`` to the commit message (where XXX is the issue 36 | number). 37 | #. Push the changeset(s) to your forked repository (``git push``) 38 | #. Navigate to Pull requests page on the original repository (not your fork) and click 39 | "New pull request" 40 | #. Click on the text "compare across forks". 41 | #. Select your own fork as the head repository and then select the correct branch name. 42 | #. Click on "Create pull request". 43 | 44 | If you have trouble, consult the `pull request making guide`_ on opensource.com. 45 | 46 | .. _main sqlacodegen repository: https://github.com/agronholm/sqlacodegen 47 | .. _pull request making guide: https://opensource.com/article/19/7/create-pull-request-github 48 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 64", 4 | "setuptools_scm[toml] >= 6.4" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "sqlacodegen" 10 | description = "Automatic model code generator for SQLAlchemy" 11 | readme = "README.rst" 12 | authors = [{name = "Alex Grönholm", email = "alex.gronholm@nextday.fi"}] 13 | maintainers = [{name = "Idan Sheinberg", email = "ishinberg0@gmail.com"}] 14 | keywords = ["sqlalchemy"] 15 | license = "MIT" 16 | classifiers = [ 17 | "Development Status :: 5 - Production/Stable", 18 | "Intended Audience :: Developers", 19 | "Environment :: Console", 20 | "Topic :: Database", 21 | "Topic :: Software Development :: Code Generators", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Programming Language :: Python :: 3.14", 29 | ] 30 | requires-python = ">=3.10" 31 | dependencies = [ 32 | "SQLAlchemy >= 2.0.29", 33 | "inflect >= 4.0.0", 34 | ] 35 | dynamic = ["version"] 36 | 37 | [project.urls] 38 | "Bug Tracker" = "https://github.com/agronholm/sqlacodegen/issues" 39 | "Source Code" = "https://github.com/agronholm/sqlacodegen" 40 | 41 | [project.optional-dependencies] 42 | test = [ 43 | "sqlacodegen[sqlmodel,pgvector,geoalchemy2]", 44 | "pytest >= 7.4", 45 | "coverage >= 7", 46 | "psycopg[binary]", 47 | "mysql-connector-python", 48 | ] 49 | sqlmodel = ["sqlmodel >= 0.0.22"] 50 | citext = ["sqlalchemy-citext >= 1.7.0"] 51 | geoalchemy2 = ["geoalchemy2 >= 0.17.0"] 52 | pgvector = ["pgvector >= 0.2.4"] 53 | 54 | [project.entry-points."sqlacodegen.generators"] 55 | tables = "sqlacodegen.generators:TablesGenerator" 56 | declarative = "sqlacodegen.generators:DeclarativeGenerator" 57 | dataclasses = "sqlacodegen.generators:DataclassGenerator" 58 | sqlmodels = "sqlacodegen.generators:SQLModelGenerator" 59 | 60 | [project.scripts] 61 | sqlacodegen = "sqlacodegen.cli:main" 62 | 63 | [tool.setuptools_scm] 64 | version_scheme = "post-release" 65 | local_scheme = "dirty-tag" 66 | 67 | [tool.ruff] 68 | src = ["src"] 69 | 70 | [tool.ruff.lint] 71 | extend-select = [ 72 | "I", # isort 73 | "ISC", # flake8-implicit-str-concat 74 | "PGH", # pygrep-hooks 75 | "RUF100", # unused noqa (yesqa) 76 | "UP", # pyupgrade 77 | "W", # pycodestyle warnings 78 | ] 79 | 80 | [tool.mypy] 81 | strict = true 82 | disable_error_code = "no-untyped-call" 83 | 84 | [tool.pytest.ini_options] 85 | addopts = "-rsfE --tb=short" 86 | testpaths = ["tests"] 87 | 88 | [coverage.run] 89 | source = ["sqlacodegen"] 90 | relative_files = true 91 | 92 | [coverage.report] 93 | show_missing = true 94 | 95 | [tool.tox] 96 | env_list = ["py310", "py311", "py312", "py313", "py314"] 97 | skip_missing_interpreters = true 98 | 99 | [tool.tox.env_run_base] 100 | package = "editable" 101 | commands = [["python", "-m", "pytest", { replace = "posargs", extend = true }]] 102 | extras = ["test"] 103 | -------------------------------------------------------------------------------- /src/sqlacodegen/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import ast 5 | import sys 6 | from contextlib import ExitStack 7 | from importlib.metadata import entry_points, version 8 | from typing import Any, TextIO 9 | 10 | from sqlalchemy.engine import create_engine 11 | from sqlalchemy.schema import MetaData 12 | 13 | try: 14 | import citext 15 | except ImportError: 16 | citext = None 17 | 18 | try: 19 | import geoalchemy2 20 | except ImportError: 21 | geoalchemy2 = None 22 | 23 | try: 24 | import pgvector.sqlalchemy 25 | except ImportError: 26 | pgvector = None 27 | 28 | 29 | def _parse_engine_arg(arg_str: str) -> tuple[str, Any]: 30 | if "=" not in arg_str: 31 | raise argparse.ArgumentTypeError("engine-arg must be in key=value format") 32 | 33 | key, value = arg_str.split("=", 1) 34 | try: 35 | value = ast.literal_eval(value) 36 | except Exception: 37 | pass # Leave as string if literal_eval fails 38 | 39 | return key, value 40 | 41 | 42 | def _parse_engine_args(arg_list: list[str]) -> dict[str, Any]: 43 | result = {} 44 | for arg in arg_list or []: 45 | key, value = _parse_engine_arg(arg) 46 | result[key] = value 47 | 48 | return result 49 | 50 | 51 | def main() -> None: 52 | generators = {ep.name: ep for ep in entry_points(group="sqlacodegen.generators")} 53 | parser = argparse.ArgumentParser( 54 | description="Generates SQLAlchemy model code from an existing database." 55 | ) 56 | parser.add_argument("url", nargs="?", help="SQLAlchemy url to the database") 57 | parser.add_argument( 58 | "--options", help="options (comma-delimited) passed to the generator class" 59 | ) 60 | parser.add_argument( 61 | "--version", action="store_true", help="print the version number and exit" 62 | ) 63 | parser.add_argument( 64 | "--schemas", help="load tables from the given schemas (comma-delimited)" 65 | ) 66 | parser.add_argument( 67 | "--generator", 68 | choices=generators, 69 | default="declarative", 70 | help="generator class to use", 71 | ) 72 | parser.add_argument( 73 | "--tables", help="tables to process (comma-delimited, default: all)" 74 | ) 75 | parser.add_argument( 76 | "--noviews", 77 | action="store_true", 78 | help="ignore views (always true for sqlmodels generator)", 79 | ) 80 | parser.add_argument( 81 | "--engine-arg", 82 | action="append", 83 | help=( 84 | "engine arguments in key=value format, e.g., " 85 | '--engine-arg=connect_args=\'{"user": "scott"}\' ' 86 | "--engine-arg thick_mode=true or " 87 | '--engine-arg thick_mode=\'{"lib_dir": "/path"}\' ' 88 | "(values are parsed with ast.literal_eval)" 89 | ), 90 | ) 91 | parser.add_argument("--outfile", help="file to write output to (default: stdout)") 92 | args = parser.parse_args() 93 | 94 | if args.version: 95 | print(version("sqlacodegen")) 96 | return 97 | 98 | if not args.url: 99 | print("You must supply a url\n", file=sys.stderr) 100 | parser.print_help() 101 | return 102 | 103 | if citext: 104 | print(f"Using sqlalchemy-citext {version('sqlalchemy-citext')}") 105 | 106 | if geoalchemy2: 107 | print(f"Using geoalchemy2 {version('geoalchemy2')}") 108 | 109 | if pgvector: 110 | print(f"Using pgvector {version('pgvector')}") 111 | 112 | # Use reflection to fill in the metadata 113 | engine_args = _parse_engine_args(args.engine_arg) 114 | engine = create_engine(args.url, **engine_args) 115 | metadata = MetaData() 116 | tables = args.tables.split(",") if args.tables else None 117 | schemas = args.schemas.split(",") if args.schemas else [None] 118 | options = set(args.options.split(",")) if args.options else set() 119 | 120 | # Instantiate the generator 121 | generator_class = generators[args.generator].load() 122 | generator = generator_class(metadata, engine, options) 123 | 124 | if not generator.views_supported: 125 | name = generator_class.__name__ 126 | print( 127 | f"VIEW models will not be generated when using the '{name}' generator", 128 | file=sys.stderr, 129 | ) 130 | 131 | for schema in schemas: 132 | metadata.reflect( 133 | engine, schema, (generator.views_supported and not args.noviews), tables 134 | ) 135 | 136 | # Open the target file (if given) 137 | with ExitStack() as stack: 138 | outfile: TextIO 139 | if args.outfile: 140 | outfile = open(args.outfile, "w", encoding="utf-8") 141 | stack.enter_context(outfile) 142 | else: 143 | outfile = sys.stdout 144 | 145 | # Write the generated model code to the specified file or standard output 146 | outfile.write(generator.generate()) 147 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sqlite3 4 | import subprocess 5 | import sys 6 | from importlib.metadata import version 7 | from pathlib import Path 8 | 9 | import pytest 10 | 11 | future_imports = "from __future__ import annotations\n\n" 12 | 13 | 14 | @pytest.fixture 15 | def db_path(tmp_path: Path) -> Path: 16 | path = tmp_path / "test.db" 17 | with sqlite3.connect(str(path)) as conn: 18 | cursor = conn.cursor() 19 | cursor.execute( 20 | "CREATE TABLE foo (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL)" 21 | ) 22 | 23 | return path 24 | 25 | 26 | def test_cli_tables(db_path: Path, tmp_path: Path) -> None: 27 | output_path = tmp_path / "outfile" 28 | subprocess.run( 29 | [ 30 | "sqlacodegen", 31 | f"sqlite:///{db_path}", 32 | "--generator", 33 | "tables", 34 | "--outfile", 35 | str(output_path), 36 | ], 37 | check=True, 38 | ) 39 | 40 | assert ( 41 | output_path.read_text() 42 | == """\ 43 | from sqlalchemy import Column, Integer, MetaData, Table, Text 44 | 45 | metadata = MetaData() 46 | 47 | 48 | t_foo = Table( 49 | 'foo', metadata, 50 | Column('id', Integer, primary_key=True), 51 | Column('name', Text, nullable=False) 52 | ) 53 | """ 54 | ) 55 | 56 | 57 | def test_cli_declarative(db_path: Path, tmp_path: Path) -> None: 58 | output_path = tmp_path / "outfile" 59 | subprocess.run( 60 | [ 61 | "sqlacodegen", 62 | f"sqlite:///{db_path}", 63 | "--generator", 64 | "declarative", 65 | "--outfile", 66 | str(output_path), 67 | ], 68 | check=True, 69 | ) 70 | 71 | assert ( 72 | output_path.read_text() 73 | == """\ 74 | from sqlalchemy import Integer, Text 75 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 76 | 77 | class Base(DeclarativeBase): 78 | pass 79 | 80 | 81 | class Foo(Base): 82 | __tablename__ = 'foo' 83 | 84 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 85 | name: Mapped[str] = mapped_column(Text, nullable=False) 86 | """ 87 | ) 88 | 89 | 90 | def test_cli_dataclass(db_path: Path, tmp_path: Path) -> None: 91 | output_path = tmp_path / "outfile" 92 | subprocess.run( 93 | [ 94 | "sqlacodegen", 95 | f"sqlite:///{db_path}", 96 | "--generator", 97 | "dataclasses", 98 | "--outfile", 99 | str(output_path), 100 | ], 101 | check=True, 102 | ) 103 | 104 | assert ( 105 | output_path.read_text() 106 | == """\ 107 | from sqlalchemy import Integer, Text 108 | from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column 109 | 110 | class Base(MappedAsDataclass, DeclarativeBase): 111 | pass 112 | 113 | 114 | class Foo(Base): 115 | __tablename__ = 'foo' 116 | 117 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 118 | name: Mapped[str] = mapped_column(Text, nullable=False) 119 | """ 120 | ) 121 | 122 | 123 | def test_cli_sqlmodels(db_path: Path, tmp_path: Path) -> None: 124 | output_path = tmp_path / "outfile" 125 | subprocess.run( 126 | [ 127 | "sqlacodegen", 128 | f"sqlite:///{db_path}", 129 | "--generator", 130 | "sqlmodels", 131 | "--outfile", 132 | str(output_path), 133 | ], 134 | check=True, 135 | ) 136 | 137 | assert ( 138 | output_path.read_text() 139 | == """\ 140 | from sqlalchemy import Column, Integer, Text 141 | from sqlmodel import Field, SQLModel 142 | 143 | class Foo(SQLModel, table=True): 144 | id: int = Field(sa_column=Column('id', Integer, primary_key=True)) 145 | name: str = Field(sa_column=Column('name', Text, nullable=False)) 146 | """ 147 | ) 148 | 149 | 150 | def test_cli_engine_arg(db_path: Path, tmp_path: Path) -> None: 151 | output_path = tmp_path / "outfile" 152 | subprocess.run( 153 | [ 154 | "sqlacodegen", 155 | f"sqlite:///{db_path}", 156 | "--generator", 157 | "tables", 158 | "--engine-arg", 159 | 'connect_args={"timeout": 10}', 160 | "--outfile", 161 | str(output_path), 162 | ], 163 | check=True, 164 | ) 165 | 166 | assert ( 167 | output_path.read_text() 168 | == """\ 169 | from sqlalchemy import Column, Integer, MetaData, Table, Text 170 | 171 | metadata = MetaData() 172 | 173 | 174 | t_foo = Table( 175 | 'foo', metadata, 176 | Column('id', Integer, primary_key=True), 177 | Column('name', Text, nullable=False) 178 | ) 179 | """ 180 | ) 181 | 182 | 183 | def test_cli_invalid_engine_arg(db_path: Path, tmp_path: Path) -> None: 184 | output_path = tmp_path / "outfile" 185 | 186 | # Expect exception: 187 | # TypeError: 'this_arg_does_not_exist' is an invalid keyword argument for Connection() 188 | with pytest.raises(subprocess.CalledProcessError) as exc_info: 189 | subprocess.run( 190 | [ 191 | "sqlacodegen", 192 | f"sqlite:///{db_path}", 193 | "--generator", 194 | "tables", 195 | "--engine-arg", 196 | 'connect_args={"this_arg_does_not_exist": 10}', 197 | "--outfile", 198 | str(output_path), 199 | ], 200 | check=True, 201 | capture_output=True, 202 | ) 203 | 204 | if sys.version_info < (3, 13): 205 | assert ( 206 | "'this_arg_does_not_exist' is an invalid keyword argument" 207 | in exc_info.value.stderr.decode() 208 | ) 209 | else: 210 | assert ( 211 | "got an unexpected keyword argument 'this_arg_does_not_exist'" 212 | in exc_info.value.stderr.decode() 213 | ) 214 | 215 | 216 | def test_main() -> None: 217 | expected_version = version("sqlacodegen") 218 | completed = subprocess.run( 219 | [sys.executable, "-m", "sqlacodegen", "--version"], 220 | stdout=subprocess.PIPE, 221 | check=True, 222 | ) 223 | assert completed.stdout.decode().strip() == expected_version 224 | -------------------------------------------------------------------------------- /tests/test_generator_sqlmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from _pytest.fixtures import FixtureRequest 5 | from sqlalchemy import Uuid 6 | from sqlalchemy.engine import Engine 7 | from sqlalchemy.schema import ( 8 | CheckConstraint, 9 | Column, 10 | ForeignKeyConstraint, 11 | Index, 12 | MetaData, 13 | Table, 14 | UniqueConstraint, 15 | ) 16 | from sqlalchemy.types import INTEGER, VARCHAR 17 | 18 | from sqlacodegen.generators import CodeGenerator, SQLModelGenerator 19 | 20 | from .conftest import validate_code 21 | 22 | 23 | @pytest.fixture 24 | def generator( 25 | request: FixtureRequest, metadata: MetaData, engine: Engine 26 | ) -> CodeGenerator: 27 | options = getattr(request, "param", []) 28 | return SQLModelGenerator(metadata, engine, options) 29 | 30 | 31 | def test_indexes(generator: CodeGenerator) -> None: 32 | simple_items = Table( 33 | "item", 34 | generator.metadata, 35 | Column("id", INTEGER, primary_key=True), 36 | Column("number", INTEGER, nullable=False), 37 | Column("text", VARCHAR), 38 | ) 39 | simple_items.indexes.add(Index("idx_number", simple_items.c.number)) 40 | simple_items.indexes.add( 41 | Index("idx_text_number", simple_items.c.text, simple_items.c.number) 42 | ) 43 | simple_items.indexes.add(Index("idx_text", simple_items.c.text, unique=True)) 44 | 45 | validate_code( 46 | generator.generate(), 47 | """\ 48 | from typing import Optional 49 | 50 | from sqlalchemy import Column, Index, Integer, String 51 | from sqlmodel import Field, SQLModel 52 | 53 | class Item(SQLModel, table=True): 54 | __table_args__ = ( 55 | Index('idx_number', 'number'), 56 | Index('idx_text', 'text', unique=True), 57 | Index('idx_text_number', 'text', 'number') 58 | ) 59 | 60 | id: int = Field(sa_column=Column('id', Integer, primary_key=True)) 61 | number: int = Field(sa_column=Column(\ 62 | 'number', Integer, nullable=False)) 63 | text: Optional[str] = Field(default=None, sa_column=Column(\ 64 | 'text', String)) 65 | """, 66 | ) 67 | 68 | 69 | def test_constraints(generator: CodeGenerator) -> None: 70 | Table( 71 | "simple_constraints", 72 | generator.metadata, 73 | Column("id", INTEGER, primary_key=True), 74 | Column("number", INTEGER), 75 | CheckConstraint("number > 0"), 76 | UniqueConstraint("id", "number"), 77 | ) 78 | 79 | validate_code( 80 | generator.generate(), 81 | """\ 82 | from typing import Optional 83 | 84 | from sqlalchemy import CheckConstraint, Column, Integer, UniqueConstraint 85 | from sqlmodel import Field, SQLModel 86 | 87 | class SimpleConstraints(SQLModel, table=True): 88 | __tablename__ = 'simple_constraints' 89 | __table_args__ = ( 90 | CheckConstraint('number > 0'), 91 | UniqueConstraint('id', 'number') 92 | ) 93 | 94 | id: int = Field(sa_column=Column('id', Integer, primary_key=True)) 95 | number: Optional[int] = Field(default=None, sa_column=Column(\ 96 | 'number', Integer)) 97 | """, 98 | ) 99 | 100 | 101 | def test_onetomany(generator: CodeGenerator) -> None: 102 | Table( 103 | "simple_goods", 104 | generator.metadata, 105 | Column("id", INTEGER, primary_key=True), 106 | Column("container_id", INTEGER), 107 | ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), 108 | ) 109 | Table( 110 | "simple_containers", 111 | generator.metadata, 112 | Column("id", INTEGER, primary_key=True), 113 | ) 114 | 115 | validate_code( 116 | generator.generate(), 117 | """\ 118 | from typing import Optional 119 | 120 | from sqlalchemy import Column, ForeignKey, Integer 121 | from sqlmodel import Field, Relationship, SQLModel 122 | 123 | class SimpleContainers(SQLModel, table=True): 124 | __tablename__ = 'simple_containers' 125 | 126 | id: int = Field(sa_column=Column('id', Integer, primary_key=True)) 127 | 128 | simple_goods: list['SimpleGoods'] = Relationship(\ 129 | back_populates='container') 130 | 131 | 132 | class SimpleGoods(SQLModel, table=True): 133 | __tablename__ = 'simple_goods' 134 | 135 | id: int = Field(sa_column=Column('id', Integer, primary_key=True)) 136 | container_id: Optional[int] = Field(default=None, sa_column=Column(\ 137 | 'container_id', ForeignKey('simple_containers.id'))) 138 | 139 | container: Optional['SimpleContainers'] = Relationship(\ 140 | back_populates='simple_goods') 141 | """, 142 | ) 143 | 144 | 145 | def test_onetoone(generator: CodeGenerator) -> None: 146 | Table( 147 | "simple_onetoone", 148 | generator.metadata, 149 | Column("id", INTEGER, primary_key=True), 150 | Column("other_item_id", INTEGER), 151 | ForeignKeyConstraint(["other_item_id"], ["other_items.id"]), 152 | UniqueConstraint("other_item_id"), 153 | ) 154 | Table("other_items", generator.metadata, Column("id", INTEGER, primary_key=True)) 155 | 156 | validate_code( 157 | generator.generate(), 158 | """\ 159 | from typing import Optional 160 | 161 | from sqlalchemy import Column, ForeignKey, Integer 162 | from sqlmodel import Field, Relationship, SQLModel 163 | 164 | class OtherItems(SQLModel, table=True): 165 | __tablename__ = 'other_items' 166 | 167 | id: int = Field(sa_column=Column('id', Integer, primary_key=True)) 168 | 169 | simple_onetoone: Optional['SimpleOnetoone'] = Relationship(\ 170 | sa_relationship_kwargs={'uselist': False}, back_populates='other_item') 171 | 172 | 173 | class SimpleOnetoone(SQLModel, table=True): 174 | __tablename__ = 'simple_onetoone' 175 | 176 | id: int = Field(sa_column=Column('id', Integer, primary_key=True)) 177 | other_item_id: Optional[int] = Field(default=None, sa_column=Column(\ 178 | 'other_item_id', ForeignKey('other_items.id'), unique=True)) 179 | 180 | other_item: Optional['OtherItems'] = Relationship(\ 181 | back_populates='simple_onetoone') 182 | """, 183 | ) 184 | 185 | 186 | def test_uuid(generator: CodeGenerator) -> None: 187 | Table( 188 | "simple_uuid", 189 | generator.metadata, 190 | Column("id", Uuid, primary_key=True), 191 | ) 192 | 193 | validate_code( 194 | generator.generate(), 195 | """\ 196 | import uuid 197 | 198 | from sqlalchemy import Column, Uuid 199 | from sqlmodel import Field, SQLModel 200 | 201 | class SimpleUuid(SQLModel, table=True): 202 | __tablename__ = 'simple_uuid' 203 | 204 | id: uuid.UUID = Field(sa_column=Column('id', Uuid, primary_key=True)) 205 | """, 206 | ) 207 | -------------------------------------------------------------------------------- /src/sqlacodegen/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import sys 5 | from collections.abc import Mapping 6 | from typing import Any, Literal, cast 7 | 8 | from sqlalchemy import PrimaryKeyConstraint, UniqueConstraint 9 | from sqlalchemy.engine import Connection, Engine 10 | from sqlalchemy.sql import ClauseElement 11 | from sqlalchemy.sql.elements import TextClause 12 | from sqlalchemy.sql.schema import ( 13 | CheckConstraint, 14 | ColumnCollectionConstraint, 15 | Constraint, 16 | ForeignKeyConstraint, 17 | Index, 18 | Table, 19 | ) 20 | 21 | _re_postgresql_nextval_sequence = re.compile(r"nextval\('(.+)'::regclass\)") 22 | _re_postgresql_sequence_delimiter = re.compile(r'(.*?)([."]|$)') 23 | 24 | 25 | def get_column_names(constraint: ColumnCollectionConstraint) -> list[str]: 26 | return list(constraint.columns.keys()) 27 | 28 | 29 | def get_constraint_sort_key(constraint: Constraint) -> str: 30 | if isinstance(constraint, CheckConstraint): 31 | return f"C{constraint.sqltext}" 32 | elif isinstance(constraint, ColumnCollectionConstraint): 33 | return constraint.__class__.__name__[0] + repr(get_column_names(constraint)) 34 | else: 35 | return str(constraint) 36 | 37 | 38 | def get_compiled_expression(statement: ClauseElement, bind: Engine | Connection) -> str: 39 | """Return the statement in a form where any placeholders have been filled in.""" 40 | return str(statement.compile(bind, compile_kwargs={"literal_binds": True})) 41 | 42 | 43 | def get_common_fk_constraints( 44 | table1: Table, table2: Table 45 | ) -> set[ForeignKeyConstraint]: 46 | """ 47 | Return a set of foreign key constraints the two tables have against each other. 48 | 49 | """ 50 | c1 = { 51 | c 52 | for c in table1.constraints 53 | if isinstance(c, ForeignKeyConstraint) and c.elements[0].column.table == table2 54 | } 55 | c2 = { 56 | c 57 | for c in table2.constraints 58 | if isinstance(c, ForeignKeyConstraint) and c.elements[0].column.table == table1 59 | } 60 | return c1.union(c2) 61 | 62 | 63 | def uses_default_name(constraint: Constraint | Index) -> bool: 64 | if not constraint.name or constraint.table is None: 65 | return True 66 | 67 | table = constraint.table 68 | values: dict[str, Any] = { 69 | "table_name": table.name, 70 | "constraint_name": constraint.name, 71 | } 72 | if isinstance(constraint, (Index, ColumnCollectionConstraint)): 73 | values.update( 74 | { 75 | "column_0N_name": "".join(col.name for col in constraint.columns), 76 | "column_0_N_name": "_".join(col.name for col in constraint.columns), 77 | "column_0N_label": "".join( 78 | col.label(col.name).name for col in constraint.columns 79 | ), 80 | "column_0_N_label": "_".join( 81 | col.label(col.name).name for col in constraint.columns 82 | ), 83 | "column_0N_key": "".join( 84 | col.key for col in constraint.columns if col.key 85 | ), 86 | "column_0_N_key": "_".join( 87 | col.key for col in constraint.columns if col.key 88 | ), 89 | } 90 | ) 91 | if constraint.columns: 92 | columns = constraint.columns.values() 93 | values.update( 94 | { 95 | "column_0_name": columns[0].name, 96 | "column_0_label": columns[0].label(columns[0].name).name, 97 | "column_0_key": columns[0].key, 98 | } 99 | ) 100 | 101 | key: Literal["fk", "pk", "ix", "ck", "uq"] 102 | if isinstance(constraint, Index): 103 | key = "ix" 104 | elif isinstance(constraint, CheckConstraint): 105 | key = "ck" 106 | elif isinstance(constraint, UniqueConstraint): 107 | key = "uq" 108 | elif isinstance(constraint, PrimaryKeyConstraint): 109 | key = "pk" 110 | elif isinstance(constraint, ForeignKeyConstraint): 111 | key = "fk" 112 | values.update( 113 | { 114 | "referred_table_name": constraint.referred_table, 115 | "referred_column_0_name": constraint.elements[0].column.name, 116 | "referred_column_0N_name": "".join( 117 | fk.column.name for fk in constraint.elements 118 | ), 119 | "referred_column_0_N_name": "_".join( 120 | fk.column.name for fk in constraint.elements 121 | ), 122 | "referred_column_0_label": constraint.elements[0] 123 | .column.label(constraint.elements[0].column.name) 124 | .name, 125 | "referred_fk.column_0N_label": "".join( 126 | fk.column.label(fk.column.name).name for fk in constraint.elements 127 | ), 128 | "referred_fk.column_0_N_label": "_".join( 129 | fk.column.label(fk.column.name).name for fk in constraint.elements 130 | ), 131 | "referred_fk.column_0_key": constraint.elements[0].column.key, 132 | "referred_fk.column_0N_key": "".join( 133 | fk.column.key for fk in constraint.elements if fk.column.key 134 | ), 135 | "referred_fk.column_0_N_key": "_".join( 136 | fk.column.key for fk in constraint.elements if fk.column.key 137 | ), 138 | } 139 | ) 140 | else: 141 | raise TypeError(f"Unknown constraint type: {constraint.__class__.__qualname__}") 142 | 143 | try: 144 | convention = cast( 145 | Mapping[str, str], 146 | table.metadata.naming_convention, 147 | )[key] 148 | return constraint.name == (convention % values) 149 | except KeyError: 150 | return False 151 | 152 | 153 | def render_callable( 154 | name: str, 155 | *args: object, 156 | kwargs: Mapping[str, object] | None = None, 157 | indentation: str = "", 158 | ) -> str: 159 | """ 160 | Render a function call. 161 | 162 | :param name: name of the callable 163 | :param args: positional arguments 164 | :param kwargs: keyword arguments 165 | :param indentation: if given, each argument will be rendered on its own line with 166 | this value used as the indentation 167 | 168 | """ 169 | if kwargs: 170 | args += tuple(f"{key}={value}" for key, value in kwargs.items()) 171 | 172 | if indentation: 173 | prefix = f"\n{indentation}" 174 | suffix = "\n" 175 | delimiter = f",\n{indentation}" 176 | else: 177 | prefix = suffix = "" 178 | delimiter = ", " 179 | 180 | rendered_args = delimiter.join(str(arg) for arg in args) 181 | return f"{name}({prefix}{rendered_args}{suffix})" 182 | 183 | 184 | def qualified_table_name(table: Table) -> str: 185 | if table.schema: 186 | return f"{table.schema}.{table.name}" 187 | else: 188 | return str(table.name) 189 | 190 | 191 | def decode_postgresql_sequence(clause: TextClause) -> tuple[str | None, str | None]: 192 | match = _re_postgresql_nextval_sequence.match(clause.text) 193 | if not match: 194 | return None, None 195 | 196 | schema: str | None = None 197 | sequence: str = "" 198 | in_quotes = False 199 | for match in _re_postgresql_sequence_delimiter.finditer(match.group(1)): 200 | sequence += match.group(1) 201 | if match.group(2) == '"': 202 | in_quotes = not in_quotes 203 | elif match.group(2) == ".": 204 | if in_quotes: 205 | sequence += "." 206 | else: 207 | schema, sequence = sequence, "" 208 | 209 | return schema, sequence 210 | 211 | 212 | def get_stdlib_module_names() -> set[str]: 213 | major, minor = sys.version_info.major, sys.version_info.minor 214 | if (major, minor) > (3, 9): 215 | return set(sys.builtin_module_names) | set(sys.stdlib_module_names) 216 | else: 217 | from stdlib_list import stdlib_list 218 | 219 | return set(sys.builtin_module_names) | set(stdlib_list(f"{major}.{minor}")) 220 | -------------------------------------------------------------------------------- /tests/test_generator_dataclass.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from _pytest.fixtures import FixtureRequest 5 | from sqlalchemy.dialects.postgresql import UUID 6 | from sqlalchemy.engine import Engine 7 | from sqlalchemy.schema import Column, ForeignKeyConstraint, MetaData, Table 8 | from sqlalchemy.sql.expression import text 9 | from sqlalchemy.types import INTEGER, VARCHAR 10 | 11 | from sqlacodegen.generators import CodeGenerator, DataclassGenerator 12 | 13 | from .conftest import validate_code 14 | 15 | 16 | @pytest.fixture 17 | def generator( 18 | request: FixtureRequest, metadata: MetaData, engine: Engine 19 | ) -> CodeGenerator: 20 | options = getattr(request, "param", []) 21 | return DataclassGenerator(metadata, engine, options) 22 | 23 | 24 | def test_basic_class(generator: CodeGenerator) -> None: 25 | Table( 26 | "simple", 27 | generator.metadata, 28 | Column("id", INTEGER, primary_key=True), 29 | Column("name", VARCHAR(20)), 30 | ) 31 | 32 | validate_code( 33 | generator.generate(), 34 | """\ 35 | from typing import Optional 36 | 37 | from sqlalchemy import Integer, String 38 | from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, \ 39 | mapped_column 40 | 41 | class Base(MappedAsDataclass, DeclarativeBase): 42 | pass 43 | 44 | 45 | class Simple(Base): 46 | __tablename__ = 'simple' 47 | 48 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 49 | name: Mapped[Optional[str]] = mapped_column(String(20)) 50 | """, 51 | ) 52 | 53 | 54 | def test_mandatory_field_last(generator: CodeGenerator) -> None: 55 | Table( 56 | "simple", 57 | generator.metadata, 58 | Column("id", INTEGER, primary_key=True), 59 | Column("name", VARCHAR(20), server_default=text("foo")), 60 | Column("age", INTEGER, nullable=False), 61 | ) 62 | 63 | validate_code( 64 | generator.generate(), 65 | """\ 66 | from typing import Optional 67 | 68 | from sqlalchemy import Integer, String, text 69 | from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, \ 70 | mapped_column 71 | 72 | class Base(MappedAsDataclass, DeclarativeBase): 73 | pass 74 | 75 | 76 | class Simple(Base): 77 | __tablename__ = 'simple' 78 | 79 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 80 | age: Mapped[int] = mapped_column(Integer, nullable=False) 81 | name: Mapped[Optional[str]] = mapped_column(String(20), \ 82 | server_default=text('foo')) 83 | """, 84 | ) 85 | 86 | 87 | def test_onetomany_optional(generator: CodeGenerator) -> None: 88 | Table( 89 | "simple_items", 90 | generator.metadata, 91 | Column("id", INTEGER, primary_key=True), 92 | Column("container_id", INTEGER), 93 | ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), 94 | ) 95 | Table( 96 | "simple_containers", 97 | generator.metadata, 98 | Column("id", INTEGER, primary_key=True), 99 | ) 100 | 101 | validate_code( 102 | generator.generate(), 103 | """\ 104 | from typing import Optional 105 | 106 | from sqlalchemy import ForeignKey, Integer 107 | from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, \ 108 | mapped_column, relationship 109 | 110 | class Base(MappedAsDataclass, DeclarativeBase): 111 | pass 112 | 113 | 114 | class SimpleContainers(Base): 115 | __tablename__ = 'simple_containers' 116 | 117 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 118 | 119 | simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 120 | back_populates='container') 121 | 122 | 123 | class SimpleItems(Base): 124 | __tablename__ = 'simple_items' 125 | 126 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 127 | container_id: Mapped[Optional[int]] = \ 128 | mapped_column(ForeignKey('simple_containers.id')) 129 | 130 | container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ 131 | back_populates='simple_items') 132 | """, 133 | ) 134 | 135 | 136 | def test_manytomany(generator: CodeGenerator) -> None: 137 | Table("simple_items", generator.metadata, Column("id", INTEGER, primary_key=True)) 138 | Table( 139 | "simple_containers", 140 | generator.metadata, 141 | Column("id", INTEGER, primary_key=True), 142 | ) 143 | Table( 144 | "container_items", 145 | generator.metadata, 146 | Column("item_id", INTEGER), 147 | Column("container_id", INTEGER), 148 | ForeignKeyConstraint(["item_id"], ["simple_items.id"]), 149 | ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), 150 | ) 151 | 152 | validate_code( 153 | generator.generate(), 154 | """\ 155 | from sqlalchemy import Column, ForeignKey, Integer, Table 156 | from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, \ 157 | mapped_column, relationship 158 | 159 | class Base(MappedAsDataclass, DeclarativeBase): 160 | pass 161 | 162 | 163 | class SimpleContainers(Base): 164 | __tablename__ = 'simple_containers' 165 | 166 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 167 | 168 | item: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 169 | secondary='container_items', back_populates='container') 170 | 171 | 172 | class SimpleItems(Base): 173 | __tablename__ = 'simple_items' 174 | 175 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 176 | 177 | container: Mapped[list['SimpleContainers']] = \ 178 | relationship('SimpleContainers', secondary='container_items', back_populates='item') 179 | 180 | 181 | t_container_items = Table( 182 | 'container_items', Base.metadata, 183 | Column('item_id', ForeignKey('simple_items.id')), 184 | Column('container_id', ForeignKey('simple_containers.id')) 185 | ) 186 | """, 187 | ) 188 | 189 | 190 | def test_named_foreign_key_constraints(generator: CodeGenerator) -> None: 191 | Table( 192 | "simple_items", 193 | generator.metadata, 194 | Column("id", INTEGER, primary_key=True), 195 | Column("container_id", INTEGER), 196 | ForeignKeyConstraint( 197 | ["container_id"], ["simple_containers.id"], name="foreignkeytest" 198 | ), 199 | ) 200 | Table( 201 | "simple_containers", 202 | generator.metadata, 203 | Column("id", INTEGER, primary_key=True), 204 | ) 205 | 206 | validate_code( 207 | generator.generate(), 208 | """\ 209 | from typing import Optional 210 | 211 | from sqlalchemy import ForeignKeyConstraint, Integer 212 | from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, \ 213 | mapped_column, relationship 214 | 215 | class Base(MappedAsDataclass, DeclarativeBase): 216 | pass 217 | 218 | 219 | class SimpleContainers(Base): 220 | __tablename__ = 'simple_containers' 221 | 222 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 223 | 224 | simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 225 | back_populates='container') 226 | 227 | 228 | class SimpleItems(Base): 229 | __tablename__ = 'simple_items' 230 | __table_args__ = ( 231 | ForeignKeyConstraint(['container_id'], ['simple_containers.id'], \ 232 | name='foreignkeytest'), 233 | ) 234 | 235 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 236 | container_id: Mapped[Optional[int]] = mapped_column(Integer) 237 | 238 | container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ 239 | back_populates='simple_items') 240 | """, 241 | ) 242 | 243 | 244 | def test_uuid_type_annotation(generator: CodeGenerator) -> None: 245 | Table( 246 | "simple", 247 | generator.metadata, 248 | Column("id", UUID, primary_key=True), 249 | ) 250 | 251 | validate_code( 252 | generator.generate(), 253 | """\ 254 | import uuid 255 | 256 | from sqlalchemy import UUID 257 | from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, \ 258 | mapped_column 259 | 260 | class Base(MappedAsDataclass, DeclarativeBase): 261 | pass 262 | 263 | 264 | class Simple(Base): 265 | __tablename__ = 'simple' 266 | 267 | id: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True) 268 | """, 269 | ) 270 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/agronholm/sqlacodegen/actions/workflows/test.yml/badge.svg 2 | :target: https://github.com/agronholm/sqlacodegen/actions/workflows/test.yml 3 | :alt: Build Status 4 | .. image:: https://coveralls.io/repos/github/agronholm/sqlacodegen/badge.svg?branch=master 5 | :target: https://coveralls.io/github/agronholm/sqlacodegen?branch=master 6 | :alt: Code Coverage 7 | 8 | This is a tool that reads the structure of an existing database and generates the 9 | appropriate SQLAlchemy model code, using the declarative style if possible. 10 | 11 | This tool was written as a replacement for `sqlautocode`_, which was suffering from 12 | several issues (including, but not limited to, incompatibility with Python 3 and the 13 | latest SQLAlchemy version). 14 | 15 | .. _sqlautocode: http://code.google.com/p/sqlautocode/ 16 | 17 | 18 | Features 19 | ======== 20 | 21 | * Supports SQLAlchemy 2.x 22 | * Produces declarative code that almost looks like it was hand written 23 | * Produces `PEP 8`_ compliant code 24 | * Accurately determines relationships, including many-to-many, one-to-one 25 | * Automatically detects joined table inheritance 26 | * Excellent test coverage 27 | 28 | .. _PEP 8: http://www.python.org/dev/peps/pep-0008/ 29 | 30 | 31 | Installation 32 | ============ 33 | 34 | To install, do:: 35 | 36 | pip install sqlacodegen 37 | 38 | To include support for the PostgreSQL ``CITEXT`` extension type (which should be 39 | considered as tested only under a few environments) specify the ``citext`` extra:: 40 | 41 | pip install sqlacodegen[citext] 42 | 43 | 44 | To include support for the PostgreSQL ``GEOMETRY``, ``GEOGRAPHY``, and ``RASTER`` types 45 | (which should be considered as tested only under a few environments) specify the 46 | ``geoalchemy2`` extra: 47 | 48 | To include support for the PostgreSQL ``PGVECTOR`` extension type, specify the 49 | ``pgvector`` extra:: 50 | 51 | pip install sqlacodegen[pgvector] 52 | 53 | .. code-block:: bash 54 | 55 | pip install sqlacodegen[geoalchemy2] 56 | 57 | 58 | Quickstart 59 | ========== 60 | 61 | At the minimum, you have to give sqlacodegen a database URL. The URL is passed directly 62 | to SQLAlchemy's `create_engine()`_ method so please refer to 63 | `SQLAlchemy's documentation`_ for instructions on how to construct a proper URL. 64 | 65 | Examples:: 66 | 67 | sqlacodegen postgresql:///some_local_db 68 | sqlacodegen --generator tables mysql+pymysql://user:password@localhost/dbname 69 | sqlacodegen --generator dataclasses sqlite:///database.db 70 | # --engine-arg values are parsed with ast.literal_eval 71 | sqlacodegen oracle+oracledb://user:pass@127.0.0.1:1521/XE --engine-arg thick_mode=True 72 | sqlacodegen oracle+oracledb://user:pass@127.0.0.1:1521/XE --engine-arg thick_mode=True --engine-arg connect_args='{"user": "user", "dsn": "..."}' 73 | 74 | To see the list of generic options:: 75 | 76 | sqlacodegen --help 77 | 78 | .. _create_engine(): http://docs.sqlalchemy.org/en/latest/core/engines.html#sqlalchemy.create_engine 79 | .. _SQLAlchemy's documentation: http://docs.sqlalchemy.org/en/latest/core/engines.html 80 | 81 | Available generators 82 | ==================== 83 | 84 | The selection of a generator determines the 85 | 86 | The following built-in generators are available: 87 | 88 | * ``tables`` (only generates ``Table`` objects, for those who don't want to use the ORM) 89 | * ``declarative`` (the default; generates classes inheriting from ``declarative_base()`` 90 | * ``dataclasses`` (generates dataclass-based models; v1.4+ only) 91 | * ``sqlmodels`` (generates model classes for SQLModel_) 92 | 93 | .. _SQLModel: https://sqlmodel.tiangolo.com/ 94 | 95 | Generator-specific options 96 | ========================== 97 | 98 | The following options can be turned on by passing them using ``--options`` (multiple 99 | values must be delimited by commas, e.g. ``--options noconstraints,nobidi``): 100 | 101 | * ``tables`` 102 | 103 | * ``noconstraints``: ignore constraints (foreign key, unique etc.) 104 | * ``nocomments``: ignore table/column comments 105 | * ``noindexes``: ignore indexes 106 | * ``noidsuffix``: prevent the special naming logic for single column many-to-one 107 | and one-to-one relationships (see `Relationship naming logic`_ for details) 108 | * ``include_dialect_options``: render a table' dialect options, such as ``starrocks_partition`` for StarRocks' specific options. 109 | * ``keep_dialect_types``: preserve dialect-specific column types instead of adapting to generic SQLAlchemy types. 110 | 111 | * ``declarative`` 112 | 113 | * all the options from ``tables`` 114 | * ``use_inflect``: use the ``inflect`` library when naming classes and relationships 115 | (turning plural names into singular; see below for details) 116 | * ``nojoined``: don't try to detect joined-class inheritance (see below for details) 117 | * ``nobidi``: generate relationships in a unidirectional fashion, so only the 118 | many-to-one or first side of many-to-many relationships gets a relationship 119 | attribute, as on v2.X 120 | 121 | * ``dataclasses`` 122 | 123 | * all the options from ``declarative`` 124 | 125 | * ``sqlmodels`` 126 | 127 | * all the options from ``declarative`` 128 | 129 | Model class generators 130 | ---------------------- 131 | 132 | The code generators that generate classes try to generate model classes whenever 133 | possible. There are two circumstances in which a ``Table`` is generated instead: 134 | 135 | * the table has no primary key constraint (which is required by SQLAlchemy for every 136 | model class) 137 | * the table is an association table between two other tables (see below for the 138 | specifics) 139 | 140 | Model class naming logic 141 | ++++++++++++++++++++++++ 142 | 143 | By default, table names are converted to valid PEP 8 compliant class names by replacing 144 | all characters unsuitable for Python identifiers with ``_``. Then, each valid parts 145 | (separated by underscores) are title cased and then joined together, eliminating the 146 | underscores. So, ``example_name`` becomes ``ExampleName``. 147 | 148 | If the ``use_inflect`` option is used, the table name (which is assumed to be in 149 | English) is converted to singular form using the "inflect" library. For example, 150 | ``sales_invoices`` becomes ``SalesInvoice``. Since table names are not always in 151 | English, and the inflection process is far from perfect, inflection is disabled by 152 | default. 153 | 154 | Relationship detection logic 155 | ++++++++++++++++++++++++++++ 156 | 157 | Relationships are detected based on existing foreign key constraints as follows: 158 | 159 | * **many-to-one**: a foreign key constraint exists on the table 160 | * **one-to-one**: same as **many-to-one**, but a unique constraint exists on the 161 | column(s) involved 162 | * **many-to-many**: (not implemented on the ``sqlmodel`` generator) an association table 163 | is found to exist between two tables 164 | 165 | A table is considered an association table if it satisfies all of the following 166 | conditions: 167 | 168 | #. has exactly two foreign key constraints 169 | #. all its columns are involved in said constraints 170 | 171 | Relationship naming logic 172 | +++++++++++++++++++++++++ 173 | 174 | Relationships are typically named based on the table name of the opposite class. 175 | For example, if a class has a relationship to another class with the table named 176 | ``companies``, the relationship would be named ``companies`` (unless the ``use_inflect`` 177 | option was enabled, in which case it would be named ``company`` in the case of a 178 | many-to-one or one-to-one relationship). 179 | 180 | A special case for single column many-to-one and one-to-one relationships, however, is 181 | if the column is named like ``employer_id``. Then the relationship is named ``employer`` 182 | due to that ``_id`` suffix. 183 | 184 | For self referential relationships, the reverse side of the relationship will be named 185 | with the ``_reverse`` suffix appended to it. 186 | 187 | Customizing code generation logic 188 | ================================= 189 | 190 | If the built-in generators with all their options don't quite do what you want, you can 191 | customize the logic by subclassing one of the existing code generator classes. Override 192 | whichever methods you need, and then add an `entry point`_ in the 193 | ``sqlacodegen.generators`` namespace that points to your new class. Once the entry point 194 | is in place (you typically have to install the project with ``pip install``), you can 195 | use ``--generator `` to invoke your custom code generator. 196 | 197 | For examples, you can look at sqlacodegen's own entry points in its `pyproject.toml`_. 198 | 199 | .. _entry point: https://setuptools.readthedocs.io/en/latest/userguide/entry_point.html 200 | .. _pyproject.toml: https://github.com/agronholm/sqlacodegen/blob/master/pyproject.toml 201 | 202 | Getting help 203 | ============ 204 | 205 | If you have problems or other questions, you should start a discussion on the 206 | `sqlacodegen discussion forum`_. As an alternative, you could also try your luck on the 207 | sqlalchemy_ room on Gitter. 208 | 209 | .. _sqlacodegen discussion forum: https://github.com/agronholm/sqlacodegen/discussions/categories/q-a 210 | .. _sqlalchemy: https://app.gitter.im/#/room/#sqlalchemy_community:gitter.im 211 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Version history 2 | =============== 3 | 4 | **3.2.0** 5 | 6 | - Dropped support for Python 3.9 7 | - Fix Postgres ``DOMAIN`` adaptation regression introduced in SQLAlchemy 2.0.42 (PR by @sheinbergon) 8 | - Support disabling special naming logic for single column many-to-one and one-to-one relationships 9 | (PR by @Henkhogan, revised by @sheinbergon) 10 | - Add ``include_dialect_options`` option to render ``Table`` and ``Column`` 11 | dialect-specific kwargs and ``info`` in generated code. (PR by @jaogoy) 12 | - Add ``keep_dialect_types`` option to preserve dialect-specific column types instead of 13 | adapting to generic SQLAlchemy types. (PR by @jaogoy) 14 | 15 | **3.1.1** 16 | 17 | - Fallback ``NotImplemented`` errors encountered when accessing ``python_type`` for 18 | non-native types to ``typing.Any`` 19 | (PR by @sheinbergon, based on work by @danplischke) 20 | 21 | **3.1.0** 22 | 23 | - Type annotations for ARRAY column attributes now include the Python type of 24 | the array elements 25 | - Added support for specifying engine arguments via ``--engine-arg`` 26 | (PR by @LajosCseppento) 27 | - Fixed incorrect package name used in ``importlib.metadata.version`` for 28 | ``sqlalchemy-citext``, resolving ``PackageNotFoundError`` (PR by @oaimtiaz) 29 | - Prevent double pluralization (PR by @dkratzert) 30 | - Fixes DOMAIN extending JSON/JSONB data types (PR by @sheinbergon) 31 | - Temporarily restrict SQLAlchemy version to 2.0.41 (PR by @sheinbergon) 32 | - Fixes ``add_import`` behavior when adding imports from sqlalchemy and overall better 33 | alignment of import behavior(s) across generators 34 | - Fixes ``nullable`` column behavior for non-null columns for both 35 | ``sqlmodels`` and ``declarative`` generators (PR by @sheinbergon) 36 | 37 | **3.0.0** 38 | 39 | - Dropped support for Python 3.8 40 | - Changed nullable relationships to include ``Optional`` in their type annotations 41 | - Fixed SQLModel code generation 42 | - Fixed two rendering issues in ``ENUM`` columns when a non-default schema is used: an 43 | unwarranted positional argument and missing the ``schema`` argument 44 | - Fixed ``AttributeError`` when metadata contains user defined column types 45 | - Fixed ``AssertionError`` when metadata contains a column type that is a type decorator 46 | with an all-uppercase name 47 | - Fixed MySQL ``DOUBLE`` column types being rendered with the wrong arguments 48 | 49 | **3.0.0rc5** 50 | 51 | - Fixed pgvector support not working 52 | 53 | **3.0.0rc4** 54 | 55 | - Dropped support for Python 3.7 56 | - Dropped support for SQLAlchemy 1.x 57 | - Added support for the ``pgvector`` extension (with help from KellyRousselHoomano) 58 | 59 | **3.0.0rc3** 60 | 61 | - Added support for SQLAlchemy 2 (PR by rbuffat with help from mhauru) 62 | - Renamed ``--option`` to ``--options`` and made its values delimited by commas 63 | - Restored CIText and GeoAlchemy2 support (PR by stavvy-rotte) 64 | 65 | **3.0.0rc2** 66 | 67 | - Added support for generating SQLModel classes (PR by Andrii Khirilov) 68 | - Fixed code generation when a single-column index is unique or does not match the 69 | dialect's naming convention (PR by Leonardus Chen) 70 | - Fixed another problem where sequence schemas were not properly separated from the 71 | sequence name 72 | - Fixed invalid generated primary/secondaryjoin expressions in self-referential 73 | many-to-many relationships by using lambdas instead of strings 74 | - Fixed ``AttributeError`` when the declarative generator encounters a table name 75 | already in singular form when ``--option use_inflect`` is enabled 76 | - Increased minimum SQLAlchemy version to 1.4.36 to address issues with ``ForeignKey`` 77 | and indexes, and to eliminate the PostgreSQL UUID column type annotation hack 78 | 79 | **3.0.0rc1** 80 | 81 | - Migrated all packaging/testing configuration to ``pyproject.toml`` 82 | - Fixed unwarranted ``ForeignKey`` declarations appearing in column attributes when 83 | there are named, single column foreign key constraints (PR by Leonardus Chen) 84 | . Fixed ``KeyError`` when rendering an index without any columns 85 | - Fixed improper handling of schema prefixes in sequence names in server defaults 86 | - Fixed identically named tables from different schemas resulting in invalid generated 87 | code 88 | - Fixed imports caused by ``server_default`` conflicting with class attribute names 89 | - Worked around PostgreSQL UUID columns getting ``Any`` as the type annotation 90 | 91 | **3.0.0b3** 92 | 93 | - Dropped support for Python < 3.7 94 | - Dropped support for SQLAlchemy 1.3 95 | - Added a ``__main__`` module which can be used as an alternate entry point to the CLI 96 | - Added detection for sequence use in column defaults on PostgreSQL 97 | - Fixed ``sqlalchemy.exc.InvalidRequestError`` when encountering a column named 98 | "metadata" (regression from 2.0) 99 | - Fixed missing ``MetaData`` import with ``DeclarativeGenerator`` when only plain tables 100 | are generated 101 | - Fixed invalid data classes being generated due to some relationships having been 102 | rendered without a default value 103 | - Improved translation of column names into column attributes where the column name has 104 | whitespace at the beginning or end 105 | - Modified constraint and index rendering to add them explicitly instead of using 106 | shortcuts like ``unique=True``, ``index=True`` or ``primary=True`` when the constraint 107 | or index has a name that does not match the default naming convention 108 | 109 | **3.0.0b2** 110 | 111 | - Fixed ``IDENTITY`` columns not rendering properly when they are part of the primary 112 | key 113 | 114 | **3.0.0b1** 115 | 116 | **NOTE**: Both the API and the command line interface have been refactored in a 117 | backwards incompatible fashion. Notably several command line options have been moved to 118 | specific generators and are no longer visible from ``sqlacodegen --help``. Their 119 | replacement are documented in the README. 120 | 121 | - Dropped support for Python < 3.6 122 | - Added support for Python 3.10 123 | - Added support for SQLAlchemy 1.4 124 | - Added support for bidirectional relationships (use ``--option nobidi``) to disable 125 | - Added support for multiple schemas via ``--schemas`` 126 | - Added support for ``IDENTITY`` columns 127 | - Disabled inflection during table/relationship name generation by default 128 | (use ``--option use_inflect`` to re-enable) 129 | - Refactored the old ``CodeGenerator`` class into separate generator classes, selectable 130 | via ``--generator`` 131 | - Refactored several command line options into generator specific options: 132 | 133 | - ``--noindexes`` → ``--option noindexes`` 134 | - ``--noconstraints`` → ``--option noconstraints`` 135 | - ``--nocomments`` → ``--option nocomments`` 136 | - ``--nojoined`` → ``--option nojoined`` (``declarative`` and ``dataclass`` generators 137 | only) 138 | - ``--noinflect`` → (now the default; use ``--option use_inflect`` instead) 139 | (``declarative`` and ``dataclass`` generators only) 140 | - Fixed missing import for ``JSONB`` ``astext_type`` argument 141 | - Fixed generated column or relationship names colliding with imports or each other 142 | - Fixed ``CompileError`` when encountering server defaults that contain colons (``:``) 143 | 144 | **2.3.0** 145 | 146 | - Added support for rendering computed columns 147 | - Fixed ``--nocomments`` not taking effect (fix proposed by AzuresYang) 148 | - Fixed handling of MySQL ``SET`` column types (and possibly others as well) 149 | 150 | **2.2.0** 151 | 152 | - Added support for rendering table comments (PR by David Hirschfeld) 153 | - Fixed bad identifier names being generated for plain tables (PR by softwarepk) 154 | 155 | **2.1.0** 156 | 157 | - Dropped support for Python 3.4 158 | - Dropped support for SQLAlchemy 0.8 159 | - Added support for Python 3.7 and 3.8 160 | - Added support for SQLAlchemy 1.3 161 | - Added support for column comments (requires SQLAlchemy 1.2+; based on PR by koalas8) 162 | - Fixed crash on unknown column types (``NullType``) 163 | 164 | **2.0.1** 165 | 166 | - Don't adapt dialect specific column types if they need special constructor arguments 167 | (thanks Nicholas Martin for the PR) 168 | 169 | **2.0.0** 170 | 171 | - Refactored code for better reuse 172 | - Dropped support for Python 2.6, 3.2 and 3.3 173 | - Dropped support for SQLAlchemy < 0.8 174 | - Worked around a bug regarding Enum on SQLAlchemy 1.2+ (``name`` was missing) 175 | - Added support for Geoalchemy2 176 | - Fixed invalid class names being generated (fixes #60; PR by Dan O'Huiginn) 177 | - Fixed array item types not being adapted or imported 178 | (fixes #46; thanks to Martin Glauer and Shawn Koschik for help) 179 | - Fixed attribute name of columns named ``metadata`` in mapped classes (fixes #62) 180 | - Fixed rendered column types being changed from the original (fixes #11) 181 | - Fixed server defaults which contain double quotes (fixes #7, #17, #28, #33, #36) 182 | - Fixed ``secondary=`` not taking into account the association table's schema name 183 | (fixes #30) 184 | - Sort models by foreign key dependencies instead of schema and name (fixes #15, #16) 185 | 186 | **1.1.6** 187 | 188 | - Fixed compatibility with SQLAlchemy 1.0 189 | - Added an option to only generate tables 190 | 191 | **1.1.5** 192 | 193 | - Fixed potential assignment of columns or relationships into invalid attribute names 194 | (fixes #10) 195 | - Fixed unique=True missing from unique Index declarations 196 | - Fixed several issues with server defaults 197 | - Fixed potential assignment of columns or relationships into invalid attribute names 198 | - Allowed pascal case for tables already using it 199 | - Switched from Mercurial to Git 200 | 201 | **1.1.4** 202 | 203 | - Fixed compatibility with SQLAlchemy 0.9.0 204 | 205 | **1.1.3** 206 | 207 | - Fixed compatibility with SQLAlchemy 0.8.3+ 208 | - Migrated tests from nose to pytest 209 | 210 | **1.1.2** 211 | 212 | - Fixed non-default schema name not being present in __table_args__ (fixes #2) 213 | - Fixed self referential foreign key causing column type to not be rendered 214 | - Fixed missing "deferrable" and "initially" keyword arguments in ForeignKey constructs 215 | - Fixed foreign key and check constraint handling with alternate schemas (fixes #3) 216 | 217 | **1.1.1** 218 | 219 | - Fixed TypeError when inflect could not determine the singular name of a table for a 220 | many-to-1 relationship 221 | - Fixed _IntegerType, _StringType etc. being rendered instead of proper types on MySQL 222 | 223 | **1.1.0** 224 | 225 | - Added automatic detection of joined-table inheritance 226 | - Fixed missing class name prefix in primary/secondary joins in relationships 227 | - Instead of wildcard imports, generate explicit imports dynamically (fixes #1) 228 | - Use the inflect library to produce better guesses for table to class name conversion 229 | - Automatically detect Boolean columns based on CheckConstraints 230 | - Skip redundant CheckConstraints for Enum and Boolean columns 231 | 232 | **1.0.0** 233 | 234 | - Initial release 235 | -------------------------------------------------------------------------------- /tests/test_generator_tables.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from textwrap import dedent 4 | 5 | import pytest 6 | from _pytest.fixtures import FixtureRequest 7 | from sqlalchemy import TypeDecorator 8 | from sqlalchemy.dialects import mysql, postgresql, registry 9 | from sqlalchemy.dialects.mysql.pymysql import MySQLDialect_pymysql 10 | from sqlalchemy.engine import Engine 11 | from sqlalchemy.schema import ( 12 | CheckConstraint, 13 | Column, 14 | Computed, 15 | ForeignKey, 16 | Identity, 17 | Index, 18 | MetaData, 19 | Table, 20 | UniqueConstraint, 21 | ) 22 | from sqlalchemy.sql.expression import text 23 | from sqlalchemy.sql.sqltypes import DateTime, NullType 24 | from sqlalchemy.types import INTEGER, NUMERIC, SMALLINT, VARCHAR, Text 25 | 26 | from sqlacodegen.generators import CodeGenerator, TablesGenerator 27 | 28 | from .conftest import validate_code 29 | 30 | 31 | # This needs to be uppercased to trigger #315 32 | class TIMESTAMP_DECORATOR(TypeDecorator[DateTime]): 33 | impl = DateTime 34 | 35 | 36 | @pytest.fixture 37 | def generator( 38 | request: FixtureRequest, metadata: MetaData, engine: Engine 39 | ) -> CodeGenerator: 40 | options = getattr(request, "param", []) 41 | return TablesGenerator(metadata, engine, options) 42 | 43 | 44 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 45 | def test_fancy_coltypes(generator: CodeGenerator) -> None: 46 | from pgvector.sqlalchemy.vector import VECTOR 47 | 48 | Table( 49 | "simple_items", 50 | generator.metadata, 51 | Column("enum", postgresql.ENUM("A", "B", name="blah", schema="someschema")), 52 | Column("bool", postgresql.BOOLEAN), 53 | Column("vector", VECTOR(3)), 54 | Column("number", NUMERIC(10, asdecimal=False)), 55 | Column("timestamp", TIMESTAMP_DECORATOR()), 56 | schema="someschema", 57 | ) 58 | 59 | validate_code( 60 | generator.generate(), 61 | """\ 62 | from tests.test_generator_tables import TIMESTAMP_DECORATOR 63 | 64 | from pgvector.sqlalchemy.vector import VECTOR 65 | from sqlalchemy import Boolean, Column, Enum, MetaData, Numeric, Table 66 | 67 | metadata = MetaData() 68 | 69 | 70 | t_simple_items = Table( 71 | 'simple_items', metadata, 72 | Column('enum', Enum('A', 'B', name='blah', schema='someschema')), 73 | Column('bool', Boolean), 74 | Column('vector', VECTOR(3)), 75 | Column('number', Numeric(10, asdecimal=False)), 76 | Column('timestamp', TIMESTAMP_DECORATOR), 77 | schema='someschema' 78 | ) 79 | """, 80 | ) 81 | 82 | 83 | def test_boolean_detection(generator: CodeGenerator) -> None: 84 | Table( 85 | "simple_items", 86 | generator.metadata, 87 | Column("bool1", INTEGER), 88 | Column("bool2", SMALLINT), 89 | Column("bool3", mysql.TINYINT), 90 | CheckConstraint("simple_items.bool1 IN (0, 1)"), 91 | CheckConstraint("simple_items.bool2 IN (0, 1)"), 92 | CheckConstraint("simple_items.bool3 IN (0, 1)"), 93 | ) 94 | 95 | validate_code( 96 | generator.generate(), 97 | """\ 98 | from sqlalchemy import Boolean, Column, MetaData, Table 99 | 100 | metadata = MetaData() 101 | 102 | 103 | t_simple_items = Table( 104 | 'simple_items', metadata, 105 | Column('bool1', Boolean), 106 | Column('bool2', Boolean), 107 | Column('bool3', Boolean) 108 | ) 109 | """, 110 | ) 111 | 112 | 113 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 114 | def test_arrays(generator: CodeGenerator) -> None: 115 | Table( 116 | "simple_items", 117 | generator.metadata, 118 | Column("dp_array", postgresql.ARRAY(postgresql.DOUBLE_PRECISION(precision=53))), 119 | Column("int_array", postgresql.ARRAY(INTEGER)), 120 | ) 121 | 122 | validate_code( 123 | generator.generate(), 124 | """\ 125 | from sqlalchemy import ARRAY, Column, Double, Integer, MetaData, Table 126 | 127 | metadata = MetaData() 128 | 129 | 130 | t_simple_items = Table( 131 | 'simple_items', metadata, 132 | Column('dp_array', ARRAY(Double(precision=53))), 133 | Column('int_array', ARRAY(Integer())) 134 | ) 135 | """, 136 | ) 137 | 138 | 139 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 140 | def test_jsonb(generator: CodeGenerator) -> None: 141 | Table( 142 | "simple_items", 143 | generator.metadata, 144 | Column("jsonb", postgresql.JSONB(astext_type=Text(50))), 145 | ) 146 | 147 | validate_code( 148 | generator.generate(), 149 | """\ 150 | from sqlalchemy import Column, MetaData, Table, Text 151 | from sqlalchemy.dialects.postgresql import JSONB 152 | 153 | metadata = MetaData() 154 | 155 | 156 | t_simple_items = Table( 157 | 'simple_items', metadata, 158 | Column('jsonb', JSONB(astext_type=Text(length=50))) 159 | ) 160 | """, 161 | ) 162 | 163 | 164 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 165 | def test_jsonb_default(generator: CodeGenerator) -> None: 166 | Table("simple_items", generator.metadata, Column("jsonb", postgresql.JSONB)) 167 | 168 | validate_code( 169 | generator.generate(), 170 | """\ 171 | from sqlalchemy import Column, MetaData, Table 172 | from sqlalchemy.dialects.postgresql import JSONB 173 | 174 | metadata = MetaData() 175 | 176 | 177 | t_simple_items = Table( 178 | 'simple_items', metadata, 179 | Column('jsonb', JSONB) 180 | ) 181 | """, 182 | ) 183 | 184 | 185 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 186 | def test_json_default(generator: CodeGenerator) -> None: 187 | Table("simple_items", generator.metadata, Column("json", postgresql.JSON)) 188 | 189 | validate_code( 190 | generator.generate(), 191 | """\ 192 | from sqlalchemy import Column, JSON, MetaData, Table 193 | 194 | metadata = MetaData() 195 | 196 | 197 | t_simple_items = Table( 198 | 'simple_items', metadata, 199 | Column('json', JSON) 200 | ) 201 | """, 202 | ) 203 | 204 | 205 | def test_enum_detection(generator: CodeGenerator) -> None: 206 | Table( 207 | "simple_items", 208 | generator.metadata, 209 | Column("enum", VARCHAR(255)), 210 | CheckConstraint(r"simple_items.enum IN ('A', '\'B', 'C')"), 211 | ) 212 | 213 | validate_code( 214 | generator.generate(), 215 | """\ 216 | from sqlalchemy import Column, Enum, MetaData, Table 217 | 218 | metadata = MetaData() 219 | 220 | 221 | t_simple_items = Table( 222 | 'simple_items', metadata, 223 | Column('enum', Enum('A', "\\\\'B", 'C')) 224 | ) 225 | """, 226 | ) 227 | 228 | 229 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 230 | def test_domain_text(generator: CodeGenerator) -> None: 231 | Table( 232 | "simple_items", 233 | generator.metadata, 234 | Column( 235 | "postal_code", 236 | postgresql.DOMAIN( 237 | "us_postal_code", 238 | Text, 239 | constraint_name="valid_us_postal_code", 240 | not_null=False, 241 | check=text("VALUE ~ '^\\d{5}$' OR VALUE ~ '^\\d{5}-\\d{4}$'"), 242 | ), 243 | nullable=False, 244 | ), 245 | ) 246 | 247 | validate_code( 248 | generator.generate(), 249 | """\ 250 | from sqlalchemy import Column, MetaData, Table, Text, text 251 | from sqlalchemy.dialects.postgresql import DOMAIN 252 | 253 | metadata = MetaData() 254 | 255 | 256 | t_simple_items = Table( 257 | 'simple_items', metadata, 258 | Column('postal_code', DOMAIN('us_postal_code', Text(), \ 259 | constraint_name='valid_us_postal_code', not_null=False, \ 260 | check=text("VALUE ~ '^\\\\d{5}$' OR VALUE ~ '^\\\\d{5}-\\\\d{4}$'")), nullable=False) 261 | ) 262 | """, 263 | ) 264 | 265 | 266 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 267 | def test_domain_int(generator: CodeGenerator) -> None: 268 | Table( 269 | "simple_items", 270 | generator.metadata, 271 | Column( 272 | "n", 273 | postgresql.DOMAIN( 274 | "positive_int", 275 | INTEGER, 276 | constraint_name="positive", 277 | not_null=False, 278 | check=text("VALUE > 0"), 279 | ), 280 | nullable=False, 281 | ), 282 | ) 283 | 284 | validate_code( 285 | generator.generate(), 286 | """\ 287 | from sqlalchemy import Column, INTEGER, MetaData, Table, text 288 | from sqlalchemy.dialects.postgresql import DOMAIN 289 | 290 | metadata = MetaData() 291 | 292 | 293 | t_simple_items = Table( 294 | 'simple_items', metadata, 295 | Column('n', DOMAIN('positive_int', INTEGER(), \ 296 | constraint_name='positive', not_null=False, \ 297 | check=text('VALUE > 0')), nullable=False) 298 | ) 299 | """, 300 | ) 301 | 302 | 303 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 304 | def test_column_adaptation(generator: CodeGenerator) -> None: 305 | Table( 306 | "simple_items", 307 | generator.metadata, 308 | Column("id", postgresql.BIGINT), 309 | Column("length", postgresql.DOUBLE_PRECISION), 310 | ) 311 | 312 | validate_code( 313 | generator.generate(), 314 | """\ 315 | from sqlalchemy import BigInteger, Column, Double, MetaData, Table 316 | 317 | metadata = MetaData() 318 | 319 | 320 | t_simple_items = Table( 321 | 'simple_items', metadata, 322 | Column('id', BigInteger), 323 | Column('length', Double) 324 | ) 325 | """, 326 | ) 327 | 328 | 329 | @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) 330 | def test_mysql_column_types(generator: CodeGenerator) -> None: 331 | Table( 332 | "simple_items", 333 | generator.metadata, 334 | Column("id", mysql.INTEGER), 335 | Column("name", mysql.VARCHAR(255)), 336 | Column("double", mysql.DOUBLE(1, 2)), 337 | Column("set", mysql.SET("one", "two")), 338 | ) 339 | 340 | validate_code( 341 | generator.generate(), 342 | """\ 343 | from sqlalchemy import Column, Integer, MetaData, String, Table 344 | from sqlalchemy.dialects.mysql import DOUBLE, SET 345 | 346 | metadata = MetaData() 347 | 348 | 349 | t_simple_items = Table( 350 | 'simple_items', metadata, 351 | Column('id', Integer), 352 | Column('name', String(255)), 353 | Column('double', DOUBLE(1, 2)), 354 | Column('set', SET('one', 'two')) 355 | ) 356 | """, 357 | ) 358 | 359 | 360 | def test_constraints(generator: CodeGenerator) -> None: 361 | Table( 362 | "simple_items", 363 | generator.metadata, 364 | Column("id", INTEGER), 365 | Column("number", INTEGER), 366 | CheckConstraint("number > 0"), 367 | UniqueConstraint("id", "number"), 368 | ) 369 | 370 | validate_code( 371 | generator.generate(), 372 | """\ 373 | from sqlalchemy import CheckConstraint, Column, Integer, MetaData, Table, \ 374 | UniqueConstraint 375 | 376 | metadata = MetaData() 377 | 378 | 379 | t_simple_items = Table( 380 | 'simple_items', metadata, 381 | Column('id', Integer), 382 | Column('number', Integer), 383 | CheckConstraint('number > 0'), 384 | UniqueConstraint('id', 'number') 385 | ) 386 | """, 387 | ) 388 | 389 | 390 | def test_indexes(generator: CodeGenerator) -> None: 391 | simple_items = Table( 392 | "simple_items", 393 | generator.metadata, 394 | Column("id", INTEGER), 395 | Column("number", INTEGER), 396 | Column("text", VARCHAR), 397 | Index("ix_empty"), 398 | ) 399 | simple_items.indexes.add(Index("ix_number", simple_items.c.number)) 400 | simple_items.indexes.add( 401 | Index( 402 | "ix_text_number", 403 | simple_items.c.text, 404 | simple_items.c.number, 405 | unique=True, 406 | ) 407 | ) 408 | simple_items.indexes.add(Index("ix_text", simple_items.c.text, unique=True)) 409 | 410 | validate_code( 411 | generator.generate(), 412 | """\ 413 | from sqlalchemy import Column, Index, Integer, MetaData, String, Table 414 | 415 | metadata = MetaData() 416 | 417 | 418 | t_simple_items = Table( 419 | 'simple_items', metadata, 420 | Column('id', Integer), 421 | Column('number', Integer, index=True), 422 | Column('text', String, unique=True, index=True), 423 | Index('ix_empty'), 424 | Index('ix_text_number', 'text', 'number', unique=True) 425 | ) 426 | """, 427 | ) 428 | 429 | 430 | def test_table_comment(generator: CodeGenerator) -> None: 431 | Table( 432 | "simple", 433 | generator.metadata, 434 | Column("id", INTEGER, primary_key=True), 435 | comment="this is a 'comment'", 436 | ) 437 | 438 | validate_code( 439 | generator.generate(), 440 | """\ 441 | from sqlalchemy import Column, Integer, MetaData, Table 442 | 443 | metadata = MetaData() 444 | 445 | 446 | t_simple = Table( 447 | 'simple', metadata, 448 | Column('id', Integer, primary_key=True), 449 | comment="this is a 'comment'" 450 | ) 451 | """, 452 | ) 453 | 454 | 455 | def test_table_name_identifiers(generator: CodeGenerator) -> None: 456 | Table( 457 | "simple-items table", 458 | generator.metadata, 459 | Column("id", INTEGER, primary_key=True), 460 | ) 461 | 462 | validate_code( 463 | generator.generate(), 464 | """\ 465 | from sqlalchemy import Column, Integer, MetaData, Table 466 | 467 | metadata = MetaData() 468 | 469 | 470 | t_simple_items_table = Table( 471 | 'simple-items table', metadata, 472 | Column('id', Integer, primary_key=True) 473 | ) 474 | """, 475 | ) 476 | 477 | 478 | @pytest.mark.parametrize("generator", [["noindexes"]], indirect=True) 479 | def test_option_noindexes(generator: CodeGenerator) -> None: 480 | simple_items = Table( 481 | "simple_items", 482 | generator.metadata, 483 | Column("number", INTEGER), 484 | CheckConstraint("number > 2"), 485 | ) 486 | simple_items.indexes.add(Index("idx_number", simple_items.c.number)) 487 | 488 | validate_code( 489 | generator.generate(), 490 | """\ 491 | from sqlalchemy import CheckConstraint, Column, Integer, MetaData, Table 492 | 493 | metadata = MetaData() 494 | 495 | 496 | t_simple_items = Table( 497 | 'simple_items', metadata, 498 | Column('number', Integer), 499 | CheckConstraint('number > 2') 500 | ) 501 | """, 502 | ) 503 | 504 | 505 | @pytest.mark.parametrize("generator", [["noconstraints"]], indirect=True) 506 | def test_option_noconstraints(generator: CodeGenerator) -> None: 507 | simple_items = Table( 508 | "simple_items", 509 | generator.metadata, 510 | Column("number", INTEGER), 511 | CheckConstraint("number > 2"), 512 | ) 513 | simple_items.indexes.add(Index("ix_number", simple_items.c.number)) 514 | 515 | validate_code( 516 | generator.generate(), 517 | """\ 518 | from sqlalchemy import Column, Integer, MetaData, Table 519 | 520 | metadata = MetaData() 521 | 522 | 523 | t_simple_items = Table( 524 | 'simple_items', metadata, 525 | Column('number', Integer, index=True) 526 | ) 527 | """, 528 | ) 529 | 530 | 531 | @pytest.mark.parametrize("generator", [["nocomments"]], indirect=True) 532 | def test_option_nocomments(generator: CodeGenerator) -> None: 533 | Table( 534 | "simple", 535 | generator.metadata, 536 | Column("id", INTEGER, primary_key=True, comment="pk column comment"), 537 | comment="this is a 'comment'", 538 | ) 539 | 540 | validate_code( 541 | generator.generate(), 542 | """\ 543 | from sqlalchemy import Column, Integer, MetaData, Table 544 | 545 | metadata = MetaData() 546 | 547 | 548 | t_simple = Table( 549 | 'simple', metadata, 550 | Column('id', Integer, primary_key=True) 551 | ) 552 | """, 553 | ) 554 | 555 | 556 | @pytest.mark.parametrize( 557 | "persisted, extra_args", 558 | [(None, ""), (False, ", persisted=False"), (True, ", persisted=True")], 559 | ) 560 | def test_computed_column( 561 | generator: CodeGenerator, persisted: bool | None, extra_args: str 562 | ) -> None: 563 | Table( 564 | "computed", 565 | generator.metadata, 566 | Column("id", INTEGER, primary_key=True), 567 | Column("computed", INTEGER, Computed("1 + 2", persisted=persisted)), 568 | ) 569 | 570 | validate_code( 571 | generator.generate(), 572 | f"""\ 573 | from sqlalchemy import Column, Computed, Integer, MetaData, Table 574 | 575 | metadata = MetaData() 576 | 577 | 578 | t_computed = Table( 579 | 'computed', metadata, 580 | Column('id', Integer, primary_key=True), 581 | Column('computed', Integer, Computed('1 + 2'{extra_args})) 582 | ) 583 | """, 584 | ) 585 | 586 | 587 | def test_schema(generator: CodeGenerator) -> None: 588 | Table( 589 | "simple_items", 590 | generator.metadata, 591 | Column("name", VARCHAR), 592 | schema="testschema", 593 | ) 594 | 595 | validate_code( 596 | generator.generate(), 597 | """\ 598 | from sqlalchemy import Column, MetaData, String, Table 599 | 600 | metadata = MetaData() 601 | 602 | 603 | t_simple_items = Table( 604 | 'simple_items', metadata, 605 | Column('name', String), 606 | schema='testschema' 607 | ) 608 | """, 609 | ) 610 | 611 | 612 | def test_foreign_key_options(generator: CodeGenerator) -> None: 613 | Table( 614 | "simple_items", 615 | generator.metadata, 616 | Column( 617 | "name", 618 | VARCHAR, 619 | ForeignKey( 620 | "simple_items.name", 621 | ondelete="CASCADE", 622 | onupdate="CASCADE", 623 | deferrable=True, 624 | initially="DEFERRED", 625 | ), 626 | ), 627 | ) 628 | 629 | validate_code( 630 | generator.generate(), 631 | """\ 632 | from sqlalchemy import Column, ForeignKey, MetaData, String, Table 633 | 634 | metadata = MetaData() 635 | 636 | 637 | t_simple_items = Table( 638 | 'simple_items', metadata, 639 | Column('name', String, ForeignKey('simple_items.name', \ 640 | ondelete='CASCADE', onupdate='CASCADE', deferrable=True, initially='DEFERRED')) 641 | ) 642 | """, 643 | ) 644 | 645 | 646 | def test_pk_default(generator: CodeGenerator) -> None: 647 | Table( 648 | "simple_items", 649 | generator.metadata, 650 | Column( 651 | "id", 652 | INTEGER, 653 | primary_key=True, 654 | server_default=text("uuid_generate_v4()"), 655 | ), 656 | ) 657 | 658 | validate_code( 659 | generator.generate(), 660 | """\ 661 | from sqlalchemy import Column, Integer, MetaData, Table, text 662 | 663 | metadata = MetaData() 664 | 665 | 666 | t_simple_items = Table( 667 | 'simple_items', metadata, 668 | Column('id', Integer, primary_key=True, \ 669 | server_default=text('uuid_generate_v4()')) 670 | ) 671 | """, 672 | ) 673 | 674 | 675 | @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) 676 | def test_mysql_timestamp(generator: CodeGenerator) -> None: 677 | Table( 678 | "simple", 679 | generator.metadata, 680 | Column("id", INTEGER, primary_key=True), 681 | Column("timestamp", mysql.TIMESTAMP), 682 | ) 683 | 684 | validate_code( 685 | generator.generate(), 686 | """\ 687 | from sqlalchemy import Column, Integer, MetaData, TIMESTAMP, Table 688 | 689 | metadata = MetaData() 690 | 691 | 692 | t_simple = Table( 693 | 'simple', metadata, 694 | Column('id', Integer, primary_key=True), 695 | Column('timestamp', TIMESTAMP) 696 | ) 697 | """, 698 | ) 699 | 700 | 701 | @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) 702 | def test_mysql_integer_display_width(generator: CodeGenerator) -> None: 703 | Table( 704 | "simple_items", 705 | generator.metadata, 706 | Column("id", INTEGER, primary_key=True), 707 | Column("number", mysql.INTEGER(11)), 708 | ) 709 | 710 | validate_code( 711 | generator.generate(), 712 | """\ 713 | from sqlalchemy import Column, Integer, MetaData, Table 714 | from sqlalchemy.dialects.mysql import INTEGER 715 | 716 | metadata = MetaData() 717 | 718 | 719 | t_simple_items = Table( 720 | 'simple_items', metadata, 721 | Column('id', Integer, primary_key=True), 722 | Column('number', INTEGER(11)) 723 | ) 724 | """, 725 | ) 726 | 727 | 728 | @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) 729 | def test_mysql_tinytext(generator: CodeGenerator) -> None: 730 | Table( 731 | "simple_items", 732 | generator.metadata, 733 | Column("id", INTEGER, primary_key=True), 734 | Column("my_tinytext", mysql.TINYTEXT), 735 | ) 736 | 737 | validate_code( 738 | generator.generate(), 739 | """\ 740 | from sqlalchemy import Column, Integer, MetaData, Table 741 | from sqlalchemy.dialects.mysql import TINYTEXT 742 | 743 | metadata = MetaData() 744 | 745 | 746 | t_simple_items = Table( 747 | 'simple_items', metadata, 748 | Column('id', Integer, primary_key=True), 749 | Column('my_tinytext', TINYTEXT) 750 | ) 751 | """, 752 | ) 753 | 754 | 755 | @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) 756 | def test_mysql_mediumtext(generator: CodeGenerator) -> None: 757 | Table( 758 | "simple_items", 759 | generator.metadata, 760 | Column("id", INTEGER, primary_key=True), 761 | Column("my_mediumtext", mysql.MEDIUMTEXT), 762 | ) 763 | 764 | validate_code( 765 | generator.generate(), 766 | """\ 767 | from sqlalchemy import Column, Integer, MetaData, Table 768 | from sqlalchemy.dialects.mysql import MEDIUMTEXT 769 | 770 | metadata = MetaData() 771 | 772 | 773 | t_simple_items = Table( 774 | 'simple_items', metadata, 775 | Column('id', Integer, primary_key=True), 776 | Column('my_mediumtext', MEDIUMTEXT) 777 | ) 778 | """, 779 | ) 780 | 781 | 782 | @pytest.mark.parametrize("engine", ["mysql"], indirect=["engine"]) 783 | def test_mysql_longtext(generator: CodeGenerator) -> None: 784 | Table( 785 | "simple_items", 786 | generator.metadata, 787 | Column("id", INTEGER, primary_key=True), 788 | Column("my_longtext", mysql.LONGTEXT), 789 | ) 790 | 791 | validate_code( 792 | generator.generate(), 793 | """\ 794 | from sqlalchemy import Column, Integer, MetaData, Table 795 | from sqlalchemy.dialects.mysql import LONGTEXT 796 | 797 | metadata = MetaData() 798 | 799 | 800 | t_simple_items = Table( 801 | 'simple_items', metadata, 802 | Column('id', Integer, primary_key=True), 803 | Column('my_longtext', LONGTEXT) 804 | ) 805 | """, 806 | ) 807 | 808 | 809 | def test_schema_boolean(generator: CodeGenerator) -> None: 810 | Table( 811 | "simple_items", 812 | generator.metadata, 813 | Column("bool1", INTEGER), 814 | CheckConstraint("testschema.simple_items.bool1 IN (0, 1)"), 815 | schema="testschema", 816 | ) 817 | 818 | validate_code( 819 | generator.generate(), 820 | """\ 821 | from sqlalchemy import Boolean, Column, MetaData, Table 822 | 823 | metadata = MetaData() 824 | 825 | 826 | t_simple_items = Table( 827 | 'simple_items', metadata, 828 | Column('bool1', Boolean), 829 | schema='testschema' 830 | ) 831 | """, 832 | ) 833 | 834 | 835 | def test_server_default_multiline(generator: CodeGenerator) -> None: 836 | Table( 837 | "simple_items", 838 | generator.metadata, 839 | Column( 840 | "id", 841 | INTEGER, 842 | primary_key=True, 843 | server_default=text( 844 | dedent( 845 | """\ 846 | /*Comment*/ 847 | /*Next line*/ 848 | something()""" 849 | ) 850 | ), 851 | ), 852 | ) 853 | 854 | validate_code( 855 | generator.generate(), 856 | """\ 857 | from sqlalchemy import Column, Integer, MetaData, Table, text 858 | 859 | metadata = MetaData() 860 | 861 | 862 | t_simple_items = Table( 863 | 'simple_items', metadata, 864 | Column('id', Integer, primary_key=True, server_default=\ 865 | text('/*Comment*/\\n/*Next line*/\\nsomething()')) 866 | ) 867 | """, 868 | ) 869 | 870 | 871 | def test_server_default_colon(generator: CodeGenerator) -> None: 872 | Table( 873 | "simple_items", 874 | generator.metadata, 875 | Column("problem", VARCHAR, server_default=text("':001'")), 876 | ) 877 | 878 | validate_code( 879 | generator.generate(), 880 | """\ 881 | from sqlalchemy import Column, MetaData, String, Table, text 882 | 883 | metadata = MetaData() 884 | 885 | 886 | t_simple_items = Table( 887 | 'simple_items', metadata, 888 | Column('problem', String, server_default=text("':001'")) 889 | ) 890 | """, 891 | ) 892 | 893 | 894 | def test_null_type(generator: CodeGenerator) -> None: 895 | Table( 896 | "simple_items", 897 | generator.metadata, 898 | Column("problem", NullType), 899 | ) 900 | 901 | validate_code( 902 | generator.generate(), 903 | """\ 904 | from sqlalchemy import Column, MetaData, Table 905 | from sqlalchemy.sql.sqltypes import NullType 906 | 907 | metadata = MetaData() 908 | 909 | 910 | t_simple_items = Table( 911 | 'simple_items', metadata, 912 | Column('problem', NullType) 913 | ) 914 | """, 915 | ) 916 | 917 | 918 | def test_identity_column(generator: CodeGenerator) -> None: 919 | Table( 920 | "simple_items", 921 | generator.metadata, 922 | Column( 923 | "id", 924 | INTEGER, 925 | primary_key=True, 926 | server_default=Identity(start=1, increment=2), 927 | ), 928 | ) 929 | 930 | validate_code( 931 | generator.generate(), 932 | """\ 933 | from sqlalchemy import Column, Identity, Integer, MetaData, Table 934 | 935 | metadata = MetaData() 936 | 937 | 938 | t_simple_items = Table( 939 | 'simple_items', metadata, 940 | Column('id', Integer, Identity(start=1, increment=2), primary_key=True) 941 | ) 942 | """, 943 | ) 944 | 945 | 946 | def test_multiline_column_comment(generator: CodeGenerator) -> None: 947 | Table( 948 | "simple_items", 949 | generator.metadata, 950 | Column("id", INTEGER, comment="This\nis a multi-line\ncomment"), 951 | ) 952 | 953 | validate_code( 954 | generator.generate(), 955 | """\ 956 | from sqlalchemy import Column, Integer, MetaData, Table 957 | 958 | metadata = MetaData() 959 | 960 | 961 | t_simple_items = Table( 962 | 'simple_items', metadata, 963 | Column('id', Integer, comment='This\\nis a multi-line\\ncomment') 964 | ) 965 | """, 966 | ) 967 | 968 | 969 | def test_multiline_table_comment(generator: CodeGenerator) -> None: 970 | Table( 971 | "simple_items", 972 | generator.metadata, 973 | Column("id", INTEGER), 974 | comment="This\nis a multi-line\ncomment", 975 | ) 976 | 977 | validate_code( 978 | generator.generate(), 979 | """\ 980 | from sqlalchemy import Column, Integer, MetaData, Table 981 | 982 | metadata = MetaData() 983 | 984 | 985 | t_simple_items = Table( 986 | 'simple_items', metadata, 987 | Column('id', Integer), 988 | comment='This\\nis a multi-line\\ncomment' 989 | ) 990 | """, 991 | ) 992 | 993 | 994 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 995 | def test_postgresql_sequence_standard_name(generator: CodeGenerator) -> None: 996 | Table( 997 | "simple_items", 998 | generator.metadata, 999 | Column( 1000 | "id", 1001 | INTEGER, 1002 | primary_key=True, 1003 | server_default=text("nextval('simple_items_id_seq'::regclass)"), 1004 | ), 1005 | ) 1006 | 1007 | validate_code( 1008 | generator.generate(), 1009 | """\ 1010 | from sqlalchemy import Column, Integer, MetaData, Table 1011 | 1012 | metadata = MetaData() 1013 | 1014 | 1015 | t_simple_items = Table( 1016 | 'simple_items', metadata, 1017 | Column('id', Integer, primary_key=True) 1018 | ) 1019 | """, 1020 | ) 1021 | 1022 | 1023 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 1024 | def test_postgresql_sequence_nonstandard_name(generator: CodeGenerator) -> None: 1025 | Table( 1026 | "simple_items", 1027 | generator.metadata, 1028 | Column( 1029 | "id", 1030 | INTEGER, 1031 | primary_key=True, 1032 | server_default=text("nextval('test_seq'::regclass)"), 1033 | ), 1034 | ) 1035 | 1036 | validate_code( 1037 | generator.generate(), 1038 | """\ 1039 | from sqlalchemy import Column, Integer, MetaData, Sequence, Table 1040 | 1041 | metadata = MetaData() 1042 | 1043 | 1044 | t_simple_items = Table( 1045 | 'simple_items', metadata, 1046 | Column('id', Integer, Sequence('test_seq'), primary_key=True) 1047 | ) 1048 | """, 1049 | ) 1050 | 1051 | 1052 | @pytest.mark.parametrize( 1053 | "schemaname, seqname", 1054 | [ 1055 | pytest.param("myschema", "test_seq"), 1056 | pytest.param("myschema", '"test_seq"'), 1057 | pytest.param('"my.schema"', "test_seq"), 1058 | pytest.param('"my.schema"', '"test_seq"'), 1059 | ], 1060 | ) 1061 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 1062 | def test_postgresql_sequence_with_schema( 1063 | generator: CodeGenerator, schemaname: str, seqname: str 1064 | ) -> None: 1065 | expected_schema = schemaname.strip('"') 1066 | Table( 1067 | "simple_items", 1068 | generator.metadata, 1069 | Column( 1070 | "id", 1071 | INTEGER, 1072 | primary_key=True, 1073 | server_default=text(f"nextval('{schemaname}.{seqname}'::regclass)"), 1074 | ), 1075 | schema=expected_schema, 1076 | ) 1077 | 1078 | validate_code( 1079 | generator.generate(), 1080 | f"""\ 1081 | from sqlalchemy import Column, Integer, MetaData, Sequence, Table 1082 | 1083 | metadata = MetaData() 1084 | 1085 | 1086 | t_simple_items = Table( 1087 | 'simple_items', metadata, 1088 | Column('id', Integer, Sequence('test_seq', \ 1089 | schema='{expected_schema}'), primary_key=True), 1090 | schema='{expected_schema}' 1091 | ) 1092 | """, 1093 | ) 1094 | 1095 | 1096 | class MockStarRocksDialect(MySQLDialect_pymysql): 1097 | name = "starrocks" 1098 | construct_arguments = [ 1099 | ( 1100 | Column, 1101 | { 1102 | "is_agg_key": None, 1103 | "agg_type": None, 1104 | "IS_AGG_KEY": None, 1105 | "AGG_TYPE": None, 1106 | }, 1107 | ), 1108 | ( 1109 | Table, 1110 | { 1111 | "primary_key": None, 1112 | "aggregate_key": None, 1113 | "unique_key": None, 1114 | "duplicate_key": None, 1115 | "engine": "OLAP", 1116 | "partition_by": None, 1117 | "order_by": None, 1118 | "security": None, 1119 | "properties": {}, 1120 | "ENGINE": "OLAP", 1121 | "PARTITION_BY": None, 1122 | "ORDER_BY": None, 1123 | "SECURITY": None, 1124 | "PROPERTIES": {}, 1125 | }, 1126 | ), 1127 | ] 1128 | 1129 | 1130 | # Register StarRocksDialect 1131 | registry.register("starrocks", __name__, "MockStarRocksDialect") 1132 | 1133 | 1134 | class _PartitionInfo: 1135 | def __init__(self, partition_by: str) -> None: 1136 | self.partition_by = partition_by 1137 | 1138 | def __str__(self) -> str: 1139 | return self.partition_by 1140 | 1141 | def __repr__(self) -> str: 1142 | return repr(self.partition_by) 1143 | 1144 | 1145 | @pytest.mark.parametrize("generator", [["include_dialect_options"]], indirect=True) 1146 | def test_include_dialect_options_starrocks_tables(generator: CodeGenerator) -> None: 1147 | Table( 1148 | "t_starrocks", 1149 | generator.metadata, 1150 | Column("id", INTEGER, primary_key=True, starrocks_is_agg_key=True), 1151 | starrocks_ENGINE="OLAP", 1152 | starrocks_PARTITION_BY=_PartitionInfo("RANGE(id)"), 1153 | starrocks_ORDER_BY="id, name", 1154 | starrocks_PROPERTIES={"replication_num": "3", "storage_medium": "SSD"}, 1155 | ).info = {"table_kind": "TABLE"} 1156 | 1157 | validate_code( 1158 | generator.generate(), 1159 | """\ 1160 | from sqlalchemy import Column, Integer, MetaData, Table 1161 | 1162 | metadata = MetaData() 1163 | 1164 | 1165 | t_t_starrocks = Table( 1166 | 't_starrocks', metadata, 1167 | Column('id', Integer, primary_key=True, starrocks_is_agg_key=True), 1168 | info={'table_kind': 'TABLE'}, 1169 | starrocks_ENGINE='OLAP', 1170 | starrocks_ORDER_BY='id, name', 1171 | starrocks_PARTITION_BY='RANGE(id)', 1172 | starrocks_PROPERTIES={'replication_num': '3', 'storage_medium': 'SSD'} 1173 | ) 1174 | """, 1175 | ) 1176 | -------------------------------------------------------------------------------- /tests/test_generator_declarative.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | import pytest 6 | from _pytest.fixtures import FixtureRequest 7 | from geoalchemy2 import Geography, Geometry 8 | from sqlalchemy import BIGINT, PrimaryKeyConstraint 9 | from sqlalchemy.dialects import postgresql 10 | from sqlalchemy.dialects.postgresql import JSON, JSONB 11 | from sqlalchemy.engine import Engine 12 | from sqlalchemy.schema import ( 13 | CheckConstraint, 14 | Column, 15 | ForeignKey, 16 | ForeignKeyConstraint, 17 | Index, 18 | MetaData, 19 | Table, 20 | UniqueConstraint, 21 | ) 22 | from sqlalchemy.sql.expression import text 23 | from sqlalchemy.types import ARRAY, INTEGER, VARCHAR, Text 24 | 25 | from sqlacodegen.generators import CodeGenerator, DeclarativeGenerator 26 | 27 | from .conftest import validate_code 28 | 29 | 30 | @pytest.fixture 31 | def generator( 32 | request: FixtureRequest, metadata: MetaData, engine: Engine 33 | ) -> CodeGenerator: 34 | options = getattr(request, "param", []) 35 | return DeclarativeGenerator(metadata, engine, options) 36 | 37 | 38 | def test_indexes(generator: CodeGenerator) -> None: 39 | simple_items = Table( 40 | "simple_items", 41 | generator.metadata, 42 | Column("id", INTEGER, primary_key=True), 43 | Column("number", INTEGER), 44 | Column("text", VARCHAR), 45 | ) 46 | simple_items.indexes.add(Index("idx_number", simple_items.c.number)) 47 | simple_items.indexes.add( 48 | Index("idx_text_number", simple_items.c.text, simple_items.c.number) 49 | ) 50 | simple_items.indexes.add(Index("idx_text", simple_items.c.text, unique=True)) 51 | 52 | validate_code( 53 | generator.generate(), 54 | """\ 55 | from typing import Optional 56 | 57 | from sqlalchemy import Index, Integer, String 58 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 59 | 60 | class Base(DeclarativeBase): 61 | pass 62 | 63 | 64 | class SimpleItems(Base): 65 | __tablename__ = 'simple_items' 66 | __table_args__ = ( 67 | Index('idx_number', 'number'), 68 | Index('idx_text', 'text', unique=True), 69 | Index('idx_text_number', 'text', 'number') 70 | ) 71 | 72 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 73 | number: Mapped[Optional[int]] = mapped_column(Integer) 74 | text: Mapped[Optional[str]] = mapped_column(String) 75 | """, 76 | ) 77 | 78 | 79 | def test_constraints(generator: CodeGenerator) -> None: 80 | Table( 81 | "simple_items", 82 | generator.metadata, 83 | Column("id", INTEGER, primary_key=True), 84 | Column("number", INTEGER), 85 | CheckConstraint("number > 0"), 86 | UniqueConstraint("id", "number"), 87 | ) 88 | 89 | validate_code( 90 | generator.generate(), 91 | """\ 92 | from typing import Optional 93 | 94 | from sqlalchemy import CheckConstraint, Integer, UniqueConstraint 95 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 96 | 97 | class Base(DeclarativeBase): 98 | pass 99 | 100 | 101 | class SimpleItems(Base): 102 | __tablename__ = 'simple_items' 103 | __table_args__ = ( 104 | CheckConstraint('number > 0'), 105 | UniqueConstraint('id', 'number') 106 | ) 107 | 108 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 109 | number: Mapped[Optional[int]] = mapped_column(Integer) 110 | """, 111 | ) 112 | 113 | 114 | @pytest.mark.parametrize("generator", [["include_dialect_options"]], indirect=True) 115 | def test_include_dialect_options_and_info_table_and_column( 116 | generator: CodeGenerator, 117 | ) -> None: 118 | from .test_generator_tables import _PartitionInfo 119 | 120 | Table( 121 | "t_opts", 122 | generator.metadata, 123 | Column("id", INTEGER, primary_key=True, starrocks_is_agg_key=True), 124 | Column("name", VARCHAR, starrocks_agg_type="REPLACE"), 125 | starrocks_aggregate_key="id", 126 | starrocks_partition_by=_PartitionInfo("RANGE(id)"), 127 | starrocks_security="DEFINER", 128 | starrocks_PROPERTIES={"replication_num": "3", "storage_medium": "SSD"}, 129 | info={ 130 | "table_kind": "MATERIALIZED VIEW", 131 | "definition": "SELECT id, name FROM t_opts_base_table", 132 | }, 133 | ) 134 | 135 | validate_code( 136 | generator.generate(), 137 | """\ 138 | from typing import Optional 139 | 140 | from sqlalchemy import Integer, String 141 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 142 | 143 | class Base(DeclarativeBase): 144 | pass 145 | 146 | 147 | class TOpts(Base): 148 | __tablename__ = 't_opts' 149 | __table_args__ = {'info': {'definition': 'SELECT id, name FROM t_opts_base_table', 150 | 'table_kind': 'MATERIALIZED VIEW'}, 151 | 'starrocks_PROPERTIES': {'replication_num': '3', 'storage_medium': 'SSD'}, 152 | 'starrocks_aggregate_key': 'id', 153 | 'starrocks_partition_by': 'RANGE(id)', 154 | 'starrocks_security': 'DEFINER'} 155 | 156 | id: Mapped[int] = mapped_column(Integer, primary_key=True, starrocks_is_agg_key=True) 157 | name: Mapped[Optional[str]] = mapped_column(String, starrocks_agg_type='REPLACE') 158 | """, 159 | ) 160 | 161 | 162 | @pytest.mark.parametrize("generator", [["include_dialect_options"]], indirect=True) 163 | def test_include_dialect_options_and_info_with_hyphen(generator: CodeGenerator) -> None: 164 | Table( 165 | "t_opts2", 166 | generator.metadata, 167 | Column("id", INTEGER, primary_key=True), 168 | mysql_engine="InnoDB", 169 | info={"table_kind": "View"}, 170 | ) 171 | 172 | validate_code( 173 | generator.generate(), 174 | """\ 175 | from sqlalchemy import Integer 176 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 177 | 178 | class Base(DeclarativeBase): 179 | pass 180 | 181 | 182 | class TOpts2(Base): 183 | __tablename__ = 't_opts2' 184 | __table_args__ = {'info': {'table_kind': 'View'}, 'mysql_engine': 'InnoDB'} 185 | 186 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 187 | """, 188 | ) 189 | 190 | 191 | def test_include_dialect_options_not_enabled_skips(generator: CodeGenerator) -> None: 192 | from .test_generator_tables import _PartitionInfo 193 | 194 | Table( 195 | "t_plain", 196 | generator.metadata, 197 | Column( 198 | "id", 199 | INTEGER, 200 | primary_key=True, 201 | info={"abc": True}, 202 | starrocks_is_agg_key=True, 203 | ), 204 | starrocks_engine="OLAP", 205 | starrocks_partition_by=_PartitionInfo("RANGE(id)"), 206 | ) 207 | 208 | validate_code( 209 | generator.generate(), 210 | """\ 211 | from sqlalchemy import Integer 212 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 213 | 214 | class Base(DeclarativeBase): 215 | pass 216 | 217 | 218 | class TPlain(Base): 219 | __tablename__ = 't_plain' 220 | 221 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 222 | """, 223 | ) 224 | 225 | 226 | def test_keep_dialect_types_adapts_mysql_integer_default( 227 | generator: CodeGenerator, 228 | ) -> None: 229 | from sqlalchemy.dialects.mysql import INTEGER as MYSQL_INTEGER 230 | 231 | Table( 232 | "num", 233 | generator.metadata, 234 | Column("id", INTEGER, primary_key=True), 235 | Column("val", MYSQL_INTEGER(), nullable=False), 236 | ) 237 | 238 | validate_code( 239 | generator.generate(), 240 | """\ 241 | from sqlalchemy import Integer 242 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 243 | 244 | class Base(DeclarativeBase): 245 | pass 246 | 247 | 248 | class Num(Base): 249 | __tablename__ = 'num' 250 | 251 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 252 | val: Mapped[int] = mapped_column(Integer, nullable=False) 253 | """, 254 | ) 255 | 256 | 257 | @pytest.mark.parametrize("generator", [["keep_dialect_types"]], indirect=True) 258 | def test_keep_dialect_types_keeps_mysql_integer(generator: CodeGenerator) -> None: 259 | from sqlalchemy.dialects.mysql import INTEGER as MYSQL_INTEGER 260 | 261 | Table( 262 | "num2", 263 | generator.metadata, 264 | Column("id", INTEGER, primary_key=True), 265 | Column("val", MYSQL_INTEGER(), nullable=False), 266 | ) 267 | 268 | validate_code( 269 | generator.generate(), 270 | """\ 271 | from sqlalchemy import INTEGER 272 | from sqlalchemy.dialects.mysql import INTEGER 273 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 274 | 275 | class Base(DeclarativeBase): 276 | pass 277 | 278 | 279 | class Num2(Base): 280 | __tablename__ = 'num2' 281 | 282 | id: Mapped[int] = mapped_column(INTEGER, primary_key=True) 283 | val: Mapped[int] = mapped_column(INTEGER, nullable=False) 284 | """, 285 | ) 286 | 287 | 288 | def test_onetomany(generator: CodeGenerator) -> None: 289 | Table( 290 | "simple_items", 291 | generator.metadata, 292 | Column("id", INTEGER, primary_key=True), 293 | Column("container_id", INTEGER), 294 | ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), 295 | ) 296 | Table( 297 | "simple_containers", 298 | generator.metadata, 299 | Column("id", INTEGER, primary_key=True), 300 | ) 301 | 302 | validate_code( 303 | generator.generate(), 304 | """\ 305 | from typing import Optional 306 | 307 | from sqlalchemy import ForeignKey, Integer 308 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 309 | 310 | class Base(DeclarativeBase): 311 | pass 312 | 313 | 314 | class SimpleContainers(Base): 315 | __tablename__ = 'simple_containers' 316 | 317 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 318 | 319 | simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 320 | back_populates='container') 321 | 322 | 323 | class SimpleItems(Base): 324 | __tablename__ = 'simple_items' 325 | 326 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 327 | container_id: Mapped[Optional[int]] = \ 328 | mapped_column(ForeignKey('simple_containers.id')) 329 | 330 | container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ 331 | back_populates='simple_items') 332 | """, 333 | ) 334 | 335 | 336 | def test_onetomany_selfref(generator: CodeGenerator) -> None: 337 | Table( 338 | "simple_items", 339 | generator.metadata, 340 | Column("id", INTEGER, primary_key=True), 341 | Column("parent_item_id", INTEGER), 342 | ForeignKeyConstraint(["parent_item_id"], ["simple_items.id"]), 343 | ) 344 | 345 | validate_code( 346 | generator.generate(), 347 | """\ 348 | from typing import Optional 349 | 350 | from sqlalchemy import ForeignKey, Integer 351 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 352 | 353 | class Base(DeclarativeBase): 354 | pass 355 | 356 | 357 | class SimpleItems(Base): 358 | __tablename__ = 'simple_items' 359 | 360 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 361 | parent_item_id: Mapped[Optional[int]] = \ 362 | mapped_column(ForeignKey('simple_items.id')) 363 | 364 | parent_item: Mapped[Optional['SimpleItems']] = relationship('SimpleItems', \ 365 | remote_side=[id], back_populates='parent_item_reverse') 366 | parent_item_reverse: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 367 | remote_side=[parent_item_id], back_populates='parent_item') 368 | """, 369 | ) 370 | 371 | 372 | def test_onetomany_selfref_multi(generator: CodeGenerator) -> None: 373 | Table( 374 | "simple_items", 375 | generator.metadata, 376 | Column("id", INTEGER, primary_key=True), 377 | Column("parent_item_id", INTEGER), 378 | Column("top_item_id", INTEGER), 379 | ForeignKeyConstraint(["parent_item_id"], ["simple_items.id"]), 380 | ForeignKeyConstraint(["top_item_id"], ["simple_items.id"]), 381 | ) 382 | 383 | validate_code( 384 | generator.generate(), 385 | """\ 386 | from typing import Optional 387 | 388 | from sqlalchemy import ForeignKey, Integer 389 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 390 | 391 | class Base(DeclarativeBase): 392 | pass 393 | 394 | 395 | class SimpleItems(Base): 396 | __tablename__ = 'simple_items' 397 | 398 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 399 | parent_item_id: Mapped[Optional[int]] = \ 400 | mapped_column(ForeignKey('simple_items.id')) 401 | top_item_id: Mapped[Optional[int]] = mapped_column(ForeignKey('simple_items.id')) 402 | 403 | parent_item: Mapped[Optional['SimpleItems']] = relationship('SimpleItems', \ 404 | remote_side=[id], foreign_keys=[parent_item_id], back_populates='parent_item_reverse') 405 | parent_item_reverse: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 406 | remote_side=[parent_item_id], foreign_keys=[parent_item_id], \ 407 | back_populates='parent_item') 408 | top_item: Mapped[Optional['SimpleItems']] = relationship('SimpleItems', remote_side=[id], \ 409 | foreign_keys=[top_item_id], back_populates='top_item_reverse') 410 | top_item_reverse: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 411 | remote_side=[top_item_id], foreign_keys=[top_item_id], back_populates='top_item') 412 | """, 413 | ) 414 | 415 | 416 | def test_onetomany_composite(generator: CodeGenerator) -> None: 417 | Table( 418 | "simple_items", 419 | generator.metadata, 420 | Column("id", INTEGER, primary_key=True), 421 | Column("container_id1", INTEGER), 422 | Column("container_id2", INTEGER), 423 | ForeignKeyConstraint( 424 | ["container_id1", "container_id2"], 425 | ["simple_containers.id1", "simple_containers.id2"], 426 | ondelete="CASCADE", 427 | onupdate="CASCADE", 428 | ), 429 | ) 430 | Table( 431 | "simple_containers", 432 | generator.metadata, 433 | Column("id1", INTEGER, primary_key=True), 434 | Column("id2", INTEGER, primary_key=True), 435 | ) 436 | 437 | validate_code( 438 | generator.generate(), 439 | """\ 440 | from typing import Optional 441 | 442 | from sqlalchemy import ForeignKeyConstraint, Integer 443 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 444 | 445 | class Base(DeclarativeBase): 446 | pass 447 | 448 | 449 | class SimpleContainers(Base): 450 | __tablename__ = 'simple_containers' 451 | 452 | id1: Mapped[int] = mapped_column(Integer, primary_key=True) 453 | id2: Mapped[int] = mapped_column(Integer, primary_key=True) 454 | 455 | simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 456 | back_populates='simple_containers') 457 | 458 | 459 | class SimpleItems(Base): 460 | __tablename__ = 'simple_items' 461 | __table_args__ = ( 462 | ForeignKeyConstraint(['container_id1', 'container_id2'], \ 463 | ['simple_containers.id1', 'simple_containers.id2'], ondelete='CASCADE', \ 464 | onupdate='CASCADE'), 465 | ) 466 | 467 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 468 | container_id1: Mapped[Optional[int]] = mapped_column(Integer) 469 | container_id2: Mapped[Optional[int]] = mapped_column(Integer) 470 | 471 | simple_containers: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ 472 | back_populates='simple_items') 473 | """, 474 | ) 475 | 476 | 477 | def test_onetomany_multiref(generator: CodeGenerator) -> None: 478 | Table( 479 | "simple_items", 480 | generator.metadata, 481 | Column("id", INTEGER, primary_key=True), 482 | Column("parent_container_id", INTEGER), 483 | Column("top_container_id", INTEGER, nullable=False), 484 | ForeignKeyConstraint(["parent_container_id"], ["simple_containers.id"]), 485 | ForeignKeyConstraint(["top_container_id"], ["simple_containers.id"]), 486 | ) 487 | Table( 488 | "simple_containers", 489 | generator.metadata, 490 | Column("id", INTEGER, primary_key=True), 491 | ) 492 | 493 | validate_code( 494 | generator.generate(), 495 | """\ 496 | from typing import Optional 497 | 498 | from sqlalchemy import ForeignKey, Integer 499 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 500 | 501 | class Base(DeclarativeBase): 502 | pass 503 | 504 | 505 | class SimpleContainers(Base): 506 | __tablename__ = 'simple_containers' 507 | 508 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 509 | 510 | simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 511 | foreign_keys='[SimpleItems.parent_container_id]', back_populates='parent_container') 512 | simple_items_: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 513 | foreign_keys='[SimpleItems.top_container_id]', back_populates='top_container') 514 | 515 | 516 | class SimpleItems(Base): 517 | __tablename__ = 'simple_items' 518 | 519 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 520 | top_container_id: Mapped[int] = \ 521 | mapped_column(ForeignKey('simple_containers.id'), nullable=False) 522 | parent_container_id: Mapped[Optional[int]] = \ 523 | mapped_column(ForeignKey('simple_containers.id')) 524 | 525 | parent_container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ 526 | foreign_keys=[parent_container_id], back_populates='simple_items') 527 | top_container: Mapped['SimpleContainers'] = relationship('SimpleContainers', \ 528 | foreign_keys=[top_container_id], back_populates='simple_items_') 529 | """, 530 | ) 531 | 532 | 533 | def test_onetoone(generator: CodeGenerator) -> None: 534 | Table( 535 | "simple_items", 536 | generator.metadata, 537 | Column("id", INTEGER, primary_key=True), 538 | Column("other_item_id", INTEGER), 539 | ForeignKeyConstraint(["other_item_id"], ["other_items.id"]), 540 | UniqueConstraint("other_item_id"), 541 | ) 542 | Table( 543 | "other_items", 544 | generator.metadata, 545 | Column("id", INTEGER, primary_key=True), 546 | ) 547 | 548 | validate_code( 549 | generator.generate(), 550 | """\ 551 | from typing import Optional 552 | 553 | from sqlalchemy import ForeignKey, Integer 554 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 555 | 556 | class Base(DeclarativeBase): 557 | pass 558 | 559 | 560 | class OtherItems(Base): 561 | __tablename__ = 'other_items' 562 | 563 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 564 | 565 | simple_items: Mapped[Optional['SimpleItems']] = relationship('SimpleItems', uselist=False, \ 566 | back_populates='other_item') 567 | 568 | 569 | class SimpleItems(Base): 570 | __tablename__ = 'simple_items' 571 | 572 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 573 | other_item_id: Mapped[Optional[int]] = \ 574 | mapped_column(ForeignKey('other_items.id'), unique=True) 575 | 576 | other_item: Mapped[Optional['OtherItems']] = relationship('OtherItems', \ 577 | back_populates='simple_items') 578 | """, 579 | ) 580 | 581 | 582 | def test_onetomany_noinflect(generator: CodeGenerator) -> None: 583 | Table( 584 | "oglkrogk", 585 | generator.metadata, 586 | Column("id", INTEGER, primary_key=True), 587 | Column("fehwiuhfiwID", INTEGER), 588 | ForeignKeyConstraint(["fehwiuhfiwID"], ["fehwiuhfiw.id"]), 589 | ) 590 | Table("fehwiuhfiw", generator.metadata, Column("id", INTEGER, primary_key=True)) 591 | 592 | validate_code( 593 | generator.generate(), 594 | """\ 595 | from typing import Optional 596 | 597 | from sqlalchemy import ForeignKey, Integer 598 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 599 | 600 | class Base(DeclarativeBase): 601 | pass 602 | 603 | 604 | class Fehwiuhfiw(Base): 605 | __tablename__ = 'fehwiuhfiw' 606 | 607 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 608 | 609 | oglkrogk: Mapped[list['Oglkrogk']] = relationship('Oglkrogk', \ 610 | back_populates='fehwiuhfiw') 611 | 612 | 613 | class Oglkrogk(Base): 614 | __tablename__ = 'oglkrogk' 615 | 616 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 617 | fehwiuhfiwID: Mapped[Optional[int]] = mapped_column(ForeignKey('fehwiuhfiw.id')) 618 | 619 | fehwiuhfiw: Mapped[Optional['Fehwiuhfiw']] = \ 620 | relationship('Fehwiuhfiw', back_populates='oglkrogk') 621 | """, 622 | ) 623 | 624 | 625 | def test_onetomany_conflicting_column(generator: CodeGenerator) -> None: 626 | Table( 627 | "simple_items", 628 | generator.metadata, 629 | Column("id", INTEGER, primary_key=True), 630 | Column("container_id", INTEGER), 631 | ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), 632 | ) 633 | Table( 634 | "simple_containers", 635 | generator.metadata, 636 | Column("id", INTEGER, primary_key=True), 637 | Column("relationship", Text), 638 | ) 639 | 640 | validate_code( 641 | generator.generate(), 642 | """\ 643 | from typing import Optional 644 | 645 | from sqlalchemy import ForeignKey, Integer, Text 646 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 647 | 648 | class Base(DeclarativeBase): 649 | pass 650 | 651 | 652 | class SimpleContainers(Base): 653 | __tablename__ = 'simple_containers' 654 | 655 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 656 | relationship_: Mapped[Optional[str]] = mapped_column('relationship', Text) 657 | 658 | simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 659 | back_populates='container') 660 | 661 | 662 | class SimpleItems(Base): 663 | __tablename__ = 'simple_items' 664 | 665 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 666 | container_id: Mapped[Optional[int]] = \ 667 | mapped_column(ForeignKey('simple_containers.id')) 668 | 669 | container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ 670 | back_populates='simple_items') 671 | """, 672 | ) 673 | 674 | 675 | def test_onetomany_conflicting_relationship(generator: CodeGenerator) -> None: 676 | Table( 677 | "simple_items", 678 | generator.metadata, 679 | Column("id", INTEGER, primary_key=True), 680 | Column("relationship_id", INTEGER), 681 | ForeignKeyConstraint(["relationship_id"], ["relationship.id"]), 682 | ) 683 | Table("relationship", generator.metadata, Column("id", INTEGER, primary_key=True)) 684 | 685 | validate_code( 686 | generator.generate(), 687 | """\ 688 | from typing import Optional 689 | 690 | from sqlalchemy import ForeignKey, Integer 691 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 692 | 693 | class Base(DeclarativeBase): 694 | pass 695 | 696 | 697 | class Relationship(Base): 698 | __tablename__ = 'relationship' 699 | 700 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 701 | 702 | simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 703 | back_populates='relationship_') 704 | 705 | 706 | class SimpleItems(Base): 707 | __tablename__ = 'simple_items' 708 | 709 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 710 | relationship_id: Mapped[Optional[int]] = \ 711 | mapped_column(ForeignKey('relationship.id')) 712 | 713 | relationship_: Mapped[Optional['Relationship']] = relationship('Relationship', \ 714 | back_populates='simple_items') 715 | """, 716 | ) 717 | 718 | 719 | @pytest.mark.parametrize("generator", [["nobidi"]], indirect=True) 720 | def test_manytoone_nobidi(generator: CodeGenerator) -> None: 721 | Table( 722 | "simple_items", 723 | generator.metadata, 724 | Column("id", INTEGER, primary_key=True), 725 | Column("container_id", INTEGER), 726 | ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), 727 | ) 728 | Table( 729 | "simple_containers", 730 | generator.metadata, 731 | Column("id", INTEGER, primary_key=True), 732 | ) 733 | 734 | validate_code( 735 | generator.generate(), 736 | """\ 737 | from typing import Optional 738 | 739 | from sqlalchemy import ForeignKey, Integer 740 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 741 | 742 | class Base(DeclarativeBase): 743 | pass 744 | 745 | 746 | class SimpleContainers(Base): 747 | __tablename__ = 'simple_containers' 748 | 749 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 750 | 751 | 752 | class SimpleItems(Base): 753 | __tablename__ = 'simple_items' 754 | 755 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 756 | container_id: Mapped[Optional[int]] = \ 757 | mapped_column(ForeignKey('simple_containers.id')) 758 | 759 | container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers') 760 | """, 761 | ) 762 | 763 | 764 | def test_manytomany(generator: CodeGenerator) -> None: 765 | Table("left_table", generator.metadata, Column("id", INTEGER, primary_key=True)) 766 | Table( 767 | "right_table", 768 | generator.metadata, 769 | Column("id", INTEGER, primary_key=True), 770 | ) 771 | Table( 772 | "association_table", 773 | generator.metadata, 774 | Column("left_id", INTEGER), 775 | Column("right_id", INTEGER), 776 | ForeignKeyConstraint(["left_id"], ["left_table.id"]), 777 | ForeignKeyConstraint(["right_id"], ["right_table.id"]), 778 | ) 779 | 780 | validate_code( 781 | generator.generate(), 782 | """\ 783 | from sqlalchemy import Column, ForeignKey, Integer, Table 784 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 785 | 786 | class Base(DeclarativeBase): 787 | pass 788 | 789 | 790 | class LeftTable(Base): 791 | __tablename__ = 'left_table' 792 | 793 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 794 | 795 | right: Mapped[list['RightTable']] = relationship('RightTable', \ 796 | secondary='association_table', back_populates='left') 797 | 798 | 799 | class RightTable(Base): 800 | __tablename__ = 'right_table' 801 | 802 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 803 | 804 | left: Mapped[list['LeftTable']] = relationship('LeftTable', \ 805 | secondary='association_table', back_populates='right') 806 | 807 | 808 | t_association_table = Table( 809 | 'association_table', Base.metadata, 810 | Column('left_id', ForeignKey('left_table.id')), 811 | Column('right_id', ForeignKey('right_table.id')) 812 | ) 813 | """, 814 | ) 815 | 816 | 817 | @pytest.mark.parametrize("generator", [["nobidi"]], indirect=True) 818 | def test_manytomany_nobidi(generator: CodeGenerator) -> None: 819 | Table("simple_items", generator.metadata, Column("id", INTEGER, primary_key=True)) 820 | Table( 821 | "simple_containers", 822 | generator.metadata, 823 | Column("id", INTEGER, primary_key=True), 824 | ) 825 | Table( 826 | "container_items", 827 | generator.metadata, 828 | Column("item_id", INTEGER), 829 | Column("container_id", INTEGER), 830 | ForeignKeyConstraint(["item_id"], ["simple_items.id"]), 831 | ForeignKeyConstraint(["container_id"], ["simple_containers.id"]), 832 | ) 833 | 834 | validate_code( 835 | generator.generate(), 836 | """\ 837 | from sqlalchemy import Column, ForeignKey, Integer, Table 838 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 839 | 840 | class Base(DeclarativeBase): 841 | pass 842 | 843 | 844 | class SimpleContainers(Base): 845 | __tablename__ = 'simple_containers' 846 | 847 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 848 | 849 | item: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 850 | secondary='container_items') 851 | 852 | 853 | class SimpleItems(Base): 854 | __tablename__ = 'simple_items' 855 | 856 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 857 | 858 | 859 | t_container_items = Table( 860 | 'container_items', Base.metadata, 861 | Column('item_id', ForeignKey('simple_items.id')), 862 | Column('container_id', ForeignKey('simple_containers.id')) 863 | ) 864 | """, 865 | ) 866 | 867 | 868 | def test_manytomany_selfref(generator: CodeGenerator) -> None: 869 | Table("simple_items", generator.metadata, Column("id", INTEGER, primary_key=True)) 870 | Table( 871 | "child_items", 872 | generator.metadata, 873 | Column("parent_id", INTEGER), 874 | Column("child_id", INTEGER), 875 | ForeignKeyConstraint(["parent_id"], ["simple_items.id"]), 876 | ForeignKeyConstraint(["child_id"], ["simple_items.id"]), 877 | schema="otherschema", 878 | ) 879 | 880 | validate_code( 881 | generator.generate(), 882 | """\ 883 | from sqlalchemy import Column, ForeignKey, Integer, Table 884 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 885 | 886 | class Base(DeclarativeBase): 887 | pass 888 | 889 | 890 | class SimpleItems(Base): 891 | __tablename__ = 'simple_items' 892 | 893 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 894 | 895 | parent: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 896 | secondary='otherschema.child_items', primaryjoin=lambda: SimpleItems.id \ 897 | == t_child_items.c.child_id, \ 898 | secondaryjoin=lambda: SimpleItems.id == \ 899 | t_child_items.c.parent_id, back_populates='child') 900 | child: Mapped[list['SimpleItems']] = \ 901 | relationship('SimpleItems', secondary='otherschema.child_items', \ 902 | primaryjoin=lambda: SimpleItems.id == t_child_items.c.parent_id, \ 903 | secondaryjoin=lambda: SimpleItems.id == t_child_items.c.child_id, \ 904 | back_populates='parent') 905 | 906 | 907 | t_child_items = Table( 908 | 'child_items', Base.metadata, 909 | Column('parent_id', ForeignKey('simple_items.id')), 910 | Column('child_id', ForeignKey('simple_items.id')), 911 | schema='otherschema' 912 | ) 913 | """, 914 | ) 915 | 916 | 917 | def test_manytomany_composite(generator: CodeGenerator) -> None: 918 | Table( 919 | "simple_items", 920 | generator.metadata, 921 | Column("id1", INTEGER, primary_key=True), 922 | Column("id2", INTEGER, primary_key=True), 923 | ) 924 | Table( 925 | "simple_containers", 926 | generator.metadata, 927 | Column("id1", INTEGER, primary_key=True), 928 | Column("id2", INTEGER, primary_key=True), 929 | ) 930 | Table( 931 | "container_items", 932 | generator.metadata, 933 | Column("item_id1", INTEGER), 934 | Column("item_id2", INTEGER), 935 | Column("container_id1", INTEGER), 936 | Column("container_id2", INTEGER), 937 | ForeignKeyConstraint( 938 | ["item_id1", "item_id2"], ["simple_items.id1", "simple_items.id2"] 939 | ), 940 | ForeignKeyConstraint( 941 | ["container_id1", "container_id2"], 942 | ["simple_containers.id1", "simple_containers.id2"], 943 | ), 944 | ) 945 | 946 | validate_code( 947 | generator.generate(), 948 | """\ 949 | from sqlalchemy import Column, ForeignKeyConstraint, Integer, Table 950 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 951 | 952 | class Base(DeclarativeBase): 953 | pass 954 | 955 | 956 | class SimpleContainers(Base): 957 | __tablename__ = 'simple_containers' 958 | 959 | id1: Mapped[int] = mapped_column(Integer, primary_key=True) 960 | id2: Mapped[int] = mapped_column(Integer, primary_key=True) 961 | 962 | simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 963 | secondary='container_items', back_populates='simple_containers') 964 | 965 | 966 | class SimpleItems(Base): 967 | __tablename__ = 'simple_items' 968 | 969 | id1: Mapped[int] = mapped_column(Integer, primary_key=True) 970 | id2: Mapped[int] = mapped_column(Integer, primary_key=True) 971 | 972 | simple_containers: Mapped[list['SimpleContainers']] = \ 973 | relationship('SimpleContainers', secondary='container_items', \ 974 | back_populates='simple_items') 975 | 976 | 977 | t_container_items = Table( 978 | 'container_items', Base.metadata, 979 | Column('item_id1', Integer), 980 | Column('item_id2', Integer), 981 | Column('container_id1', Integer), 982 | Column('container_id2', Integer), 983 | ForeignKeyConstraint(['container_id1', 'container_id2'], \ 984 | ['simple_containers.id1', 'simple_containers.id2']), 985 | ForeignKeyConstraint(['item_id1', 'item_id2'], \ 986 | ['simple_items.id1', 'simple_items.id2']) 987 | ) 988 | """, 989 | ) 990 | 991 | 992 | def test_composite_nullable_pk(generator: CodeGenerator) -> None: 993 | Table( 994 | "simple_items", 995 | generator.metadata, 996 | Column("id1", INTEGER, primary_key=True), 997 | Column("id2", INTEGER, primary_key=True, nullable=True), 998 | ) 999 | validate_code( 1000 | generator.generate(), 1001 | """\ 1002 | from typing import Optional 1003 | 1004 | from sqlalchemy import Integer 1005 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1006 | 1007 | class Base(DeclarativeBase): 1008 | pass 1009 | 1010 | 1011 | class SimpleItems(Base): 1012 | __tablename__ = 'simple_items' 1013 | 1014 | id1: Mapped[int] = mapped_column(Integer, primary_key=True) 1015 | id2: Mapped[Optional[int]] = mapped_column(Integer, primary_key=True, nullable=True) 1016 | """, 1017 | ) 1018 | 1019 | 1020 | def test_joined_inheritance(generator: CodeGenerator) -> None: 1021 | Table( 1022 | "simple_sub_items", 1023 | generator.metadata, 1024 | Column("simple_items_id", INTEGER, primary_key=True), 1025 | Column("data3", INTEGER), 1026 | ForeignKeyConstraint(["simple_items_id"], ["simple_items.super_item_id"]), 1027 | ) 1028 | Table( 1029 | "simple_super_items", 1030 | generator.metadata, 1031 | Column("id", INTEGER, primary_key=True), 1032 | Column("data1", INTEGER), 1033 | ) 1034 | Table( 1035 | "simple_items", 1036 | generator.metadata, 1037 | Column("super_item_id", INTEGER, primary_key=True), 1038 | Column("data2", INTEGER), 1039 | ForeignKeyConstraint(["super_item_id"], ["simple_super_items.id"]), 1040 | ) 1041 | 1042 | validate_code( 1043 | generator.generate(), 1044 | """\ 1045 | from typing import Optional 1046 | 1047 | from sqlalchemy import ForeignKey, Integer 1048 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1049 | 1050 | class Base(DeclarativeBase): 1051 | pass 1052 | 1053 | 1054 | class SimpleSuperItems(Base): 1055 | __tablename__ = 'simple_super_items' 1056 | 1057 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1058 | data1: Mapped[Optional[int]] = mapped_column(Integer) 1059 | 1060 | 1061 | class SimpleItems(SimpleSuperItems): 1062 | __tablename__ = 'simple_items' 1063 | 1064 | super_item_id: Mapped[int] = mapped_column(ForeignKey('simple_super_items.id'), \ 1065 | primary_key=True) 1066 | data2: Mapped[Optional[int]] = mapped_column(Integer) 1067 | 1068 | 1069 | class SimpleSubItems(SimpleItems): 1070 | __tablename__ = 'simple_sub_items' 1071 | 1072 | simple_items_id: Mapped[int] = \ 1073 | mapped_column(ForeignKey('simple_items.super_item_id'), primary_key=True) 1074 | data3: Mapped[Optional[int]] = mapped_column(Integer) 1075 | """, 1076 | ) 1077 | 1078 | 1079 | def test_joined_inheritance_same_table_name(generator: CodeGenerator) -> None: 1080 | Table( 1081 | "simple", 1082 | generator.metadata, 1083 | Column("id", INTEGER, primary_key=True), 1084 | ) 1085 | Table( 1086 | "simple", 1087 | generator.metadata, 1088 | Column("id", INTEGER, ForeignKey("simple.id"), primary_key=True), 1089 | schema="altschema", 1090 | ) 1091 | 1092 | validate_code( 1093 | generator.generate(), 1094 | """\ 1095 | from sqlalchemy import ForeignKey, Integer 1096 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1097 | 1098 | class Base(DeclarativeBase): 1099 | pass 1100 | 1101 | 1102 | class Simple(Base): 1103 | __tablename__ = 'simple' 1104 | 1105 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1106 | 1107 | 1108 | class Simple_(Simple): 1109 | __tablename__ = 'simple' 1110 | __table_args__ = {'schema': 'altschema'} 1111 | 1112 | id: Mapped[int] = mapped_column(ForeignKey('simple.id'), primary_key=True) 1113 | """, 1114 | ) 1115 | 1116 | 1117 | @pytest.mark.parametrize("generator", [["use_inflect"]], indirect=True) 1118 | def test_use_inflect(generator: CodeGenerator) -> None: 1119 | Table("simple_items", generator.metadata, Column("id", INTEGER, primary_key=True)) 1120 | 1121 | Table("singular", generator.metadata, Column("id", INTEGER, primary_key=True)) 1122 | 1123 | validate_code( 1124 | generator.generate(), 1125 | """\ 1126 | from sqlalchemy import Integer 1127 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1128 | 1129 | class Base(DeclarativeBase): 1130 | pass 1131 | 1132 | 1133 | class SimpleItem(Base): 1134 | __tablename__ = 'simple_items' 1135 | 1136 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1137 | 1138 | 1139 | class Singular(Base): 1140 | __tablename__ = 'singular' 1141 | 1142 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1143 | """, 1144 | ) 1145 | 1146 | 1147 | @pytest.mark.parametrize("generator", [["use_inflect"]], indirect=True) 1148 | @pytest.mark.parametrize( 1149 | argnames=("table_name", "class_name", "relationship_name"), 1150 | argvalues=[ 1151 | ("manufacturers", "manufacturer", "manufacturer"), 1152 | ("statuses", "status", "status"), 1153 | ("studies", "study", "study"), 1154 | ("moose", "moose", "moose"), 1155 | ], 1156 | ids=[ 1157 | "test_inflect_manufacturer", 1158 | "test_inflect_status", 1159 | "test_inflect_study", 1160 | "test_inflect_moose", 1161 | ], 1162 | ) 1163 | def test_use_inflect_plural( 1164 | generator: CodeGenerator, 1165 | table_name: str, 1166 | class_name: str, 1167 | relationship_name: str, 1168 | ) -> None: 1169 | Table( 1170 | "simple_items", 1171 | generator.metadata, 1172 | Column("id", INTEGER, primary_key=True), 1173 | Column(f"{relationship_name}_id", INTEGER), 1174 | ForeignKeyConstraint([f"{relationship_name}_id"], [f"{table_name}.id"]), 1175 | UniqueConstraint(f"{relationship_name}_id"), 1176 | ) 1177 | Table(table_name, generator.metadata, Column("id", INTEGER, primary_key=True)) 1178 | 1179 | validate_code( 1180 | generator.generate(), 1181 | f"""\ 1182 | from typing import Optional 1183 | 1184 | from sqlalchemy import ForeignKey, Integer 1185 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 1186 | 1187 | class Base(DeclarativeBase): 1188 | pass 1189 | 1190 | 1191 | class {class_name.capitalize()}(Base): 1192 | __tablename__ = '{table_name}' 1193 | 1194 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1195 | 1196 | simple_item: Mapped[Optional['SimpleItem']] = relationship('SimpleItem', uselist=False, \ 1197 | back_populates='{relationship_name}') 1198 | 1199 | 1200 | class SimpleItem(Base): 1201 | __tablename__ = 'simple_items' 1202 | 1203 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1204 | {relationship_name}_id: Mapped[Optional[int]] = \ 1205 | mapped_column(ForeignKey('{table_name}.id'), unique=True) 1206 | 1207 | {relationship_name}: Mapped[Optional['{class_name.capitalize()}']] = \ 1208 | relationship('{class_name.capitalize()}', back_populates='simple_item') 1209 | """, 1210 | ) 1211 | 1212 | 1213 | @pytest.mark.parametrize("generator", [["use_inflect"]], indirect=True) 1214 | def test_use_inflect_plural_double_pluralize(generator: CodeGenerator) -> None: 1215 | Table( 1216 | "users", 1217 | generator.metadata, 1218 | Column("users_id", INTEGER), 1219 | Column("groups_id", INTEGER), 1220 | ForeignKeyConstraint( 1221 | ["groups_id"], ["groups.groups_id"], name="fk_users_groups_id" 1222 | ), 1223 | PrimaryKeyConstraint("users_id", name="users_pkey"), 1224 | ) 1225 | 1226 | Table( 1227 | "groups", 1228 | generator.metadata, 1229 | Column("groups_id", INTEGER), 1230 | Column("group_name", Text(50), nullable=False), 1231 | PrimaryKeyConstraint("groups_id", name="groups_pkey"), 1232 | ) 1233 | 1234 | validate_code( 1235 | generator.generate(), 1236 | """\ 1237 | from typing import Optional 1238 | 1239 | from sqlalchemy import ForeignKeyConstraint, Integer, PrimaryKeyConstraint, Text 1240 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 1241 | 1242 | class Base(DeclarativeBase): 1243 | pass 1244 | 1245 | 1246 | class Group(Base): 1247 | __tablename__ = 'groups' 1248 | __table_args__ = ( 1249 | PrimaryKeyConstraint('groups_id', name='groups_pkey'), 1250 | ) 1251 | 1252 | groups_id: Mapped[int] = mapped_column(Integer, primary_key=True) 1253 | group_name: Mapped[str] = mapped_column(Text(50), nullable=False) 1254 | 1255 | users: Mapped[list['User']] = relationship('User', back_populates='group') 1256 | 1257 | 1258 | class User(Base): 1259 | __tablename__ = 'users' 1260 | __table_args__ = ( 1261 | ForeignKeyConstraint(['groups_id'], ['groups.groups_id'], name='fk_users_groups_id'), 1262 | PrimaryKeyConstraint('users_id', name='users_pkey') 1263 | ) 1264 | 1265 | users_id: Mapped[int] = mapped_column(Integer, primary_key=True) 1266 | groups_id: Mapped[Optional[int]] = mapped_column(Integer) 1267 | 1268 | group: Mapped[Optional['Group']] = relationship('Group', back_populates='users') 1269 | """, 1270 | ) 1271 | 1272 | 1273 | def test_table_kwargs(generator: CodeGenerator) -> None: 1274 | Table( 1275 | "simple_items", 1276 | generator.metadata, 1277 | Column("id", INTEGER, primary_key=True), 1278 | schema="testschema", 1279 | ) 1280 | 1281 | validate_code( 1282 | generator.generate(), 1283 | """\ 1284 | from sqlalchemy import Integer 1285 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1286 | 1287 | class Base(DeclarativeBase): 1288 | pass 1289 | 1290 | 1291 | class SimpleItems(Base): 1292 | __tablename__ = 'simple_items' 1293 | __table_args__ = {'schema': 'testschema'} 1294 | 1295 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1296 | """, 1297 | ) 1298 | 1299 | 1300 | def test_table_args_kwargs(generator: CodeGenerator) -> None: 1301 | simple_items = Table( 1302 | "simple_items", 1303 | generator.metadata, 1304 | Column("id", INTEGER, primary_key=True), 1305 | Column("name", VARCHAR), 1306 | schema="testschema", 1307 | ) 1308 | simple_items.indexes.add(Index("testidx", simple_items.c.id, simple_items.c.name)) 1309 | 1310 | validate_code( 1311 | generator.generate(), 1312 | """\ 1313 | from typing import Optional 1314 | 1315 | from sqlalchemy import Index, Integer, String 1316 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1317 | 1318 | class Base(DeclarativeBase): 1319 | pass 1320 | 1321 | 1322 | class SimpleItems(Base): 1323 | __tablename__ = 'simple_items' 1324 | __table_args__ = ( 1325 | Index('testidx', 'id', 'name'), 1326 | {'schema': 'testschema'} 1327 | ) 1328 | 1329 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1330 | name: Mapped[Optional[str]] = mapped_column(String) 1331 | """, 1332 | ) 1333 | 1334 | 1335 | def test_foreign_key_schema(generator: CodeGenerator) -> None: 1336 | Table( 1337 | "simple_items", 1338 | generator.metadata, 1339 | Column("id", INTEGER, primary_key=True), 1340 | Column("other_item_id", INTEGER), 1341 | ForeignKeyConstraint(["other_item_id"], ["otherschema.other_items.id"]), 1342 | ) 1343 | Table( 1344 | "other_items", 1345 | generator.metadata, 1346 | Column("id", INTEGER, primary_key=True), 1347 | schema="otherschema", 1348 | ) 1349 | 1350 | validate_code( 1351 | generator.generate(), 1352 | """\ 1353 | from typing import Optional 1354 | 1355 | from sqlalchemy import ForeignKey, Integer 1356 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 1357 | 1358 | class Base(DeclarativeBase): 1359 | pass 1360 | 1361 | 1362 | class OtherItems(Base): 1363 | __tablename__ = 'other_items' 1364 | __table_args__ = {'schema': 'otherschema'} 1365 | 1366 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1367 | 1368 | simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 1369 | back_populates='other_item') 1370 | 1371 | 1372 | class SimpleItems(Base): 1373 | __tablename__ = 'simple_items' 1374 | 1375 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1376 | other_item_id: Mapped[Optional[int]] = \ 1377 | mapped_column(ForeignKey('otherschema.other_items.id')) 1378 | 1379 | other_item: Mapped[Optional['OtherItems']] = relationship('OtherItems', \ 1380 | back_populates='simple_items') 1381 | """, 1382 | ) 1383 | 1384 | 1385 | def test_invalid_attribute_names(generator: CodeGenerator) -> None: 1386 | Table( 1387 | "simple-items", 1388 | generator.metadata, 1389 | Column("id-test", INTEGER, primary_key=True), 1390 | Column("4test", INTEGER), 1391 | Column("_4test", INTEGER), 1392 | Column("def", INTEGER), 1393 | ) 1394 | 1395 | validate_code( 1396 | generator.generate(), 1397 | """\ 1398 | from typing import Optional 1399 | 1400 | from sqlalchemy import Integer 1401 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1402 | 1403 | class Base(DeclarativeBase): 1404 | pass 1405 | 1406 | 1407 | class SimpleItems(Base): 1408 | __tablename__ = 'simple-items' 1409 | 1410 | id_test: Mapped[int] = mapped_column('id-test', Integer, primary_key=True) 1411 | _4test: Mapped[Optional[int]] = mapped_column('4test', Integer) 1412 | _4test_: Mapped[Optional[int]] = mapped_column('_4test', Integer) 1413 | def_: Mapped[Optional[int]] = mapped_column('def', Integer) 1414 | """, 1415 | ) 1416 | 1417 | 1418 | def test_pascal(generator: CodeGenerator) -> None: 1419 | Table( 1420 | "CustomerAPIPreference", 1421 | generator.metadata, 1422 | Column("id", INTEGER, primary_key=True), 1423 | ) 1424 | 1425 | validate_code( 1426 | generator.generate(), 1427 | """\ 1428 | from sqlalchemy import Integer 1429 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1430 | 1431 | class Base(DeclarativeBase): 1432 | pass 1433 | 1434 | 1435 | class CustomerAPIPreference(Base): 1436 | __tablename__ = 'CustomerAPIPreference' 1437 | 1438 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1439 | """, 1440 | ) 1441 | 1442 | 1443 | def test_underscore(generator: CodeGenerator) -> None: 1444 | Table( 1445 | "customer_api_preference", 1446 | generator.metadata, 1447 | Column("id", INTEGER, primary_key=True), 1448 | ) 1449 | 1450 | validate_code( 1451 | generator.generate(), 1452 | """\ 1453 | from sqlalchemy import Integer 1454 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1455 | 1456 | class Base(DeclarativeBase): 1457 | pass 1458 | 1459 | 1460 | class CustomerApiPreference(Base): 1461 | __tablename__ = 'customer_api_preference' 1462 | 1463 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1464 | """, 1465 | ) 1466 | 1467 | 1468 | def test_pascal_underscore(generator: CodeGenerator) -> None: 1469 | Table( 1470 | "customer_API_Preference", 1471 | generator.metadata, 1472 | Column("id", INTEGER, primary_key=True), 1473 | ) 1474 | 1475 | validate_code( 1476 | generator.generate(), 1477 | """\ 1478 | from sqlalchemy import Integer 1479 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1480 | 1481 | class Base(DeclarativeBase): 1482 | pass 1483 | 1484 | 1485 | class CustomerAPIPreference(Base): 1486 | __tablename__ = 'customer_API_Preference' 1487 | 1488 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1489 | """, 1490 | ) 1491 | 1492 | 1493 | def test_pascal_multiple_underscore(generator: CodeGenerator) -> None: 1494 | Table( 1495 | "customer_API__Preference", 1496 | generator.metadata, 1497 | Column("id", INTEGER, primary_key=True), 1498 | ) 1499 | 1500 | validate_code( 1501 | generator.generate(), 1502 | """\ 1503 | from sqlalchemy import Integer 1504 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1505 | 1506 | class Base(DeclarativeBase): 1507 | pass 1508 | 1509 | 1510 | class CustomerAPIPreference(Base): 1511 | __tablename__ = 'customer_API__Preference' 1512 | 1513 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1514 | """, 1515 | ) 1516 | 1517 | 1518 | @pytest.mark.parametrize( 1519 | "generator, nocomments", 1520 | [([], False), (["nocomments"], True)], 1521 | indirect=["generator"], 1522 | ) 1523 | def test_column_comment(generator: CodeGenerator, nocomments: bool) -> None: 1524 | Table( 1525 | "simple", 1526 | generator.metadata, 1527 | Column("id", INTEGER, primary_key=True, comment="this is a 'comment'"), 1528 | ) 1529 | 1530 | comment_part = "" if nocomments else ", comment=\"this is a 'comment'\"" 1531 | validate_code( 1532 | generator.generate(), 1533 | f"""\ 1534 | from sqlalchemy import Integer 1535 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1536 | 1537 | class Base(DeclarativeBase): 1538 | pass 1539 | 1540 | 1541 | class Simple(Base): 1542 | __tablename__ = 'simple' 1543 | 1544 | id: Mapped[int] = mapped_column(Integer, primary_key=True{comment_part}) 1545 | """, 1546 | ) 1547 | 1548 | 1549 | def test_table_comment(generator: CodeGenerator) -> None: 1550 | Table( 1551 | "simple", 1552 | generator.metadata, 1553 | Column("id", INTEGER, primary_key=True), 1554 | comment="this is a 'comment'", 1555 | ) 1556 | 1557 | validate_code( 1558 | generator.generate(), 1559 | """\ 1560 | from sqlalchemy import Integer 1561 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1562 | 1563 | class Base(DeclarativeBase): 1564 | pass 1565 | 1566 | 1567 | class Simple(Base): 1568 | __tablename__ = 'simple' 1569 | __table_args__ = {'comment': "this is a 'comment'"} 1570 | 1571 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1572 | """, 1573 | ) 1574 | 1575 | 1576 | def test_metadata_column(generator: CodeGenerator) -> None: 1577 | Table( 1578 | "simple", 1579 | generator.metadata, 1580 | Column("id", INTEGER, primary_key=True), 1581 | Column("metadata", VARCHAR), 1582 | ) 1583 | 1584 | validate_code( 1585 | generator.generate(), 1586 | """\ 1587 | from typing import Optional 1588 | 1589 | from sqlalchemy import Integer, String 1590 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1591 | 1592 | class Base(DeclarativeBase): 1593 | pass 1594 | 1595 | 1596 | class Simple(Base): 1597 | __tablename__ = 'simple' 1598 | 1599 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1600 | metadata_: Mapped[Optional[str]] = mapped_column('metadata', String) 1601 | """, 1602 | ) 1603 | 1604 | 1605 | def test_invalid_variable_name_from_column(generator: CodeGenerator) -> None: 1606 | Table( 1607 | "simple", 1608 | generator.metadata, 1609 | Column(" id ", INTEGER, primary_key=True), 1610 | ) 1611 | 1612 | validate_code( 1613 | generator.generate(), 1614 | """\ 1615 | from sqlalchemy import Integer 1616 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1617 | 1618 | class Base(DeclarativeBase): 1619 | pass 1620 | 1621 | 1622 | class Simple(Base): 1623 | __tablename__ = 'simple' 1624 | 1625 | id: Mapped[int] = mapped_column(' id ', Integer, primary_key=True) 1626 | """, 1627 | ) 1628 | 1629 | 1630 | def test_only_tables(generator: CodeGenerator) -> None: 1631 | Table("simple", generator.metadata, Column("id", INTEGER)) 1632 | 1633 | validate_code( 1634 | generator.generate(), 1635 | """\ 1636 | from sqlalchemy import Column, Integer, MetaData, Table 1637 | 1638 | metadata = MetaData() 1639 | 1640 | 1641 | t_simple = Table( 1642 | 'simple', metadata, 1643 | Column('id', Integer) 1644 | ) 1645 | """, 1646 | ) 1647 | 1648 | 1649 | def test_named_constraints(generator: CodeGenerator) -> None: 1650 | Table( 1651 | "simple", 1652 | generator.metadata, 1653 | Column("id", INTEGER), 1654 | Column("text", VARCHAR), 1655 | CheckConstraint("id > 0", name="checktest"), 1656 | PrimaryKeyConstraint("id", name="primarytest"), 1657 | UniqueConstraint("text", name="uniquetest"), 1658 | ) 1659 | 1660 | validate_code( 1661 | generator.generate(), 1662 | """\ 1663 | from typing import Optional 1664 | 1665 | from sqlalchemy import CheckConstraint, Integer, PrimaryKeyConstraint, \ 1666 | String, UniqueConstraint 1667 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1668 | 1669 | class Base(DeclarativeBase): 1670 | pass 1671 | 1672 | 1673 | class Simple(Base): 1674 | __tablename__ = 'simple' 1675 | __table_args__ = ( 1676 | CheckConstraint('id > 0', name='checktest'), 1677 | PrimaryKeyConstraint('id', name='primarytest'), 1678 | UniqueConstraint('text', name='uniquetest') 1679 | ) 1680 | 1681 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1682 | text: Mapped[Optional[str]] = mapped_column(String) 1683 | """, 1684 | ) 1685 | 1686 | 1687 | def test_named_foreign_key_constraints(generator: CodeGenerator) -> None: 1688 | Table( 1689 | "simple_items", 1690 | generator.metadata, 1691 | Column("id", INTEGER, primary_key=True), 1692 | Column("container_id", INTEGER), 1693 | ForeignKeyConstraint( 1694 | ["container_id"], ["simple_containers.id"], name="foreignkeytest" 1695 | ), 1696 | ) 1697 | Table( 1698 | "simple_containers", 1699 | generator.metadata, 1700 | Column("id", INTEGER, primary_key=True), 1701 | ) 1702 | 1703 | validate_code( 1704 | generator.generate(), 1705 | """\ 1706 | from typing import Optional 1707 | 1708 | from sqlalchemy import ForeignKeyConstraint, Integer 1709 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 1710 | 1711 | class Base(DeclarativeBase): 1712 | pass 1713 | 1714 | 1715 | class SimpleContainers(Base): 1716 | __tablename__ = 'simple_containers' 1717 | 1718 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1719 | 1720 | simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 1721 | back_populates='container') 1722 | 1723 | 1724 | class SimpleItems(Base): 1725 | __tablename__ = 'simple_items' 1726 | __table_args__ = ( 1727 | ForeignKeyConstraint(['container_id'], ['simple_containers.id'], \ 1728 | name='foreignkeytest'), 1729 | ) 1730 | 1731 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1732 | container_id: Mapped[Optional[int]] = mapped_column(Integer) 1733 | 1734 | container: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ 1735 | back_populates='simple_items') 1736 | """, 1737 | ) 1738 | 1739 | 1740 | @pytest.mark.parametrize("generator", [["noidsuffix"]], indirect=True) 1741 | def test_named_foreign_key_constraints_with_noidsuffix( 1742 | generator: CodeGenerator, 1743 | ) -> None: 1744 | Table( 1745 | "simple_items", 1746 | generator.metadata, 1747 | Column("id", INTEGER, primary_key=True), 1748 | Column("container_id", INTEGER), 1749 | ForeignKeyConstraint( 1750 | ["container_id"], ["simple_containers.id"], name="foreignkeytest" 1751 | ), 1752 | ) 1753 | Table( 1754 | "simple_containers", 1755 | generator.metadata, 1756 | Column("id", INTEGER, primary_key=True), 1757 | ) 1758 | 1759 | validate_code( 1760 | generator.generate(), 1761 | """\ 1762 | from typing import Optional 1763 | 1764 | from sqlalchemy import ForeignKeyConstraint, Integer 1765 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 1766 | 1767 | class Base(DeclarativeBase): 1768 | pass 1769 | 1770 | 1771 | class SimpleContainers(Base): 1772 | __tablename__ = 'simple_containers' 1773 | 1774 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1775 | 1776 | simple_items: Mapped[list['SimpleItems']] = relationship('SimpleItems', \ 1777 | back_populates='simple_containers') 1778 | 1779 | 1780 | class SimpleItems(Base): 1781 | __tablename__ = 'simple_items' 1782 | __table_args__ = ( 1783 | ForeignKeyConstraint(['container_id'], ['simple_containers.id'], \ 1784 | name='foreignkeytest'), 1785 | ) 1786 | 1787 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1788 | container_id: Mapped[Optional[int]] = mapped_column(Integer) 1789 | 1790 | simple_containers: Mapped[Optional['SimpleContainers']] = relationship('SimpleContainers', \ 1791 | back_populates='simple_items') 1792 | """, 1793 | ) 1794 | 1795 | 1796 | # @pytest.mark.xfail(strict=True) 1797 | def test_colname_import_conflict(generator: CodeGenerator) -> None: 1798 | Table( 1799 | "simple", 1800 | generator.metadata, 1801 | Column("id", INTEGER, primary_key=True), 1802 | Column("text", VARCHAR), 1803 | Column("textwithdefault", VARCHAR, server_default=text("'test'")), 1804 | ) 1805 | 1806 | validate_code( 1807 | generator.generate(), 1808 | """\ 1809 | from typing import Optional 1810 | 1811 | from sqlalchemy import Integer, String, text 1812 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1813 | 1814 | class Base(DeclarativeBase): 1815 | pass 1816 | 1817 | 1818 | class Simple(Base): 1819 | __tablename__ = 'simple' 1820 | 1821 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1822 | text_: Mapped[Optional[str]] = mapped_column('text', String) 1823 | textwithdefault: Mapped[Optional[str]] = mapped_column(String, \ 1824 | server_default=text("'test'")) 1825 | """, 1826 | ) 1827 | 1828 | 1829 | def test_table_with_arrays(generator: CodeGenerator) -> None: 1830 | Table( 1831 | "with_items", 1832 | generator.metadata, 1833 | Column("id", INTEGER, primary_key=True), 1834 | Column("int_items_not_optional", ARRAY(INTEGER()), nullable=False), 1835 | Column("str_matrix", ARRAY(VARCHAR(), dimensions=2)), 1836 | ) 1837 | 1838 | validate_code( 1839 | generator.generate(), 1840 | """\ 1841 | from typing import Optional 1842 | 1843 | from sqlalchemy import ARRAY, INTEGER, Integer, VARCHAR 1844 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1845 | 1846 | class Base(DeclarativeBase): 1847 | pass 1848 | 1849 | 1850 | class WithItems(Base): 1851 | __tablename__ = 'with_items' 1852 | 1853 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1854 | int_items_not_optional: Mapped[list[int]] = mapped_column(ARRAY(INTEGER()), nullable=False) 1855 | str_matrix: Mapped[Optional[list[list[str]]]] = mapped_column(ARRAY(VARCHAR(), dimensions=2)) 1856 | """, 1857 | ) 1858 | 1859 | 1860 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 1861 | def test_domain_json(generator: CodeGenerator) -> None: 1862 | Table( 1863 | "test_domain_json", 1864 | generator.metadata, 1865 | Column("id", BIGINT, primary_key=True), 1866 | Column( 1867 | "foo", 1868 | postgresql.DOMAIN( 1869 | "domain_json", 1870 | JSON, 1871 | not_null=False, 1872 | ), 1873 | nullable=True, 1874 | ), 1875 | ) 1876 | 1877 | validate_code( 1878 | generator.generate(), 1879 | """\ 1880 | from typing import Optional 1881 | 1882 | from sqlalchemy import BigInteger 1883 | from sqlalchemy.dialects.postgresql import DOMAIN, JSON 1884 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1885 | 1886 | class Base(DeclarativeBase): 1887 | pass 1888 | 1889 | 1890 | class TestDomainJson(Base): 1891 | __tablename__ = 'test_domain_json' 1892 | 1893 | id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 1894 | foo: Mapped[Optional[dict]] = mapped_column(DOMAIN('domain_json', JSON(), not_null=False)) 1895 | """, 1896 | ) 1897 | 1898 | 1899 | @pytest.mark.parametrize( 1900 | "domain_type", 1901 | [JSONB, JSON], 1902 | ) 1903 | def test_domain_non_default_json( 1904 | generator: CodeGenerator, 1905 | domain_type: type[JSON] | type[JSONB], 1906 | ) -> None: 1907 | Table( 1908 | "test_domain_json", 1909 | generator.metadata, 1910 | Column("id", BIGINT, primary_key=True), 1911 | Column( 1912 | "foo", 1913 | postgresql.DOMAIN( 1914 | "domain_json", 1915 | domain_type(astext_type=Text(128)), 1916 | not_null=False, 1917 | ), 1918 | nullable=True, 1919 | ), 1920 | ) 1921 | 1922 | validate_code( 1923 | generator.generate(), 1924 | f"""\ 1925 | from typing import Optional 1926 | 1927 | from sqlalchemy import BigInteger, Text 1928 | from sqlalchemy.dialects.postgresql import DOMAIN, {domain_type.__name__} 1929 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1930 | 1931 | class Base(DeclarativeBase): 1932 | pass 1933 | 1934 | 1935 | class TestDomainJson(Base): 1936 | __tablename__ = 'test_domain_json' 1937 | 1938 | id: Mapped[int] = mapped_column(BigInteger, primary_key=True) 1939 | foo: Mapped[Optional[dict]] = mapped_column(DOMAIN('domain_json', {domain_type.__name__}(astext_type=Text(length=128)), not_null=False)) 1940 | """, 1941 | ) 1942 | 1943 | 1944 | @pytest.mark.skipif( 1945 | sys.version_info < (3, 10), 1946 | reason="This test assumes GeoAlchemy2 0.18.x and above, which does not support python 3.9", 1947 | ) 1948 | @pytest.mark.parametrize("engine", ["postgresql"], indirect=["engine"]) 1949 | def test_geoalchemy2_types(generator: CodeGenerator) -> None: 1950 | Table( 1951 | "spatial_table", 1952 | generator.metadata, 1953 | Column("id", INTEGER, primary_key=True), 1954 | Column("geom", Geometry("POINT", srid=4326, dimension=2), nullable=False), 1955 | Column("geog", Geography("POLYGON", dimension=2)), 1956 | ) 1957 | 1958 | validate_code( 1959 | generator.generate(), 1960 | """\ 1961 | from typing import Any, Optional 1962 | 1963 | from geoalchemy2.types import Geography, Geometry 1964 | from sqlalchemy import Index, Integer 1965 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 1966 | 1967 | class Base(DeclarativeBase): 1968 | pass 1969 | 1970 | 1971 | class SpatialTable(Base): 1972 | __tablename__ = 'spatial_table' 1973 | __table_args__ = ( 1974 | Index('idx_spatial_table_geog', 'geog'), 1975 | Index('idx_spatial_table_geom', 'geom') 1976 | ) 1977 | 1978 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 1979 | geom: Mapped[Any] = mapped_column(Geometry('POINT', 4326, 2, from_text='ST_GeomFromEWKT', name='geometry', nullable=False), nullable=False) 1980 | geog: Mapped[Optional[Any]] = mapped_column(Geography('POLYGON', dimension=2, from_text='ST_GeogFromText', name='geography')) 1981 | """, 1982 | ) 1983 | --------------------------------------------------------------------------------