Skip to content

Commit ded9df3

Browse files
committed
docs(adr): ADR-011 多方言策略 - DialectAdapter 而非 single-dispatch
记录 v0.3 引入 database/dialect.py 的决策: - 选 ABC 子类 + registry 方案,而非 functools.singledispatch / if-elif / SQLAlchemy type 借用 / YAML 配置驱动 - 关键差异点 (整数族 / SERIAL / BOOLEAN / BYTEA / TIMESTAMPTZ / JSONB / UUID) 作为决策依据列表 - template_context.py 的旧 MySQL 映射保留 (向后兼容),v0.3.1 再渐进迁移 - OCP 在真实项目的体现:加方言变成"加类"而非"改代码" 对比了 4 个替代方案。
1 parent c58d429 commit ded9df3

1 file changed

Lines changed: 159 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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

Comments
 (0)