Data Formulator DataFrame 序列化规范:统一 df_to_safe_records 解决 datetime 与 JSON 安全序列化
【免费下载链接】data-formulator🪄 Data Formulator is an interactive AI-powered data analysis system makes it easy to connect, explore and visualize data.项目地址: https://gitcode.com/GitHub_Trending/da/data-formulator
导读
在 Data Formulator 这类 AI 驱动的交互式数据分析系统中,后端(Python/pandas)与前端(TypeScript/React)之间的一切数据交换都依赖 JSON。pandas 自带的DataFrame.to_json(orient='records')默认把datetime64列序列化为epoch 毫秒整数,而to_dict(orient='records')更会直接返回不可 JSON 序列化的pd.Timestamp对象,导致前端把时间列当成普通数字显示、响应体无法正常编码等一系列问题。本文基于仓库内的开发指南 15-dataframe-serialization.md,完整讲解 Data Formulator 的 DataFrame 序列化约定:统一入口函数df_to_safe_records的实现原理、适用场景、豁免情形,以及新模块接入时的检查清单。读完本文,你将掌握一套可复用的「DataFrame → JSON 安全记录」最佳实践,并理解其在 Agent 结果、表格采样、数据加载器预览、文件解析等链路中的落地方式。
问题:pandas 默认序列化的两个陷阱
陷阱一:to_json(orient='records')默认输出 epoch 毫秒
pandas 的DataFrame.to_json(orient='records')默认采用date_format='epoch',会把datetime64列序列化为 epoch 毫秒整数。文档中给出的例子1773532800000(对应 2026-03-15 左右)正是此类输出。
问题出在前端展示环节:Data Formulator 前端的数据表格在渲染单元格时调用 formatCellValue,它只有在dataType为Type.DateTime/Type.Date/Type.Time时才走时间格式化分支;当后端把时间列发成纯数字时,前端把它当作typeof value === 'number'处理,走千分位格式化分支,最终在表格里显示成1,773,532,800,000而不是可读的日期时间字符串。文档原文即指出:"When the frontend receives these numbers,formatCellValuetreats them as plain integers and displays1,773,532,800,000instead of a formatted date."
陷阱二:to_dict(orient='records')产生不可序列化的对象
DataFrame.to_dict(orient='records')的问题更严重:它返回的是 Pythonpd.Timestamp对象,本身根本不是 JSON 可序列化类型,最终能否输出完全取决于作用域内恰好存在的json.dumps兜底逻辑(default参数)。这种"碰运气"式的序列化在不同模块、不同调用栈下表现不可预测,是典型的隐患来源。
解决方案:df_to_safe_records单一入口
核心实现
文档给出的规范解决方案是在data_formulator.datalake.parquet_utils中提供一个统一工具函数。仓库中的实际实现位于 parquet_utils.py:
def df_to_safe_records(df: pd.DataFrame) -> list[dict[str, Any]]: """Convert a pandas DataFrame to a list of JSON-safe record dicts. Uses ``date_format='iso'`` so that datetime columns are serialized as ISO-8601 strings instead of epoch milliseconds. ``default_handler=str`` provides a safety net for exotic types (Decimal, bytes, etc.). """ safe_df = df.copy(deep=False) for column in safe_df.select_dtypes(include=["object", "string"]).columns: safe_df[column] = safe_df[column].map(_repair_invalid_unicode) return json.loads( safe_df.to_json(orient="records", date_format="iso", default_handler=str) )两个关键点:
date_format="iso":强制datetime64列以 ISO-8601 字符串输出(如2026-03-15T00:00:00.000),从源头杜绝 epoch 毫秒数字。default_handler=str:作为「兜底安全网」,任何 pandas 无法直接 JSON 化的"奇异类型"(Decimal、bytes、numpy标量等)都会被str()兜底转换,保证to_json调用不抛异常。
内部细节:无效 Unicode 修复
仓库实现比文档描述更进一步:序列化前会对object/string类型列逐元素调用_repair_invalid_unicode(见 parquet_utils.py),把格式错误的 surrogate 码点(如"\ud800"这类在 UTF-8 中无法编码的字符)替换为?,同时保留合法 Unicode(包括中文、日文等)。该函数还会递归处理嵌套的dict/list/tuple/set结构。注意df.copy(deep=False)只做浅拷贝,配合map生成新列,因此不会改动调用方的原始 DataFrame——这一点在测试中也有专门验证(详见下文「测试验证」一节)。
为什么必须经过json.loads回读
to_json先生成 JSON 字符串,再json.loads回读成 Python 原生类型(str/int/float/bool/None/list/dict)的列表。这一步保证了返回的list[dict[str, Any]]中绝对不存在pd.Timestamp、np.int64之类的非原生类型,交给 Flaskjsonify或任何 JSON 序列化器时万无一失。
何时使用:五类标准场景
文档用一张表明确了df_to_safe_records的标准适用位置,仓库源码中每一类都能找到对应实现:
| 场景 | 规范调用 | 仓库中的实际落点 |
|---|---|---|
Agent 结果行(content["rows"]) | df_to_safe_records(query_output) | analyst/agent.py、routes/agents.py |
表采样行(sample_rows) | df_to_safe_records(sample_df) | local_folder_data_loader.py、sample_datasets_loader.py 等 |
| 数据加载器元数据预览 | df_to_safe_records(df.head(5)) | mysql_data_loader.py、postgresql_data_loader.py、s3_data_loader.py 等 |
| 文件解析结果(Excel/CSV) | df_to_safe_records(df) | routes/tables.py、data_connector.py |
| Arrow 表采样 | get_sample_rows_from_arrow(table) | parquet_utils.py |
分页 / 派生表接口中的实战用法
在表格数据接口 routes/tables.py 中,无论走 DuckDB 的LIMIT/OFFSET分页路径(df_to_safe_records(page_df))还是整表读取后切片路径,返回给前端的rows一律经过df_to_safe_records,并附带columns、total_rows、page、page_size元信息。在 Agent 派生数据刷新接口 routes/agents.py 中,virtual表会先head(max_display_rows)截断再转换,避免把超大结果集整体塞进响应。
此外,data_connector.py与各类data_loader中的预览数据同样统一走该入口,例如mssql_data_loader.py在采样前先fillna(value=None)再转换,确保 NaN 不会以非法 JSON 形式出现。
Arrow 表采样:get_sample_rows_from_arrow与make_json_safe
对于 PyArrow 表,不需要绕道 pandas。既有函数 get_sample_rows_from_arrow 已经正确处理了序列化:
def get_sample_rows_from_arrow( table: pa.Table, limit: int = DEFAULT_METADATA_SAMPLE_ROWS ) -> list[dict[str, Any]]: if table.num_rows <= 0 or limit <= 0: return [] sample = table.slice(0, min(limit, table.num_rows)) return make_json_safe(sample.to_pylist())其底层依赖 workspace_metadata.py 中的make_json_safe,它是一套递归的「JSON/YAML 安全」转换器:
datetime/date→value.isoformat()Decimal、Path→str(value)dict/list/tuple→ 递归转换- 其余 numpy / pandas 标量 → 尝试
.item()提取原生值,失败则回退str(value)
table.to_pylist()把 Arrow 标量转为 Python 原生值后,再经make_json_safe统一收口,与df_to_safe_records达到同样的"响应安全"效果。默认采样行数由常量DEFAULT_METADATA_SAMPLE_ROWS = 50控制(见 parquet_utils.py)。
豁免场景:允许继续使用to_dict(orient='records')的地方
并非所有to_dict(orient='records')都要替换。文档明确指出以下两类使用永远不会进入 JSON 序列化或前端渲染,因此豁免:
- Kusto SDK 元数据——
.show tables details的查询结果只在 Python 侧迭代使用(位于 kusto_data_loader.py 等 Kusto 相关加载链路中),不跨进程传输,不存在序列化风险。 - Vega-Lite spec 构造——create_vl_plots.py 在构造图表时将内联数据写入 Vega spec,而 Vega 自己会处理时间格式(
timeUnit、format等),不需要后端预先把时间转成 ISO 字符串。
判断豁免的核心原则是:只要数据最终会经过 JSON 序列化或到达前端,就必须走df_to_safe_records;只有纯 Python 内部消费的数据才可以例外。
新模块接入检查清单
文档给出了开发者在编写新的 Agent、DataLoader 或 route 并返回 DataFrame 行数据时必须遵守的三步流程:
- 导入:
from data_formulator.datalake.parquet_utils import df_to_safe_records - 转换:
rows = df_to_safe_records(df),禁止直接使用裸的to_json/to_dict - 测试:验证响应中的 datetime 列以 ISO 字符串出现(可参照下文测试断言模式)
仓库中 agent_data_loading_chat.py 的预览(df.head(3))与采样(df.head(5))、azure_blob_data_loader.py 的sample_rows等,都是这套清单的既有落地范例,新模块照此接入即可保持全仓库行为一致。
测试验证:test_df_to_safe_records 的断言模式
专门的单元测试文件 tests/backend/data/test_df_to_safe_records.py 从多个维度锁定了该函数的契约:
- datetime 列必须是 ISO 字符串:
pd.to_datetime(["2026-03-15", "2026-04-20"])转换后断言records[0]["ts"] == "2026-03-15T00:00:00.000";带时间分量的值断言包含"14:30:00"。 - NaT → null:缺失时间值输出
None,不会变成非法 JSON。 - 混合类型:整型、字符串、datetime 共存时各自保持正确类型(
isinstance(records[0]["created"], str))。 - NaN → null:
float("nan")输出None,1.5与3.0保持数值。 - 空 DataFrame:无行(
== [])与无列(pd.DataFrame()→[])均安全返回空列表。 - 兜底 handler 生效:
np.int64(42)/np.float64(3.14)正确转为42/3.14。 - 非法 Unicode 修复且不改动输入:
"\ud800"被替换为?,嵌套 dict 内的畸形字符同样被修复,且断言df.iloc[0]["value"] == malformed证明原始 DataFrame 未被就地修改。
这套测试既是回归保护,也是新模块接入时"照着写断言"的模板。
前端配合:类型化格式化是最后一道防线
序列化规范并不止步于后端。前端 ViewUtils.tsx 的formatCellValue按dataType分流:DateTime/Date/Time走Intl本地化时间格式化,Duration走可读的 h/m/s 格式化,普通数字才加千分位分隔符并限制最多 4 位小数。也就是说,只要后端把时间列发成 ISO 字符串(并在列元信息中正确标注datetime等 App Type),前端就能自动渲染成用户语言环境的日期时间;而一旦后端发成 epoch 数字,前端就会落入千分位分支。normalize_dtype_to_app_type(parquet_utils.py)负责把 pandas/Arrow 的 dtype 字符串映射为前端src/data/types.ts中Type枚举对应的标签,前后端在「列类型 + 单元格值」两个层面共同保证展示正确。
总结
Data Formulator 的 DataFrame 序列化规范可以浓缩为一句话:所有需要过 JSON 或到前端的 DataFrame,一律通过df_to_safe_records(Arrow 场景用get_sample_rows_from_arrow),用date_format='iso'消灭 epoch 毫秒、用default_handler=str兜底奇异类型、用_repair_invalid_unicode保证 Unicode 安全,并在列元信息中同步标注正确的 App Type。该约定已贯穿 Agent 结果、表格分页接口、各类数据加载器预览、文件解析等全部链路,并有 test_df_to_safe_records.py 提供回归保障。对任何正在或将要为 Data Formulator 贡献 Agent、DataLoader 与 route 的开发者而言,遵循 15-dataframe-serialization.md 中的检查清单,是保证「时间列正确显示、响应永远可 JSON 化」的最简路径。
【免费下载链接】data-formulator🪄 Data Formulator is an interactive AI-powered data analysis system makes it easy to connect, explore and visualize data.项目地址: https://gitcode.com/GitHub_Trending/da/data-formulator
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考