├── 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 |
--------------------------------------------------------------------------------