fix(db): 新增日期列类型适配层,修复无法经 db 处理 DATETIME 列的问题
JSON 无日期类型,而 SQLite DATETIME 只接受 Python datetime 对象, 导致各服务对日期列的写入与 where 比较全部失败。_coerce_value 按反射 列类型把 ISO 8601 字符串转为 datetime/date/time(带时区偏移统一转 UTC),并支持 "$now" 哨兵取服务端当前时间;响应中日期时间改为一律 序列化为 ISO 8601(T 分隔)。黑盒测试补 3 个日期列场景。
This commit is contained in:
1 parent
12e736d264
commit
19d5b65625
4 files changed
+167
-11
No files matched your search
@@ -107,6 +107,7 @@
|
||||
- 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。
|
||||
- 仅监听 127.0.0.1,自动提交/回滚,不暴露 DDL 操作。
|
||||
- 默认使用 SQLite(`database.db`),可通过 `DATABASE_URL` 环境变量切换至 PostgreSQL / MySQL。
|
||||
- **日期/时间列适配**:values 与 where 中的 DATETIME/DATE/TIME 列接受 ISO 8601 字符串(带时区偏移自动转 UTC)或哨兵值 `"$now"`(服务端当前 UTC);响应中的日期时间一律序列化为 ISO 8601(T 分隔)。
|
||||
- **内置日志子模块**:
|
||||
- 启动时自动建 `Logs` 表(service / level / message / trace_id / extra + 4 个索引)。
|
||||
- 外部服务通过 `log_client.py` 以 pymsg 调 execute 路由逐条 INSERT 写入日志。
|
||||
|
||||
+35
-1
@@ -78,7 +78,7 @@
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{"id": 1, "username": "Alice"}
|
||||
{"id": 1, "username": "Alice", "create_at": "2026-07-28T14:30:00"}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -91,6 +91,40 @@
|
||||
{"success": false, "error": "错误描述"}
|
||||
```
|
||||
|
||||
## 日期/时间列的处理
|
||||
|
||||
JSON 没有日期类型,db 内置了类型适配层:按反射得到的列类型自动在
|
||||
ISO 8601 字符串与数据库 DATETIME / DATE / TIME 之间转换。
|
||||
|
||||
**写入(values / where 条件值)**:
|
||||
|
||||
| 输入 | 说明 |
|
||||
|------|------|
|
||||
| `"2026-07-28T22:30:00"` | ISO 8601(T 分隔),推荐 |
|
||||
| `"2026-07-28 22:30:00"` | 空格分隔同样接受(SQLite 原生格式) |
|
||||
| `"2026-07-28T22:30:00+08:00"` | 带时区偏移:自动转 UTC 后存储 |
|
||||
| `"2026-07-28T14:30:00Z"` | Z 后缀等同 +00:00 |
|
||||
| `"$now"` | 哨兵值:服务端当前 UTC 时间(DATETIME/DATE 列可用) |
|
||||
|
||||
存储统一为 UTC 朴素时间(无时区)。非法日期字符串会返回明确的错误:
|
||||
`{"success": false, "error": "列 last_login 的日期时间值无法解析(需 ISO 8601 或 \"$now\"): ..."}`
|
||||
|
||||
**读取(select 响应)**:日期时间值一律序列化为 ISO 8601(T 分隔),
|
||||
如 `"2026-07-28T22:30:00"`,客户端可用 `datetime.fromisoformat()` 直接解析。
|
||||
|
||||
**注意**:`like` 运算符是字符串模式匹配,对日期列使用时不做转换。
|
||||
|
||||
```json
|
||||
// 示例:登录后更新 last_login 为服务端当前时间
|
||||
{
|
||||
"_route": "execute",
|
||||
"action": "update",
|
||||
"table": "Users",
|
||||
"values": {"last_login": "$now"},
|
||||
"where": {"username": "alice"}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例(Python + pymsg)
|
||||
|
||||
```python
|
||||
|
||||
+88
-10
@@ -18,18 +18,26 @@ dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。
|
||||
支持的路由::
|
||||
|
||||
execute → 通用 CRUD(select / insert / update / delete)
|
||||
|
||||
日期/时间列::
|
||||
|
||||
values 与 where 中的 DATETIME / DATE / TIME 列接受 ISO 8601 字符串
|
||||
(如 "2026-07-28T22:30:00",可带时区偏移,统一转 UTC 存储),
|
||||
或哨兵值 "$now"(服务端当前 UTC 时间)。
|
||||
响应中的日期时间一律序列化为 ISO 8601(T 分隔)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import datetime, date, time as dt_time, timezone, timedelta
|
||||
|
||||
from sqlalchemy import (
|
||||
create_engine, MetaData, Table,
|
||||
select, insert, update, delete, func, and_,
|
||||
)
|
||||
from sqlalchemy import types as sa_types
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
# pymsg 导入
|
||||
@@ -50,6 +58,16 @@ DEFAULT_RETENTION_DAYS = 30
|
||||
DEFAULT_MAX_RECORDS = 1_000_000
|
||||
DEFAULT_CLEANUP_INTERVAL = 3600 # 秒
|
||||
|
||||
#: where/values 中的哨兵值:服务端当前 UTC 时间
|
||||
NOW_SENTINEL = "$now"
|
||||
|
||||
|
||||
def _json_default(o):
|
||||
"""json.dumps 兜底序列化:日期时间一律 ISO 8601(T 分隔),其余 str()。"""
|
||||
if isinstance(o, (datetime, date, dt_time)):
|
||||
return o.isoformat()
|
||||
return str(o)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# DBServer — 主服务类
|
||||
@@ -113,7 +131,8 @@ class DBServer:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False, default=str))
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False,
|
||||
default=_json_default))
|
||||
|
||||
# ── 表反射 ---------------------------------------------------------
|
||||
def _get_table(self, table_name):
|
||||
@@ -121,6 +140,54 @@ class DBServer:
|
||||
Table(table_name, self.metadata, autoload_with=self.engine)
|
||||
return self.metadata.tables[table_name]
|
||||
|
||||
# ── 日期/时间类型适配 ------------------------------------------------
|
||||
def _coerce_value(self, column, value):
|
||||
"""按反射列类型把 JSON 值适配为 SQLAlchemy 需要的 Python 对象。
|
||||
|
||||
JSON 没有日期类型,客户端只能传字符串;而 SQLite 的 DATETIME/DATE
|
||||
类型只接受 Python datetime/date 对象,字符串会被直接拒绝
|
||||
(TypeError: SQLite DateTime type only accepts Python datetime ...)。
|
||||
这里统一把 ISO 8601 字符串转为对应对象;带时区偏移的一律转 UTC
|
||||
并去掉 tzinfo,与 SQLite 朴素存储保持一致。
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
col_type = column.type
|
||||
if isinstance(col_type, sa_types.DateTime):
|
||||
if value == NOW_SENTINEL:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
text = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"列 {column.name} 的日期时间值无法解析"
|
||||
f"(需 ISO 8601 或 \"{NOW_SENTINEL}\"): {value!r}"
|
||||
)
|
||||
if dt.tzinfo is not None:
|
||||
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
if isinstance(col_type, sa_types.Date):
|
||||
if value == NOW_SENTINEL:
|
||||
return datetime.now(timezone.utc).date()
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"列 {column.name} 的日期值无法解析"
|
||||
f"(需 ISO 8601 或 \"{NOW_SENTINEL}\"): {value!r}"
|
||||
)
|
||||
if isinstance(col_type, sa_types.Time):
|
||||
try:
|
||||
return dt_time.fromisoformat(value)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"列 {column.name} 的时间值无法解析"
|
||||
f"(需 ISO 8601): {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
def _build_where(self, table, conditions):
|
||||
if not conditions:
|
||||
return None
|
||||
@@ -129,23 +196,26 @@ class DBServer:
|
||||
column = table.columns[col]
|
||||
if isinstance(val, dict):
|
||||
if "eq" in val:
|
||||
clauses.append(column == val["eq"])
|
||||
clauses.append(column == self._coerce_value(column, val["eq"]))
|
||||
if "ne" in val:
|
||||
clauses.append(column != val["ne"])
|
||||
clauses.append(column != self._coerce_value(column, val["ne"]))
|
||||
if "gt" in val:
|
||||
clauses.append(column > val["gt"])
|
||||
clauses.append(column > self._coerce_value(column, val["gt"]))
|
||||
if "lt" in val:
|
||||
clauses.append(column < val["lt"])
|
||||
clauses.append(column < self._coerce_value(column, val["lt"]))
|
||||
if "ge" in val:
|
||||
clauses.append(column >= val["ge"])
|
||||
clauses.append(column >= self._coerce_value(column, val["ge"]))
|
||||
if "le" in val:
|
||||
clauses.append(column <= val["le"])
|
||||
clauses.append(column <= self._coerce_value(column, val["le"]))
|
||||
if "like" in val:
|
||||
# like 是字符串模式匹配,不做日期转换
|
||||
clauses.append(column.like(val["like"]))
|
||||
if "in" in val:
|
||||
clauses.append(column.in_(val["in"]))
|
||||
clauses.append(column.in_(
|
||||
[self._coerce_value(column, v) for v in val["in"]]
|
||||
))
|
||||
else:
|
||||
clauses.append(column == val)
|
||||
clauses.append(column == self._coerce_value(column, val))
|
||||
return and_(*clauses)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
@@ -205,6 +275,10 @@ class DBServer:
|
||||
|
||||
def _do_insert(self, conn, table, data):
|
||||
values = data.get("values", {})
|
||||
values = {
|
||||
k: self._coerce_value(table.columns[k], v)
|
||||
for k, v in values.items()
|
||||
}
|
||||
stmt = insert(table).values(**values)
|
||||
res = conn.execute(stmt)
|
||||
conn.commit()
|
||||
@@ -212,6 +286,10 @@ class DBServer:
|
||||
|
||||
def _do_update(self, conn, table, data):
|
||||
values = data.get("values", {})
|
||||
values = {
|
||||
k: self._coerce_value(table.columns[k], v)
|
||||
for k, v in values.items()
|
||||
}
|
||||
where_cond = self._build_where(table, data.get("where"))
|
||||
stmt = update(table).values(**values)
|
||||
if where_cond is not None:
|
||||
|
||||
@@ -6,6 +6,7 @@ db 黑盒用户模拟测试 — pymsg 协议 CRUD。
|
||||
2. 通过 pymsg 协议查询 Logs 表
|
||||
3. 通过 pymsg 协议带 where 条件查询
|
||||
4. 无效路由被拒绝
|
||||
5. 日期列 where(ISO 字符串 / $now 哨兵 / 非法值拒绝)
|
||||
|
||||
注意: dbv2.py 为纯 pymsg/TCP 服务(无 HTTP 接口),日志通过
|
||||
log_client.py 走 execute 路由 INSERT 写入 Logs 表。
|
||||
@@ -101,6 +102,48 @@ def run(url: str = None):
|
||||
"pymsg-无效路由-拒绝", c,
|
||||
)
|
||||
|
||||
# 场景 5: 日期列 where — ISO 8601 字符串(dbv2 日期适配层)
|
||||
result = client.call({
|
||||
"_route": "execute",
|
||||
"action": "select",
|
||||
"table": "Users",
|
||||
"columns": ["id", "username", "create_at"],
|
||||
"where": {"create_at": {"lt": "2999-01-01T00:00:00"}},
|
||||
"limit": 1,
|
||||
})
|
||||
assert_true(
|
||||
result.get("success") is True,
|
||||
"pymsg-日期列where-ISO字符串", c,
|
||||
f"error={result.get('error', '')}",
|
||||
)
|
||||
|
||||
# 场景 6: 日期列 where — $now 哨兵
|
||||
result = client.call({
|
||||
"_route": "execute",
|
||||
"action": "select",
|
||||
"table": "Users",
|
||||
"columns": ["id", "username", "create_at"],
|
||||
"where": {"create_at": {"lt": "$now"}},
|
||||
"limit": 1,
|
||||
})
|
||||
assert_true(
|
||||
result.get("success") is True,
|
||||
"pymsg-日期列where-$now哨兵", c,
|
||||
f"error={result.get('error', '')}",
|
||||
)
|
||||
|
||||
# 场景 7: 日期列 where — 非法日期字符串应报错
|
||||
result = client.call({
|
||||
"_route": "execute",
|
||||
"action": "select",
|
||||
"table": "Users",
|
||||
"where": {"create_at": {"lt": "not-a-date"}},
|
||||
})
|
||||
assert_true(
|
||||
result.get("success") is False,
|
||||
"pymsg-日期列where-非法值-拒绝", c,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 汇总
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user