|
| 1 | +# ADR-011: 多方言策略 — DialectAdapter 而非 single-dispatch |
| 2 | + |
| 3 | +**状态**: Accepted (v0.3 落地, 2026-06) |
| 4 | +**关联**: `src/dbjavagenix/database/dialect.py` |
| 5 | +**前置**: ADR-001 (三层架构) |
| 6 | + |
| 7 | +## 背景 |
| 8 | + |
| 9 | +v0.1 ~ v0.2.2 期间,SQL → Java 类型映射散落在 `generator/template_context.py` |
| 10 | +里,代码形如: |
| 11 | + |
| 12 | +```python |
| 13 | +def _map_java_type(self, db_type: str) -> str: |
| 14 | + type_mapping = { |
| 15 | + "TINYINT": "Byte", |
| 16 | + "INT": "Integer", |
| 17 | + "BIGINT": "Long", |
| 18 | + ... |
| 19 | + } |
| 20 | + base = re.sub(r"\([^)]*\)", "", db_type.upper()) |
| 21 | + return type_mapping.get(base, "String") |
| 22 | +``` |
| 23 | + |
| 24 | +只对 MySQL 设计。当 v0.3 要加 PostgreSQL 时,差异点很多: |
| 25 | + |
| 26 | +| 维度 | MySQL | PostgreSQL | |
| 27 | +|------|-------|-----------| |
| 28 | +| 整数族 | TINYINT / SMALLINT / MEDIUMINT / INT / BIGINT | INT2/4/8 + SMALLINT/INTEGER/BIGINT | |
| 29 | +| 自增 | AUTO_INCREMENT 修饰 | SERIAL / BIGSERIAL 类型 | |
| 30 | +| 布尔 | TINYINT(1) 模拟 | 原生 BOOLEAN | |
| 31 | +| 二进制 | BLOB 家族 | BYTEA | |
| 32 | +| 时区 | DATETIME / TIMESTAMP 都无时区 | TIMESTAMPTZ → OffsetDateTime | |
| 33 | +| JSON | JSON (只一种) | JSON + JSONB (两种) | |
| 34 | +| UUID | 无,CHAR(36) | 原生 UUID 类型 | |
| 35 | + |
| 36 | +把这些差异塞回 `_map_java_type()` 会让函数膨胀 + 难维护。 |
| 37 | + |
| 38 | +## 决定 |
| 39 | + |
| 40 | +把方言策略抽成独立模块 `src/dbjavagenix/database/dialect.py`: |
| 41 | + |
| 42 | +```python |
| 43 | +class DialectAdapter(ABC): |
| 44 | + name: str |
| 45 | + @property |
| 46 | + @abstractmethod |
| 47 | + def type_to_java(self) -> dict[str, str]: ... |
| 48 | + @property |
| 49 | + @abstractmethod |
| 50 | + def type_to_jdbc(self) -> dict[str, str]: ... |
| 51 | + |
| 52 | + def java_type_for(self, db_type: str) -> str: ... # 公共算法 |
| 53 | + def jdbc_type_for(self, db_type: str) -> str: ... |
| 54 | + def is_string_type(self, db_type: str) -> bool: ... |
| 55 | + def is_date_type(self, db_type: str) -> bool: ... |
| 56 | + def is_decimal_type(self, db_type: str) -> bool: ... |
| 57 | + |
| 58 | +class MySQLDialect(DialectAdapter): ... |
| 59 | +class PostgreSQLDialect(DialectAdapter): ... |
| 60 | + |
| 61 | +_REGISTRY = {"mysql": MySQLDialect(), "postgresql": PostgreSQLDialect()} |
| 62 | +def get_dialect(db_type: str) -> DialectAdapter: ... |
| 63 | +``` |
| 64 | + |
| 65 | +要点: |
| 66 | +1. **每个方言一个类,各自定义映射表** — 类型查询/字符串分类等共用逻辑在基类 |
| 67 | +2. **registry + factory function** 而非 if/elif 链 — Oracle/SQLServer 加进来时 |
| 68 | + 新增一行 dict + 一个子类 |
| 69 | +3. **未识别方言退回 MySQL** — 向后兼容承诺,老调用方传 None / "" 不会崩 |
| 70 | +4. **不直接绑定 `DatabaseType` enum** — 接受字符串,降低耦合 |
| 71 | + |
| 72 | +## 替代方案 |
| 73 | + |
| 74 | +### A. `functools.singledispatch` 按类型分发 |
| 75 | + |
| 76 | +```python |
| 77 | +@singledispatch |
| 78 | +def java_type_for(dialect, db_type): ... |
| 79 | + |
| 80 | +@java_type_for.register(MySQLConfig) |
| 81 | +def _(dialect, db_type): ... |
| 82 | +``` |
| 83 | + |
| 84 | +**否决**: |
| 85 | +- 我们的方言不是按 *Python 类型* 分,而是按字符串 ("mysql" / "postgresql") |
| 86 | +- single-dispatch 需要给每个方言定义一个 marker class,反而多一层抽象 |
| 87 | +- IDE 跳转体验差(看到 `java_type_for(d, ...)` 不知道实际调到哪个 impl) |
| 88 | + |
| 89 | +### B. 不抽象,只在 `_map_java_type` 里 if db_type==... |
| 90 | + |
| 91 | +```python |
| 92 | +def _map_java_type(self, db_type: str, dialect: str = "mysql") -> str: |
| 93 | + if dialect == "mysql": |
| 94 | + return MYSQL_MAP.get(...) |
| 95 | + elif dialect == "postgresql": |
| 96 | + return PG_MAP.get(...) |
| 97 | + ... |
| 98 | +``` |
| 99 | + |
| 100 | +**否决**: |
| 101 | +- 每个差异点都要 if 一遍 (string_type, date_type, jdbc_type, java_type) |
| 102 | +- 加第三个方言时,所有函数都要改 |
| 103 | +- 测试不好写,要 parametrize 所有函数 × 所有 dialect |
| 104 | + |
| 105 | +### C. 用 SQLAlchemy 的 type 系统 |
| 106 | + |
| 107 | +SQLAlchemy 本身有完整的 dialect → Python type 映射 (`mysql.dialect()`, |
| 108 | +`postgresql.dialect()` 各自的 `ischema_names`)。 |
| 109 | + |
| 110 | +**否决**: |
| 111 | +- 我们要 Java 类型,SQLAlchemy 给 Python 类型 |
| 112 | +- SQLAlchemy type 名 (`Integer`, `BigInteger`) 和 Java 类型 (`Integer`, `Long`) |
| 113 | + 正好不一致,二次映射反而麻烦 |
| 114 | +- 引入 SQLAlchemy 已经在 deps 里,但用它做 dialect 抽象会绑得太深 — ADR-005 |
| 115 | + "不引入不必要抽象" 也反对 |
| 116 | + |
| 117 | +### D. 配置驱动 (YAML/JSON 类型映射表) |
| 118 | + |
| 119 | +```yaml |
| 120 | +mysql: |
| 121 | + TINYINT: Byte |
| 122 | + ... |
| 123 | +postgresql: |
| 124 | + INT2: Short |
| 125 | + ... |
| 126 | +``` |
| 127 | + |
| 128 | +**否决**: |
| 129 | +- 短期看更"灵活",但长期是反模式 — 类型映射逻辑跟代码版本绑死,放代码里更安全 |
| 130 | +- 用户基本不会改这表(改了等于改代码生成行为) |
| 131 | +- 失去 IDE 类型检查 + 跳转 |
| 132 | + |
| 133 | +## 后果 |
| 134 | + |
| 135 | +**好**: |
| 136 | +- 加新方言 (Oracle / SQLServer) 只动 1 个文件 + 1 行 registry |
| 137 | +- 36 个 unit test 覆盖 mysql/postgres 两套映射,跨方言隔离测试防串 |
| 138 | +- D3 用真实 PG container 验证 information_schema 上报的字符串确实命中 |
| 139 | + 我们的 key (这是配置驱动方案做不到的) |
| 140 | +- `template_context.py` 之后会渐进迁移到调用 `get_dialect(...).java_type_for()`, |
| 141 | + 本 ADR 不强制一次性切换 |
| 142 | + |
| 143 | +**坏**: |
| 144 | +- `dialect.py` 文件略大 (~300 行,主要是两张映射表),但都是数据 |
| 145 | +- `template_context.py` 现在有重复的 MySQL 映射,**v0.3.1 计划重构** — 不在这个 |
| 146 | + ADR 范围内,先把 PG 跑起来,old code 保留向后兼容 |
| 147 | + |
| 148 | +**实测**: |
| 149 | +- D3 PG 16 实测 23 个 PG 类型全部命中预期 Java 类型 |
| 150 | +- D2 36 个 unit test 全绿,dialect.py 100% line coverage |
| 151 | + |
| 152 | +## 叙事意义 |
| 153 | + |
| 154 | +"代码生成器多方言支持" 是 v0.1 立项时就吹的目标,但 v0.1 ~ v0.2.2 实际只能用 MySQL。 |
| 155 | +v0.3 才真正落地 PostgreSQL,核心动作就是这个 ADR — 把方言策略从模板里剥出来, |
| 156 | +让后续扩展从"重写代码"变成"加一个类"。 |
| 157 | + |
| 158 | +更深的含义:**"加一个 if 分支"看起来比"做一层抽象"省事,但 N 次 if 累积成本超过 |
| 159 | +抽象**。这是 Effective Java + Refactoring 的 OCP (开闭原则) 在真实项目里的体现。 |
0 commit comments