• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

agronholm / sqlacodegen / 19762862247

28 Nov 2025 11:43AM UTC coverage: 97.36% (-0.3%) from 97.64%
19762862247

push

github

web-flow
Add support for rendering dialect kwargs and info, and introduce keep-dialect-types option (#438)

* [Add support for rendering dialect kwargs and info; introduce keep-dialect-types option

### Summary
- Render `Table`/`Column` dialect-specific kwargs and `info` in
generated code.
- Add `keep-dialect-types` generator option to preserve dialect-specific
column types instead of adapting to generic SQLAlchemy types.

### Motivation
- Some dialect features (e.g., engine/storage params, partitioning,
materialized view metadata) are carried in SQLAlchemy `dialect_kwargs`
or `info` but weren’t emitted by sqlacodegen.
- For custom dialects with their own Column types, the current type
“adaptation” step collapses them into generic SQLAlchemy types, losing
fidelity.

### What’s changed
- Render dialect kwargs and info
- Table (Core): include table-level `dialect_kwargs` and `info` in
`Table(...)` kwargs.
- Column (Core/ORM): include column-level `dialect_kwargs` and `info`
in `Column(...)` / `mapped_column(...)`.
- Declarative: include table-level `dialect_kwargs` and `info` in
`__table_args__` dict.
- New option: keep-dialect-types
  - Generator option: `keep_dialect_types`.
  - CLI flag: `--options=keep_dialect_types`.
- Behavior: gates only the type adaptation step in
`fix_column_types()`; Boolean/Enum inference and PostgreSQL sequence
handling remain intact.

### Usage examples

- Core (Table)

```python
t_orders = Table(
    'orders',
    metadata,
    Column('id', INTEGER, primary_key=True, starrocks_aggr_type='SUM'),
    schema='public',
    comment='orders table',

    info={'table_kind': 'view'}
)
```

- Declarative (`__table_args__`)

```python
class Orders(Base):
    __tablename__ = 'orders'
    __table_args__ = (
        {
            'schema': 'public',
            'comment': 'orders table',
            'info': {'table_kind': 'view'},
	    'starrocks_properties': {'replication_num': '1'},
        }
    )
id: Mapped[INTEGER] = mapped_column(primary_... (continued)

71 of 77 new or added lines in 3 files covered. (92.21%)

1512 of 1553 relevant lines covered (97.36%)

4.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.61
/src/sqlacodegen/generators.py
1
from __future__ import annotations
5✔
2

3
import inspect
5✔
4
import re
5✔
5
import sys
5✔
6
from abc import ABCMeta, abstractmethod
5✔
7
from collections import defaultdict
5✔
8
from collections.abc import Collection, Iterable, Sequence
5✔
9
from dataclasses import dataclass
5✔
10
from importlib import import_module
5✔
11
from inspect import Parameter
5✔
12
from itertools import count
5✔
13
from keyword import iskeyword
5✔
14
from pprint import pformat
5✔
15
from textwrap import indent
5✔
16
from typing import Any, ClassVar, Literal, cast
5✔
17

18
import inflect
5✔
19
import sqlalchemy
5✔
20
from sqlalchemy import (
5✔
21
    ARRAY,
22
    Boolean,
23
    CheckConstraint,
24
    Column,
25
    Computed,
26
    Constraint,
27
    DefaultClause,
28
    Enum,
29
    ForeignKey,
30
    ForeignKeyConstraint,
31
    Identity,
32
    Index,
33
    MetaData,
34
    PrimaryKeyConstraint,
35
    String,
36
    Table,
37
    Text,
38
    TypeDecorator,
39
    UniqueConstraint,
40
)
41
from sqlalchemy.dialects.postgresql import DOMAIN, JSON, JSONB
5✔
42
from sqlalchemy.engine import Connection, Engine
5✔
43
from sqlalchemy.exc import CompileError
5✔
44
from sqlalchemy.sql.elements import TextClause
5✔
45
from sqlalchemy.sql.type_api import UserDefinedType
5✔
46
from sqlalchemy.types import TypeEngine
5✔
47

48
from .models import (
5✔
49
    ColumnAttribute,
50
    JoinType,
51
    Model,
52
    ModelClass,
53
    RelationshipAttribute,
54
    RelationshipType,
55
)
56
from .utils import (
5✔
57
    decode_postgresql_sequence,
58
    get_column_names,
59
    get_common_fk_constraints,
60
    get_compiled_expression,
61
    get_constraint_sort_key,
62
    get_stdlib_module_names,
63
    qualified_table_name,
64
    render_callable,
65
    uses_default_name,
66
)
67

68
_re_boolean_check_constraint = re.compile(r"(?:.*?\.)?(.*?) IN \(0, 1\)")
5✔
69
_re_column_name = re.compile(r'(?:(["`]?).*\1\.)?(["`]?)(.*)\2')
5✔
70
_re_enum_check_constraint = re.compile(r"(?:.*?\.)?(.*?) IN \((.+)\)")
5✔
71
_re_enum_item = re.compile(r"'(.*?)(?<!\\)'")
5✔
72
_re_invalid_identifier = re.compile(r"(?u)\W")
5✔
73

74

75
@dataclass
5✔
76
class LiteralImport:
5✔
77
    pkgname: str
5✔
78
    name: str
5✔
79

80

81
@dataclass
5✔
82
class Base:
5✔
83
    """Representation of MetaData for Tables, respectively Base for classes"""
84

85
    literal_imports: list[LiteralImport]
5✔
86
    declarations: list[str]
5✔
87
    metadata_ref: str
5✔
88
    decorator: str | None = None
5✔
89
    table_metadata_declaration: str | None = None
5✔
90

91

92
class CodeGenerator(metaclass=ABCMeta):
5✔
93
    valid_options: ClassVar[set[str]] = set()
5✔
94

95
    def __init__(
5✔
96
        self, metadata: MetaData, bind: Connection | Engine, options: Sequence[str]
97
    ):
98
        self.metadata: MetaData = metadata
5✔
99
        self.bind: Connection | Engine = bind
5✔
100
        self.options: set[str] = set(options)
5✔
101

102
        # Validate options
103
        invalid_options = {opt for opt in options if opt not in self.valid_options}
5✔
104
        if invalid_options:
5✔
105
            raise ValueError("Unrecognized options: " + ", ".join(invalid_options))
×
106

107
    @property
5✔
108
    @abstractmethod
5✔
109
    def views_supported(self) -> bool:
5✔
110
        pass
×
111

112
    @abstractmethod
5✔
113
    def generate(self) -> str:
5✔
114
        """
115
        Generate the code for the given metadata.
116
        .. note:: May modify the metadata.
117
        """
118

119

120
@dataclass(eq=False)
5✔
121
class TablesGenerator(CodeGenerator):
5✔
122
    valid_options: ClassVar[set[str]] = {
5✔
123
        "noindexes",
124
        "noconstraints",
125
        "nocomments",
126
        "include_dialect_options",
127
        "keep_dialect_types",
128
    }
129
    stdlib_module_names: ClassVar[set[str]] = get_stdlib_module_names()
5✔
130

131
    def __init__(
5✔
132
        self,
133
        metadata: MetaData,
134
        bind: Connection | Engine,
135
        options: Sequence[str],
136
        *,
137
        indentation: str = "    ",
138
    ):
139
        super().__init__(metadata, bind, options)
5✔
140
        self.indentation: str = indentation
5✔
141
        self.imports: dict[str, set[str]] = defaultdict(set)
5✔
142
        self.module_imports: set[str] = set()
5✔
143

144
        # Render SchemaItem.info and dialect kwargs (Table/Column) into output
145
        self.include_dialect_options_and_info: bool = (
5✔
146
            "include_dialect_options" in self.options
147
        )
148
        # Keep dialect-specific types instead of adapting to generic SQLAlchemy types
149
        self.keep_dialect_types: bool = "keep_dialect_types" in self.options
5✔
150

151
    @property
5✔
152
    def views_supported(self) -> bool:
5✔
153
        return True
×
154

155
    def generate_base(self) -> None:
5✔
156
        self.base = Base(
5✔
157
            literal_imports=[LiteralImport("sqlalchemy", "MetaData")],
158
            declarations=["metadata = MetaData()"],
159
            metadata_ref="metadata",
160
        )
161

162
    def generate(self) -> str:
5✔
163
        self.generate_base()
5✔
164

165
        sections: list[str] = []
5✔
166

167
        # Remove unwanted elements from the metadata
168
        for table in list(self.metadata.tables.values()):
5✔
169
            if self.should_ignore_table(table):
5✔
170
                self.metadata.remove(table)
×
171
                continue
×
172

173
            if "noindexes" in self.options:
5✔
174
                table.indexes.clear()
5✔
175

176
            if "noconstraints" in self.options:
5✔
177
                table.constraints.clear()
5✔
178

179
            if "nocomments" in self.options:
5✔
180
                table.comment = None
5✔
181

182
            for column in table.columns:
5✔
183
                if "nocomments" in self.options:
5✔
184
                    column.comment = None
5✔
185

186
        # Use information from column constraints to figure out the intended column
187
        # types
188
        for table in self.metadata.tables.values():
5✔
189
            self.fix_column_types(table)
5✔
190

191
        # Generate the models
192
        models: list[Model] = self.generate_models()
5✔
193

194
        # Render module level variables
195
        variables = self.render_module_variables(models)
5✔
196
        if variables:
5✔
197
            sections.append(variables + "\n")
5✔
198

199
        # Render models
200
        rendered_models = self.render_models(models)
5✔
201
        if rendered_models:
5✔
202
            sections.append(rendered_models)
5✔
203

204
        # Render collected imports
205
        groups = self.group_imports()
5✔
206
        imports = "\n\n".join("\n".join(line for line in group) for group in groups)
5✔
207
        if imports:
5✔
208
            sections.insert(0, imports)
5✔
209

210
        return "\n\n".join(sections) + "\n"
5✔
211

212
    def collect_imports(self, models: Iterable[Model]) -> None:
5✔
213
        for literal_import in self.base.literal_imports:
5✔
214
            self.add_literal_import(literal_import.pkgname, literal_import.name)
5✔
215

216
        for model in models:
5✔
217
            self.collect_imports_for_model(model)
5✔
218

219
    def collect_imports_for_model(self, model: Model) -> None:
5✔
220
        if model.__class__ is Model:
5✔
221
            self.add_import(Table)
5✔
222

223
        for column in model.table.c:
5✔
224
            self.collect_imports_for_column(column)
5✔
225

226
        for constraint in model.table.constraints:
5✔
227
            self.collect_imports_for_constraint(constraint)
5✔
228

229
        for index in model.table.indexes:
5✔
230
            self.collect_imports_for_constraint(index)
5✔
231

232
    def collect_imports_for_column(self, column: Column[Any]) -> None:
5✔
233
        self.add_import(column.type)
5✔
234

235
        if isinstance(column.type, ARRAY):
5✔
236
            self.add_import(column.type.item_type.__class__)
5✔
237
        elif isinstance(column.type, (JSONB, JSON)):
5✔
238
            if (
5✔
239
                not isinstance(column.type.astext_type, Text)
240
                or column.type.astext_type.length is not None
241
            ):
242
                self.add_import(column.type.astext_type)
5✔
243
        elif isinstance(column.type, DOMAIN):
5✔
244
            self.add_import(column.type.data_type.__class__)
5✔
245

246
        if column.default:
5✔
247
            self.add_import(column.default)
5✔
248

249
        if column.server_default:
5✔
250
            if isinstance(column.server_default, (Computed, Identity)):
5✔
251
                self.add_import(column.server_default)
5✔
252
            elif isinstance(column.server_default, DefaultClause):
5✔
253
                self.add_literal_import("sqlalchemy", "text")
5✔
254

255
    def collect_imports_for_constraint(self, constraint: Constraint | Index) -> None:
5✔
256
        if isinstance(constraint, Index):
5✔
257
            if len(constraint.columns) > 1 or not uses_default_name(constraint):
5✔
258
                self.add_literal_import("sqlalchemy", "Index")
5✔
259
        elif isinstance(constraint, PrimaryKeyConstraint):
5✔
260
            if not uses_default_name(constraint):
5✔
261
                self.add_literal_import("sqlalchemy", "PrimaryKeyConstraint")
5✔
262
        elif isinstance(constraint, UniqueConstraint):
5✔
263
            if len(constraint.columns) > 1 or not uses_default_name(constraint):
5✔
264
                self.add_literal_import("sqlalchemy", "UniqueConstraint")
5✔
265
        elif isinstance(constraint, ForeignKeyConstraint):
5✔
266
            if len(constraint.columns) > 1 or not uses_default_name(constraint):
5✔
267
                self.add_literal_import("sqlalchemy", "ForeignKeyConstraint")
5✔
268
            else:
269
                self.add_import(ForeignKey)
5✔
270
        else:
271
            self.add_import(constraint)
5✔
272

273
    def add_import(self, obj: Any) -> None:
5✔
274
        # Don't store builtin imports
275
        if getattr(obj, "__module__", "builtins") == "builtins":
5✔
276
            return
×
277

278
        type_ = type(obj) if not isinstance(obj, type) else obj
5✔
279
        pkgname = type_.__module__
5✔
280

281
        # The column types have already been adapted towards generic types if possible,
282
        # so if this is still a vendor specific type (e.g., MySQL INTEGER) be sure to
283
        # use that rather than the generic sqlalchemy type as it might have different
284
        # constructor parameters.
285
        if pkgname.startswith("sqlalchemy.dialects."):
5✔
286
            dialect_pkgname = ".".join(pkgname.split(".")[0:3])
5✔
287
            dialect_pkg = import_module(dialect_pkgname)
5✔
288

289
            if type_.__name__ in dialect_pkg.__all__:
5✔
290
                pkgname = dialect_pkgname
5✔
291
        elif type_ is getattr(sqlalchemy, type_.__name__, None):
5✔
292
            pkgname = "sqlalchemy"
5✔
293
        else:
294
            pkgname = type_.__module__
5✔
295

296
        self.add_literal_import(pkgname, type_.__name__)
5✔
297

298
    def add_literal_import(self, pkgname: str, name: str) -> None:
5✔
299
        names = self.imports.setdefault(pkgname, set())
5✔
300
        names.add(name)
5✔
301

302
    def remove_literal_import(self, pkgname: str, name: str) -> None:
5✔
303
        names = self.imports.setdefault(pkgname, set())
5✔
304
        if name in names:
5✔
305
            names.remove(name)
×
306

307
    def add_module_import(self, pgkname: str) -> None:
5✔
308
        self.module_imports.add(pgkname)
5✔
309

310
    def group_imports(self) -> list[list[str]]:
5✔
311
        future_imports: list[str] = []
5✔
312
        stdlib_imports: list[str] = []
5✔
313
        thirdparty_imports: list[str] = []
5✔
314

315
        def get_collection(package: str) -> list[str]:
5✔
316
            collection = thirdparty_imports
5✔
317
            if package == "__future__":
5✔
318
                collection = future_imports
×
319
            elif package in self.stdlib_module_names:
5✔
320
                collection = stdlib_imports
5✔
321
            elif package in sys.modules:
5✔
322
                if "site-packages" not in (sys.modules[package].__file__ or ""):
5✔
323
                    collection = stdlib_imports
5✔
324
            return collection
5✔
325

326
        for package in sorted(self.imports):
5✔
327
            imports = ", ".join(sorted(self.imports[package]))
5✔
328

329
            collection = get_collection(package)
5✔
330
            collection.append(f"from {package} import {imports}")
5✔
331

332
        for module in sorted(self.module_imports):
5✔
333
            collection = get_collection(module)
5✔
334
            collection.append(f"import {module}")
5✔
335

336
        return [
5✔
337
            group
338
            for group in (future_imports, stdlib_imports, thirdparty_imports)
339
            if group
340
        ]
341

342
    def generate_models(self) -> list[Model]:
5✔
343
        models = [Model(table) for table in self.metadata.sorted_tables]
5✔
344

345
        # Collect the imports
346
        self.collect_imports(models)
5✔
347

348
        # Generate names for models
349
        global_names = {
5✔
350
            name for namespace in self.imports.values() for name in namespace
351
        }
352
        for model in models:
5✔
353
            self.generate_model_name(model, global_names)
5✔
354
            global_names.add(model.name)
5✔
355

356
        return models
5✔
357

358
    def generate_model_name(self, model: Model, global_names: set[str]) -> None:
5✔
359
        preferred_name = f"t_{model.table.name}"
5✔
360
        model.name = self.find_free_name(preferred_name, global_names)
5✔
361

362
    def render_module_variables(self, models: list[Model]) -> str:
5✔
363
        declarations = self.base.declarations
5✔
364

365
        if any(not isinstance(model, ModelClass) for model in models):
5✔
366
            if self.base.table_metadata_declaration is not None:
5✔
367
                declarations.append(self.base.table_metadata_declaration)
×
368

369
        return "\n".join(declarations)
5✔
370

371
    def render_models(self, models: list[Model]) -> str:
5✔
372
        rendered: list[str] = []
5✔
373
        for model in models:
5✔
374
            rendered_table = self.render_table(model.table)
5✔
375
            rendered.append(f"{model.name} = {rendered_table}")
5✔
376

377
        return "\n\n".join(rendered)
5✔
378

379
    def render_table(self, table: Table) -> str:
5✔
380
        args: list[str] = [f"{table.name!r}, {self.base.metadata_ref}"]
5✔
381
        kwargs: dict[str, object] = {}
5✔
382
        for column in table.columns:
5✔
383
            # Cast is required because of a bug in the SQLAlchemy stubs regarding
384
            # Table.columns
385
            args.append(self.render_column(column, True, is_table=True))
5✔
386

387
        for constraint in sorted(table.constraints, key=get_constraint_sort_key):
5✔
388
            if uses_default_name(constraint):
5✔
389
                if isinstance(constraint, PrimaryKeyConstraint):
5✔
390
                    continue
5✔
391
                elif isinstance(constraint, (ForeignKeyConstraint, UniqueConstraint)):
5✔
392
                    if len(constraint.columns) == 1:
5✔
393
                        continue
5✔
394

395
            args.append(self.render_constraint(constraint))
5✔
396

397
        for index in sorted(table.indexes, key=lambda i: cast(str, i.name)):
5✔
398
            # One-column indexes should be rendered as index=True on columns
399
            if len(index.columns) > 1 or not uses_default_name(index):
5✔
400
                args.append(self.render_index(index))
5✔
401

402
        if table.schema:
5✔
403
            kwargs["schema"] = repr(table.schema)
5✔
404

405
        table_comment = getattr(table, "comment", None)
5✔
406
        if table_comment:
5✔
407
            kwargs["comment"] = repr(table.comment)
5✔
408

409
        # add info + dialect kwargs for callable context (opt-in)
410
        if self.include_dialect_options_and_info:
5✔
411
            self._add_dialect_kwargs_and_info(table, kwargs, values_for_dict=False)
5✔
412

413
        return render_callable("Table", *args, kwargs=kwargs, indentation="    ")
5✔
414

415
    def render_index(self, index: Index) -> str:
5✔
416
        extra_args = [repr(col.name) for col in index.columns]
5✔
417
        kwargs = {}
5✔
418
        if index.unique:
5✔
419
            kwargs["unique"] = True
5✔
420

421
        return render_callable("Index", repr(index.name), *extra_args, kwargs=kwargs)
5✔
422

423
    # TODO find better solution for is_table
424
    def render_column(
5✔
425
        self, column: Column[Any], show_name: bool, is_table: bool = False
426
    ) -> str:
427
        args = []
5✔
428
        kwargs: dict[str, Any] = {}
5✔
429
        kwarg = []
5✔
430
        is_part_of_composite_pk = (
5✔
431
            column.primary_key and len(column.table.primary_key) > 1
432
        )
433
        dedicated_fks = [
5✔
434
            c
435
            for c in column.foreign_keys
436
            if c.constraint
437
            and len(c.constraint.columns) == 1
438
            and uses_default_name(c.constraint)
439
        ]
440
        is_unique = any(
5✔
441
            isinstance(c, UniqueConstraint)
442
            and set(c.columns) == {column}
443
            and uses_default_name(c)
444
            for c in column.table.constraints
445
        )
446
        is_unique = is_unique or any(
5✔
447
            i.unique and set(i.columns) == {column} and uses_default_name(i)
448
            for i in column.table.indexes
449
        )
450
        is_primary = (
5✔
451
            any(
452
                isinstance(c, PrimaryKeyConstraint)
453
                and column.name in c.columns
454
                and uses_default_name(c)
455
                for c in column.table.constraints
456
            )
457
            or column.primary_key
458
        )
459
        has_index = any(
5✔
460
            set(i.columns) == {column} and uses_default_name(i)
461
            for i in column.table.indexes
462
        )
463

464
        if show_name:
5✔
465
            args.append(repr(column.name))
5✔
466

467
        # Render the column type if there are no foreign keys on it or any of them
468
        # points back to itself
469
        if not dedicated_fks or any(fk.column is column for fk in dedicated_fks):
5✔
470
            args.append(self.render_column_type(column.type))
5✔
471

472
        for fk in dedicated_fks:
5✔
473
            args.append(self.render_constraint(fk))
5✔
474

475
        if column.default:
5✔
476
            args.append(repr(column.default))
5✔
477

478
        if column.key != column.name:
5✔
479
            kwargs["key"] = column.key
×
480
        if is_primary:
5✔
481
            kwargs["primary_key"] = True
5✔
482
        if not column.nullable and not column.primary_key:
5✔
483
            kwargs["nullable"] = False
5✔
484
        if column.nullable and is_part_of_composite_pk:
5✔
485
            kwargs["nullable"] = True
5✔
486

487
        if is_unique:
5✔
488
            column.unique = True
5✔
489
            kwargs["unique"] = True
5✔
490
        if has_index:
5✔
491
            column.index = True
5✔
492
            kwarg.append("index")
5✔
493
            kwargs["index"] = True
5✔
494

495
        if isinstance(column.server_default, DefaultClause):
5✔
496
            kwargs["server_default"] = render_callable(
5✔
497
                "text", repr(cast(TextClause, column.server_default.arg).text)
498
            )
499
        elif isinstance(column.server_default, Computed):
5✔
500
            expression = str(column.server_default.sqltext)
5✔
501

502
            computed_kwargs = {}
5✔
503
            if column.server_default.persisted is not None:
5✔
504
                computed_kwargs["persisted"] = column.server_default.persisted
5✔
505

506
            args.append(
5✔
507
                render_callable("Computed", repr(expression), kwargs=computed_kwargs)
508
            )
509
        elif isinstance(column.server_default, Identity):
5✔
510
            args.append(repr(column.server_default))
5✔
511
        elif column.server_default:
5✔
512
            kwargs["server_default"] = repr(column.server_default)
×
513

514
        comment = getattr(column, "comment", None)
5✔
515
        if comment:
5✔
516
            kwargs["comment"] = repr(comment)
5✔
517

518
        # add column info + dialect kwargs for callable context (opt-in)
519
        if self.include_dialect_options_and_info:
5✔
520
            self._add_dialect_kwargs_and_info(column, kwargs, values_for_dict=False)
5✔
521

522
        return self.render_column_callable(is_table, *args, **kwargs)
5✔
523

524
    def render_column_callable(self, is_table: bool, *args: Any, **kwargs: Any) -> str:
5✔
525
        if is_table:
5✔
526
            self.add_import(Column)
5✔
527
            return render_callable("Column", *args, kwargs=kwargs)
5✔
528
        else:
529
            return render_callable("mapped_column", *args, kwargs=kwargs)
5✔
530

531
    def render_column_type(self, coltype: TypeEngine[Any]) -> str:
5✔
532
        args = []
5✔
533
        kwargs: dict[str, Any] = {}
5✔
534
        sig = inspect.signature(coltype.__class__.__init__)
5✔
535
        defaults = {param.name: param.default for param in sig.parameters.values()}
5✔
536
        missing = object()
5✔
537
        use_kwargs = False
5✔
538
        for param in list(sig.parameters.values())[1:]:
5✔
539
            # Remove annoyances like _warn_on_bytestring
540
            if param.name.startswith("_"):
5✔
541
                continue
5✔
542
            elif param.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD):
5✔
543
                use_kwargs = True
5✔
544
                continue
5✔
545

546
            value = getattr(coltype, param.name, missing)
5✔
547

548
            if isinstance(value, (JSONB, JSON)):
5✔
549
                # Remove astext_type if it's the default
550
                if (
5✔
551
                    isinstance(value.astext_type, Text)
552
                    and value.astext_type.length is None
553
                ):
554
                    value.astext_type = None  # type: ignore[assignment]
5✔
555
                else:
556
                    self.add_import(Text)
5✔
557

558
            default = defaults.get(param.name, missing)
5✔
559
            if isinstance(value, TextClause):
5✔
560
                self.add_literal_import("sqlalchemy", "text")
5✔
561
                rendered_value = render_callable("text", repr(value.text))
5✔
562
            else:
563
                rendered_value = repr(value)
5✔
564

565
            if value is missing or value == default:
5✔
566
                use_kwargs = True
5✔
567
            elif use_kwargs:
5✔
568
                kwargs[param.name] = rendered_value
5✔
569
            else:
570
                args.append(rendered_value)
5✔
571

572
        vararg = next(
5✔
573
            (
574
                param.name
575
                for param in sig.parameters.values()
576
                if param.kind is Parameter.VAR_POSITIONAL
577
            ),
578
            None,
579
        )
580
        if vararg and hasattr(coltype, vararg):
5✔
581
            varargs_repr = [repr(arg) for arg in getattr(coltype, vararg)]
5✔
582
            args.extend(varargs_repr)
5✔
583

584
        # These arguments cannot be autodetected from the Enum initializer
585
        if isinstance(coltype, Enum):
5✔
586
            for colname in "name", "schema":
5✔
587
                if (value := getattr(coltype, colname)) is not None:
5✔
588
                    kwargs[colname] = repr(value)
5✔
589

590
        if isinstance(coltype, (JSONB, JSON)):
5✔
591
            # Remove astext_type if it's the default
592
            if (
5✔
593
                isinstance(coltype.astext_type, Text)
594
                and coltype.astext_type.length is None
595
            ):
596
                del kwargs["astext_type"]
5✔
597

598
        if args or kwargs:
5✔
599
            return render_callable(coltype.__class__.__name__, *args, kwargs=kwargs)
5✔
600
        else:
601
            return coltype.__class__.__name__
5✔
602

603
    def render_constraint(self, constraint: Constraint | ForeignKey) -> str:
5✔
604
        def add_fk_options(*opts: Any) -> None:
5✔
605
            args.extend(repr(opt) for opt in opts)
5✔
606
            for attr in "ondelete", "onupdate", "deferrable", "initially", "match":
5✔
607
                value = getattr(constraint, attr, None)
5✔
608
                if value:
5✔
609
                    kwargs[attr] = repr(value)
5✔
610

611
        args: list[str] = []
5✔
612
        kwargs: dict[str, Any] = {}
5✔
613
        if isinstance(constraint, ForeignKey):
5✔
614
            remote_column = (
5✔
615
                f"{constraint.column.table.fullname}.{constraint.column.name}"
616
            )
617
            add_fk_options(remote_column)
5✔
618
        elif isinstance(constraint, ForeignKeyConstraint):
5✔
619
            local_columns = get_column_names(constraint)
5✔
620
            remote_columns = [
5✔
621
                f"{fk.column.table.fullname}.{fk.column.name}"
622
                for fk in constraint.elements
623
            ]
624
            add_fk_options(local_columns, remote_columns)
5✔
625
        elif isinstance(constraint, CheckConstraint):
5✔
626
            args.append(repr(get_compiled_expression(constraint.sqltext, self.bind)))
5✔
627
        elif isinstance(constraint, (UniqueConstraint, PrimaryKeyConstraint)):
5✔
628
            args.extend(repr(col.name) for col in constraint.columns)
5✔
629
        else:
630
            raise TypeError(
×
631
                f"Cannot render constraint of type {constraint.__class__.__name__}"
632
            )
633

634
        if isinstance(constraint, Constraint) and not uses_default_name(constraint):
5✔
635
            kwargs["name"] = repr(constraint.name)
5✔
636

637
        return render_callable(constraint.__class__.__name__, *args, kwargs=kwargs)
5✔
638

639
    def _add_dialect_kwargs_and_info(
5✔
640
        self, obj: Any, target_kwargs: dict[str, object], *, values_for_dict: bool
641
    ) -> None:
642
        """
643
        Merge SchemaItem-like object's .info and .dialect_kwargs into target_kwargs.
644
        - values_for_dict=True: keep raw values so pretty-printer emits repr() (for __table_args__ dict)
645
        - values_for_dict=False: set values to repr() strings (for callable kwargs)
646
        """
647
        info_dict = getattr(obj, "info", None)
5✔
648
        if info_dict:
5✔
649
            target_kwargs["info"] = info_dict if values_for_dict else repr(info_dict)
5✔
650

651
        dialect_keys: list[str]
652
        try:
5✔
653
            dialect_keys = sorted(getattr(obj, "dialect_kwargs"))
5✔
NEW
654
        except Exception:
×
NEW
655
            return
×
656

657
        dialect_kwargs = getattr(obj, "dialect_kwargs", {})
5✔
658
        for key in dialect_keys:
5✔
659
            try:
5✔
660
                value = dialect_kwargs[key]
5✔
NEW
661
            except Exception:
×
NEW
662
                continue
×
663

664
            # Render values:
665
            # - callable context (values_for_dict=False): produce a string expression.
666
            #   primitives use repr(value); custom objects stringify then repr().
667
            # - dict context (values_for_dict=True): pass raw primitives / str;
668
            #   custom objects become str(value) so pformat quotes them.
669
            if values_for_dict:
5✔
670
                if isinstance(value, type(None) | bool | int | float):
5✔
NEW
671
                    target_kwargs[key] = value
×
672
                elif isinstance(value, str | dict | list):
5✔
673
                    target_kwargs[key] = value
5✔
674
                else:
675
                    target_kwargs[key] = str(value)
5✔
676
            else:
677
                if isinstance(
5✔
678
                    value, type(None) | bool | int | float | str | dict | list
679
                ):
680
                    target_kwargs[key] = repr(value)
5✔
681
                else:
682
                    target_kwargs[key] = repr(str(value))
5✔
683

684
    def should_ignore_table(self, table: Table) -> bool:
5✔
685
        # Support for Alembic and sqlalchemy-migrate -- never expose the schema version
686
        # tables
687
        return table.name in ("alembic_version", "migrate_version")
5✔
688

689
    def find_free_name(
5✔
690
        self, name: str, global_names: set[str], local_names: Collection[str] = ()
691
    ) -> str:
692
        """
693
        Generate an attribute name that does not clash with other local or global names.
694
        """
695
        name = name.strip()
5✔
696
        assert name, "Identifier cannot be empty"
5✔
697
        name = _re_invalid_identifier.sub("_", name)
5✔
698
        if name[0].isdigit():
5✔
699
            name = "_" + name
5✔
700
        elif iskeyword(name) or name == "metadata":
5✔
701
            name += "_"
5✔
702

703
        original = name
5✔
704
        for i in count():
5✔
705
            if name not in global_names and name not in local_names:
5✔
706
                break
5✔
707

708
            name = original + (str(i) if i else "_")
5✔
709

710
        return name
5✔
711

712
    def fix_column_types(self, table: Table) -> None:
5✔
713
        """Adjust the reflected column types."""
714
        # Detect check constraints for boolean and enum columns
715
        for constraint in table.constraints.copy():
5✔
716
            if isinstance(constraint, CheckConstraint):
5✔
717
                sqltext = get_compiled_expression(constraint.sqltext, self.bind)
5✔
718

719
                # Turn any integer-like column with a CheckConstraint like
720
                # "column IN (0, 1)" into a Boolean
721
                match = _re_boolean_check_constraint.match(sqltext)
5✔
722
                if match:
5✔
723
                    colname_match = _re_column_name.match(match.group(1))
5✔
724
                    if colname_match:
5✔
725
                        colname = colname_match.group(3)
5✔
726
                        table.constraints.remove(constraint)
5✔
727
                        table.c[colname].type = Boolean()
5✔
728
                        continue
5✔
729

730
                # Turn any string-type column with a CheckConstraint like
731
                # "column IN (...)" into an Enum
732
                match = _re_enum_check_constraint.match(sqltext)
5✔
733
                if match:
5✔
734
                    colname_match = _re_column_name.match(match.group(1))
5✔
735
                    if colname_match:
5✔
736
                        colname = colname_match.group(3)
5✔
737
                        items = match.group(2)
5✔
738
                        if isinstance(table.c[colname].type, String):
5✔
739
                            table.constraints.remove(constraint)
5✔
740
                            if not isinstance(table.c[colname].type, Enum):
5✔
741
                                options = _re_enum_item.findall(items)
5✔
742
                                table.c[colname].type = Enum(
5✔
743
                                    *options, native_enum=False
744
                                )
745

746
                            continue
5✔
747

748
        for column in table.c:
5✔
749
            if not self.keep_dialect_types:
5✔
750
                try:
5✔
751
                    column.type = self.get_adapted_type(column.type)
5✔
752
                except CompileError:
5✔
753
                    continue
5✔
754

755
            # PostgreSQL specific fix: detect sequences from server_default
756
            if column.server_default and self.bind.dialect.name == "postgresql":
5✔
757
                if isinstance(column.server_default, DefaultClause) and isinstance(
5✔
758
                    column.server_default.arg, TextClause
759
                ):
760
                    schema, seqname = decode_postgresql_sequence(
5✔
761
                        column.server_default.arg
762
                    )
763
                    if seqname:
5✔
764
                        # Add an explicit sequence
765
                        if seqname != f"{column.table.name}_{column.name}_seq":
5✔
766
                            column.default = sqlalchemy.Sequence(seqname, schema=schema)
5✔
767

768
                        column.server_default = None
5✔
769

770
    def get_adapted_type(self, coltype: Any) -> Any:
5✔
771
        compiled_type = coltype.compile(self.bind.engine.dialect)
5✔
772
        for supercls in coltype.__class__.__mro__:
5✔
773
            if not supercls.__name__.startswith("_") and hasattr(
5✔
774
                supercls, "__visit_name__"
775
            ):
776
                # Don't try to adapt UserDefinedType as it's not a proper column type
777
                if supercls is UserDefinedType or issubclass(supercls, TypeDecorator):
5✔
778
                    return coltype
5✔
779

780
                # Hack to fix adaptation of the Enum class which is broken since
781
                # SQLAlchemy 1.2
782
                kw = {}
5✔
783
                if supercls is Enum:
5✔
784
                    kw["name"] = coltype.name
5✔
785
                    if coltype.schema:
5✔
786
                        kw["schema"] = coltype.schema
5✔
787

788
                # Hack to fix Postgres DOMAIN type adaptation, broken as of SQLAlchemy 2.0.42
789
                # For additional information - https://github.com/agronholm/sqlacodegen/issues/416#issuecomment-3417480599
790
                if supercls is DOMAIN:
5✔
791
                    if coltype.default:
5✔
792
                        kw["default"] = coltype.default
×
793
                    if coltype.constraint_name is not None:
5✔
794
                        kw["constraint_name"] = coltype.constraint_name
5✔
795
                    if coltype.not_null:
5✔
796
                        kw["not_null"] = coltype.not_null
×
797
                    if coltype.check is not None:
5✔
798
                        kw["check"] = coltype.check
5✔
799
                    if coltype.create_type:
5✔
800
                        kw["create_type"] = coltype.create_type
5✔
801

802
                try:
5✔
803
                    new_coltype = coltype.adapt(supercls)
5✔
804
                except TypeError:
5✔
805
                    # If the adaptation fails, don't try again
806
                    break
5✔
807

808
                for key, value in kw.items():
5✔
809
                    setattr(new_coltype, key, value)
5✔
810

811
                if isinstance(coltype, ARRAY):
5✔
812
                    new_coltype.item_type = self.get_adapted_type(new_coltype.item_type)
5✔
813

814
                try:
5✔
815
                    # If the adapted column type does not render the same as the
816
                    # original, don't substitute it
817
                    if new_coltype.compile(self.bind.engine.dialect) != compiled_type:
5✔
818
                        break
5✔
819
                except CompileError:
5✔
820
                    # If the adapted column type can't be compiled, don't substitute it
821
                    break
5✔
822

823
                # Stop on the first valid non-uppercase column type class
824
                coltype = new_coltype
5✔
825
                if supercls.__name__ != supercls.__name__.upper():
5✔
826
                    break
5✔
827

828
        return coltype
5✔
829

830

831
class DeclarativeGenerator(TablesGenerator):
5✔
832
    valid_options: ClassVar[set[str]] = TablesGenerator.valid_options | {
5✔
833
        "use_inflect",
834
        "nojoined",
835
        "nobidi",
836
        "noidsuffix",
837
    }
838

839
    def __init__(
5✔
840
        self,
841
        metadata: MetaData,
842
        bind: Connection | Engine,
843
        options: Sequence[str],
844
        *,
845
        indentation: str = "    ",
846
        base_class_name: str = "Base",
847
    ):
848
        super().__init__(metadata, bind, options, indentation=indentation)
5✔
849
        self.base_class_name: str = base_class_name
5✔
850
        self.inflect_engine = inflect.engine()
5✔
851

852
    def generate_base(self) -> None:
5✔
853
        self.base = Base(
5✔
854
            literal_imports=[LiteralImport("sqlalchemy.orm", "DeclarativeBase")],
855
            declarations=[
856
                f"class {self.base_class_name}(DeclarativeBase):",
857
                f"{self.indentation}pass",
858
            ],
859
            metadata_ref=f"{self.base_class_name}.metadata",
860
        )
861

862
    def collect_imports(self, models: Iterable[Model]) -> None:
5✔
863
        super().collect_imports(models)
5✔
864
        if any(isinstance(model, ModelClass) for model in models):
5✔
865
            self.add_literal_import("sqlalchemy.orm", "Mapped")
5✔
866
            self.add_literal_import("sqlalchemy.orm", "mapped_column")
5✔
867

868
    def collect_imports_for_model(self, model: Model) -> None:
5✔
869
        super().collect_imports_for_model(model)
5✔
870
        if isinstance(model, ModelClass):
5✔
871
            if model.relationships:
5✔
872
                self.add_literal_import("sqlalchemy.orm", "relationship")
5✔
873

874
    def generate_models(self) -> list[Model]:
5✔
875
        models_by_table_name: dict[str, Model] = {}
5✔
876

877
        # Pick association tables from the metadata into their own set, don't process
878
        # them normally
879
        links: defaultdict[str, list[Model]] = defaultdict(lambda: [])
5✔
880
        for table in self.metadata.sorted_tables:
5✔
881
            qualified_name = qualified_table_name(table)
5✔
882

883
            # Link tables have exactly two foreign key constraints and all columns are
884
            # involved in them
885
            fk_constraints = sorted(
5✔
886
                table.foreign_key_constraints, key=get_constraint_sort_key
887
            )
888
            if len(fk_constraints) == 2 and all(
5✔
889
                col.foreign_keys for col in table.columns
890
            ):
891
                model = models_by_table_name[qualified_name] = Model(table)
5✔
892
                tablename = fk_constraints[0].elements[0].column.table.name
5✔
893
                links[tablename].append(model)
5✔
894
                continue
5✔
895

896
            # Only form model classes for tables that have a primary key and are not
897
            # association tables
898
            if not table.primary_key:
5✔
899
                models_by_table_name[qualified_name] = Model(table)
5✔
900
            else:
901
                model = ModelClass(table)
5✔
902
                models_by_table_name[qualified_name] = model
5✔
903

904
                # Fill in the columns
905
                for column in table.c:
5✔
906
                    column_attr = ColumnAttribute(model, column)
5✔
907
                    model.columns.append(column_attr)
5✔
908

909
        # Add relationships
910
        for model in models_by_table_name.values():
5✔
911
            if isinstance(model, ModelClass):
5✔
912
                self.generate_relationships(
5✔
913
                    model, models_by_table_name, links[model.table.name]
914
                )
915

916
        # Nest inherited classes in their superclasses to ensure proper ordering
917
        if "nojoined" not in self.options:
5✔
918
            for model in list(models_by_table_name.values()):
5✔
919
                if not isinstance(model, ModelClass):
5✔
920
                    continue
5✔
921

922
                pk_column_names = {col.name for col in model.table.primary_key.columns}
5✔
923
                for constraint in model.table.foreign_key_constraints:
5✔
924
                    if set(get_column_names(constraint)) == pk_column_names:
5✔
925
                        target = models_by_table_name[
5✔
926
                            qualified_table_name(constraint.elements[0].column.table)
927
                        ]
928
                        if isinstance(target, ModelClass):
5✔
929
                            model.parent_class = target
5✔
930
                            target.children.append(model)
5✔
931

932
        # Change base if we only have tables
933
        if not any(
5✔
934
            isinstance(model, ModelClass) for model in models_by_table_name.values()
935
        ):
936
            super().generate_base()
5✔
937

938
        # Collect the imports
939
        self.collect_imports(models_by_table_name.values())
5✔
940

941
        # Rename models and their attributes that conflict with imports or other
942
        # attributes
943
        global_names = {
5✔
944
            name for namespace in self.imports.values() for name in namespace
945
        }
946
        for model in models_by_table_name.values():
5✔
947
            self.generate_model_name(model, global_names)
5✔
948
            global_names.add(model.name)
5✔
949

950
        return list(models_by_table_name.values())
5✔
951

952
    def generate_relationships(
5✔
953
        self,
954
        source: ModelClass,
955
        models_by_table_name: dict[str, Model],
956
        association_tables: list[Model],
957
    ) -> list[RelationshipAttribute]:
958
        relationships: list[RelationshipAttribute] = []
5✔
959
        reverse_relationship: RelationshipAttribute | None
960

961
        # Add many-to-one (and one-to-many) relationships
962
        pk_column_names = {col.name for col in source.table.primary_key.columns}
5✔
963
        for constraint in sorted(
5✔
964
            source.table.foreign_key_constraints, key=get_constraint_sort_key
965
        ):
966
            target = models_by_table_name[
5✔
967
                qualified_table_name(constraint.elements[0].column.table)
968
            ]
969
            if isinstance(target, ModelClass):
5✔
970
                if "nojoined" not in self.options:
5✔
971
                    if set(get_column_names(constraint)) == pk_column_names:
5✔
972
                        parent = models_by_table_name[
5✔
973
                            qualified_table_name(constraint.elements[0].column.table)
974
                        ]
975
                        if isinstance(parent, ModelClass):
5✔
976
                            source.parent_class = parent
5✔
977
                            parent.children.append(source)
5✔
978
                            continue
5✔
979

980
                # Add uselist=False to One-to-One relationships
981
                column_names = get_column_names(constraint)
5✔
982
                if any(
5✔
983
                    isinstance(c, (PrimaryKeyConstraint, UniqueConstraint))
984
                    and {col.name for col in c.columns} == set(column_names)
985
                    for c in constraint.table.constraints
986
                ):
987
                    r_type = RelationshipType.ONE_TO_ONE
5✔
988
                else:
989
                    r_type = RelationshipType.MANY_TO_ONE
5✔
990

991
                relationship = RelationshipAttribute(r_type, source, target, constraint)
5✔
992
                source.relationships.append(relationship)
5✔
993

994
                # For self referential relationships, remote_side needs to be set
995
                if source is target:
5✔
996
                    relationship.remote_side = [
5✔
997
                        source.get_column_attribute(col.name)
998
                        for col in constraint.referred_table.primary_key
999
                    ]
1000

1001
                # If the two tables share more than one foreign key constraint,
1002
                # SQLAlchemy needs an explicit primaryjoin to figure out which column(s)
1003
                # it needs
1004
                common_fk_constraints = get_common_fk_constraints(
5✔
1005
                    source.table, target.table
1006
                )
1007
                if len(common_fk_constraints) > 1:
5✔
1008
                    relationship.foreign_keys = [
5✔
1009
                        source.get_column_attribute(key)
1010
                        for key in constraint.column_keys
1011
                    ]
1012

1013
                # Generate the opposite end of the relationship in the target class
1014
                if "nobidi" not in self.options:
5✔
1015
                    if r_type is RelationshipType.MANY_TO_ONE:
5✔
1016
                        r_type = RelationshipType.ONE_TO_MANY
5✔
1017

1018
                    reverse_relationship = RelationshipAttribute(
5✔
1019
                        r_type,
1020
                        target,
1021
                        source,
1022
                        constraint,
1023
                        foreign_keys=relationship.foreign_keys,
1024
                        backref=relationship,
1025
                    )
1026
                    relationship.backref = reverse_relationship
5✔
1027
                    target.relationships.append(reverse_relationship)
5✔
1028

1029
                    # For self referential relationships, remote_side needs to be set
1030
                    if source is target:
5✔
1031
                        reverse_relationship.remote_side = [
5✔
1032
                            source.get_column_attribute(colname)
1033
                            for colname in constraint.column_keys
1034
                        ]
1035

1036
        # Add many-to-many relationships
1037
        for association_table in association_tables:
5✔
1038
            fk_constraints = sorted(
5✔
1039
                association_table.table.foreign_key_constraints,
1040
                key=get_constraint_sort_key,
1041
            )
1042
            target = models_by_table_name[
5✔
1043
                qualified_table_name(fk_constraints[1].elements[0].column.table)
1044
            ]
1045
            if isinstance(target, ModelClass):
5✔
1046
                relationship = RelationshipAttribute(
5✔
1047
                    RelationshipType.MANY_TO_MANY,
1048
                    source,
1049
                    target,
1050
                    fk_constraints[1],
1051
                    association_table,
1052
                )
1053
                source.relationships.append(relationship)
5✔
1054

1055
                # Generate the opposite end of the relationship in the target class
1056
                reverse_relationship = None
5✔
1057
                if "nobidi" not in self.options:
5✔
1058
                    reverse_relationship = RelationshipAttribute(
5✔
1059
                        RelationshipType.MANY_TO_MANY,
1060
                        target,
1061
                        source,
1062
                        fk_constraints[0],
1063
                        association_table,
1064
                        relationship,
1065
                    )
1066
                    relationship.backref = reverse_relationship
5✔
1067
                    target.relationships.append(reverse_relationship)
5✔
1068

1069
                # Add a primary/secondary join for self-referential many-to-many
1070
                # relationships
1071
                if source is target:
5✔
1072
                    both_relationships = [relationship]
5✔
1073
                    reverse_flags = [False, True]
5✔
1074
                    if reverse_relationship:
5✔
1075
                        both_relationships.append(reverse_relationship)
5✔
1076

1077
                    for relationship, reverse in zip(both_relationships, reverse_flags):
5✔
1078
                        if (
5✔
1079
                            not relationship.association_table
1080
                            or not relationship.constraint
1081
                        ):
1082
                            continue
×
1083

1084
                        constraints = sorted(
5✔
1085
                            relationship.constraint.table.foreign_key_constraints,
1086
                            key=get_constraint_sort_key,
1087
                            reverse=reverse,
1088
                        )
1089
                        pri_pairs = zip(
5✔
1090
                            get_column_names(constraints[0]), constraints[0].elements
1091
                        )
1092
                        sec_pairs = zip(
5✔
1093
                            get_column_names(constraints[1]), constraints[1].elements
1094
                        )
1095
                        relationship.primaryjoin = [
5✔
1096
                            (
1097
                                relationship.source,
1098
                                elem.column.name,
1099
                                relationship.association_table,
1100
                                col,
1101
                            )
1102
                            for col, elem in pri_pairs
1103
                        ]
1104
                        relationship.secondaryjoin = [
5✔
1105
                            (
1106
                                relationship.target,
1107
                                elem.column.name,
1108
                                relationship.association_table,
1109
                                col,
1110
                            )
1111
                            for col, elem in sec_pairs
1112
                        ]
1113

1114
        return relationships
5✔
1115

1116
    def generate_model_name(self, model: Model, global_names: set[str]) -> None:
5✔
1117
        if isinstance(model, ModelClass):
5✔
1118
            preferred_name = _re_invalid_identifier.sub("_", model.table.name)
5✔
1119
            preferred_name = "".join(
5✔
1120
                part[:1].upper() + part[1:] for part in preferred_name.split("_")
1121
            )
1122
            if "use_inflect" in self.options:
5✔
1123
                singular_name = self.inflect_engine.singular_noun(preferred_name)
5✔
1124
                if singular_name:
5✔
1125
                    preferred_name = singular_name
5✔
1126

1127
            model.name = self.find_free_name(preferred_name, global_names)
5✔
1128

1129
            # Fill in the names for column attributes
1130
            local_names: set[str] = set()
5✔
1131
            for column_attr in model.columns:
5✔
1132
                self.generate_column_attr_name(column_attr, global_names, local_names)
5✔
1133
                local_names.add(column_attr.name)
5✔
1134

1135
            # Fill in the names for relationship attributes
1136
            for relationship in model.relationships:
5✔
1137
                self.generate_relationship_name(relationship, global_names, local_names)
5✔
1138
                local_names.add(relationship.name)
5✔
1139
        else:
1140
            super().generate_model_name(model, global_names)
5✔
1141

1142
    def generate_column_attr_name(
5✔
1143
        self,
1144
        column_attr: ColumnAttribute,
1145
        global_names: set[str],
1146
        local_names: set[str],
1147
    ) -> None:
1148
        column_attr.name = self.find_free_name(
5✔
1149
            column_attr.column.name, global_names, local_names
1150
        )
1151

1152
    def generate_relationship_name(
5✔
1153
        self,
1154
        relationship: RelationshipAttribute,
1155
        global_names: set[str],
1156
        local_names: set[str],
1157
    ) -> None:
1158
        # Self referential reverse relationships
1159
        preferred_name: str
1160
        if (
5✔
1161
            relationship.type
1162
            in (RelationshipType.ONE_TO_MANY, RelationshipType.ONE_TO_ONE)
1163
            and relationship.source is relationship.target
1164
            and relationship.backref
1165
            and relationship.backref.name
1166
        ):
1167
            preferred_name = relationship.backref.name + "_reverse"
5✔
1168
        else:
1169
            preferred_name = relationship.target.table.name
5✔
1170

1171
            # If there's a constraint with a single column that ends with "_id", use the
1172
            # preceding part as the relationship name
1173
            if relationship.constraint and "noidsuffix" not in self.options:
5✔
1174
                is_source = relationship.source.table is relationship.constraint.table
5✔
1175
                if is_source or relationship.type not in (
5✔
1176
                    RelationshipType.ONE_TO_ONE,
1177
                    RelationshipType.ONE_TO_MANY,
1178
                ):
1179
                    column_names = [c.name for c in relationship.constraint.columns]
5✔
1180
                    if len(column_names) == 1 and column_names[0].endswith("_id"):
5✔
1181
                        preferred_name = column_names[0][:-3]
5✔
1182

1183
            if "use_inflect" in self.options:
5✔
1184
                inflected_name: str | Literal[False]
1185
                if relationship.type in (
5✔
1186
                    RelationshipType.ONE_TO_MANY,
1187
                    RelationshipType.MANY_TO_MANY,
1188
                ):
1189
                    if not self.inflect_engine.singular_noun(preferred_name):
5✔
1190
                        preferred_name = self.inflect_engine.plural_noun(preferred_name)
×
1191
                else:
1192
                    inflected_name = self.inflect_engine.singular_noun(preferred_name)
5✔
1193
                    if inflected_name:
5✔
1194
                        preferred_name = inflected_name
5✔
1195

1196
        relationship.name = self.find_free_name(
5✔
1197
            preferred_name, global_names, local_names
1198
        )
1199

1200
    def render_models(self, models: list[Model]) -> str:
5✔
1201
        rendered: list[str] = []
5✔
1202
        for model in models:
5✔
1203
            if isinstance(model, ModelClass):
5✔
1204
                rendered.append(self.render_class(model))
5✔
1205
            else:
1206
                rendered.append(f"{model.name} = {self.render_table(model.table)}")
5✔
1207

1208
        return "\n\n\n".join(rendered)
5✔
1209

1210
    def render_class(self, model: ModelClass) -> str:
5✔
1211
        sections: list[str] = []
5✔
1212

1213
        # Render class variables / special declarations
1214
        class_vars: str = self.render_class_variables(model)
5✔
1215
        if class_vars:
5✔
1216
            sections.append(class_vars)
5✔
1217

1218
        # Render column attributes
1219
        rendered_column_attributes: list[str] = []
5✔
1220
        for nullable in (False, True):
5✔
1221
            for column_attr in model.columns:
5✔
1222
                if column_attr.column.nullable is nullable:
5✔
1223
                    rendered_column_attributes.append(
5✔
1224
                        self.render_column_attribute(column_attr)
1225
                    )
1226

1227
        if rendered_column_attributes:
5✔
1228
            sections.append("\n".join(rendered_column_attributes))
5✔
1229

1230
        # Render relationship attributes
1231
        rendered_relationship_attributes: list[str] = [
5✔
1232
            self.render_relationship(relationship)
1233
            for relationship in model.relationships
1234
        ]
1235

1236
        if rendered_relationship_attributes:
5✔
1237
            sections.append("\n".join(rendered_relationship_attributes))
5✔
1238

1239
        declaration = self.render_class_declaration(model)
5✔
1240
        rendered_sections = "\n\n".join(
5✔
1241
            indent(section, self.indentation) for section in sections
1242
        )
1243
        return f"{declaration}\n{rendered_sections}"
5✔
1244

1245
    def render_class_declaration(self, model: ModelClass) -> str:
5✔
1246
        parent_class_name = (
5✔
1247
            model.parent_class.name if model.parent_class else self.base_class_name
1248
        )
1249
        return f"class {model.name}({parent_class_name}):"
5✔
1250

1251
    def render_class_variables(self, model: ModelClass) -> str:
5✔
1252
        variables = [f"__tablename__ = {model.table.name!r}"]
5✔
1253

1254
        # Render constraints and indexes as __table_args__
1255
        table_args = self.render_table_args(model.table)
5✔
1256
        if table_args:
5✔
1257
            variables.append(f"__table_args__ = {table_args}")
5✔
1258

1259
        return "\n".join(variables)
5✔
1260

1261
    def render_table_args(self, table: Table) -> str:
5✔
1262
        args: list[str] = []
5✔
1263
        kwargs: dict[str, object] = {}
5✔
1264

1265
        # Render constraints
1266
        for constraint in sorted(table.constraints, key=get_constraint_sort_key):
5✔
1267
            if uses_default_name(constraint):
5✔
1268
                if isinstance(constraint, PrimaryKeyConstraint):
5✔
1269
                    continue
5✔
1270
                if (
5✔
1271
                    isinstance(constraint, (ForeignKeyConstraint, UniqueConstraint))
1272
                    and len(constraint.columns) == 1
1273
                ):
1274
                    continue
5✔
1275

1276
            args.append(self.render_constraint(constraint))
5✔
1277

1278
        # Render indexes
1279
        for index in sorted(table.indexes, key=lambda i: cast(str, i.name)):
5✔
1280
            if len(index.columns) > 1 or not uses_default_name(index):
5✔
1281
                args.append(self.render_index(index))
5✔
1282

1283
        if table.schema:
5✔
1284
            kwargs["schema"] = table.schema
5✔
1285

1286
        if table.comment:
5✔
1287
            kwargs["comment"] = table.comment
5✔
1288

1289
        # add info + dialect kwargs for dict context (__table_args__) (opt-in)
1290
        if self.include_dialect_options_and_info:
5✔
1291
            self._add_dialect_kwargs_and_info(table, kwargs, values_for_dict=True)
5✔
1292

1293
        if kwargs:
5✔
1294
            formatted_kwargs = pformat(kwargs)
5✔
1295
            if not args:
5✔
1296
                return formatted_kwargs
5✔
1297
            else:
1298
                args.append(formatted_kwargs)
5✔
1299

1300
        if args:
5✔
1301
            rendered_args = f",\n{self.indentation}".join(args)
5✔
1302
            if len(args) == 1:
5✔
1303
                rendered_args += ","
5✔
1304

1305
            return f"(\n{self.indentation}{rendered_args}\n)"
5✔
1306
        else:
1307
            return ""
5✔
1308

1309
    def render_column_python_type(self, column: Column[Any]) -> str:
5✔
1310
        def get_type_qualifiers() -> tuple[str, TypeEngine[Any], str]:
5✔
1311
            column_type = column.type
5✔
1312
            pre: list[str] = []
5✔
1313
            post_size = 0
5✔
1314
            if column.nullable:
5✔
1315
                self.add_literal_import("typing", "Optional")
5✔
1316
                pre.append("Optional[")
5✔
1317
                post_size += 1
5✔
1318

1319
            if isinstance(column_type, ARRAY):
5✔
1320
                dim = getattr(column_type, "dimensions", None) or 1
5✔
1321
                pre.extend("list[" for _ in range(dim))
5✔
1322
                post_size += dim
5✔
1323

1324
                column_type = column_type.item_type
5✔
1325

1326
            return "".join(pre), column_type, "]" * post_size
5✔
1327

1328
        def render_python_type(column_type: TypeEngine[Any]) -> str:
5✔
1329
            if isinstance(column_type, DOMAIN):
5✔
1330
                column_type = column_type.data_type
5✔
1331

1332
            try:
5✔
1333
                python_type = column_type.python_type
5✔
1334
                python_type_module = python_type.__module__
5✔
1335
                python_type_name = python_type.__name__
5✔
1336
            except NotImplementedError:
5✔
1337
                self.add_literal_import("typing", "Any")
5✔
1338
                return "Any"
5✔
1339

1340
            if python_type_module == "builtins":
5✔
1341
                return python_type_name
5✔
1342

1343
            self.add_module_import(python_type_module)
5✔
1344
            return f"{python_type_module}.{python_type_name}"
5✔
1345

1346
        pre, col_type, post = get_type_qualifiers()
5✔
1347
        column_python_type = f"{pre}{render_python_type(col_type)}{post}"
5✔
1348
        return column_python_type
5✔
1349

1350
    def render_column_attribute(self, column_attr: ColumnAttribute) -> str:
5✔
1351
        column = column_attr.column
5✔
1352
        rendered_column = self.render_column(column, column_attr.name != column.name)
5✔
1353
        rendered_column_python_type = self.render_column_python_type(column)
5✔
1354

1355
        return f"{column_attr.name}: Mapped[{rendered_column_python_type}] = {rendered_column}"
5✔
1356

1357
    def render_relationship(self, relationship: RelationshipAttribute) -> str:
5✔
1358
        def render_column_attrs(column_attrs: list[ColumnAttribute]) -> str:
5✔
1359
            rendered = []
5✔
1360
            for attr in column_attrs:
5✔
1361
                if attr.model is relationship.source:
5✔
1362
                    rendered.append(attr.name)
5✔
1363
                else:
1364
                    rendered.append(repr(f"{attr.model.name}.{attr.name}"))
×
1365

1366
            return "[" + ", ".join(rendered) + "]"
5✔
1367

1368
        def render_foreign_keys(column_attrs: list[ColumnAttribute]) -> str:
5✔
1369
            rendered = []
5✔
1370
            render_as_string = False
5✔
1371
            # Assume that column_attrs are all in relationship.source or none
1372
            for attr in column_attrs:
5✔
1373
                if attr.model is relationship.source:
5✔
1374
                    rendered.append(attr.name)
5✔
1375
                else:
1376
                    rendered.append(f"{attr.model.name}.{attr.name}")
5✔
1377
                    render_as_string = True
5✔
1378

1379
            if render_as_string:
5✔
1380
                return "'[" + ", ".join(rendered) + "]'"
5✔
1381
            else:
1382
                return "[" + ", ".join(rendered) + "]"
5✔
1383

1384
        def render_join(terms: list[JoinType]) -> str:
5✔
1385
            rendered_joins = []
5✔
1386
            for source, source_col, target, target_col in terms:
5✔
1387
                rendered = f"lambda: {source.name}.{source_col} == {target.name}."
5✔
1388
                if target.__class__ is Model:
5✔
1389
                    rendered += "c."
5✔
1390

1391
                rendered += str(target_col)
5✔
1392
                rendered_joins.append(rendered)
5✔
1393

1394
            if len(rendered_joins) > 1:
5✔
1395
                rendered = ", ".join(rendered_joins)
×
1396
                return f"and_({rendered})"
×
1397
            else:
1398
                return rendered_joins[0]
5✔
1399

1400
        # Render keyword arguments
1401
        kwargs: dict[str, Any] = {}
5✔
1402
        if relationship.type is RelationshipType.ONE_TO_ONE and relationship.constraint:
5✔
1403
            if relationship.constraint.referred_table is relationship.source.table:
5✔
1404
                kwargs["uselist"] = False
5✔
1405

1406
        # Add the "secondary" keyword for many-to-many relationships
1407
        if relationship.association_table:
5✔
1408
            table_ref = relationship.association_table.table.name
5✔
1409
            if relationship.association_table.schema:
5✔
1410
                table_ref = f"{relationship.association_table.schema}.{table_ref}"
5✔
1411

1412
            kwargs["secondary"] = repr(table_ref)
5✔
1413

1414
        if relationship.remote_side:
5✔
1415
            kwargs["remote_side"] = render_column_attrs(relationship.remote_side)
5✔
1416

1417
        if relationship.foreign_keys:
5✔
1418
            kwargs["foreign_keys"] = render_foreign_keys(relationship.foreign_keys)
5✔
1419

1420
        if relationship.primaryjoin:
5✔
1421
            kwargs["primaryjoin"] = render_join(relationship.primaryjoin)
5✔
1422

1423
        if relationship.secondaryjoin:
5✔
1424
            kwargs["secondaryjoin"] = render_join(relationship.secondaryjoin)
5✔
1425

1426
        if relationship.backref:
5✔
1427
            kwargs["back_populates"] = repr(relationship.backref.name)
5✔
1428

1429
        rendered_relationship = render_callable(
5✔
1430
            "relationship", repr(relationship.target.name), kwargs=kwargs
1431
        )
1432

1433
        relationship_type: str
1434
        if relationship.type == RelationshipType.ONE_TO_MANY:
5✔
1435
            relationship_type = f"list['{relationship.target.name}']"
5✔
1436
        elif relationship.type in (
5✔
1437
            RelationshipType.ONE_TO_ONE,
1438
            RelationshipType.MANY_TO_ONE,
1439
        ):
1440
            relationship_type = f"'{relationship.target.name}'"
5✔
1441
            if relationship.constraint and any(
5✔
1442
                col.nullable for col in relationship.constraint.columns
1443
            ):
1444
                self.add_literal_import("typing", "Optional")
5✔
1445
                relationship_type = f"Optional[{relationship_type}]"
5✔
1446
        elif relationship.type == RelationshipType.MANY_TO_MANY:
5✔
1447
            relationship_type = f"list['{relationship.target.name}']"
5✔
1448
        else:
1449
            self.add_literal_import("typing", "Any")
×
1450
            relationship_type = "Any"
×
1451

1452
        return (
5✔
1453
            f"{relationship.name}: Mapped[{relationship_type}] "
1454
            f"= {rendered_relationship}"
1455
        )
1456

1457

1458
class DataclassGenerator(DeclarativeGenerator):
5✔
1459
    def __init__(
5✔
1460
        self,
1461
        metadata: MetaData,
1462
        bind: Connection | Engine,
1463
        options: Sequence[str],
1464
        *,
1465
        indentation: str = "    ",
1466
        base_class_name: str = "Base",
1467
        quote_annotations: bool = False,
1468
        metadata_key: str = "sa",
1469
    ):
1470
        super().__init__(
5✔
1471
            metadata,
1472
            bind,
1473
            options,
1474
            indentation=indentation,
1475
            base_class_name=base_class_name,
1476
        )
1477
        self.metadata_key: str = metadata_key
5✔
1478
        self.quote_annotations: bool = quote_annotations
5✔
1479

1480
    def generate_base(self) -> None:
5✔
1481
        self.base = Base(
5✔
1482
            literal_imports=[
1483
                LiteralImport("sqlalchemy.orm", "DeclarativeBase"),
1484
                LiteralImport("sqlalchemy.orm", "MappedAsDataclass"),
1485
            ],
1486
            declarations=[
1487
                (f"class {self.base_class_name}(MappedAsDataclass, DeclarativeBase):"),
1488
                f"{self.indentation}pass",
1489
            ],
1490
            metadata_ref=f"{self.base_class_name}.metadata",
1491
        )
1492

1493

1494
class SQLModelGenerator(DeclarativeGenerator):
5✔
1495
    def __init__(
5✔
1496
        self,
1497
        metadata: MetaData,
1498
        bind: Connection | Engine,
1499
        options: Sequence[str],
1500
        *,
1501
        indentation: str = "    ",
1502
        base_class_name: str = "SQLModel",
1503
    ):
1504
        super().__init__(
5✔
1505
            metadata,
1506
            bind,
1507
            options,
1508
            indentation=indentation,
1509
            base_class_name=base_class_name,
1510
        )
1511

1512
    @property
5✔
1513
    def views_supported(self) -> bool:
5✔
1514
        return False
×
1515

1516
    def render_column_callable(self, is_table: bool, *args: Any, **kwargs: Any) -> str:
5✔
1517
        self.add_import(Column)
5✔
1518
        return render_callable("Column", *args, kwargs=kwargs)
5✔
1519

1520
    def generate_base(self) -> None:
5✔
1521
        self.base = Base(
5✔
1522
            literal_imports=[],
1523
            declarations=[],
1524
            metadata_ref="",
1525
        )
1526

1527
    def collect_imports(self, models: Iterable[Model]) -> None:
5✔
1528
        super(DeclarativeGenerator, self).collect_imports(models)
5✔
1529
        if any(isinstance(model, ModelClass) for model in models):
5✔
1530
            self.remove_literal_import("sqlalchemy", "MetaData")
5✔
1531
            self.add_literal_import("sqlmodel", "SQLModel")
5✔
1532
            self.add_literal_import("sqlmodel", "Field")
5✔
1533

1534
    def collect_imports_for_model(self, model: Model) -> None:
5✔
1535
        super(DeclarativeGenerator, self).collect_imports_for_model(model)
5✔
1536
        if isinstance(model, ModelClass):
5✔
1537
            for column_attr in model.columns:
5✔
1538
                if column_attr.column.nullable:
5✔
1539
                    self.add_literal_import("typing", "Optional")
5✔
1540
                    break
5✔
1541

1542
            if model.relationships:
5✔
1543
                self.add_literal_import("sqlmodel", "Relationship")
5✔
1544

1545
    def render_module_variables(self, models: list[Model]) -> str:
5✔
1546
        declarations: list[str] = []
5✔
1547
        if any(not isinstance(model, ModelClass) for model in models):
5✔
1548
            if self.base.table_metadata_declaration is not None:
×
1549
                declarations.append(self.base.table_metadata_declaration)
×
1550

1551
        return "\n".join(declarations)
5✔
1552

1553
    def render_class_declaration(self, model: ModelClass) -> str:
5✔
1554
        if model.parent_class:
5✔
1555
            parent = model.parent_class.name
×
1556
        else:
1557
            parent = self.base_class_name
5✔
1558

1559
        superclass_part = f"({parent}, table=True)"
5✔
1560
        return f"class {model.name}{superclass_part}:"
5✔
1561

1562
    def render_class_variables(self, model: ModelClass) -> str:
5✔
1563
        variables = []
5✔
1564

1565
        if model.table.name != model.name.lower():
5✔
1566
            variables.append(f"__tablename__ = {model.table.name!r}")
5✔
1567

1568
        # Render constraints and indexes as __table_args__
1569
        table_args = self.render_table_args(model.table)
5✔
1570
        if table_args:
5✔
1571
            variables.append(f"__table_args__ = {table_args}")
5✔
1572

1573
        return "\n".join(variables)
5✔
1574

1575
    def render_column_attribute(self, column_attr: ColumnAttribute) -> str:
5✔
1576
        column = column_attr.column
5✔
1577
        rendered_column = self.render_column(column, True)
5✔
1578
        rendered_column_python_type = self.render_column_python_type(column)
5✔
1579

1580
        kwargs: dict[str, Any] = {}
5✔
1581
        if column.nullable:
5✔
1582
            kwargs["default"] = None
5✔
1583
        kwargs["sa_column"] = f"{rendered_column}"
5✔
1584

1585
        rendered_field = render_callable("Field", kwargs=kwargs)
5✔
1586

1587
        return f"{column_attr.name}: {rendered_column_python_type} = {rendered_field}"
5✔
1588

1589
    def render_relationship(self, relationship: RelationshipAttribute) -> str:
5✔
1590
        rendered = super().render_relationship(relationship).partition(" = ")[2]
5✔
1591
        args = self.render_relationship_args(rendered)
5✔
1592
        kwargs: dict[str, Any] = {}
5✔
1593
        annotation = repr(relationship.target.name)
5✔
1594

1595
        if relationship.type in (
5✔
1596
            RelationshipType.ONE_TO_MANY,
1597
            RelationshipType.MANY_TO_MANY,
1598
        ):
1599
            annotation = f"list[{annotation}]"
5✔
1600
        else:
1601
            self.add_literal_import("typing", "Optional")
5✔
1602
            annotation = f"Optional[{annotation}]"
5✔
1603

1604
        rendered_field = render_callable("Relationship", *args, kwargs=kwargs)
5✔
1605
        return f"{relationship.name}: {annotation} = {rendered_field}"
5✔
1606

1607
    def render_relationship_args(self, arguments: str) -> list[str]:
5✔
1608
        argument_list = arguments.split(",")
5✔
1609
        # delete ')' and ' ' from args
1610
        argument_list[-1] = argument_list[-1][:-1]
5✔
1611
        argument_list = [argument[1:] for argument in argument_list]
5✔
1612

1613
        rendered_args: list[str] = []
5✔
1614
        for arg in argument_list:
5✔
1615
            if "back_populates" in arg:
5✔
1616
                rendered_args.append(arg)
5✔
1617
            if "uselist=False" in arg:
5✔
1618
                rendered_args.append("sa_relationship_kwargs={'uselist': False}")
5✔
1619

1620
        return rendered_args
5✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc