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:
qing committed 2026-07-28 23:16:17 +08:00
1 parent 12e736d264
commit 19d5b65625
4 files changed
+167 -11

No files matched your search

+1
View File
@@ -107,6 +107,7 @@
- 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。 - 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。
- 仅监听 127.0.0.1,自动提交/回滚,不暴露 DDL 操作。 - 仅监听 127.0.0.1,自动提交/回滚,不暴露 DDL 操作。
- 默认使用 SQLite(`database.db`),可通过 `DATABASE_URL` 环境变量切换至 PostgreSQL / MySQL。 - 默认使用 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 个索引)。 - 启动时自动建 `Logs` 表(service / level / message / trace_id / extra + 4 个索引)。
- 外部服务通过 `log_client.py` 以 pymsg 调 execute 路由逐条 INSERT 写入日志。 - 外部服务通过 `log_client.py` 以 pymsg 调 execute 路由逐条 INSERT 写入日志。
+35 -1
View File
@@ -78,7 +78,7 @@
{ {
"success": true, "success": true,
"data": [ "data": [
{"id": 1, "username": "Alice"} {"id": 1, "username": "Alice", "create_at": "2026-07-28T14:30:00"}
] ]
} }
``` ```
@@ -91,6 +91,40 @@
{"success": false, "error": "错误描述"} {"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 + pymsg)
```python ```python
+88 -10
View File
@@ -18,18 +18,26 @@ dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。
支持的路由:: 支持的路由::
execute → 通用 CRUD(select / insert / update / delete) 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 json
import os import os
import time import time
import traceback import traceback
from datetime import datetime, timezone, timedelta from datetime import datetime, date, time as dt_time, timezone, timedelta
from sqlalchemy import ( from sqlalchemy import (
create_engine, MetaData, Table, create_engine, MetaData, Table,
select, insert, update, delete, func, and_, select, insert, update, delete, func, and_,
) )
from sqlalchemy import types as sa_types
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
# pymsg 导入 # pymsg 导入
@@ -50,6 +58,16 @@ DEFAULT_RETENTION_DAYS = 30
DEFAULT_MAX_RECORDS = 1_000_000 DEFAULT_MAX_RECORDS = 1_000_000
DEFAULT_CLEANUP_INTERVAL = 3600 # 秒 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 — 主服务类 # DBServer — 主服务类
@@ -113,7 +131,8 @@ class DBServer:
resp["data"] = data resp["data"] = data
if error is not None: if error is not None:
resp["error"] = error 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): def _get_table(self, table_name):
@@ -121,6 +140,54 @@ class DBServer:
Table(table_name, self.metadata, autoload_with=self.engine) Table(table_name, self.metadata, autoload_with=self.engine)
return self.metadata.tables[table_name] 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): def _build_where(self, table, conditions):
if not conditions: if not conditions:
return None return None
@@ -129,23 +196,26 @@ class DBServer:
column = table.columns[col] column = table.columns[col]
if isinstance(val, dict): if isinstance(val, dict):
if "eq" in val: if "eq" in val:
clauses.append(column == val["eq"]) clauses.append(column == self._coerce_value(column, val["eq"]))
if "ne" in val: if "ne" in val:
clauses.append(column != val["ne"]) clauses.append(column != self._coerce_value(column, val["ne"]))
if "gt" in val: if "gt" in val:
clauses.append(column > val["gt"]) clauses.append(column > self._coerce_value(column, val["gt"]))
if "lt" in val: if "lt" in val:
clauses.append(column < val["lt"]) clauses.append(column < self._coerce_value(column, val["lt"]))
if "ge" in val: if "ge" in val:
clauses.append(column >= val["ge"]) clauses.append(column >= self._coerce_value(column, val["ge"]))
if "le" in val: if "le" in val:
clauses.append(column <= val["le"]) clauses.append(column <= self._coerce_value(column, val["le"]))
if "like" in val: if "like" in val:
# like 是字符串模式匹配,不做日期转换
clauses.append(column.like(val["like"])) clauses.append(column.like(val["like"]))
if "in" in val: 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: else:
clauses.append(column == val) clauses.append(column == self._coerce_value(column, val))
return and_(*clauses) return and_(*clauses)
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
@@ -205,6 +275,10 @@ class DBServer:
def _do_insert(self, conn, table, data): def _do_insert(self, conn, table, data):
values = data.get("values", {}) values = data.get("values", {})
values = {
k: self._coerce_value(table.columns[k], v)
for k, v in values.items()
}
stmt = insert(table).values(**values) stmt = insert(table).values(**values)
res = conn.execute(stmt) res = conn.execute(stmt)
conn.commit() conn.commit()
@@ -212,6 +286,10 @@ class DBServer:
def _do_update(self, conn, table, data): def _do_update(self, conn, table, data):
values = data.get("values", {}) 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")) where_cond = self._build_where(table, data.get("where"))
stmt = update(table).values(**values) stmt = update(table).values(**values)
if where_cond is not None: if where_cond is not None:
+43
View File
@@ -6,6 +6,7 @@ db 黑盒用户模拟测试 — pymsg 协议 CRUD。
2. 通过 pymsg 协议查询 Logs 表 2. 通过 pymsg 协议查询 Logs 表
3. 通过 pymsg 协议带 where 条件查询 3. 通过 pymsg 协议带 where 条件查询
4. 无效路由被拒绝 4. 无效路由被拒绝
5. 日期列 where(ISO 字符串 / $now 哨兵 / 非法值拒绝)
注意: dbv2.py 为纯 pymsg/TCP 服务(无 HTTP 接口),日志通过 注意: dbv2.py 为纯 pymsg/TCP 服务(无 HTTP 接口),日志通过
log_client.py 走 execute 路由 INSERT 写入 Logs 表。 log_client.py 走 execute 路由 INSERT 写入 Logs 表。
@@ -101,6 +102,48 @@ def run(url: str = None):
"pymsg-无效路由-拒绝", c, "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,
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 汇总 # 汇总
# ------------------------------------------------------------------ # ------------------------------------------------------------------