Skip to content

Commit c0d6e4e

Browse files
authored
Merge pull request #547 from fastapiadmin/dev
fix(generator): 方言类型 import 修正 + jsonb 统一为 JSONType(关闭 #542 核心路径)
2 parents 95a9e30 + 30c0e63 commit c0d6e4e

4 files changed

Lines changed: 174 additions & 6 deletions

File tree

‎backend/app/common/constant.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,9 @@ class GenConstant:
277277
"timestamp without time zone": "DateTime",
278278
"interval": "Interval",
279279
"json": "JSON",
280-
"jsonb": "JSONB",
280+
# jsonb 使用基类的跨方言类型(PG → JSONB,SQLite/MySQL → JSON),
281+
# 不再硬编码 JSONB:后者在非 PG 方言下无法渲染,且 JSONB 不在顶层 sqlalchemy 命名空间
282+
"jsonb": "JSONType",
281283
"uuid": "Uuid",
282284
"inet": "INET",
283285
"cidr": "CIDR",
@@ -289,8 +291,8 @@ class GenConstant:
289291
"path": "Geometry",
290292
"polygon": "Geometry",
291293
"circle": "Geometry",
292-
"bit": "Bit",
293-
"bit varying": "Bit",
294+
"bit": "BIT",
295+
"bit varying": "BIT",
294296
"tsvector": "TSVECTOR",
295297
"tsquery": "TSQUERY",
296298
"xml": "String",

‎backend/app/modules/generator/gencode/jinja2_template_util.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
GenTableOutSchema,
1818
)
1919
from .template_safety import (
20+
DIALECT_TYPE_IMPORTS,
2021
column_type_base,
2122
escape_template_text,
2223
html_attr,
@@ -696,9 +697,11 @@ def get_model_import_list(cls, gen_table: GenTableOutSchema, *, is_sub_entity: b
696697
continue
697698
if column.column_type:
698699
data_type = cls.get_db_type(column.column_type)
699-
if data_type in GenConstant.COLUMNTYPE_GEOMETRY:
700-
import_list.add("from geoalchemy2 import Geometry")
701-
import_list.add(f"from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, data_type)}")
700+
# 非顶层导出的类型(JSONB/INET/CIDR/MACADDR/BIT/TSVECTOR/TSQUERY/Geometry 等)
701+
# 走明确的模块映射,绝不再拼 `from sqlalchemy import <方言类型>`(非法语句)
702+
mapped = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, data_type)
703+
module, type_name = DIALECT_TYPE_IMPORTS.get(mapped, ("sqlalchemy", mapped))
704+
import_list.add(f"from {module} import {type_name}")
702705
# 处理datetime类型的导入
703706
if column.python_type and column.python_type in GenConstant.TYPE_DATE:
704707
if column.python_type == "datetime":

‎backend/app/modules/generator/gencode/template_safety.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,21 @@ def assert_python_syntax(content: str, *, filename: str = "<generated>") -> None
311311
# 避免对全大写常量二次赋值(类型检查会报 reportConstantRedefinition,也是不必要的可变性)。
312312
_SQLALCHEMY_TYPE_WHITELIST: frozenset[str] = frozenset(GenConstant.DB_TO_SQLALCHEMY.values()) | {"Boolean"}
313313

314+
# 非顶层 sqlalchemy 导出的类型 → (模块, 类名)。
315+
# 生成器只按此表拼 import,避免产出 `from sqlalchemy import JSONB` 这类非法语句:
316+
# JSONB / INET / CIDR / MACADDR / BIT / TSVECTOR / TSQUERY 实际位于
317+
# sqlalchemy.dialects.postgresql;JSONType 是基类提供的跨方言类型;Geometry 位于 geoalchemy2。
318+
DIALECT_TYPE_IMPORTS: dict[str, tuple[str, str]] = {
319+
"JSONType": ("app.core.base_model", "JSONType"),
320+
"INET": ("sqlalchemy.dialects.postgresql", "INET"),
321+
"CIDR": ("sqlalchemy.dialects.postgresql", "CIDR"),
322+
"MACADDR": ("sqlalchemy.dialects.postgresql", "MACADDR"),
323+
"BIT": ("sqlalchemy.dialects.postgresql", "BIT"),
324+
"TSVECTOR": ("sqlalchemy.dialects.postgresql", "TSVECTOR"),
325+
"TSQUERY": ("sqlalchemy.dialects.postgresql", "TSQUERY"),
326+
"Geometry": ("geoalchemy2", "Geometry"),
327+
}
328+
314329

315330
def _lookup_sqlalchemy_type(base: str) -> str:
316331
"""按基名(大小写不敏感)在常量白名单中查表。

‎backend/tests/test_generator_template_safety.py‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"""
1212

1313
import ast
14+
from pathlib import Path
1415
from typing import Any
1516

1617
import pytest
@@ -544,3 +545,150 @@ async def test_all_templates_pass_structural_gate_with_hostile_text() -> None:
544545
assert_python_structure(real, ref, filename=name)
545546
checked += 1
546547
assert checked == 13 # 10 主模板 + 3 子表模板
548+
549+
# ══════════════════════════════════════════════════════════════════════════════
550+
# 方言类型与跨方言 JSON(issue #542)
551+
# 生成的 import 必须可解析;jsonb 必须走基类 JSONType 而非硬编码 JSONB。
552+
# 注意:GenConstant.DB_TO_SQLALCHEMY 按 settings.DATABASE_TYPE 条件定义,
553+
# 因此 PG 专有类型用 DATABASE_TYPE=postgres 的子进程验证(本进程 conftest 设为 sqlite)。
554+
# ══════════════════════════════════════════════════════════════════════════════
555+
556+
_BACKEND_DIR = Path(__file__).resolve().parents[1]
557+
558+
_POSTGRES_PROBE = r'''
559+
import asyncio, json
560+
from app.modules.generator.gencode.schema import GenTableColumnOutSchema, GenTableOutSchema
561+
from app.modules.generator.gencode.jinja2_template_util import Jinja2TemplateUtil
562+
563+
564+
def _col(name, column_type, python_type, **kw):
565+
data = dict(table_id=1, column_name=name, column_comment="x", column_type=column_type, column_length=None,
566+
column_default="", is_pk=False, is_increment=False, is_nullable=True, is_unique=False,
567+
python_type=python_type, python_field=name, is_insert=True, is_edit=True, is_list=True,
568+
is_query=False, query_type=None, html_type="input", dict_type="", sort=1)
569+
data.update(kw)
570+
return GenTableColumnOutSchema(**data)
571+
572+
573+
def _table(cols):
574+
return GenTableOutSchema(table_name="gen_demo", table_comment="示例", class_name="Demo",
575+
package_name="module_gen", module_name="demo", business_name="demo", function_name="示例",
576+
columns=cols, parent_menu_id=None, sub_table_name=None, sub_table_fk_name=None,
577+
pk_column=cols[0], sub=False, sub_table=None)
578+
579+
580+
result = {}
581+
for ct in ("jsonb", "inet", "cidr", "macaddr", "bit", "tsvector", "tsquery", "point"):
582+
table = _table([_col("payload", ct, "dict")])
583+
result[ct] = {
584+
"imports": Jinja2TemplateUtil.get_model_import_list(table),
585+
"type": Jinja2TemplateUtil.get_sqlalchemy_type(table.columns[0]),
586+
}
587+
588+
589+
async def _render_model():
590+
table = _table([_col("id", "integer", "int", is_pk=True, is_increment=True, sort=1),
591+
_col("payload", "jsonb", "dict", sort=2)])
592+
env = Jinja2TemplateUtil.get_env()
593+
ctx = Jinja2TemplateUtil.prepare_context(table)
594+
for tpl in Jinja2TemplateUtil.get_template_list():
595+
src = await env.get_template(tpl).render_async(**ctx)
596+
if "class Demo" in src and "__tablename__" in src:
597+
return src
598+
return ""
599+
600+
601+
result["model_source"] = asyncio.run(_render_model())
602+
print("__RESULT__" + json.dumps(result))
603+
'''
604+
605+
606+
def _postgres_dialect_probe() -> dict[str, Any]:
607+
"""在 DATABASE_TYPE=postgres 的子进程里跑生成器,返回各类型的 import 与渲染结果。"""
608+
import json
609+
import os
610+
import subprocess
611+
import sys
612+
613+
env = {**os.environ, "DATABASE_TYPE": "postgres"}
614+
proc = subprocess.run(
615+
[sys.executable, "-c", _POSTGRES_PROBE],
616+
cwd=_BACKEND_DIR,
617+
env=env,
618+
capture_output=True,
619+
text=True,
620+
timeout=180,
621+
)
622+
assert proc.returncode == 0, f"PG 方言子进程失败: {proc.stderr[-2000:]}"
623+
line = next(line for line in proc.stdout.splitlines() if line.startswith("__RESULT__"))
624+
return json.loads(line[len("__RESULT__") :])
625+
626+
627+
def test_dialect_type_import_map_targets_resolve() -> None:
628+
"""DIALECT_TYPE_IMPORTS 的每个 (模块, 类名) 都必须真实存在(几何类型是可选依赖,跳过)。"""
629+
import importlib
630+
631+
from app.modules.generator.gencode.template_safety import DIALECT_TYPE_IMPORTS
632+
633+
for type_name, (module, symbol) in DIALECT_TYPE_IMPORTS.items():
634+
if module == "geoalchemy2":
635+
continue
636+
mod = importlib.import_module(module)
637+
assert hasattr(mod, symbol), f"{type_name}: {module}.{symbol} 不存在"
638+
639+
640+
def test_postgres_dialect_types_emit_valid_imports() -> None:
641+
"""PG 方言下:方言类型从正确模块导入,且不产生 `from sqlalchemy import <方言类型>`。"""
642+
result = _postgres_dialect_probe()
643+
expected = {
644+
"jsonb": ("app.core.base_model", "JSONType"),
645+
"inet": ("sqlalchemy.dialects.postgresql", "INET"),
646+
"cidr": ("sqlalchemy.dialects.postgresql", "CIDR"),
647+
"macaddr": ("sqlalchemy.dialects.postgresql", "MACADDR"),
648+
"bit": ("sqlalchemy.dialects.postgresql", "BIT"),
649+
"tsvector": ("sqlalchemy.dialects.postgresql", "TSVECTOR"),
650+
"tsquery": ("sqlalchemy.dialects.postgresql", "TSQUERY"),
651+
"point": ("geoalchemy2", "Geometry"),
652+
}
653+
for column_type, (module, symbol) in expected.items():
654+
imports = result[column_type]["imports"]
655+
assert f"from {module} import {symbol}" in imports, f"{column_type} 的 import 不正确: {imports}"
656+
assert f"from sqlalchemy import {symbol}" not in imports, f"{column_type} 仍产生非法顶层 import"
657+
658+
659+
def test_postgres_jsonb_column_renders_cross_dialect_type() -> None:
660+
"""PG 方言下 jsonb 列渲染为 JSONType(而非 JSONB)。"""
661+
result = _postgres_dialect_probe()
662+
assert result["jsonb"]["type"] == "JSONType"
663+
assert result["bit"]["type"] == "BIT"
664+
665+
666+
def test_postgres_rendered_model_imports_cross_dialect_type() -> None:
667+
"""端到端:含 jsonb 列的模型产物可 ast.parse,且从基类导入 JSONType。"""
668+
result = _postgres_dialect_probe()
669+
source = result["model_source"]
670+
assert source, "未渲染出模型文件"
671+
assert "from app.core.base_model import JSONType" in source
672+
assert "from sqlalchemy import JSONB" not in source
673+
ast.parse(source)
674+
675+
676+
def test_json_type_compiles_on_all_supported_dialects() -> None:
677+
"""JSONType:PG 渲染 JSONB,SQLite/MySQL 渲染 JSON(issue #542 的核心承诺)。"""
678+
from sqlalchemy import Column, Integer, MetaData, Table
679+
from sqlalchemy.dialects import mysql, postgresql, sqlite
680+
from sqlalchemy.schema import CreateTable
681+
682+
from app.core.base_model import JSONType
683+
684+
metadata = MetaData()
685+
table = Table("t_json_type", metadata, Column("id", Integer, primary_key=True), Column("payload", JSONType))
686+
687+
def _ddl(dialect: Any) -> str:
688+
return " ".join(str(CreateTable(table).compile(dialect=dialect)).split())
689+
690+
assert "payload JSONB" in _ddl(postgresql.dialect())
691+
for dialect in (sqlite.dialect(), mysql.dialect()):
692+
ddl = _ddl(dialect)
693+
assert "payload JSON" in ddl, ddl
694+
assert "JSONB" not in ddl, ddl

0 commit comments

Comments
 (0)