Npgsql 读回 DateInterval 多了一天,核对 daterange 的上界
Npgsql.NodaTime10.0.3可把有限9999-12-30结束日读成9999-12-31,也可能为无界范围造出边界。绕过有损转换,先保留数据库范围边界的文本与标记。
写入 DateInterval 的结束日是 9999-12-30,重新查询却变成 9999-12-31;或者数据库里是没有下界的日期范围,读回后却凭空多出 0001-01-01。这类表现应先检查 Npgsql.NodaTime 的范围转换,暂时停止把读回值自动保存回去。
Npgsql 与 Npgsql.NodaTime 10.0.3 的具体场景中,两种异常都出现在 DateIntervalConverter.Read()。它们不是数据库偷偷把每一条日期都加了一天。
两套区间边界的表示不同
NodaTime DateInterval 的结束日包含在区间内。PostgreSQL daterange 通常规范化为下界包含、上界不包含。因此,结束于 9999-12-30 的闭区间可以存成:
[2026-01-01,9999-12-31)
读回时,有限、排他的上界应减一天。受影响的转换器却把映射后的 LocalDate.MaxIsoValue 用作特殊判断;有限的 9999-12-31 与映射后的无穷日期可能得到相同值,导致跳过减一天。
无界范围则有另一个缺口:转换器没有先检查范围的无限边界标记,便用取出的边界值构造 DateInterval。而 DateInterval 本身不能忠实表示没有起点或终点的区间。
在转换前保留原始边界
已经确认触发该问题时,可为这条查询采用显式读取投影,绕开实体属性的自动 DateInterval 物化。下面是只读结构示例,假设业务表为 events,范围列为 period:
SELECT period::text AS period_text,
isempty(period) AS is_empty,
lower_inf(period) AS lower_unbounded,
upper_inf(period) AS upper_unbounded,
lower_inc(period) AS lower_inclusive,
upper_inc(period) AS upper_inclusive,
lower(period)::text AS lower_text,
upper(period)::text AS upper_text
FROM events;
表名与列名要替换为项目已确认的对象。边界转为文本后,有限的 9999-12-31 与显式的 infinity 不会先被折叠成同一个 LocalDate 值,是否无界也有独立标记。
应用应先区分SQL NULL、空范围、无界范围和显式无穷日期。只有两个边界都有限、且业务要求输出闭区间时,才按实际包含标记转换边界;对于排他的有限日期上界,再按日历减一天。不能忽略标记,直接对所有上界统一减一。
需要支持无界范围的业务,应使用能表达边界状态的模型;不支持时明确拒绝,而不是补成最小日期或最大日期。普通有限范围可以继续采用既有映射,但这条显式读取路线要与自动物化边界分清,不能只在实体getter里补救一个早已被错误转换的值。
不用全局日期开关掩盖范围问题
本题与普通 DateTime.MinValue 写成 -infinity 的预期映射不同。修改全进程的无穷值兼容开关,会影响其他日期读写,也不能自动补上无界范围检查。
后续修复应核对真实正式版本;11的预发布环境出现或修正某段代码,都不代表10.0.3已更新。现存数据先用只读投影核对,不能按客户端显示结果直接批量修正数据库。
在隔离数据上覆盖普通闭区间、结束于9999-12-30、真正无界、显式无穷、空范围与SQL NULL,比较数据库边界和业务结果。随后再验证读写往返,确认没有因一次读取就不断延长范围。


