跳到主要内容

数据表操作

什么时候读:技能要建表、查询、写入、删除数据表时。 原则:技能不自建数据库连接、不自拼建表 DDL,全部走统一入口 datatable 技能(经 call_skill)。 本文长,按目录跳读。

目录​

  1. 调用方式与技能名探测
  2. 存储模型
  3. 权限模型(含现状与文档的出入)
  4. 操作清单
  5. 参数规范(filters / metrics / sort / 写入 / 批量路径)
  6. 字段类型与写入值
  7. SQL 通道约束
  8. 删除保护(confirm_token / 备份 / 三条线皆 chat_mode=True)
  9. 技能内约束
  10. 踩坑档案摘录

1. 调用方式​

input_data = {"action": "<操作名>", "payload": {...}}
# payload 内参数也允许平铺在顶层,入口自动归拢
r = await ctx["call_skill"]("datatable", {"action": "query", "table_name": "客户", "limit": 50})

技能名探测:统一入口的注册名不同平台可能不同(线上 datatable,历史 nocodb)。 技能侧写死候选名列表逐个探测(list_tables 回「未注册」换下一个),命中即缓存, 探测结果进「环境」自检和连接类错误的信封。确认后把真名放第一位。

DT_CANDIDATES = ["datatable", "nocodb"] # 确认真名后放第一位
_dt_name = None

async def _resolve_dt(ctx):
"""用 list_tables 逐个探测,回「未注册」换下一个;命中即缓存。"""
global _dt_name
if _dt_name:
return _dt_name, None
last = None
for n in DT_CANDIDATES:
r = await ctx["call_skill"](n, {"action": "list_tables"})
_e = str(r.get("error", ""))
if r.get("ok") is False and ("未注册" in _e or "未知工具" in _e):
last = r
continue
_dt_name = n
return n, None
return None, {"ok": False, "error": f"数据表技能未注册(已探测 {DT_CANDIDATES})",
"error_type": "config", "non_retriable": True, "upstream": last}

async def dt(ctx, action, **kw):
name, err = await _resolve_dt(ctx)
if err:
return err
return await ctx["call_skill"](name, {"action": action, **kw})

探测结果(命中的名字)写进「环境」自检动作的返回,连接类错误的信封也带上。 平台对未注册技能的实际文案是「未知工具:X」(pipeline),探测时同时匹配「未注册」和「未知工具」。

返回形状(datatable 自己的信封,call_skill 不再包层):成功 {"success": True, "data": <§4 表里的返回>}, 所以 r["data"]["list"]、r["data"]["pageInfo"]["totalRows"];失败时 datatable 自己返回 {"success": False, "user_message", "need_param"?, ...},经 call_skill 后变成 {"ok": False, "error": "技能自报失败", "skill": "datatable", "output": <那份信封>}——要读 r["output"]["user_message"]。 调用前先提交自己的写入:子技能是独立 session,未提交的写入它看不到。用 §9 / spec §7A 的 _db(commit=True) 现开现还写法时天然满足(每次写就已提交)。

2. 存储模型​

项规则
组织隔离每 org 一个 PG schema:org_data_<org_id 去非字母数字并小写>
逻辑表schema 下真实表,表名即中文名
系统列id(bigserial 主键)、created_at 由平台创建,不得自建、不得写入
业务语义类型/必填/唯一/选项/精度存于元数据表,不从 information_schema 推断
标识符中文、字母、数字、下划线;1~50 字符;不得数字开头;禁 % 等符号
外键不使用

org_id 为空 → 拒绝执行,error_type=config + non_retriable=true。不补伪 org。

3. 权限模型​

表级:授权表是「例外表」不是白名单——某表无授权记录即全开;多角色取并集、行范围取宽;某表存在未配置角色则全开。

行级:row_scope="own" 时按归属字段 = 当前操作人工号(actor_emp_no)强制过滤,三处收口缺一不可: 筛选注入(query / aggregate / update_by / delete_by)、id 条件追加(update / delete / delete_batch)、写入盖值(insert / insert_batch / insert_from_file / upsert)。 归属字段必须真实存在;取不到工号 fail closed;归属字段不得被 update 修改;行数统计同受过滤。

操作级(正式规则,已定案,见 pending-issues.md ISSUE-2):结构类(create_table / add_field / update_field / delete_field / delete_table,及 SQL 通道的 CREATE / 结构变更)仅 team_role ∈ {owner, admin}。 team_role 的判定与 Web 数据管理接口同一口径:用户所属团队 Team.owner_id == user_id → owner;否则取 TeamMember.role;都取不到 → member(fail-closed)。团队主账号即 owner;子账号(团队成员)按其成员角色, 被设为 admin 的子账号也能改结构。SQL 类另要求 allow_sql=True;批量脚本仅管理员线。

Web 数据管理页(/api/v1/nocodb/*,nocodb.py)已按此执行:建表 / 加改删字段 / 删表 / 只读 SQL / 授权管理均要 owner/admin, 否则 403「只有团队管理员(owner/admin)可以新建表、修改结构或删除表」;非管理员的读写按其名下角色的授权并集、 行级锚点为本人 User.emp_no。

现状与正式规则的出入,技能作者不要依赖:

  1. 技能线(datatable handler 约 755 / 1010 行)这两段 owner/admin 判断当前仍被注释掉(待平台恢复,见 ISSUE-2 落地项), 经 call_skill / 对话调用时任何角色都能建表/改结构。技能不得把「非管理员建表会被拒」当安全边界; 要求只有管理员能做的结构操作,自己先判 ctx["team_role"] in ("owner", "admin")(页面面板线不注入 team_role,按缺失拒绝)。
  2. 「row_scope=own 禁 SQL」在代码里不按 row_scope 判,只看 allow_sql(缺省 True,无任何线注入 False)。own 范围的行级隔离在 SQL 通道前不成立,裸 SELECT 可绕过。要对某角色启用 own 范围表,必须同时给该角色每条线注入 allow_sql=False。
  3. allow_sql 已入 call_skill 透传白名单。一旦某条线设 False,子技能继承。

4. 操作清单​

action必填参数返回
list_tables—[{id, title, schema, qualified}]
list_columnstable_id[{title, uidt, required, options, precision, scale}]
querytable_id{list, pageInfo}
aggregatetable_id, metrics{rows, group_by, metrics}
inserttable_id, data写入后的完整记录
insert_batchtable_id, records{inserted, skipped, failed, errors, verified_total}
insert_from_filetable_id, file同上。可选 mapping({目标列: 源键})、defaults、on_conflict=skip
upserttable_id, key_fields, data 或 records{inserted, updated, failed, errors, verified_total}
updatetable_id, row_id, data更新后的完整记录
update_bytable_id, filters, data{updated}
deletetable_id, row_idtrue
delete_batchtable_id, row_ids{deleted, requested, backup_file}
delete_bytable_id, filters{deleted, backup_file}
sql_querysql{rows, row_count, sql}
sql_executesql{affected, rows?, created?, table_id?}
create_tabletable_name, fields{id, title};已存在返回已有表并带 already_exists=true
add_fieldtable_id, field字段定义
update_fieldtable_id, field_title, patch字段最新定义
delete_fieldtable_id, field_title{deleted, field}
delete_tabletable_idtrue

create_table.fields 每项:{"title": str, "type": str, "required"?: bool, "unique"?: bool, "options"?: "a,b,c"}(options 是字符串)。type 枚举见 code-facts.md §1.2——没有 JSON 类型,存 JSON 用 长文本。

table_id 是 UUID 不是表名。缺 table_id 但给了 table_name(或 table / table_title)时,入口按 title 精确匹配自动解析;匹配不到才返回清单。

5. 参数规范​

filters:数组,项间 AND(OR 用 sql_query)。

[{"field": "部门", "op": "eq", "value": "研发"},
{"field": "薪资", "op": "between", "value": [8000, 20000]}]

op:eq ne gt gte lt lte contains in(数组)between(两元素数组)is_null not_null(省略 value)。 不得写字段映射 {"部门": "研发"}。字段不存在报错不静默。值里残留 {xxx} / $steps. 拒绝执行,且返回 non_retriable: True(datatable 本轮被拉黑)——链里引用名打错代价很大。 入口对字段映射形状有容错,但数组里无法解析的项(字符串、空串)会被静默丢弃,丢光就是全表返回——按规范写,别赌容错。

metrics:[{"field": "租金", "op": "sum", "alias": "总租金"}],op ∈ {sum, avg, max, min, count},count 可省 field。

sort / limit / offset:sort 单字段,前缀 - 降序,默认 id DESC;query 上限 1000(默认 100),aggregate 上限 10000(默认 1000)。

写入:data 单条对象不得传数组;records 对象数组;on_conflict=skip 要求表上已有唯一约束;upsert 必须给 key_fields。

批量路径:

  • 1 条 → insert
  • 2~100 条 → insert_batch
  • 100 条 → 整份写入工作区 JSON 文件,insert_from_file 导入。不得拆成多次 insert_batch。

  • insert 的 data 传数组会被放行按批量处理,但没有 100 条上限检查,不得借此绕过文件线。
  • insert_from_file 的文件由调用方生成、调用方清理:放工作区子目录,成功删除,失败保留。datatable 本身不删。

6. 字段类型与写入值​

create_table / add_field 可传的 type(handler INPUT_SCHEMA 枚举,以此为准): 文本 长文本 数字 整数 金额 百分比 日期 日期时间 时间 勾选 单选 多选 邮箱 电话 链接。 没有 JSON、没有 高精度数字(规范原文列过,枚举里没有)——存 JSON 用 长文本。 金额 / 百分比 落库归一为 数字 + 预设精度(金额 18,2;百分比 12,4;默认 38,2)。

写入值校验:数字全角转半角、允许千分位逗号;含数量级词(万/亿/千/k/M/bn)一律拒绝不换算;整数收到小数拒绝;日期用 ISO;单选/多选值必须在已配选项内;未知字段静默丢弃;空串归一 NULL。必填校验只在插入时执行。

将来要加的字段,塞进已有 JSON 列,不靠加列升级。初始化要幂等(可重复执行零重复)。

7. SQL 通道​

  • 一次一条语句,禁分号拼接。
  • 表名写裸名由系统路由。
  • 建表不得定义 id / created_at / PRIMARY KEY。
  • UPDATE / DELETE 必须带 WHERE。
  • 禁事务控制语句;禁访问 public、pg_catalog、information_schema 及其他组织 schema。
  • CREATE 仅支持 TABLE / INDEX / VIEW。(踩坑档案补充:ALTER / DROP / TRUNCATE 也被执行器拒绝。)

2026-08 实测补充:

  1. 不支持 JSONB。JSON 存 text 列、应用层解析;不得出现 ::jsonb(DEFAULT '[]'::jsonb 写 DEFAULT '[]')。text 不校验 JSON 合法性,解析处兜住坏 JSON(跳过坏行并点名,不整轮失败)。
  2. 建表字段名受 §2 标识符规则约束。「容差%」写「容差百分比」。数据值不受限。
  3. 字符串字面量内 : 后紧跟字母或数字被识别为绑定参数占位符,报 A value is required for bind parameter。内联 JSON 冒号后留空格("value": 0.85);运行时写 JSON 列优先走结构化 insert / update,不拼 SQL。

8. 删除保护​

chat_mode=True 且影响行数超过 10(或未知)时返回 confirm_token,用户确认后原参数不变、携带 token 重调,5 分钟有效。 所有删除在执行前把命中行整份落盘到工作区 del_backup_<表>_<时间戳>.json,经 backup_file 返回;备份失败记日志不阻断。 filters / row_ids 为空一律拒绝。

精确口径:确认与备份覆盖 delete_batch(按 row_ids 个数)、delete_by(按命中行数)、sql_execute 的 DELETE(按预检行数,预检失败视为未知→要确认);按单个 row_id 的 delete 既不确认也不备份。备份文件落工作区根目录,会被当附件展示——刻意的。

chat_mode 三条线都是 True(对话线/定时任务/技能链)。后果:链或定时任务里删除命中 >10 行同样返回 confirm_token,而链里没人能回 token,该步必然卡死。 链内删除:控制在 ≤10 行(分批),或走 sql_execute 且预检 ≤10。真需要大批量自动清理,给 SkillRunPolicy 加 chat_mode 字段随 policy 走,不在技能里绕闸。

9. 技能内约束(自己直连库时)​

会话纪律以 spec §7A / code-facts.md §3.7 为准:库访问收进单一 _db(),每次从 db_factory 现开一条短会话、 用完即还,不用 ctx["db"](入口 async);写要 commit,回滚/关闭交给 async with——现开现还天然免 25P02,不用手写 rollback;仅当手动攥一条会话跨语句才需 rollback 再复用,并提前把表名/字段名取成普通变量防 ORM expire。此外:

  1. 批量写入逐行 SAVEPOINT 隔离,单行失败不影响整批。
  2. 错误分类按 SQLSTATE,不按异常类名/英文文本;无 SQLSTATE = 没到库,是参数编码问题。
  3. 不假设唯一约束;upsert 按 key_fields 匹配;upsert 前查 pg_constraint、缺了先去重再补建。
  4. 返回可核验数字(inserted / updated / failed / verified_total)。
  5. 表名全限定 org_data_<org>."表"、保留字加引号、绑定参数不写 :name::type。
  6. 不存在的表先查存在性;不用裸 SQL 建表(走 create_table)。
  7. 真库端到端验一次(SQL 语法、提交落盘、约束、事务连坐,mock 覆盖不到)。

10. 踩坑档案摘录​

  • 所有表由 create_table 建,没有一列是 SQL 补的。
  • datatable handler 曾读 emp_no,经 call_skill 调用时工号必为空,own 表一律 fail closed。已改为读 actor_emp_no 并回退 emp_no。新技能一律用 actor_emp_no。
  • 20 个导入 JSON 落在工作区根目录全被当产物展示——放子目录,成功即删。