feat(bitable): formula relations v2 — U1-U8 + review fixes (#26)
Deploy to Production / deploy (push) Waiting to run Details
Test / backend-test (push) Waiting to run Details
Test / frontend-unit (push) Waiting to run Details
Test / api-e2e (push) Waiting to run Details
Test / frontend-e2e (push) Waiting to run Details

PR #26: bitable v2 module

- Formula engine: 10→64 functions
- Cross-table formula syntax
- Relation CRUD (1:1/1:N/N:N + bidirectional + self-ref)
- Lookup & Rollup field types with recalc worker
- Cross-table dependency graph
- Automation engine (3 triggers + 5 actions + retries + Webhook)
- 11 REST API endpoints + 17 BitableTool actions

Bug fixes (review + AE1):
- SSRF guard for outbound webhooks
- Bidirectional association reverse field cleanup
- Target table ownership checks
- Typed AutomationUpdate models
- Decimal handling for JSONB numbers
- merge_record_values (preserve fields on link update)
- enqueue_recalc ON CONFLICT DO UPDATE (reset done→pending)
- _trigger_cross_table_recalc on record update

Tests: 267 unit passed, AE1 e2e 3/3 passed, ruff clean
This commit is contained in:
Fischer 2026-07-06 13:35:31 +08:00
commit 8063f4efe0
33 changed files with 6566 additions and 216 deletions

View File

@ -0,0 +1,762 @@
---
title: Bitable 公式与关联联动 v2 - Plan
type: feat
date: 2026-07-06
topic: bitable-formula-relations-v2
artifact_contract: ce-unified-plan/v1
artifact_readiness: implementation-ready
product_contract_source: ce-brainstorm
execution: code
---
# Bitable 公式与关联联动 v2 - Plan
## Goal Capsule
**Objective:** 把 bitable 从 "agent companion" 推进到"公式+关联联动的数据建模层",向飞书/Notion/Baserow 等成熟产品看齐。v2 聚焦公式引擎扩展、表关联(含多对多)、跨表公式与依赖追踪、轻量自动化,并以 agent 生成公式/自动化作为差异化亮点。
**Product authority:** 产品负责人(用户)已确认范围;技术实现细节由 ce-plan 决定。
**Open blockers:** 无。三个 call-out多对多纳入 v2、agent 差异化亮点、存储模型扩展)均已确认。
## Product Contract
**Product Contract unchanged** — R1-R20 / F1-F4 / AE1-AE5 来自 ce-brainstorm未做产品范围变更。Planning Contract 在下方追加。
### Summary
v2 把 bitable 的公式引擎从 10 函数扩展到 40+(含跨表引用),同步引入单向/双向/多对多表关联 + Rollup 聚合 + 存储模型扩展轻量自动化Webhook + 触发器 + 动作)打底,并以 agent 生成公式/自动化作为差异化亮点。协作与视图扩展留给 v3+。
### Problem Frame
当前 bitable 定位为 AgentKit 的 "companion service",已有 9 字段类型、5 视图枚举(仅 grid 实现、10 个公式函数、3 个数据导入源、无自动化、无表关联、无实时协作。
竞品对比2026 年数据)显示巨大差距:
| 维度 | 当前 | 飞书 | Notion | Baserow | NocoDB |
|---|---|---|---|---|---|
| 公式函数 | 10 | 100+ | 80+ | 100+ | Excel 兼容 |
| 自动化 | 无 | 工作流+AI Agent | 4 触发器+4 动作 | Workflow Builder | Workflows+Scripts |
| 表关联 | lookup 仅显示 | 单/双向/跨表公式 | 单/双向/自引用/Rollup | Link/Lookup/Rollup | LTAR V2 |
| 字段类型 | 9 | 26 | 21-23 | 25+ | 40+ |
目标是对标补齐做完整的多人协作多维表格产品。v2 聚焦单用户深度(公式+关联),协作与视图扩展留给后续版本。
### Key Decisions
**公式+关联同步做(方案 B。** 跨表公式依赖关联基础设施,分开做会导致公式引擎返工。同步推进避免架构级重构。
**多对多关联纳入 v2。** 多对多需扩展存储模型junction table 或等价方案),增加架构工作量。尽早实施避免 v3 返工——关联是数据建模的灵魂,缺多对多则复杂业务场景无法支撑。
**自动化轻量化。** v2 仅做 Webhook + 3 触发器 + 5 动作不做画布式工作流编排if/else/switch/循环)和 AI Agent 节点。优先把数据建模层做扎实,自动化深度留给 v3+。
**Agent 生成公式/自动化作为差异化亮点。** 飞书 AI Agent 节点很强,但我们是 agent-native 架构——agent 是"一等公民"。agent 通过自然语言为用户生成复杂公式、描述目标生成自动化规则,这是竞品没有的独特价值。
**视图类型全部推迟到 v3。** 牺牲短期"完整度感知"换取公式+关联的深度。kanban/gallery/gantt/form/calendar 等视图留给 v3。
### Actors
- **用户User**:创建/编辑表、字段、记录、公式、关联、自动化规则;消费数据
- **Agent**:通过 BitableTool REST API 读写数据;生成公式/自动化规则差异化能力upsert agent-owned 字段
- **系统Recalc Worker**:异步重算公式字段(单表+跨表);处理自动化触发器
### Requirements
#### 公式引擎扩展
R1. 公式函数从 10 扩展到 40+覆盖日期15+,如 DATE/DATEDIF/NETWORKDAYS/WEEKDAY/EOMONTH、文本10+,如 FIND/MID/REPLACE/SUBSTITUTE/UPPER/LOWER、逻辑10+,如 IFS/SWITCH/IFERROR/ISBLANK/AND/OR/NOT、条件聚合5+,如 SUMIF/COUNTIF/AVERAGEIF、查找引用5+,如 LOOKUP/FILTER/MATCH五大类别。
R2. 支持跨表公式引用——公式可引用关联表的字段(如 `[关联表].[字段]` 整列引用或 `{关联字段}.{目标字段}` 行级引用),依赖关联基础设施(见 R5
R3. 公式引擎保持 AST 白名单安全机制KTD7新增函数需注册到白名单禁止 `eval()`/`exec()`。
R4. 公式嵌套深度上限设为 15 层(对齐 Notion超过则报错提示。
#### 表关联与数据模型
R5. 支持三种关联类型单向关联A→BB 无反向字段、双向关联A↔B自动创建反向字段、多对多关联通过 junction table 或等价机制)。
R6. 关联字段显示被关联记录的主键值;可通过 Lookup 字段拉取关联记录的其他字段;可通过 Rollup 字段对关联记录聚合sum/count/avg/min/max/unique
R7. 支持自引用关联(关联"当前表"),用于父子层级/依赖关系建模。
R8. 关联关系删除时,依赖该关联的 Lookup/Rollup/跨表公式字段需级联处理(报错提示或级联删除,由 force 参数控制,对齐现有字段删除 guard 模式)。
R9. 存储模型扩展以支持关联——JSONB 记录存储关联引用(记录 ID 列表junction table 存储多对多关系。具体 schema 由 ce-plan 决定。
#### 跨表公式与依赖追踪
R10. Recalc worker 从单表内重算扩展到跨表依赖追踪——当表 A 的记录变更时,表 B 中引用该记录的公式/Rollup 字段需触发重算。
R11. 跨表依赖图构建——维护字段级依赖关系(哪些表的哪些字段依赖当前记录),支持拓扑排序避免循环。
R12. 跨表重算异步执行,不阻塞原始记录写入;通过现有 recalc queue 机制FOR UPDATE SKIP LOCKED扩展。
#### 轻量自动化
R13. 支持 3 种触发器:记录新增、记录修改(可指定字段)、记录删除。
R14. 支持 5 种动作:新增记录、修改记录、发送 HTTP 请求Webhook 出站)、发送邮件、发送消息(集成现有消息总线)。
R15. 支持 Webhook 入站——外部系统 POST 到指定端点触发自动化规则。
R16. 自动化规则配置存储在 bitable schema 中(新增 automation 表或扩展现有 meta具体 schema 由 ce-plan 决定。
R17. 自动化规则执行异步,失败重试 3 次后标记错误状态;执行日志可查。
#### Agent 集成与差异化
R18. BitableTool 扩展新 action`generate_formula`(自然语言描述 → 生成可用公式)、`generate_automation`(描述目标 → 生成自动化规则)、`create_relation`(创建关联字段)、`create_rollup`(创建 Rollup 字段)。
R19. Agent 生成的公式/自动化规则需经用户确认后落地(或直接落地但可撤销,由 ce-plan 决定交互模式)。
R20. Agent 生成公式时,公式引擎的 validate-formula 端点(已存在)复用,确保生成结果语法正确。
### Key Flows
**F1. 跨表公式重算流程**
- **触发:** 用户或 agent 修改表 A 记录的字段值
- **步骤:** 1) 识别表 A 中依赖该字段的公式字段,加入 recalc queue2) 查跨表依赖图,找出表 B 中引用表 A 该记录的公式/Rollup 字段3) 跨表依赖任务加入 recalc queue标记跨表4) Recalc worker 按拓扑排序处理5) 写回重算结果
- **Covers R10, R11, R12.**
**F2. 多对多关联创建流程**
- **触发:** 用户或 agent 在表 A 创建多对多关联字段,指向表 B
- **步骤:** 1) 创建 junction table或等价结构存储 A-B 关联对2) 在表 A 创建关联字段(显示 B 记录列表3) 在表 B 创建反向关联字段(双向,显示 A 记录列表4) 更新跨表依赖图
- **Covers R5, R9.**
**F3. Agent 生成公式流程**
- **触发:** 用户通过 agent 描述需求(如"计算订单金额 = 单价 × 数量"
- **步骤:** 1) Agent 调用 BitableTool `generate_formula` action2) Agent LLM 生成公式表达式3) 调用 validate-formula 端点验证语法4) 验证通过 → 创建公式字段5) Recalc worker 触发重算
- **Covers R18, R19, R20.**
**F4. 自动化规则执行流程**
- **触发:** 记录变更满足自动化规则的触发条件
- **步骤:** 1) 触发器检测变更2) 匹配自动化规则3) 异步执行动作(新增记录/HTTP/邮件/消息4) 失败重试 3 次5) 写执行日志
- **Covers R13, R14, R15, R17.**
### Scope Boundaries
**Deferred for later (v3+):**
- 新视图类型kanban、gallery、gantt、form、calendar、timeline
- 仪表盘/Dashboard、Chart 视图、15+ 图表组件
- 画布式工作流编排if/else/switch/循环节点、AI Agent 节点
- 工作流画布编辑器 UI
- Formula 2.0 高级特性:`let`/`lets` 局部变量、`map`/`filter`/`find` 列表操作、富数据输出(返回 page/date/people/list
**Outside this product's identity (v4+ 协作阶段):**
- 实时协作OT/CRDT 算法,多人并发编辑)
- 分享/协作权限模型(行级/列级/记录级 RBAC
- 评论与 @ 提及
- 视图级权限restricted view
**Explicitly excluded:**
- 跨多维表格文件关联(飞书也不支持,需通过同步数据)
- 单元格内插入公式(仅公式字段支持公式,对齐飞书/Notion 模式)
### Deferred to Follow-Up Work
- 前端公式编辑器智能补全(函数名/字段名 autocomplete— 后端 API 已支持,前端深度优化留给后续打磨
- 自动化规则的可视化编排 UI — v2 仅做表单式配置,画布式 UI 推迟
- 跨表公式的 EXPLAIN 依赖图可视化 — 后端依赖图已构建,可视化留给后续
### Acceptance Examples
**AE1. 跨表公式重算Covers R2, R10, R11**
- **Given:** 表 Orders 有字段 `amount`,表 Customers 有 Rollup 字段 `total_amount` = SUM(Orders.amount) where Orders.customer_id = this.id
- **When:** 用户修改 Orders 表某记录的 `amount`
- **Then:** Customers 表对应客户的 `total_amount` 异步重算并更新
**AE2. 多对多关联Covers R5, R6, R9**
- **Given:** 表 Students 和表 Courses
- **When:** 用户在 Students 表创建多对多关联字段指向 Courses
- **Then:** 1) junction table 创建2) Students 显示关联的 Courses 列表3) Courses 显示反向关联的 Students 列表4) 在 Students 可创建 Lookup 拉取 Course 的 `credit` 字段5) 可创建 Rollup 统计 Student 选课的总学分
**AE3. 公式函数扩展Covers R1, R3**
- **Given:** 表有 `start_date`date、`end_date`date字段
- **When:** 用户创建公式字段 `=NETWORKDAYS({start_date}, {end_date})`
- **Then:** 公式验证通过,重算出工作日天数
**AE4. 自动化触发与执行Covers R13, R14, R17**
- **Given:** 表 Tasks 有自动化规则:当 `status` 修改为"已完成"时,调用 Webhook
- **When:** 用户将某记录 `status` 改为"已完成"
- **Then:** 1) 触发器检测到变更2) 异步执行 Webhook 动作3) 执行日志记录结果4) 若 Webhook 失败,重试 3 次后标记错误
**AE5. Agent 生成公式Covers R18, R19, R20**
- **Given:** 表有 `price`、`quantity` 字段
- **When:** 用户通过 agent 说"帮我算总价"
- **Then:** 1) Agent 调用 `generate_formula` action2) 生成公式 `={price} * {quantity}`3) validate-formula 验证通过4) 创建公式字段 `total_price`5) 重算所有记录
### Dependencies / Assumptions
- **假设:** 现有 PostgreSQL JSONB 存储模型可扩展支持关联引用(记录 ID 列表嵌入 JSONB 或 junction table
- **假设:** 现有 recalc workerFOR UPDATE SKIP LOCKED可扩展到跨表依赖追踪无需替换为消息队列
- **依赖:** 现有 BitableTool REST API 边界KTD5保持不变新 action 通过 HTTP 调用
- **依赖:** 现有公式引擎 AST 白名单KTD7扩展不引入 `eval()`/`exec()`
### Sources / Research
**竞品调研2026 年数据):**
- 飞书多维表格6 视图 + 仪表盘(15+)、26 字段类型、100+ 公式函数、工作流+AI Agent 节点、单/双向关联/跨表公式
- Notion11 视图、21-23 字段类型、80+ 公式Formulas 2.0let/lambda/map/filter、4 触发器+4 动作自动化、单/双向/自引用/Rollup
- Baserow6 视图、25+ 字段类型、100+ Excel 风格公式、Workflow Builder、Link/Lookup/Rollup/Count
- NocoDB9 视图(含 Gantt/Timeline/Map、40+ 字段类型、LTAR V2 统一 junction-table
- APITable7 视图(含 Mindmap、24 字段类型、OT 实时协作ot-json0+Yjs、Magic Link 双向关联
- Twenty3 视图、17+ 字段类型、CRM-as-code SDK、原生 MCP Server
**当前实现 grounding**
- Dossier 路径:`/tmp/compound-engineering/ce-brainstorm/bitable-1783306851/grounding.md`
- 关键文件:`src/agentkit/bitable/models.py`FieldType/ViewType/FieldOwner 枚举)、`formula/engine.py`DAG+拓扑排序)、`recalc_worker.py`(异步重算)、`server/routes/bitable.py`API 端点)、`tools/bitable_tool.py`agent REST 边界)
---
## Planning Contract
### High-Level Technical Design
```mermaid
graph TB
subgraph "Storage Layer (Schema V3)"
DB[(bitable DB)]
JT[junction tables<br/>多对多]
AUTO[bitable_automations<br/>+ bitable_automation_logs]
DEP[bitable_cross_table_deps<br/>依赖图]
end
subgraph "Formula Engine"
FE[FormulaEngine<br/>DAG + topo sort]
FUNC[40+ functions<br/>date/text/logical/agg/lookup]
XCTBL[Cross-table ref resolver<br/>relation_field.target_field]
end
subgraph "Relation Layer"
REL[RelationService<br/>single/bi/multi/self-ref]
LK[Lookup evaluator]
RU[Rollup evaluator<br/>sum/count/avg/min/max/unique]
end
subgraph "Recalc Pipeline"
RW[RecalcWorker<br/>FOR UPDATE SKIP LOCKED]
DG[DependencyGraph<br/>field-level cross-table]
Q[recalc_queue<br/>+ cross_table flag]
end
subgraph "Automation Engine"
TRIG[Trigger detector<br/>create/update/delete]
ACT[Action executor<br/>5 actions + retry]
WH[Webhook in/out]
LOG[Execution logs]
end
subgraph "Agent Surface"
BT[BitableTool<br/>REST boundary KTD5]
GF[generate_formula]
GA[generate_automation]
CR[create_relation]
CU[create_rollup]
end
DB --> JT
DB --> AUTO
DB --> DEP
FE --> FUNC
FE --> XCTBL
REL --> LK
REL --> RU
REL --> JT
RW --> DG
RW --> Q
RW --> FE
TRIG --> ACT
ACT --> WH
ACT --> LOG
BT --> GF
BT --> GA
BT --> CR
BT --> CU
GF --> FE
CR --> REL
CU --> RU
XCTBL --> DG
RU --> DG
LK --> DG
```
**关键数据流:**
1. **写入路径**:用户/agent 修改记录 → service 层识别受影响字段 → 查跨表依赖图 → enqueue 本表 + 跨表 recalc 任务 → worker 异步处理
2. **重算路径**worker claim 任务 → 构建引擎 → 解析跨表引用(通过 relation field → target field→ 拉取关联记录值 → evaluate → 写回
3. **自动化路径**record mutation hook → trigger matcher → async action executor → retry 3x → log
### Key Technical Decisions
**KTD-1. Junction table 用独立表,不用 JSONB 数组。**
多对多关联用独立 `bitable_relation_links` 表存储 `(relation_field_id, source_record_id, target_record_id)` 三元组。理由1) 支持索引和反向查询2) JSONB 数组在记录多时性能退化3) 对齐 NocoDB LTAR V2 / Baserow Link 表模式。一对一/一对多关联仍在记录 JSONB 中存 `["rec_id_1", "rec_id_2"]` 数组(轻量,无独立表开销)。
**KTD-2. 跨表公式语法用 `{relation_field}.{target_field}` 行级引用 + `[relation_field].[target_field]` 整列引用。**
对齐 Notion 的 `relation.property` 语法。行级引用返回当前记录关联的目标记录的该字段值(标量或列表);整列引用返回关联表所有记录该字段的列表(用于 SUMIF 等。parser 扩展 `_FIELD_REF_RE` 支持 `.` 分隔的二级路径。
**KTD-3. 跨表依赖图用物化表 `bitable_cross_table_deps`,不用运行时计算。**
字段创建/更新时service 层解析公式 + relation 结构,写入 `(source_table_id, source_field_id, target_table_id, target_field_id, dep_type)` 记录。recalc worker 通过该表 O(1) 查找跨表依赖,避免每次重算都解析 AST。字段变更时同步更新该表service 层 hook
**KTD-4. 自动化执行用独立 asyncio task不进 recalc queue。**
自动化触发器在 service 层 record mutation hook 中同步匹配,匹配到的规则创建独立 asyncio task 执行动作。理由1) 自动化动作HTTP/邮件延迟特性与公式重算不同2) 失败重试逻辑独立3) 避免污染 recalc queue 的 FOR UPDATE SKIP LOCKED 语义。重试用 exponential backoff1s/2s/4s3 次失败后标记 error。
**KTD-5. Agent 生成公式/自动化用"直接落地 + 可撤销"模式,不做确认弹窗。**
agent 生成结果直接创建字段/规则,但在响应中返回 `created_id``undo_token`。用户可在 30 秒内通过 `undo` action 撤销。理由1) agent-native 架构强调低延迟2) 确认弹窗打断 agent 流3) 撤销比确认更符合人类直觉。`undo_token` 存 Redis5 分钟 TTL
**KTD-6. 公式函数扩展按类别分模块,不堆在一个文件。**
当前 `functions.py` 已有 10 个函数。扩展到 40+ 后单文件过大。按类别拆分:`functions/datetime.py`、`functions/text.py`、`functions/logical.py`、`functions/aggregate.py`(含条件聚合)、`functions/lookup.py`。`functions/__init__.py` 聚合注册到 `FUNCTION_REGISTRY`。保持 `AGGREGATE_FUNCTIONS` frozenset 同步更新。
**KTD-7. Lookup/Rollup 作为独立 FieldType不作为 formula 子类型。**
新增 `FieldType.lookup`(已存在,扩展 config`FieldType.rollup`新增。理由1) 类型明确UI 渲染清晰2) 重算逻辑独立lookup 不聚合rollup 聚合3) 对齐飞书/Notion/Baserow 的字段类型划分。config 形状:`lookup: {relation_field_id, target_field_id}``rollup: {relation_field_id, target_field_id, aggregation: "sum"|"count"|"avg"|"min"|"max"|"unique"}`。
**KTD-8. AST 深度限制在 parser 层而非 engine 层。**
`parse_formula()` 中递归计算 AST 深度,超过 15 抛 `FormulaDepthExceededError`。理由1) 早失败,避免构建无效 DAG2) validate-formula 端点直接返回错误3) 不污染 engine 的拓扑排序逻辑。
**KTD-9. Webhook 入站用独立路由前缀 `/api/v1/bitable/webhooks/{token}`。**
不用 JWT 认证,用 webhook-specific token每规则唯一。理由1) 外部系统无法获取 JWT2) token 可在 UI 重置3) 与现有 `/api/v1/bitable` 内部端点隔离安全边界清晰。token 存 automation config恒定时间比较。
### Implementation Units
#### Phase 1: Storage & Model Foundation
### U1. Schema V3 Migration + Data Models Extension
**Goal:** 扩展 bitable 存储模型支持关联、自动化、跨表依赖追踪。新增 junction table、automation 表、依赖图表,扩展 Field/Record 模型。
**Requirements:** R5, R9, R16
**Dependencies:** 无(基础单元)
**Files:**
- `src/agentkit/bitable/db.py` — bump `_SCHEMA_VERSION` 2→3新增 ORM modelsRelationLinkModel, AutomationModel, AutomationLogModel, CrossTableDepModel新增 `_apply_v3_migration`
- `src/agentkit/bitable/models.py` — 新增 `FieldType.rollup`、`AutomationTrigger`、`AutomationAction`、`AutomationRule`、`AutomationLog`、`RelationLink`、`CrossTableDep` Pydantic 模型;扩展 `Field.config` 文档说明 relation/rollup 配置形状
- `src/agentkit/bitable/repository.py` — 新增 RelationLinks / Automations / AutomationLogs / CrossTableDeps CRUD 方法
- `tests/unit/bitable/test_db.py` — V3 migration 测试
- `tests/unit/bitable/test_models.py` — 新模型序列化测试
**Approach:**
- `_apply_v3_migration(conn)` 创建 4 张新表idempotent CREATE TABLE IF NOT EXISTS
- `bitable_relation_links``(id, relation_field_id, source_record_id, target_record_id, created_at)` + 索引 `(relation_field_id, source_record_id)``(relation_field_id, target_record_id)` 用于双向查询
- `bitable_automations``(id, table_id, name, trigger_config JSONB, action_config JSONB, enabled BOOL, created_at)` + 索引 `table_id`
- `bitable_automation_logs``(id, automation_id, trigger_record_id, status, error_message, started_at, completed_at)` + 索引 `automation_id, started_at`
- `bitable_cross_table_deps``(id, source_table_id, source_field_id, target_table_id, target_field_id, dep_type)` + 唯一约束 `(source_field_id, target_field_id)` + 索引 `target_field_id`(反向查询用)
- `FieldType` 新增 `rollup = "rollup"``relation` 不新增(用 `lookup` field_type + `config.relation_field_id` 表示关联字段,对齐现有 lookup 设计但扩展 config
- `Field.config` 形状扩展relation 字段 `{"relation_type": "one_to_one"|"one_to_many"|"many_to_many", "target_table_id": "...", "is_bidirectional": bool, "reverse_field_id": "..."}`rollup 字段 `{"relation_field_id": "...", "target_field_id": "...", "aggregation": "sum"|"count"|"avg"|"min"|"max"|"unique"}`
**Patterns to follow:**
- `_apply_v2_migration` 的 idempotent 模式CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS
- 现有 ORM models 的 `__table_args__` schema 限定模式
- Pydantic `model_config = ConfigDict(from_attributes=True)` 约定
**Test scenarios:**
- **Happy path:** 全新 DB init → V3 schema 创建 4 张新表 + V2 表保留
- **Migration:** V2 DB → init → V3 migration 应用,新表创建,现有数据不丢失
- **Idempotent:** V3 DB → init → 不重复创建表
- **Covers AE2 (junction table 创建部分).**
- **Edge case:** meta 表 schema_version 正确更新为 3
- **Integration:** repository 新增 CRUD 方法create_relation_link, list_relation_links_by_source, etc.)读写正常
**Verification:** `pytest tests/unit/bitable/test_db.py tests/unit/bitable/test_models.py -x -q` 通过V3 migration 在空 DB 和 V2 DB 上都幂等。
---
#### Phase 2: Formula Engine Expansion
### U2. Formula Function Expansion (10 → 40+)
**Goal:** 扩展公式函数从 10 个到 40+,覆盖日期、文本、逻辑、条件聚合、查找引用五大类别,保持 AST 白名单安全。
**Requirements:** R1, R3, R4
**Dependencies:** 无(与 U1 并行)
**Files:**
- `src/agentkit/bitable/formula/functions/` — 新建包目录
- `__init__.py` — 聚合所有子模块到 `FUNCTION_REGISTRY``AGGREGATE_FUNCTIONS`
- `datetime.py` — DATE, DATEDIF, NETWORKDAYS, WEEKDAY, EOMONTH, TODAY, NOW, YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, DATEADD, TIMESTAMP15 个)
- `text.py` — FIND, MID, REPLACE, SUBSTITUTE, UPPER, LOWER, TRIM, LEFT, RIGHT, REPT, TEXT, VALUE, STARTSWITH, ENDSWITH, SPLIT15 个)
- `logical.py` — IFS, SWITCH, IFERROR, ISBLANK, AND, OR, NOT, ISNUMBER, ISTEXT, ISDATE, COALESCE, NULL, TRUE, FALSE14 个)
- `aggregate.py` — SUMIF, COUNTIF, AVERAGEIF, SUMIFS, COUNTIFS5 个条件聚合,迁移现有 SUM/AVG/COUNT/MIN/MAX
- `lookup.py` — LOOKUP, FILTER, MATCH, VLOOKUP, XLOOKUP5 个,跨表引用支持在 U3 实现)
- `src/agentkit/bitable/formula/functions.py` — 删除(迁移到包),保留兼容 re-export
- `src/agentkit/bitable/formula/parser.py` — 新增 `FormulaDepthExceededError``parse_formula` 中计算 AST 深度,超 15 抛错;`_ALLOWED_NODES` 扩展支持 `ast.List`(用于 FILTER/MATCH 返回列表)和 `ast.Tuple`(用于多参数)
- `src/agentkit/bitable/formula/engine.py``evaluate_all_for_record` 扩展支持列表类型返回值
- `tests/unit/bitable/test_formula_parser.py` — 深度限制测试
- `tests/unit/bitable/test_formula_engine.py` — 40+ 函数逐个测试
- `tests/unit/bitable/test_functions_datetime.py` — 日期函数专项测试
- `tests/unit/bitable/test_functions_text.py` — 文本函数专项测试
- `tests/unit/bitable/test_functions_logical.py` — 逻辑函数专项测试
- `tests/unit/bitable/test_functions_aggregate.py` — 条件聚合测试
**Approach:**
- 日期函数处理 ISO 8601 字符串(现有 date 字段存储格式NETWORKDAYS 用 Python `datetime.weekday()` 排除周末(不支持节假日表,留给 v3
- 文本函数对齐 Excel/Notion 命名FIND/MID/REPLACE 与 Excel 一致)
- 条件聚合 `SUMIF(range, criteria, [sum_range])` — range 是字段引用criteria 是字符串/数字/表达式sum_range 可选
- LOOKUP/FILTER/MATCH 在 U3 实现跨表引用逻辑,本单元先实现单表内查找
- AST 深度计算:递归 `_ast_depth(node)` 返回 `1 + max(child_depths)`,在 `parse_formula` 末尾检查
- `FUNCTION_REGISTRY` 兼容:现有代码 `from agentkit.bitable.formula.functions import FUNCTION_REGISTRY` 通过 `__init__.py` re-export 保持不变
**Patterns to follow:**
- 现有 `functions.py``FormulaResult: TypeAlias = str | int | float | None` 类型约定
- 现有 `_sum`/`_avg` 的 None/空字符串过滤模式
- 现有 `AGGREGATE_FUNCTIONS` frozenset 注册模式
**Test scenarios:**
- **Happy path (per function):** 每个新函数至少 1 个 happy path 测试,验证返回值正确
- **Covers AE3:** `=NETWORKDAYS({start_date}, {end_date})` 验证通过并返回工作日天数
- **Edge cases:** 空输入None/空字符串)处理;日期函数对非日期字符串报错;条件聚合无匹配返回 0
- **Error path:** 未知函数仍抛 `UnknownFunctionError`AST 深度超 15 抛 `FormulaDepthExceededError`
- **Integration:** `parse_formula("=SUMIF({f1}, '>10')")` + `evaluate_ast` 全流程通过validate-formula 端点对新函数返回 200
- **Security:** 尝试 `=eval("code")` 仍被 `_SecurityVisitor` 拒绝;`=__import__('os')` 拒绝
**Verification:** `pytest tests/unit/bitable/test_functions_*.py tests/unit/bitable/test_formula_parser.py -x -q` 通过40+ 函数全部在 `FUNCTION_REGISTRY` 注册。
---
### U3. Cross-Table Formula References
**Goal:** 支持跨表公式引用 `{relation_field}.{target_field}` 行级 + `[relation_field].[target_field]` 整列,依赖 U1 的 relation 基础设施。
**Requirements:** R2
**Dependencies:** U1, U2
**Files:**
- `src/agentkit/bitable/formula/parser.py` — 扩展 `_FIELD_REF_RE` 支持 `.` 分隔的二级路径;`_substitute_field_refs` 返回 `field_mapping` 增加 `cross_table_refs: list[(safe_name, relation_field_id, target_field_id)]`
- `src/agentkit/bitable/formula/engine.py``add_formula` 扩展 `_classify_refs` 识别跨表引用;`evaluate` 扩展 `relation_context` 参数;新增 `_resolve_cross_table_ref` 方法
- `src/agentkit/bitable/formula/functions/lookup.py` — 实现 LOOKUP/FILTER/MATCH 的跨表逻辑(依赖 relation_context
- `src/agentkit/bitable/service.py``create_field` / `update_field` 中解析公式跨表引用,写入 `bitable_cross_table_deps`KTD-3
- `tests/unit/bitable/test_formula_parser.py` — 跨表引用解析测试
- `tests/unit/bitable/test_formula_engine.py` — 跨表公式求值测试
**Approach:**
- parser 扩展正则:`_FIELD_REF_RE = re.compile(r"\{([a-zA-Z0-9_-]+)(?:\.([a-zA-Z0-9_-]+))?\}")`,第二组为可选 target_field
- 整列引用 `[rel_field].[target_field]` 用单独正则 `_\[([a-zA-Z0-9_-]+)\]\.\[([a-zA-Z0-9_-]+)\]`,替换为 `__col_<rel>_<target>` Name 节点
- engine `evaluate` 签名扩展:新增 `relation_context: dict[str, list[dict[str, object]]]` 参数key 为 relation_field_idvalue 为关联记录列表
- service 层在创建/更新 formula 字段时,解析公式 AST 提取跨表引用,写入 `bitable_cross_table_deps`dep_type: `formula`
- LOOKUP/FILTER/MATCH 函数接收 relation_context通过 target_field_id 拉取值
**Patterns to follow:**
- 现有 `_substitute_field_refs` 的 safe_name 映射模式
- 现有 `_classify_refs` 的 aggregate vs row 分类模式
**Test scenarios:**
- **Happy path:** `={rel_field}.{target_field}` 解析为跨表行级引用;`=[rel_field].[target_field]` 解析为整列引用
- **Covers AE1 (跨表公式部分):** Orders.amount 修改 → Customers.total_amount通过 rollupU5 实现)跨表重算
- **Edge case:** 自引用关联rel_field 指向当前表)正常解析
- **Error path:** relation_field 不存在 → 抛 `FormulaParseError`target_field 不存在 → 抛 `FormulaParseError`
- **Integration:** service.create_field 写入 cross_table_deps 记录engine.evaluate 传入 relation_context 后正确求值
**Verification:** `pytest tests/unit/bitable/test_formula_parser.py tests/unit/bitable/test_formula_engine.py -x -q` 通过;跨表引用解析和求值正确。
---
#### Phase 3: Relations
### U4. Relation CRUD + Junction Table Management
**Goal:** 实现单向/双向/多对多/自引用关联字段的创建、更新、删除,管理 junction table 生命周期。
**Requirements:** R5, R7, R8
**Dependencies:** U1
**Files:**
- `src/agentkit/bitable/service.py` — 新增 `create_relation_field`、`update_relation_link`(添加/删除关联记录)、`delete_relation_field`(级联处理)方法
- `src/agentkit/bitable/repository.py` — 新增 `add_relation_link`、`remove_relation_link`、`list_relation_links`、`list_reverse_relation_links` 方法
- `src/agentkit/server/routes/bitable.py` — 新增端点:`POST /tables/{id}/fields`(扩展支持 relation field_type、`POST /fields/{id}/links`(添加关联)、`DELETE /fields/{id}/links/{record_id}`(删除关联)
- `tests/unit/bitable/test_service.py` — relation CRUD 测试
- `tests/unit/bitable/test_routes.py` — relation 端点测试
**Approach:**
- `create_relation_field(table_id, name, target_table_id, relation_type, is_bidirectional)`
- one_to_one / one_to_many关联值存记录 JSONB `values[relation_field_id]` = `["rec_id"]``["rec_id1", "rec_id2"]`
- many_to_many创建独立 junction table 引用KTD-1字段 config 存 `relation_type` + `target_table_id`
- is_bidirectional=True在 target_table 自动创建反向字段config 互相引用 `reverse_field_id`
- 自引用target_table_id = 当前 table_id正常流程
- `update_relation_link(field_id, source_record_id, target_record_ids)`many_to_many 操作 `bitable_relation_links`;其他类型更新记录 JSONB
- `delete_relation_field(field_id, force)`:扩展 `FieldDependencyError` 检查——查找依赖该 relation 的 lookup/rollup/跨表公式字段force=True 时级联删除依赖字段的 config
**Patterns to follow:**
- 现有 `delete_field``FieldDependencyError` + force 级联模式service.py:234-282
- 现有 `_check_table_ownership` IDOR 模式
- 现有 `create_field` 的 config 验证模式
**Test scenarios:**
- **Happy path:** 创建单向关联A→B创建双向关联A↔B自动建反向字段创建多对多关联junction table 生成)
- **Covers AE2 (关联创建部分):** Students↔Courses 多对多创建,双向字段生成
- **Covers F2:** 多对多关联创建流程完整验证
- **Edge case:** 自引用关联table A 关联自身)正常工作
- **Error path:** target_table_id 不存在 → 400删除有依赖的 relation 字段未 force → `FieldDependencyError`
- **Integration:** 创建关联字段 → 添加关联记录 → 查询关联记录列表 → 删除关联
**Verification:** `pytest tests/unit/bitable/test_service.py tests/unit/bitable/test_routes.py -x -q` 通过;三种关联类型 CRUD 完整。
---
### U5. Lookup & Rollup Fields
**Goal:** 实现 Lookup 字段拉取关联记录字段值Rollup 字段对关联记录聚合sum/count/avg/min/max/unique
**Requirements:** R6, R8
**Dependencies:** U1, U4
**Files:**
- `src/agentkit/bitable/service.py` — 新增 `create_lookup_field`、`create_rollup_field` 方法;扩展 `_trigger_recalc_for_affected_fields` 识别 lookup/rollup 字段
- `src/agentkit/bitable/recalc_worker.py` — 扩展 `process_task` 处理 lookup/rollup 字段类型;新增 `_resolve_lookup`、`_resolve_rollup` 方法
- `src/agentkit/bitable/formula/engine.py` — 扩展支持 lookup/rollup 字段作为"虚拟公式"注册到 DAG
- `tests/unit/bitable/test_service.py` — lookup/rollup 创建测试
- `tests/unit/bitable/test_recalc.py` — lookup/rollup 重算测试
**Approach:**
- lookup 字段 config: `{"relation_field_id": "...", "target_field_id": "..."}`,重算时通过 relation 找到关联记录,拉取 target_field 值
- rollup 字段 config: `{"relation_field_id": "...", "target_field_id": "...", "aggregation": "sum"}`,重算时拉取关联记录的 target_field 列表,应用聚合函数
- recalc_worker 扩展:`process_task` 检查 field.field_typeformula → 现有逻辑lookup → `_resolve_lookup`rollup → `_resolve_rollup`
- `_resolve_lookup(field, record)`:查 `bitable_relation_links`(多对多)或记录 JSONB一对多获取关联记录 ID 列表 → `get_records_batch` → 提取 target_field 值 → 返回(单值或列表)
- `_resolve_rollup(field, record)`:同 lookup 拉取值列表 → 应用 aggregation 函数 → 返回标量
- 跨表依赖lookup/rollup 字段创建时写入 `bitable_cross_table_deps`dep_type: `lookup` / `rollup`
**Patterns to follow:**
- 现有 `process_task` 的 formula 处理流程recalc_worker.py:177-235
- 现有 `get_column_values` 批量拉取模式
- 现有 `FieldDependencyError` 级联模式R8
**Test scenarios:**
- **Happy path:** 创建 lookup 字段拉取关联记录的 text 字段;创建 rollup 字段对关联记录 number 字段求和
- **Covers AE2 (lookup/rollup 部分):** Students 创建 lookup 拉取 Course.credit创建 rollup 统计总学分
- **Covers AE1:** Orders.amount 修改 → Customers.total_amountrollup SUM跨表重算
- **Edge case:** 关联记录为空时 lookup 返回 Nonerollup 返回 0unique 聚合去重
- **Error path:** relation_field 已删除 → 字段 config 标记 errortarget_field 已删除 → 同上
- **Integration:** 创建关联 → 添加关联记录 → 创建 rollup → 验证重算结果正确
**Verification:** `pytest tests/unit/bitable/test_service.py tests/unit/bitable/test_recalc.py -x -q` 通过lookup/rollup 重算结果正确。
---
#### Phase 4: Cross-Table Recalc
### U6. Cross-Table Dependency Graph + Recalc Worker Extension
**Goal:** 构建跨表字段级依赖图,扩展 recalc worker 支持跨表重算,异步不阻塞原始写入。
**Requirements:** R10, R11, R12
**Dependencies:** U1, U3, U5
**Files:**
- `src/agentkit/bitable/repository.py` — 新增 `find_cross_table_dependents(target_table_id, target_field_id)` 方法
- `src/agentkit/bitable/recalc_worker.py` — 扩展 `_run` 循环处理跨表任务;新增 `_enqueue_cross_table_recalc` 方法;`process_task` 扩展跨表上下文加载
- `src/agentkit/bitable/service.py``_trigger_recalc_for_affected_fields` 扩展查跨表依赖图enqueue 跨表任务
- `src/agentkit/bitable/models.py``RecalcTask` 新增 `is_cross_table: bool` 字段
- `src/agentkit/bitable/db.py``RecalcQueueModel` 新增 `is_cross_table`V3 migration 一部分)
- `tests/unit/bitable/test_recalc.py` — 跨表重算测试
**Approach:**
- `RecalcTask.is_cross_table` 标记跨表任务(默认 False
- service `_trigger_recalc_for_affected_fields(table_id, record_id, changed_field_ids)`
1. 查本表公式字段依赖 → enqueue 本表 recalc现有逻辑
2. 查 `bitable_cross_table_deps` where `target_table_id = table_id AND target_field_id IN changed_field_ids` → 对每个依赖字段,找到依赖它的记录(通过 relation_links→ enqueue 跨表 recalc 任务
- worker `process_task` 扩展if `task.is_cross_table` → 加载 relation_context关联记录列表→ 传给 engine.evaluate
- 跨表依赖图构建在字段创建/更新时同步U3 service hook + U5 lookup/rollup 创建 hook
- 拓扑排序扩展:跨表依赖也加入 DAG循环检测覆盖跨表
**Patterns to follow:**
- 现有 `claim_recalc_tasks``FOR UPDATE SKIP LOCKED` 模式
- 现有 `_sort_by_topological_order` 的 Kahn 算法
- 现有 `reset_stale_recalc_tasks` 的 crash recovery 模式
**Test scenarios:**
- **Happy path:** 修改表 A 记录 → 表 B 依赖该记录的 rollup 字段异步重算并更新
- **Covers AE1 完整:** Orders.amount 修改 → Customers.total_amount 跨表重算
- **Covers F1:** 跨表公式重算流程完整验证
- **Edge case:** 无跨表依赖时不 enqueue 跨表任务循环依赖检测A 依赖 BB 依赖 A`CircularReferenceError`
- **Error path:** 跨表任务失败 → 状态 error + error_messagestale 任务被 reaper 重置
- **Integration:** 创建关联 → 创建 rollup → 修改源记录 → 验证目标记录异步更新(轮询 recalc 完成状态)
**Verification:** `pytest tests/unit/bitable/test_recalc.py -x -q` 通过;跨表重算异步且正确。
---
#### Phase 5: Automation
### U7. Automation Engine (Triggers + Actions + Webhook + Retry + Logging)
**Goal:** 实现 3 种触发器、5 种动作、Webhook 入站、失败重试 3 次、执行日志可查。
**Requirements:** R13, R14, R15, R16, R17
**Dependencies:** U1
**Files:**
- `src/agentkit/bitable/automation/` — 新建包
- `__init__.py`
- `engine.py``AutomationEngine` 类:`match_triggers(table_id, record_id, change_type, changed_fields)`、`execute_action(rule, trigger_record)`
- `triggers.py` — 触发器匹配逻辑record_created / record_updated / record_deleted
- `actions.py` — 5 种动作执行器create_record / update_record / send_webhook / send_email / send_message
- `webhook.py` — Webhook 入站端点处理 + 出站 HTTP 调用(含 SSRF guard复用 `ingestion/excel.py` 的 SSRF 防护)
- `src/agentkit/bitable/service.py` — record mutation hook 调用 `AutomationEngine.match_triggers``create_automation_rule`、`list_automation_rules`、`update_automation_rule`、`delete_automation_rule`、`list_automation_logs` 方法
- `src/agentkit/server/routes/bitable.py` — 新增端点:`POST /tables/{id}/automations`、`GET /tables/{id}/automations`、`PATCH /automations/{id}`、`DELETE /automations/{id}`、`GET /automations/{id}/logs`、`POST /webhooks/{token}`(入站 webhook无 JWT 认证)
- `tests/unit/bitable/test_automation.py` — 自动化引擎测试
- `tests/unit/bitable/test_routes.py` — 自动化端点测试
**Approach:**
- `AutomationEngine` 在 service record mutation hook 后同步调用 `match_triggers`,匹配到的规则创建独立 asyncio taskKTD-4
- 触发器 config 形状:`{"type": "record_updated", "field_ids": ["f1"]}`field_ids 可选,不指定则任何字段变更触发)
- 动作 config 形状:`{"type": "send_webhook", "url": "https://...", "method": "POST", "headers": {...}}``{"type": "create_record", "target_table_id": "...", "values": {...}}``{"type": "send_email", "to": "...", "subject": "...", "body": "..."}``{"type": "send_message", "channel": "...", "message": "..."}`
- Webhook 出站复用 `ingestion/excel.py``_is_private_ip` / `_validate_url` SSRF 防护
- Webhook 入站:`POST /api/v1/bitable/webhooks/{token}` → 查 automation by token恒定时间比较→ 匹配 trigger → 执行 action
- 重试exponential backoff1s/2s/4s3 次失败后 `AutomationLog.status = "error"`
- `send_message` 集成现有 `bus/` MemoryBus 或 RedisBus视配置
- `send_email``aiosmtplib`(如已安装)或 stdlib `smtplib` via `asyncio.to_thread`
**Patterns to follow:**
- 现有 `RecalcWorker` 的 asyncio task 生命周期管理start/stop/cancel
- 现有 `ingestion/excel.py` 的 SSRF 防护(`_is_private_ip`, URL scheme 验证)
- 现有 `hmac.compare_digest` 恒定时间比较模式
- 现有 route 的 IDOR ownership 检查模式
**Test scenarios:**
- **Happy path:** 创建自动化规则record_updated + send_webhook→ 修改记录 → webhook 被调用
- **Covers AE4 完整:** Tasks.status 改为"已完成" → webhook 触发 → 失败重试 3 次 → 标记 error
- **Covers F4:** 自动化规则执行流程完整验证
- **Edge case:** trigger 指定 field_ids 时非指定字段变更不触发webhook 出站 URL 为私网 IP → 拒绝SSRF guard
- **Error path:** 动作执行失败 → 重试 3 次 → AutomationLog 记录 error + error_messagewebhook token 无效 → 404
- **Integration:** 创建规则 → 触发 → 查询日志 → 日志显示成功/失败状态
**Verification:** `pytest tests/unit/bitable/test_automation.py tests/unit/bitable/test_routes.py -x -q` 通过5 种动作 + 重试 + 日志完整。
---
#### Phase 6: Agent Integration
### U8. BitableTool Agent Actions + REST API + Frontend
**Goal:** BitableTool 扩展 4 个新 action实现 agent 生成公式/自动化 + 创建关联/Rollup 的差异化能力,配套前端组件。
**Requirements:** R18, R19, R20
**Dependencies:** U1, U2, U3, U4, U5, U6, U7
**Files:**
- `src/agentkit/tools/bitable_tool.py` — 新增 4 个 action`generate_formula`、`generate_automation`、`create_relation`、`create_rollup`;新增 `undo` action撤销最近创建
- `src/agentkit/server/routes/bitable.py` — 新增端点:`POST /tables/{id}/generate-formula`LLM 生成 + validate + 创建)、`POST /tables/{id}/generate-automation`、`POST /undo/{undo_token}`
- `src/agentkit/server/frontend/src/api/bitable.ts` — 新增 API 方法:`generateFormula`、`generateAutomation`、`createRelation`、`createRollup`、`undoAction`
- `src/agentkit/server/frontend/src/components/bitable/RelationFieldEditor.vue` — 新建:关联字段配置组件(选择目标表、关联类型、双向开关)
- `src/agentkit/server/frontend/src/components/bitable/RollupFieldEditor.vue` — 新建Rollup 字段配置组件(选择关联字段、目标字段、聚合函数)
- `src/agentkit/server/frontend/src/components/bitable/AutomationRuleList.vue` — 新建:自动化规则列表 + 表单式编辑器
- `src/agentkit/server/frontend/src/components/bitable/FormulaGeneratorModal.vue` — 新建:自然语言生成公式 modal
- `src/agentkit/server/frontend/src/stores/bitable.ts` — 扩展 state`automations`、`automationLogs`actions`generateFormula`、`createRelation` 等
- `tests/unit/bitable/test_bitable_tool.py` — 新 action 测试
**Approach:**
- `generate_formula(table_id, description, field_name)`
1. 调用 LLM通过现有 `llm/` 网关)将 description + 表 schema 转为公式表达式
2. 调用 `validate-formula` 端点验证语法R20 复用)
3. 验证通过 → `create_field(field_type="formula", config={"formula_expr": ...})`
4. 返回 `{success, field_id, undo_token}`
5. undo_token 存 Redis5 分钟 TTLundo 时删除字段
- `generate_automation(table_id, description)`类似流程LLM 生成 trigger_config + action_config
- `create_relation(table_id, name, target_table_id, relation_type, is_bidirectional)`:调用 U4 的 service 方法
- `create_rollup(table_id, name, relation_field_id, target_field_id, aggregation)`:调用 U5 的 service 方法
- LLM prompt 工程:传入表字段 schema + 用户描述,要求 LLM 返回严格 JSON `{formula_expr: "..."}``{trigger: {...}, action: {...}}`
- 前端组件遵循现有 Ant Design Vue 4 风格,`FieldConfigForm.vue` 扩展支持 relation/rollup 类型配置
**Patterns to follow:**
- 现有 BitableTool 的 handler dispatch + httpx.AsyncClient 模式bitable_tool.py:175-186
- 现有 `_create_view` / `_update_field` 的 REST 调用模式
- 现有 `FieldConfigForm.vue` 的表单布局
- 现有 Pinia store 的 actions 模式
**Test scenarios:**
- **Happy path:** `generate_formula(description="计算总价 = 单价 × 数量")` → LLM 生成 `={price} * {quantity}` → validate 通过 → 创建字段
- **Covers AE5 完整:** Agent 生成公式流程端到端验证
- **Covers F3:** Agent 生成公式流程完整验证
- **Edge case:** LLM 生成无效公式 → validate 失败 → 返回错误不创建字段undo 在 5 分钟内有效,超时失效
- **Error path:** LLM 调用失败 → 返回错误;表不存在 → 404
- **Integration:** agent 调用 → 生成 → 创建字段 → recalc 触发 → 撤销 → 字段删除
- **Frontend smoke:** RelationFieldEditor 选择目标表 + 关联类型 → 保存 → 字段创建FormulaGeneratorModal 输入描述 → 生成 → 字段创建
**Verification:** `pytest tests/unit/bitable/test_bitable_tool.py -x -q` 通过;`cd src/agentkit/server/frontend && npm run typecheck` 通过4 个新 action 端到端可用。
---
### Verification Contract
**Per-unit verification (each U-ID):**
- 每个单元的 `pytest tests/unit/bitable/test_*.py -x -q` 必须通过
- 涉及前端改动的单元U8`npm run typecheck` 通过
- 涉及 DB migration 的单元U1需在空 DB 和 V2 DB 上都幂等
**Integration verification (after all units):**
- `pytest tests/unit/bitable/ -x -q` 全部通过
- `ruff check src/agentkit/bitable/ src/agentkit/tools/bitable_tool.py && ruff format src/agentkit/bitable/` 无错误
- 手动 smoke test可选启动服务 → 创建两表 → 建立多对多关联 → 创建 rollup → 修改源记录 → 验证跨表重算
**Acceptance Examples enforcement:**
- AE1 (跨表重算): U5 + U6 实现并测试
- AE2 (多对多): U1 + U4 + U5 实现并测试
- AE3 (公式扩展): U2 实现并测试
- AE4 (自动化): U7 实现并测试
- AE5 (Agent 生成): U8 实现并测试
**Behavior change verification:**
- 公式引擎扩展是增量,现有 10 个函数行为不变(向后兼容)
- 现有 lookup 字段类型扩展 config旧 lookup 字段(无 relation_field_id仍可读
- recalc worker 扩展,现有单表 recalc 流程不变
### Definition of Done
- [ ] U1-U8 全部实现,单元测试通过
- [ ] `pytest tests/unit/bitable/ -x -q` 全绿
- [ ] `ruff check src/agentkit/bitable/ && ruff format src/agentkit/bitable/` 无错误
- [ ] 前端 `npm run typecheck` 通过U8 部分)
- [ ] Schema V3 migration 在空 DB 和 V2 DB 上幂等
- [ ] 现有 bitable 功能无回归(现有测试全绿)
- [ ] AE1-AE5 验收示例对应的测试场景全部通过
- [ ] BitableTool 新 action 通过 REST API 可用
- [ ] 公式引擎 40+ 函数在 `FUNCTION_REGISTRY` 注册
- [ ] 跨表依赖图正确构建和更新
- [ ] 自动化引擎 3 触发器 + 5 动作 + 重试 + 日志完整
### Open Questions
- **email 发送实现**`aiosmtplib` 是否已在依赖中?如无,用 stdlib `smtplib` via `asyncio.to_thread`ponytail: 不引入新依赖)。实现时检查 `pyproject.toml`
- **LLM 网关调用细节**`generate_formula` 的 LLM 调用走现有 `llm/` 网关的哪个接口?实现时参考 `chat/handler.py` 的 LLM 调用模式。
- **前端组件 SSR**Tauri 桌面端是否需要同步更新v2 聚焦 WebTauri 兼容性留给实现时验证。
### Risks & Dependencies
**Risks:**
- **跨表重算性能**:跨表依赖图可能在大数据量下产生大量 recalc 任务。缓解:`is_cross_table` 标记 + 批量 enqueue + worker 限流(现有 `claim_recalc_tasks(limit=10)`)。
- **LLM 生成公式质量**LLM 可能生成语法正确但语义错误的公式。缓解validate-formula 仅验证语法agent 返回结果包含 `formula_expr` 供用户审查undo 机制。
- **多对多 junction table 膨胀**:高频关联操作可能产生大量 junction 记录。缓解定期清理孤立链接v3 优化);索引覆盖查询路径。
- **Schema migration 风险**V3 migration 在生产数据上失败。缓解idempotent CREATE IF NOT EXISTS现有 V2 migration 模式已验证无生产数据grounding 确认)。
**Dependencies:**
- 现有 `llm/` 网关U8 LLM 调用)
- 现有 `bus/` 消息总线U7 send_message 动作)
- 现有 `ingestion/excel.py` SSRF 防护U7 webhook 出站)
- 现有 `hmac.compare_digest`U7 webhook token 比较)
### System-Wide Impact
- **后端**bitable 子系统独立 schema 扩展,不影响其他模块;新增 `automation/` 包;`tools/bitable_tool.py` 扩展
- **前端**:新增 4 个组件,扩展 store 和 API client现有 `FieldConfigForm.vue` 扩展(向后兼容)
- **数据库**bitable schema V34 张新表;不影响其他 schema
- **部署**无新依赖ponytail: 复用现有无新环境变量webhook token 存 DB
- **文档**:无需更新 AGENTS.mdbitable 模块自包含)
### Phased Delivery
| 阶段 | 单元 | 依赖 | 可交付价值 |
|---|---|---|---|
| 1 | U1 | 无 | 存储基础 + 数据模型 |
| 2 | U2, U4 | U1 | 公式 40+ 函数 + 关联 CRUD |
| 3 | U3, U5 | U1, U2, U4 | 跨表公式 + Lookup/Rollup |
| 4 | U6 | U1, U3, U5 | 跨表重算 |
| 5 | U7 | U1 | 自动化引擎 |
| 6 | U8 | 全部 | Agent 集成 + 前端 |
**执行方向提示:** 每个单元实现后立即运行对应测试U1 migration 优先验证幂等性U2 函数扩展按类别分模块并行实现U6 跨表重算需端到端集成测试。

View File

@ -0,0 +1,349 @@
"""Automation engine — trigger evaluation, action execution, retry, logging (U7, R13-R17).
Lifecycle:
engine = AutomationEngine(service)
await engine.start() # starts background task
...
await engine.stop()
Triggers (R13):
- record_created: fires when a record is created in the watched table
- record_updated: fires when a record is updated (optionally filtered by field_ids)
- record_deleted: fires when a record is deleted
Actions (R14):
- create_record: insert a new record in a target table
- update_record: update fields on the triggering record (or a related record)
- send_webhook: POST to a URL with the trigger payload
- send_email: placeholder logs the email content (SMTP integration is app-level)
- send_message: placeholder logs the message (channel integration is app-level)
Retry: each action is retried up to 3 times with exponential backoff (1s, 2s, 4s).
All executions are logged to ``automation_logs`` (R17).
Webhook inbound (R16): ``handle_webhook(token, payload)`` matches a rule by its
``webhook_token`` and fires its action with the payload as trigger context.
"""
from __future__ import annotations
import asyncio
import json
import logging
import secrets
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
import httpx
from agentkit.bitable.ingestion.excel import _assert_safe_host
from agentkit.bitable.models import (
AutomationActionType,
AutomationStatus,
AutomationTriggerType,
)
if TYPE_CHECKING:
from agentkit.bitable.service import BitableService
logger = logging.getLogger(__name__)
_MAX_RETRIES = 3
_RETRY_DELAYS = [1.0, 2.0, 4.0] # seconds, exponential backoff
_WEBHOOK_TIMEOUT = 10.0 # seconds
class AutomationEngine:
"""Background engine that evaluates triggers and executes actions.
The engine itself is triggerless it exposes ``fire_trigger`` which
the service layer calls after record writes. Actions execute in an
isolated asyncio task (KTD-4) so failures don't block the caller.
"""
def __init__(self, service: BitableService) -> None:
self._service = service
self._repo = service.repo
self._task: asyncio.Task[None] | None = None
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
self._stop_event = asyncio.Event()
async def start(self) -> None:
"""Start the background action executor."""
self._stop_event.clear()
self._task = asyncio.create_task(self._run(), name="automation-engine")
logger.info("AutomationEngine started")
async def stop(self) -> None:
"""Gracefully stop the engine."""
self._stop_event.set()
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info("AutomationEngine stopped")
async def _run(self) -> None:
"""Main loop — process action executions from the queue."""
while not self._stop_event.is_set():
try:
job = await asyncio.wait_for(self._queue.get(), timeout=1.0)
await self._execute_with_retry(job)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
break
except Exception:
logger.exception("AutomationEngine error in main loop")
async def fire_trigger(
self,
table_id: str,
trigger_type: AutomationTriggerType,
record_id: str | None = None,
changed_field_ids: list[str] | None = None,
record_values: dict[str, object] | None = None,
) -> int:
"""Evaluate trigger rules for a table event and enqueue matching actions.
Returns the number of rules matched (not necessarily executed yet
actions run asynchronously).
Args:
table_id: The table where the event occurred.
trigger_type: create / update / delete.
record_id: The affected record (None for bulk operations).
changed_field_ids: For record_updated only fire if the rule
watches these fields (empty/None means any field).
record_values: Snapshot of the record's values (for action context).
"""
rules = await self._repo.list_automations_for_trigger(
table_id=table_id, trigger_type=trigger_type.value
)
matched = 0
for rule in rules:
if not rule.enabled:
continue
# For record_updated with field_ids filter, check overlap
if trigger_type == AutomationTriggerType.record_updated:
watched = rule.trigger_config.get("field_ids", [])
if watched and changed_field_ids:
if not set(watched) & set(changed_field_ids):
continue # No overlap — skip
matched += 1
await self._queue.put(
{
"automation_id": rule.id,
"table_id": table_id,
"trigger_type": trigger_type.value,
"record_id": record_id,
"record_values": record_values or {},
"action_config": rule.action_config,
}
)
return matched
async def handle_webhook(self, token: str, payload: dict[str, Any]) -> bool:
"""Handle an inbound webhook (R16).
Returns True if a rule matched the token and was executed.
"""
rule = await self._repo.get_automation_by_webhook_token(token)
if rule is None or not rule.enabled:
return False
await self._queue.put(
{
"automation_id": rule.id,
"table_id": rule.table_id,
"trigger_type": "webhook",
"record_id": None,
"record_values": payload,
"action_config": rule.action_config,
}
)
return True
async def _execute_with_retry(self, job: dict[str, Any]) -> None:
"""Execute an action with up to 3 retries (R15)."""
automation_id = job["automation_id"]
record_id = job.get("record_id")
last_error: str | None = None
for attempt in range(1, _MAX_RETRIES + 1):
log_id = await self._repo.create_automation_log(
automation_id=automation_id,
trigger_record_id=record_id,
status=AutomationStatus.retrying,
attempt=attempt,
)
try:
await self._execute_action(job)
await self._repo.update_automation_log(
log_id=log_id,
status=AutomationStatus.success,
completed_at=datetime.now(timezone.utc),
)
return
except Exception as e:
last_error = str(e)[:500]
logger.warning(
"AutomationEngine: attempt %d failed for rule %s: %s",
attempt,
automation_id,
last_error,
)
await self._repo.update_automation_log(
log_id=log_id,
status=AutomationStatus.error,
error_message=last_error,
completed_at=datetime.now(timezone.utc),
)
if attempt < _MAX_RETRIES:
await asyncio.sleep(_RETRY_DELAYS[attempt - 1])
logger.error(
"AutomationEngine: rule %s failed after %d attempts: %s",
automation_id,
_MAX_RETRIES,
last_error,
)
async def _execute_action(self, job: dict[str, Any]) -> None:
"""Dispatch to the correct action handler based on action_config type."""
action_config = job["action_config"]
action_type = str(action_config.get("type", ""))
record_values = job.get("record_values", {})
if action_type == AutomationActionType.create_record.value:
await self._action_create_record(action_config, record_values)
elif action_type == AutomationActionType.update_record.value:
await self._action_update_record(action_config, job.get("record_id"), record_values)
elif action_type == AutomationActionType.send_webhook.value:
await self._action_send_webhook(action_config, job)
elif action_type == AutomationActionType.send_email.value:
self._action_send_email(action_config, job)
elif action_type == AutomationActionType.send_message.value:
self._action_send_message(action_config, job)
else:
raise ValueError(f"Unknown action type: {action_type}")
async def _action_create_record(
self, config: dict[str, Any], trigger_values: dict[str, object]
) -> None:
"""Create a record in a target table (R14 create_record).
Config: ``{target_table_id, values: {field_id: value | "@trigger.field_id"}}``
``@trigger.X`` placeholders are resolved from the triggering record's values.
"""
target_table_id = str(config.get("target_table_id", ""))
if not target_table_id:
raise ValueError("create_record action missing target_table_id")
raw_values: dict[str, object] = config.get("values", {}) # type: ignore[assignment]
resolved: dict[str, str | int | float | None] = {}
for k, v in raw_values.items():
if isinstance(v, str) and v.startswith("@trigger."):
src_field = v[len("@trigger.") :]
resolved[k] = trigger_values.get(src_field) # type: ignore[assignment]
else:
resolved[k] = v # type: ignore[assignment]
await self._service.create_record(target_table_id, resolved)
async def _action_update_record(
self,
config: dict[str, Any],
trigger_record_id: str | None,
trigger_values: dict[str, object],
) -> None:
"""Update a record's fields (R14 update_record).
Config: ``{target_record_id (optional, defaults to trigger), values: {...}}``
"""
target_record_id = str(config.get("target_record_id") or trigger_record_id or "")
if not target_record_id:
raise ValueError("update_record action: no target_record_id and no trigger record")
raw_values: dict[str, object] = config.get("values", {}) # type: ignore[assignment]
resolved: dict[str, str | int | float | None] = {}
for k, v in raw_values.items():
if isinstance(v, str) and v.startswith("@trigger."):
src_field = v[len("@trigger.") :]
resolved[k] = trigger_values.get(src_field) # type: ignore[assignment]
else:
resolved[k] = v # type: ignore[assignment]
await self._service.update_record_values(target_record_id, resolved)
async def _action_send_webhook(self, config: dict[str, Any], job: dict[str, Any]) -> None:
"""POST a JSON payload to a webhook URL (R14 send_webhook).
Config: ``{url, headers (optional), body_template (optional)}``
SSRF guard (KTD-9): the URL hostname is validated against
private/loopback/reserved IPs via ``_assert_safe_host`` before sending.
"""
url = str(config.get("url", ""))
if not url:
raise ValueError("send_webhook action missing url")
# SSRF protection (KTD-9): reject private/loopback/reserved targets
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Disallowed webhook URL scheme: {parsed.scheme!r}")
if not parsed.hostname:
raise ValueError("Webhook URL has no hostname")
_assert_safe_host(parsed.hostname)
headers = config.get("headers", {"Content-Type": "application/json"})
body_template = config.get("body_template")
if body_template:
# ponytail: simple @trigger.X substitution in JSON body.
body = json.dumps(body_template).replace(
"@trigger.record_id", str(job.get("record_id") or "")
)
else:
body = json.dumps(
{
"event": job.get("trigger_type"),
"table_id": job.get("table_id"),
"record_id": job.get("record_id"),
"values": job.get("record_values", {}),
}
)
async with httpx.AsyncClient(timeout=_WEBHOOK_TIMEOUT, follow_redirects=False) as client:
resp = await client.post(url, content=body, headers=headers)
resp.raise_for_status()
def _action_send_email(self, config: dict[str, Any], job: dict[str, Any]) -> None:
"""Send email — placeholder (SMTP integration is app-level).
Logs the email content; production deployments override this with
an actual SMTP/SES/SendGrid call.
"""
logger.info(
"AutomationEngine send_email (placeholder): to=%s subject=%s",
config.get("to"),
config.get("subject"),
)
def _action_send_message(self, config: dict[str, Any], job: dict[str, Any]) -> None:
"""Send message — placeholder (channel integration is app-level).
Logs the message; production deployments override this with
Slack/DingTalk/Feishu integration.
"""
logger.info(
"AutomationEngine send_message (placeholder): channel=%s text=%s",
config.get("channel"),
config.get("text"),
)
def generate_webhook_token() -> str:
"""Generate a cryptographically-secure webhook token (R16).
Uses ``secrets.token_urlsafe(32)`` for 256 bits of entropy.
"""
return "whk_" + secrets.token_urlsafe(32)

View File

@ -21,9 +21,11 @@ from datetime import datetime, timezone
from types import TracebackType
from sqlalchemy import (
Boolean,
Column,
DateTime,
Index,
Integer,
String,
Text,
UniqueConstraint,
@ -37,7 +39,9 @@ logger = logging.getLogger(__name__)
# Current schema version — bump when adding migrations.
# V1: initial schema (tables/fields/records/views/recalc/meta).
# V2: add bitable_files table + file_id column on bitable_tables (R1).
_SCHEMA_VERSION = 2
# V3: add relation_links / automations / automation_logs / cross_table_deps
# tables + is_cross_table column on bitable_recalc_queue (R5/R9/R10/R16).
_SCHEMA_VERSION = 3
_META_SCHEMA_VERSION_KEY = "schema_version"
@ -155,6 +159,7 @@ class RecalcQueueModel(BitableBase):
The ``(record_id, field_id)`` unique index prevents duplicate enqueues.
The ``(status, queued_at)`` index supports efficient worker consumption.
``is_cross_table`` marks tasks triggered by a cross-table dependency (V3).
"""
__tablename__ = "bitable_recalc_queue"
@ -172,6 +177,7 @@ class RecalcQueueModel(BitableBase):
error_message = Column(Text, nullable=True)
queued_at = Column(DateTime(timezone=True), default=_utcnow)
completed_at = Column(DateTime(timezone=True), nullable=True)
is_cross_table = Column(Boolean, default=False)
class MetaModel(BitableBase):
@ -185,6 +191,106 @@ class MetaModel(BitableBase):
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
class RelationLinkModel(BitableBase):
"""ORM model for ``bitable.bitable_relation_links`` — junction table (KTD-1, V3).
Stores many_to_many relation pairs as
``(relation_field_id, source_record_id, target_record_id)``. Two indexes
cover forward (sourcetarget) and reverse (targetsource) lookups for
bidirectional relations.
"""
__tablename__ = "bitable_relation_links"
__table_args__ = (
Index(
"ix_relation_links_field_source",
"relation_field_id",
"source_record_id",
),
Index(
"ix_relation_links_field_target",
"relation_field_id",
"target_record_id",
),
{"schema": "bitable"},
)
id = Column(String, primary_key=True, default=_uuid_str)
relation_field_id = Column(String, nullable=False)
source_record_id = Column(String, nullable=False)
target_record_id = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), default=_utcnow)
class AutomationModel(BitableBase):
"""ORM model for ``bitable.bitable_automations`` — automation rules (V3, R16)."""
__tablename__ = "bitable_automations"
__table_args__ = (
Index("ix_automations_table_id", "table_id"),
Index("ix_automations_webhook_token", "webhook_token"),
{"schema": "bitable"},
)
id = Column(String, primary_key=True, default=_uuid_str)
table_id = Column(String, nullable=False)
name = Column(String, nullable=False)
trigger_config = Column(JSONB, default=dict)
action_config = Column(JSONB, default=dict)
enabled = Column(Boolean, default=True)
webhook_token = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), default=_utcnow)
updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow)
class AutomationLogModel(BitableBase):
"""ORM model for ``bitable.bitable_automation_logs`` — execution logs (V3, R17)."""
__tablename__ = "bitable_automation_logs"
__table_args__ = (
Index("ix_automation_logs_automation_id", "automation_id", "started_at"),
{"schema": "bitable"},
)
id = Column(String, primary_key=True, default=_uuid_str)
automation_id = Column(String, nullable=False)
trigger_record_id = Column(String, nullable=True)
status = Column(String, default="success")
error_message = Column(Text, nullable=True)
attempt = Column(Integer, default=1)
started_at = Column(DateTime(timezone=True), default=_utcnow)
completed_at = Column(DateTime(timezone=True), nullable=True)
class CrossTableDepModel(BitableBase):
"""ORM model for ``bitable.bitable_cross_table_deps`` — materialized dep graph (V3, KTD-3).
One row per ``(source_field, target_field)`` edge. ``target_field_id``
index supports the recalc worker's reverse-edge lookup: "which fields in
other tables depend on this changed field?"
"""
__tablename__ = "bitable_cross_table_deps"
__table_args__ = (
UniqueConstraint(
"source_field_id",
"target_field_id",
name="uq_cross_table_dep_source_target",
),
Index("ix_cross_table_deps_target_field", "target_field_id"),
Index("ix_cross_table_deps_target_table", "target_table_id"),
{"schema": "bitable"},
)
id = Column(String, primary_key=True, default=_uuid_str)
source_table_id = Column(String, nullable=False)
source_field_id = Column(String, nullable=False)
target_table_id = Column(String, nullable=False)
target_field_id = Column(String, nullable=False)
dep_type = Column(String, default="formula")
created_at = Column(DateTime(timezone=True), default=_utcnow)
# ---------------------------------------------------------------------------
# Schema is created via BitableBase.metadata.create_all (see BitableDB.init).
# The ORM models above are the single source of truth for table/index DDL.
@ -242,6 +348,132 @@ async def _apply_v2_migration(conn: object) -> None:
logger.info("applied V2 migration: bitable_files table + file_id column")
async def _apply_v3_migration(conn: object) -> None:
"""V3 migration: add relation_links, automations, automation_logs,
cross_table_deps tables + ``is_cross_table`` column on recalc_queue.
Idempotent ``create_all`` already made the V3 tables on fresh installs;
the raw SQL here ensures V2V3 upgrades (where ``create_all`` skips
existing tables) also get the new schema. ``ADD COLUMN IF NOT EXISTS``
covers the recalc_queue extension.
"""
# 1. bitable_relation_links (junction table for many_to_many, KTD-1)
await conn.execute(
text(
"CREATE TABLE IF NOT EXISTS bitable.bitable_relation_links ("
" id VARCHAR PRIMARY KEY,"
" relation_field_id VARCHAR NOT NULL,"
" source_record_id VARCHAR NOT NULL,"
" target_record_id VARCHAR NOT NULL,"
" created_at TIMESTAMPTZ DEFAULT NOW()"
")"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_relation_links_field_source "
"ON bitable.bitable_relation_links (relation_field_id, source_record_id)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_relation_links_field_target "
"ON bitable.bitable_relation_links (relation_field_id, target_record_id)"
)
)
# 2. bitable_automations
await conn.execute(
text(
"CREATE TABLE IF NOT EXISTS bitable.bitable_automations ("
" id VARCHAR PRIMARY KEY,"
" table_id VARCHAR NOT NULL,"
" name VARCHAR NOT NULL,"
" trigger_config JSONB DEFAULT '{}'::jsonb,"
" action_config JSONB DEFAULT '{}'::jsonb,"
" enabled BOOLEAN DEFAULT TRUE,"
" webhook_token VARCHAR,"
" created_at TIMESTAMPTZ DEFAULT NOW(),"
" updated_at TIMESTAMPTZ DEFAULT NOW()"
")"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_automations_table_id "
"ON bitable.bitable_automations (table_id)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_automations_webhook_token "
"ON bitable.bitable_automations (webhook_token) "
"WHERE webhook_token IS NOT NULL"
)
)
# 3. bitable_automation_logs
await conn.execute(
text(
"CREATE TABLE IF NOT EXISTS bitable.bitable_automation_logs ("
" id VARCHAR PRIMARY KEY,"
" automation_id VARCHAR NOT NULL,"
" trigger_record_id VARCHAR,"
" status VARCHAR DEFAULT 'success',"
" error_message TEXT,"
" attempt INTEGER DEFAULT 1,"
" started_at TIMESTAMPTZ DEFAULT NOW(),"
" completed_at TIMESTAMPTZ"
")"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_automation_logs_automation_id "
"ON bitable.bitable_automation_logs (automation_id, started_at)"
)
)
# 4. bitable_cross_table_deps
await conn.execute(
text(
"CREATE TABLE IF NOT EXISTS bitable.bitable_cross_table_deps ("
" id VARCHAR PRIMARY KEY,"
" source_table_id VARCHAR NOT NULL,"
" source_field_id VARCHAR NOT NULL,"
" target_table_id VARCHAR NOT NULL,"
" target_field_id VARCHAR NOT NULL,"
" dep_type VARCHAR DEFAULT 'formula',"
" created_at TIMESTAMPTZ DEFAULT NOW(),"
" CONSTRAINT uq_cross_table_dep_source_target "
" UNIQUE (source_field_id, target_field_id)"
")"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_cross_table_deps_target_field "
"ON bitable.bitable_cross_table_deps (target_field_id)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_cross_table_deps_target_table "
"ON bitable.bitable_cross_table_deps (target_table_id)"
)
)
# 5. is_cross_table column on recalc_queue
await conn.execute(
text(
"ALTER TABLE bitable.bitable_recalc_queue "
"ADD COLUMN IF NOT EXISTS is_cross_table BOOLEAN DEFAULT FALSE"
)
)
logger.info("applied V3 migration: relation_links / automations / logs / cross_table_deps + is_cross_table")
def _resolve_database_url(database_url: str | None = None) -> str | None:
"""Resolve PostgreSQL connection URL.
@ -343,6 +575,9 @@ class BitableDB:
# V2: file layer (bitable_files table + file_id column on tables)
if current_version < 2:
await _apply_v2_migration(conn)
# V3: relation_links + automations + logs + cross_table_deps + is_cross_table
if current_version < 3:
await _apply_v3_migration(conn)
await conn.execute(
text(
"INSERT INTO bitable.bitable_meta (key, value, updated_at) "

View File

@ -16,18 +16,22 @@ from agentkit.bitable.formula.engine import (
)
from agentkit.bitable.formula.functions import FUNCTION_REGISTRY
from agentkit.bitable.formula.parser import (
FormulaDepthExceededError,
FormulaParseError,
FormulaSecurityError,
MAX_AST_DEPTH,
UnknownFunctionError,
parse_formula,
)
__all__ = [
"CircularReferenceError",
"FormulaDepthExceededError",
"FormulaEngine",
"FormulaParseError",
"FormulaSecurityError",
"FUNCTION_REGISTRY",
"MAX_AST_DEPTH",
"UnknownFunctionError",
"parse_formula",
]

View File

@ -19,7 +19,10 @@ from collections import deque
from agentkit.bitable.formula.functions import AGGREGATE_FUNCTIONS, FUNCTION_REGISTRY
from agentkit.bitable.formula.parser import (
FormulaDepthExceededError,
FormulaParseError,
FormulaSecurityError,
UnknownFunctionError,
evaluate_ast,
parse_formula,
)
@ -56,7 +59,9 @@ class FormulaEngine:
UnknownFunctionError: Unregistered function.
CircularReferenceError: Adding this formula creates a cycle.
"""
tree, field_mapping = parse_formula(formula, set(FUNCTION_REGISTRY.keys()))
tree, field_mapping, cross_table_mapping = parse_formula(
formula, set(FUNCTION_REGISTRY.keys())
)
# Classify field refs into aggregate vs row context
aggregate_refs, row_refs = _classify_refs(tree, field_mapping)
@ -66,11 +71,14 @@ class FormulaEngine:
field_mapping=field_mapping,
aggregate_refs=aggregate_refs,
row_refs=row_refs,
cross_table_mapping=cross_table_mapping,
formula=formula,
)
self._formulas[field_id] = entry
# Update DAG: this field depends on all referenced fields
self._dag[field_id] = aggregate_refs | row_refs
# Cross-table refs depend on the relation field (local) + target field (foreign)
cross_table_local_deps = {rel_id for rel_id, _ in cross_table_mapping.values()}
self._dag[field_id] = aggregate_refs | row_refs | cross_table_local_deps
# Check for cycles
cycle = _detect_cycle(self._dag)
@ -92,6 +100,17 @@ class FormulaEngine:
"""Get the set of field IDs that ``field_id`` depends on."""
return self._dag.get(field_id, set()).copy()
def get_cross_table_mapping(self, field_id: str) -> dict[str, tuple[str, str]] | None:
"""Get the cross-table reference mapping for a formula field.
Returns ``{safe_name: (relation_field_id, target_field_id)}`` or
``None`` if the field is not registered or has no cross-table refs.
"""
entry = self._formulas.get(field_id)
if entry is None:
return None
return entry.cross_table_mapping or None
def get_dependents(self, field_id: str) -> set[str]:
"""Get the set of formula field IDs that depend on ``field_id``."""
return {fid for fid, deps in self._dag.items() if field_id in deps}
@ -105,6 +124,7 @@ class FormulaEngine:
field_id: str,
row_values: dict[str, object],
column_values: dict[str, list[object]] | None = None,
cross_table_values: dict[str, list[object]] | None = None,
) -> object:
"""Evaluate a formula field for a specific record.
@ -113,6 +133,9 @@ class FormulaEngine:
row_values: Field ID value for the current record (row context).
column_values: Field ID list of all values in that column
(aggregate context). Required for aggregate references.
cross_table_values: Safe-name list of values from related
records (cross-table context, U3). The service layer resolves
these before calling evaluate.
Returns:
The computed value.
@ -126,6 +149,7 @@ class FormulaEngine:
entry = self._formulas[field_id]
column_values = column_values or {}
cross_table_values = cross_table_values or {}
# Build the field_values dict for the evaluator
# Aggregate refs get column values (lists), row refs get row values (scalars)
@ -138,6 +162,13 @@ class FormulaEngine:
else:
eval_values[safe_name] = row_values.get(real_id)
# Cross-table refs: the service layer pre-resolves these to value lists
for safe_name in entry.cross_table_mapping:
if safe_name in cross_table_values:
eval_values[safe_name] = cross_table_values[safe_name]
else:
eval_values[safe_name] = []
return evaluate_ast(entry.tree, eval_values, FUNCTION_REGISTRY)
def evaluate_all_for_record(
@ -150,6 +181,9 @@ class FormulaEngine:
Returns a dict of field_id computed value.
Formulas are evaluated in topological order so that formula-to-formula
dependencies are resolved correctly.
List return values (from FILTER/SPLIT/LOOKUP) are stored as-is.
Downstream formulas referencing a list-valued field receive the list.
"""
results: dict[str, object] = {}
column_values = column_values or {}
@ -159,7 +193,15 @@ class FormulaEngine:
merged_row = {**row_values, **results}
try:
results[field_id] = self.evaluate(field_id, merged_row, column_values)
except (FormulaParseError, ZeroDivisionError, TypeError) as e:
except (
FormulaParseError,
FormulaSecurityError,
UnknownFunctionError,
FormulaDepthExceededError,
ZeroDivisionError,
TypeError,
ValueError,
) as e:
results[field_id] = {"__error": str(e)}
return results
@ -171,7 +213,14 @@ class FormulaEngine:
class _FormulaEntry:
"""Parsed formula metadata."""
__slots__ = ("tree", "field_mapping", "aggregate_refs", "row_refs", "formula")
__slots__ = (
"tree",
"field_mapping",
"aggregate_refs",
"row_refs",
"cross_table_mapping",
"formula",
)
def __init__(
self,
@ -179,12 +228,14 @@ class _FormulaEntry:
field_mapping: dict[str, str],
aggregate_refs: set[str],
row_refs: set[str],
cross_table_mapping: dict[str, tuple[str, str]],
formula: str,
) -> None:
self.tree = tree
self.field_mapping = field_mapping
self.aggregate_refs = aggregate_refs
self.row_refs = row_refs
self.cross_table_mapping = cross_table_mapping
self.formula = formula

View File

@ -1,110 +0,0 @@
"""Built-in formula functions for the bitable formula engine.
v1 implements: SUM, AVG, COUNT, MIN, MAX, CONCAT, ABS, ROUND, IF, LEN.
Aggregate functions (SUM/AVG/COUNT/MIN/MAX) accept a list of values
(the entire column) and return a scalar. Non-aggregate functions
(ABS/ROUND/IF/LEN/CONCAT) operate on scalars.
The engine determines whether to pass a column (list) or scalar (row value)
based on the calling context see :mod:`agentkit.bitable.formula.engine`.
"""
from __future__ import annotations
from typing import Callable, TypeAlias
# A formula evaluates to a scalar primitive: text, number, or nothing.
# bool is intentionally excluded — comparisons live in the parser layer
# and never reach the function registry.
FormulaResult: TypeAlias = str | int | float | None
# ── Aggregate functions (operate on lists) ────────────────
def _sum(values: list[FormulaResult]) -> float | int:
"""Sum of numeric values, ignoring None/empty."""
total = 0
for v in values:
if v is None or v == "":
continue
total += v
return total
def _avg(values: list[FormulaResult]) -> float:
"""Average of numeric values, ignoring None/empty."""
nums = [v for v in values if v is not None and v != ""]
if not nums:
return 0.0
return sum(nums) / len(nums)
def _count(values: list[FormulaResult]) -> int:
"""Count of non-empty values."""
return sum(1 for v in values if v is not None and v != "")
def _min(values: list[FormulaResult]) -> FormulaResult:
"""Minimum of numeric values, ignoring None/empty."""
nums = [v for v in values if v is not None and v != ""]
if not nums:
return 0
return min(nums)
def _max(values: list[FormulaResult]) -> FormulaResult:
"""Maximum of numeric values, ignoring None/empty."""
nums = [v for v in values if v is not None and v != ""]
if not nums:
return 0
return max(nums)
# ── Scalar functions ──────────────────────────────────────
def _abs(value: FormulaResult) -> FormulaResult:
return abs(value)
def _round(value: FormulaResult, digits: int = 0) -> float:
return round(value, digits)
def _if(
condition: FormulaResult,
true_val: FormulaResult,
false_val: FormulaResult = None,
) -> FormulaResult:
return true_val if condition else false_val
def _len(value: FormulaResult) -> int:
if value is None:
return 0
return len(str(value))
def _concat(*args: FormulaResult) -> str:
"""Concatenate all arguments as strings."""
return "".join(str(a) for a in args if a is not None)
# ── Registry ──────────────────────────────────────────────
# Functions that aggregate a column (receive a list of all column values)
AGGREGATE_FUNCTIONS: frozenset[str] = frozenset({"SUM", "AVG", "COUNT", "MIN", "MAX"})
FUNCTION_REGISTRY: dict[str, Callable[..., FormulaResult]] = {
"SUM": _sum,
"AVG": _avg,
"COUNT": _count,
"MIN": _min,
"MAX": _max,
"ABS": _abs,
"ROUND": _round,
"IF": _if,
"LEN": _len,
"CONCAT": _concat,
}

View File

@ -0,0 +1,64 @@
"""Built-in formula function registry for the bitable formula engine.
This is a package (not a single module) functions are split by category
per KTD-6:
- :mod:`agentkit.bitable.formula.functions.aggregate` SUM, AVG, COUNT, MIN, MAX, ABS, ROUND, SUMIF, COUNTIF, AVERAGEIF, SUMIFS, COUNTIFS
- :mod:`agentkit.bitable.formula.functions.datetime` DATE, TODAY, NOW, YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, WEEKDAY, DATEDIF, DATEADD, NETWORKDAYS, EOMONTH, TIMESTAMP
- :mod:`agentkit.bitable.formula.functions.logical` IF, IFS, SWITCH, IFERROR, COALESCE, AND, OR, NOT, ISBLANK, ISNUMBER, ISTEXT, ISDATE, TRUE, FALSE, NULL
- :mod:`agentkit.bitable.formula.functions.text` CONCAT, LEN, FIND, MID, LEFT, RIGHT, REPLACE, SUBSTITUTE, UPPER, LOWER, TRIM, REPT, TEXT, VALUE, STARTSWITH, ENDSWITH, SPLIT
- :mod:`agentkit.bitable.formula.functions.lookup` LOOKUP, FILTER, MATCH, VLOOKUP, XLOOKUP
Aggregate functions (SUM/AVG/COUNT/MIN/MAX and conditional variants)
receive a list of all column values from the engine. Non-aggregate
functions operate on scalars (row context). The engine's
``_classify_refs()`` decides context based on AGGREGATE_FUNCTIONS.
"""
from __future__ import annotations
from typing import Callable, TypeAlias
# A formula evaluates to a scalar primitive: text, number, bool, or nothing.
# Lookup/FILTER may also return lists; the engine tolerates this at runtime.
# bool is included for logical functions (TRUE/FALSE/AND/OR/NOT/predicates).
FormulaResult: TypeAlias = str | int | float | bool | None
from agentkit.bitable.formula.functions.aggregate import AGGREGATE_FUNCTIONS_DICT # noqa: E402
from agentkit.bitable.formula.functions.datetime import DATETIME_FUNCTIONS # noqa: E402
from agentkit.bitable.formula.functions.logical import LOGICAL_FUNCTIONS # noqa: E402
from agentkit.bitable.formula.functions.lookup import LOOKUP_FUNCTIONS # noqa: E402
from agentkit.bitable.formula.functions.text import TEXT_FUNCTIONS # noqa: E402
# ── Aggregate function names (receive column lists) ──────
#: Functions that aggregate a column (receive a list of all column values).
#: Used by engine._classify_refs() to distinguish aggregate vs row context.
AGGREGATE_FUNCTIONS: frozenset[str] = frozenset(
{
"SUM",
"AVG",
"COUNT",
"MIN",
"MAX",
"SUMIF",
"COUNTIF",
"AVERAGEIF",
"SUMIFS",
"COUNTIFS",
}
)
# ── Unified registry ────────────────────────────────────
#: All registered functions. Merged from category submodules.
FUNCTION_REGISTRY: dict[str, Callable[..., FormulaResult]] = {
**AGGREGATE_FUNCTIONS_DICT,
**DATETIME_FUNCTIONS,
**LOGICAL_FUNCTIONS,
**TEXT_FUNCTIONS,
**LOOKUP_FUNCTIONS,
}
__all__ = [
"AGGREGATE_FUNCTIONS",
"FUNCTION_REGISTRY",
"FormulaResult",
]

View File

@ -0,0 +1,251 @@
"""Aggregate and math formula functions (R1).
Aggregate functions (SUM/AVG/COUNT/MIN/MAX and conditional variants)
operate on lists the entire column. Scalar math functions (ABS, ROUND)
operate on single values.
The engine's ``_classify_refs()`` decides whether a field reference is
aggregate (column) or row (scalar) based on whether it appears as a
direct argument to one of these functions.
"""
from __future__ import annotations
from typing import Callable
from agentkit.bitable.formula.functions import FormulaResult
def _is_numeric(v: FormulaResult) -> bool:
return isinstance(v, (int, float)) and not isinstance(v, bool)
def _to_number(v: FormulaResult) -> float | None:
"""Coerce a value to number. Returns None if not coercible."""
if v is None or v == "":
return None
if isinstance(v, bool):
return 1.0 if v else 0.0
if isinstance(v, (int, float)):
return float(v)
try:
return float(v)
except (ValueError, TypeError):
return None
# ── migrated aggregate functions ────────────────────────
def _sum(values: list[FormulaResult]) -> float | int:
"""Sum of numeric values, ignoring None/empty/non-numeric."""
total: float | int = 0
for v in values:
n = _to_number(v)
if n is not None:
total += n
return total
def _avg(values: list[FormulaResult]) -> float:
"""Average of numeric values, ignoring None/empty/non-numeric."""
nums = [n for n in (_to_number(v) for v in values) if n is not None]
if not nums:
return 0.0
return sum(nums) / len(nums)
def _count(values: list[FormulaResult]) -> int:
"""Count of non-empty values."""
return sum(1 for v in values if v is not None and v != "")
def _min(values: list[FormulaResult]) -> FormulaResult:
"""Minimum of numeric values, ignoring None/empty."""
nums = [n for n in (_to_number(v) for v in values) if n is not None]
if not nums:
return 0
return min(nums)
def _max(values: list[FormulaResult]) -> FormulaResult:
"""Maximum of numeric values, ignoring None/empty."""
nums = [n for n in (_to_number(v) for v in values) if n is not None]
if not nums:
return 0
return max(nums)
# ── migrated scalar math ────────────────────────────────
def _abs(value: FormulaResult) -> FormulaResult:
n = _to_number(value)
if n is None:
return None
return abs(n)
def _round(value: FormulaResult, digits: int = 0) -> float | None:
n = _to_number(value)
if n is None:
return None
return round(n, digits)
# ── conditional aggregates ──────────────────────────────
def _sumif(values: list[FormulaResult], criteria: FormulaResult) -> float | int:
"""SUMIF(values, criteria) → sum of values matching criteria.
criteria may be a number (equality) or string (e.g. ">10").
ponytail: criteria parser handles >, <, >=, <=, <>, = prefixes.
"""
total: float | int = 0
op, target = _parse_criteria(criteria)
for v in values:
n = _to_number(v)
if n is None:
continue
if _matches_criteria(n, op, target):
total += n
return total
def _countif(values: list[FormulaResult], criteria: FormulaResult) -> int:
"""COUNTIF(values, criteria) → count of values matching criteria."""
count = 0
op, target = _parse_criteria(criteria)
for v in values:
n = _to_number(v)
if n is None:
continue
if _matches_criteria(n, op, target):
count += 1
return count
def _averageif(values: list[FormulaResult], criteria: FormulaResult) -> float:
"""AVERAGEIF(values, criteria) → average of values matching criteria."""
nums: list[float] = []
op, target = _parse_criteria(criteria)
for v in values:
n = _to_number(v)
if n is None:
continue
if _matches_criteria(n, op, target):
nums.append(n)
if not nums:
return 0.0
return sum(nums) / len(nums)
def _sumifs(
values: list[FormulaResult], criteria_range: list[FormulaResult], criteria: FormulaResult
) -> float | int:
"""SUMIFS(values, criteria_range, criteria) → sum of values where criteria_range matches.
ponytail: single criteria range only. True SUMIFS in spreadsheets takes
multiple (range, criteria) pairs. Ceiling: variadic version would require
AST list-pair handling.
"""
if len(values) != len(criteria_range):
raise ValueError("SUMIFS requires values and criteria_range of equal length")
total: float | int = 0
op, target = _parse_criteria(criteria)
for v, c in zip(values, criteria_range):
n = _to_number(v)
if n is None:
continue
cn = _to_number(c)
if cn is not None and _matches_criteria(cn, op, target):
total += n
return total
def _countifs(
values: list[FormulaResult], criteria_range: list[FormulaResult], criteria: FormulaResult
) -> int:
"""COUNTIFS(values, criteria_range, criteria) → count where criteria_range matches."""
if len(values) != len(criteria_range):
raise ValueError("COUNTIFS requires values and criteria_range of equal length")
count = 0
op, target = _parse_criteria(criteria)
for v, c in zip(values, criteria_range):
n = _to_number(v)
if n is None:
continue
cn = _to_number(c)
if cn is not None and _matches_criteria(cn, op, target):
count += 1
return count
# ── criteria helpers ────────────────────────────────────
def _parse_criteria(criteria: FormulaResult) -> tuple[str, float | None]:
"""Parse a criteria value into (operator, target_number).
Supports: ">10", "<10", ">=10", "<=10", "<>10", "=10", or plain 10.
Returns ("=", None) for non-string criteria (equality against the value itself).
"""
if isinstance(criteria, str):
s = criteria.strip()
for op_prefix in (">=", "<=", "<>", ">", "<", "="):
if s.startswith(op_prefix):
target_str = s[len(op_prefix) :].strip()
try:
return op_prefix, float(target_str)
except ValueError:
return op_prefix, None
# String criteria without operator — try numeric equality
try:
return "=", float(s)
except ValueError:
return "=", None
if isinstance(criteria, (int, float)) and not isinstance(criteria, bool):
return "=", float(criteria)
return "=", None
def _matches_criteria(value: float, op: str, target: float | None) -> bool:
"""Check if value matches the parsed criteria."""
if target is None:
return False
if op == ">":
return value > target
if op == "<":
return value < target
if op == ">=":
return value >= target
if op == "<=":
return value <= target
if op == "<>":
return value != target
if op == "=":
return value == target
return False
# ── Registry ────────────────────────────────────────────
AGGREGATE_FUNCTIONS_LIST: frozenset[str] = frozenset(
{"SUM", "AVG", "COUNT", "MIN", "MAX", "SUMIF", "COUNTIF", "AVERAGEIF", "SUMIFS", "COUNTIFS"}
)
AGGREGATE_FUNCTIONS_DICT: dict[str, Callable[..., FormulaResult]] = {
"SUM": _sum,
"AVG": _avg,
"COUNT": _count,
"MIN": _min,
"MAX": _max,
"ABS": _abs,
"ROUND": _round,
"SUMIF": _sumif,
"COUNTIF": _countif,
"AVERAGEIF": _averageif,
"SUMIFS": _sumifs,
"COUNTIFS": _countifs,
}

View File

@ -0,0 +1,214 @@
"""Date/time formula functions (R1).
All functions accept ISO 8601 strings (the storage format for ``date`` fields)
or Python datetime objects. NETWORKDAYS uses Python's ``datetime.weekday()``
to exclude Saturdays/Sundays; holiday calendars are out of scope for v2
(ponytail: ceiling upgrade path is a holidays JSON config).
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from typing import Callable
from agentkit.bitable.formula.functions import FormulaResult
def _parse_date(value: FormulaResult) -> datetime | None:
"""Parse an ISO 8601 string or pass through a datetime.
Returns None if value is None/empty/invalid. Raises ValueError on
non-date strings so callers can surface a formula error.
"""
if value is None or value == "":
return None
if isinstance(value, datetime):
return value
if isinstance(value, date):
return datetime(value.year, value.month, value.day)
if isinstance(value, str):
# ISO 8601 — datetime.fromisoformat handles "2026-07-06" and
# "2026-07-06T10:30:00" and "2026-07-06T10:30:00+00:00".
return datetime.fromisoformat(value)
# Numbers aren't dates here — surface as a formula error.
raise ValueError(f"Cannot parse date from: {value!r}")
def _to_serializable(dt: datetime) -> str:
"""Serialize a datetime to ISO 8601 string (storage format)."""
return dt.isoformat()
# ── Constructors ─────────────────────────────────────────
def _date(year: int, month: int, day: int) -> str:
"""DATE(year, month, day) → ISO 8601 date string."""
return _to_serializable(datetime(year, month, day))
def _today() -> str:
"""TODAY() → current date (UTC, midnight)."""
now = datetime.now(timezone.utc)
return _to_serializable(now.replace(hour=0, minute=0, second=0, microsecond=0))
def _now() -> str:
"""NOW() → current datetime (UTC)."""
return _to_serializable(datetime.now(timezone.utc))
def _timestamp() -> str:
"""TIMESTAMP() → current datetime (UTC). Alias for NOW."""
return _now()
# ── Accessors ────────────────────────────────────────────
def _year(value: FormulaResult) -> int | None:
dt = _parse_date(value)
return dt.year if dt else None
def _month(value: FormulaResult) -> int | None:
dt = _parse_date(value)
return dt.month if dt else None
def _day(value: FormulaResult) -> int | None:
dt = _parse_date(value)
return dt.day if dt else None
def _hour(value: FormulaResult) -> int | None:
dt = _parse_date(value)
return dt.hour if dt else None
def _minute(value: FormulaResult) -> int | None:
dt = _parse_date(value)
return dt.minute if dt else None
def _second(value: FormulaResult) -> int | None:
dt = _parse_date(value)
return dt.second if dt else None
def _weekday(value: FormulaResult) -> int | None:
"""WEEKDAY(date) → day of week (1=Monday … 7=Sunday, ISO 8601)."""
dt = _parse_date(value)
if dt is None:
return None
return dt.isoweekday()
# ── Arithmetic ───────────────────────────────────────────
def _datedif(start: FormulaResult, end: FormulaResult, unit: str = "days") -> int | float | None:
"""DATEDIF(start, end, unit) → difference in days/months/years.
unit: "days" (default), "months", "years".
"""
s = _parse_date(start)
e = _parse_date(end)
if s is None or e is None:
return None
if unit == "days":
return (e - s).days
if unit == "months":
return (e.year - s.year) * 12 + (e.month - s.month)
if unit == "years":
return e.year - s.year
raise ValueError(f"DATEDIF unit must be days/months/years, got: {unit!r}")
def _dateadd(value: FormulaResult, amount: int, unit: str = "days") -> str | None:
"""DATEADD(date, amount, unit) → new date string.
unit: "days" (default), "months", "years".
"""
dt = _parse_date(value)
if dt is None:
return None
if unit == "days":
return _to_serializable(dt + timedelta(days=amount))
if unit == "months":
# ponytail: naive month arithmetic — no day-of-month overflow handling.
# Ceiling: Jan 31 + 1 month → Feb 28/29 not preserved. Upgrade path:
# use dateutil.relativedelta (would add a dependency).
new_month = dt.month + amount
new_year = dt.year + (new_month - 1) // 12
new_month = ((new_month - 1) % 12) + 1
# Clamp day to last day of new month
import calendar
last_day = calendar.monthrange(new_year, new_month)[1]
new_day = min(dt.day, last_day)
return _to_serializable(dt.replace(year=new_year, month=new_month, day=new_day))
if unit == "years":
try:
return _to_serializable(dt.replace(year=dt.year + amount))
except ValueError:
# Feb 29 → non-leap year: clamp to Feb 28
return _to_serializable(dt.replace(year=dt.year + amount, day=28))
raise ValueError(f"DATEADD unit must be days/months/years, got: {unit!r}")
def _networkdays(start: FormulaResult, end: FormulaResult) -> int | None:
"""NETWORKDAYS(start, end) → number of working days (Mon-Fri) between dates.
Both endpoints inclusive. Holiday calendars are out of scope (v3+).
"""
s = _parse_date(start)
e = _parse_date(end)
if s is None or e is None:
return None
if s > e:
s, e = e, s
days = 0
cur = s.date()
end_date = e.date()
while cur <= end_date:
if cur.weekday() < 5: # Mon-Fri
days += 1
cur += timedelta(days=1)
return days
def _eomonth(value: FormulaResult, months: int = 0) -> str | None:
"""EOMONTH(date, months) → last day of the month N months after date."""
dt = _parse_date(value)
if dt is None:
return None
import calendar
new_month = dt.month + months
new_year = dt.year + (new_month - 1) // 12
new_month = ((new_month - 1) % 12) + 1
last_day = calendar.monthrange(new_year, new_month)[1]
return _to_serializable(dt.replace(year=new_year, month=new_month, day=last_day))
# ── Registry ─────────────────────────────────────────────
#: Functions in this module that should be registered globally.
DATETIME_FUNCTIONS: dict[str, Callable[..., FormulaResult]] = {
"DATE": _date,
"DATEDIF": _datedif,
"NETWORKDAYS": _networkdays,
"WEEKDAY": _weekday,
"EOMONTH": _eomonth,
"TODAY": _today,
"NOW": _now,
"YEAR": _year,
"MONTH": _month,
"DAY": _day,
"HOUR": _hour,
"MINUTE": _minute,
"SECOND": _second,
"DATEADD": _dateadd,
"TIMESTAMP": _timestamp,
}

View File

@ -0,0 +1,164 @@
"""Logical formula functions (R1).
Boolean, conditional, and type-checking functions. All operate on
scalars (row context). Boolean returns are Python ``bool`` which the
engine treats as truthy/falsy in conditional context.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Callable
from agentkit.bitable.formula.functions import FormulaResult
def _is_truthy(value: FormulaResult) -> bool:
"""Truthiness for formula context: None/0/'' are falsy, everything else truthy."""
if value is None:
return False
if isinstance(value, str):
return value != "" and value.lower() != "false"
if isinstance(value, (int, float)):
return value != 0
return bool(value)
# ── migrated from functions.py ──────────────────────────
def _if(
condition: FormulaResult,
true_val: FormulaResult,
false_val: FormulaResult = None,
) -> FormulaResult:
return true_val if _is_truthy(condition) else false_val
# ── multi-branch conditionals ───────────────────────────
def _ifs(*pairs: FormulaResult) -> FormulaResult:
"""IFS(c1, v1, c2, v2, ...) → value of first truthy condition.
Pairs must be (condition, value, condition, value, ...).
Returns None if no condition matches.
"""
if len(pairs) % 2 != 0:
raise ValueError("IFS requires pairs of (condition, value)")
for i in range(0, len(pairs), 2):
if _is_truthy(pairs[i]):
return pairs[i + 1]
return None
def _switch(value: FormulaResult, *pairs: FormulaResult) -> FormulaResult:
"""SWITCH(value, c1, v1, c2, v2, ..., default?) → first matching value.
Compares value against each case cN; returns vN on first match.
Optional trailing default (odd arg count) returned if no match.
"""
n = len(pairs)
has_default = n % 2 == 1
limit = n - 1 if has_default else n
for i in range(0, limit, 2):
if value == pairs[i]:
return pairs[i + 1]
return pairs[-1] if has_default else None
def _iferror(value: FormulaResult, default: FormulaResult = None) -> FormulaResult:
"""IFERROR(value, default=None) → value, or default if value is an error dict.
The engine wraps exceptions as ``{"__error": "..."}`` dicts; this function
unwraps them. Non-error values pass through unchanged.
"""
if isinstance(value, dict) and "__error" in value:
return default
return value
def _coalesce(*values: FormulaResult) -> FormulaResult:
"""COALESCE(v1, v2, ...) → first non-None/non-empty value."""
for v in values:
if v is not None and v != "":
return v
return None
# ── boolean operators ───────────────────────────────────
def _and(*args: FormulaResult) -> bool:
return all(_is_truthy(a) for a in args)
def _or(*args: FormulaResult) -> bool:
return any(_is_truthy(a) for a in args)
def _not(value: FormulaResult) -> bool:
return not _is_truthy(value)
# ── predicates ──────────────────────────────────────────
def _isblank(value: FormulaResult) -> bool:
return value is None or value == ""
def _isnumber(value: FormulaResult) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _istext(value: FormulaResult) -> bool:
return isinstance(value, str)
def _isdate(value: FormulaResult) -> bool:
if isinstance(value, (datetime, date)):
return True
if isinstance(value, str):
try:
datetime.fromisoformat(value)
return True
except ValueError:
return False
return False
# ── constants ───────────────────────────────────────────
def _true() -> bool:
return True
def _false() -> bool:
return False
def _null() -> None:
return None
# ── Registry ────────────────────────────────────────────
LOGICAL_FUNCTIONS: dict[str, Callable[..., FormulaResult]] = {
"IF": _if,
"IFS": _ifs,
"SWITCH": _switch,
"IFERROR": _iferror,
"COALESCE": _coalesce,
"AND": _and,
"OR": _or,
"NOT": _not,
"ISBLANK": _isblank,
"ISNUMBER": _isnumber,
"ISTEXT": _istext,
"ISDATE": _isdate,
"TRUE": _true,
"FALSE": _false,
"NULL": _null,
}

View File

@ -0,0 +1,189 @@
"""Lookup formula functions (R3 partial).
LOOKUP / FILTER / MATCH / VLOOKUP / XLOOKUP operate on lists of values
and return scalars or lists. Cross-table resolution (resolving relation
fields to foreign-table columns) is deferred to U3 these functions
expect the engine to have already expanded relation context into flat
value lists.
ponytail: ceiling true cross-table LOOKUP requires the U3 relation
context expansion in the engine. The functions here operate on
in-memory lists passed by the engine, keeping them usable for same-table
lookups now and cross-table later without API change.
"""
from __future__ import annotations
from typing import Callable
from agentkit.bitable.formula.functions import FormulaResult
def _to_number(v: FormulaResult) -> float | None:
if v is None or v == "":
return None
if isinstance(v, bool):
return 1.0 if v else 0.0
if isinstance(v, (int, float)):
return float(v)
try:
return float(v)
except (ValueError, TypeError):
return None
def _lookup(
lookup_value: FormulaResult,
lookup_vector: list[FormulaResult],
result_vector: list[FormulaResult],
) -> FormulaResult:
"""LOOKUP(value, lookup_vector, result_vector) → first result where lookup_vector matches.
Approximate match: lookup_vector must be sorted ascending. Returns the
last result where lookup_vector value <= lookup_value.
"""
target = _to_number(lookup_value)
if target is None:
return None
if len(lookup_vector) != len(result_vector):
raise ValueError("LOOKUP requires lookup_vector and result_vector of equal length")
last_match: FormulaResult = None
for lv, rv in zip(lookup_vector, result_vector):
n = _to_number(lv)
if n is None:
continue
if n <= target:
last_match = rv
else:
break
return last_match
def _filter(
values: list[FormulaResult],
criteria_range: list[FormulaResult],
criteria: FormulaResult,
) -> list[FormulaResult]:
"""FILTER(values, criteria_range, criteria) → values where criteria_range matches.
Returns a list (multi-value result). The engine treats list returns as
multi-value results useful for downstream LOOKUP/MATCH.
"""
if len(values) != len(criteria_range):
raise ValueError("FILTER requires values and criteria_range of equal length")
target = criteria
result: list[FormulaResult] = []
for v, c in zip(values, criteria_range):
if c == target:
result.append(v)
return result
def _match(
lookup_value: FormulaResult,
lookup_array: list[FormulaResult],
match_type: int = 0,
) -> int | None:
"""MATCH(value, lookup_array, match_type=0) → 0-based position.
match_type:
0 (default) exact match
1 largest value <= lookup_value (array must be sorted ascending)
-1 smallest value >= lookup_value (array must be sorted descending)
Returns None if no match.
"""
if match_type == 0:
for i, v in enumerate(lookup_array):
if v == lookup_value:
return i
return None
target = _to_number(lookup_value)
if target is None:
return None
if match_type == 1:
last_match: int | None = None
for i, v in enumerate(lookup_array):
n = _to_number(v)
if n is None:
continue
if n <= target:
last_match = i
else:
break
return last_match
if match_type == -1:
last_match = None
for i, v in enumerate(lookup_array):
n = _to_number(v)
if n is None:
continue
if n >= target:
last_match = i
else:
break
return last_match
return None
def _vlookup(
lookup_value: FormulaResult,
table_vector: list[list[FormulaResult]],
col_index: int,
range_lookup: bool = True,
) -> FormulaResult:
"""VLOOKUP(value, table, col_index, range_lookup=True) → value from col_index of matching row.
table_vector is a list of rows (each row a list of values).
col_index is 1-based.
range_lookup: True (default) = approximate match (sorted); False = exact.
"""
if col_index < 1:
raise ValueError("VLOOKUP col_index must be >= 1")
target = _to_number(lookup_value) if range_lookup else lookup_value
last_match: FormulaResult = None
for row in table_vector:
if not row:
continue
first = row[0]
if range_lookup:
n = _to_number(first)
if n is None:
continue
if n <= (target or 0):
if col_index <= len(row):
last_match = row[col_index - 1]
else:
break
else:
if first == lookup_value:
if col_index <= len(row):
return row[col_index - 1]
return None
return last_match
def _xlookup(
lookup_value: FormulaResult,
lookup_array: list[FormulaResult],
return_array: list[FormulaResult],
if_not_found: FormulaResult = None,
) -> FormulaResult:
"""XLOOKUP(value, lookup_array, return_array, if_not_found=None) → first match's return.
Exact match by default. Returns if_not_found (default None) if no match.
"""
for lv, rv in zip(lookup_array, return_array):
if lv == lookup_value:
return rv
return if_not_found
# ── Registry ────────────────────────────────────────────
LOOKUP_FUNCTIONS: dict[str, Callable[..., FormulaResult]] = {
"LOOKUP": _lookup,
"FILTER": _filter,
"MATCH": _match,
"VLOOKUP": _vlookup,
"XLOOKUP": _xlookup,
}

View File

@ -0,0 +1,212 @@
"""Text formula functions (R1).
All functions operate on scalars (row context). Storage format for text
fields is ``str``; numbers are coerced via ``str()`` where appropriate.
"""
from __future__ import annotations
from typing import Callable
from agentkit.bitable.formula.functions import FormulaResult
def _to_str(value: FormulaResult) -> str:
"""Coerce a formula value to string. None → empty string."""
if value is None:
return ""
return str(value)
# ── migrated from functions.py ──────────────────────────
def _concat(*args: FormulaResult) -> str:
"""Concatenate all arguments as strings, skipping None."""
return "".join(_to_str(a) for a in args if a is not None)
def _len(value: FormulaResult) -> int:
"""Length of value's string form. None → 0."""
if value is None:
return 0
return len(str(value))
# ── find / extract ──────────────────────────────────────
def _find(text: FormulaResult, substring: FormulaResult, start: int = 0) -> int:
"""FIND(text, substring, start=0) → 0-based index, -1 if not found."""
t = _to_str(text)
s = _to_str(substring)
if not s:
return 0
return t.find(s, max(0, start))
def _mid(text: FormulaResult, start: int, length: int) -> str:
"""MID(text, start, length) → substring from 0-based start for length chars."""
t = _to_str(text)
if start < 0:
start = 0
if length < 0:
length = 0
return t[start : start + length]
def _left(text: FormulaResult, n: int = 1) -> str:
"""LEFT(text, n=1) → first n characters."""
t = _to_str(text)
if n <= 0:
return ""
return t[:n]
def _right(text: FormulaResult, n: int = 1) -> str:
"""RIGHT(text, n=1) → last n characters."""
t = _to_str(text)
if n <= 0:
return ""
return t[-n:] if n <= len(t) else t
# ── replace / substitute ────────────────────────────────
def _replace(text: FormulaResult, start: int, length: int, new: FormulaResult) -> str:
"""REPLACE(text, start, length, new) → text with [start:start+length] replaced by new."""
t = _to_str(text)
n = _to_str(new)
if start < 0:
start = 0
if length < 0:
length = 0
return t[:start] + n + t[start + length :]
def _substitute(
text: FormulaResult, old: FormulaResult, new: FormulaResult, occurrence: int = -1
) -> str:
"""SUBSTITUTE(text, old, new, occurrence=-1) → text with old replaced by new.
occurrence: -1 (default) replaces all; otherwise replaces the Nth occurrence (1-based).
"""
t = _to_str(text)
o = _to_str(old)
n = _to_str(new)
if not o:
return t
if occurrence < 0:
return t.replace(o, n)
# Replace only the Nth occurrence (1-based)
parts = t.split(o)
if len(parts) <= occurrence:
return t # not enough occurrences
return o.join(parts[:occurrence]) + n + o.join(parts[occurrence:])
# ── case / trim ─────────────────────────────────────────
def _upper(value: FormulaResult) -> str:
return _to_str(value).upper()
def _lower(value: FormulaResult) -> str:
return _to_str(value).lower()
def _trim(value: FormulaResult) -> str:
"""TRIM(text) → strip leading/trailing whitespace and collapse internal runs."""
return " ".join(_to_str(value).split())
# ── repeat / format / convert ───────────────────────────
def _rept(text: FormulaResult, n: int) -> str:
"""REPT(text, n) → text repeated n times."""
if n <= 0:
return ""
return _to_str(text) * n
def _text(value: FormulaResult, fmt: str = "") -> str:
"""TEXT(value, fmt) → value formatted as string.
fmt is a simple format spec passed to Python's ``format()``.
Empty fmt ``str(value)``.
"""
if value is None:
return ""
if not fmt:
return str(value)
try:
return format(value, fmt)
except (ValueError, TypeError):
return str(value)
def _value(text: FormulaResult) -> float | int | None:
"""VALUE(text) → parse text as number. Returns None if unparseable."""
if text is None:
return None
if isinstance(text, (int, float)):
return text
s = str(text).strip()
if not s:
return None
try:
# Prefer int if no decimal point
if "." not in s and "e" not in s.lower():
return int(s)
return float(s)
except ValueError:
try:
return float(s)
except ValueError:
return None
# ── predicates / split ──────────────────────────────────
def _startswith(text: FormulaResult, prefix: FormulaResult) -> bool:
return _to_str(text).startswith(_to_str(prefix))
def _endswith(text: FormulaResult, suffix: FormulaResult) -> bool:
return _to_str(text).endswith(_to_str(suffix))
def _split(text: FormulaResult, delimiter: str = ",") -> list[str]:
"""SPLIT(text, delimiter=',') → list of substrings.
Note: returns a list. The formula engine treats list returns as
multi-value results (used by LOOKUP/FILTER context in U3).
"""
return _to_str(text).split(delimiter) if delimiter else [_to_str(text)]
# ── Registry ────────────────────────────────────────────
TEXT_FUNCTIONS: dict[str, Callable[..., FormulaResult]] = {
"CONCAT": _concat,
"LEN": _len,
"FIND": _find,
"MID": _mid,
"LEFT": _left,
"RIGHT": _right,
"REPLACE": _replace,
"SUBSTITUTE": _substitute,
"UPPER": _upper,
"LOWER": _lower,
"TRIM": _trim,
"REPT": _rept,
"TEXT": _text,
"VALUE": _value,
"STARTSWITH": _startswith,
"ENDSWITH": _endswith,
"SPLIT": _split,
}

View File

@ -44,30 +44,65 @@ class UnknownFunctionError(Exception):
"""Raised when a formula calls a function not in the registry."""
class FormulaDepthExceededError(Exception):
"""Raised when a formula AST exceeds the max depth (KTD8).
Limits deeply nested expressions that could cause exponential
evaluation time. Default limit: 15 levels.
"""
# KTD8: AST depth limit — prevents exponential-time evaluation of
# deeply nested expressions (e.g. nested IF chains, arithmetic towers).
MAX_AST_DEPTH: int = 15
# ── Field reference substitution ──────────────────────────
# Match {field_id} — field IDs are UUIDs or alphanumeric.
_FIELD_REF_RE = re.compile(r"\{([a-zA-Z0-9_-]+)\}")
# Match {rel_field_id.target_field_id} — cross-table reference (KTD-2, U3).
# Must be matched BEFORE _FIELD_REF_RE to avoid stripping the rel part.
_CROSS_TABLE_REF_RE = re.compile(r"\{([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)\}")
def _substitute_field_refs(formula: str) -> tuple[str, dict[str, str]]:
"""Replace ``{field_id}`` with ``_f_<safe_name>`` (a Python Name node).
Field IDs are UUIDs that may start with a digit, which is invalid in Python
identifiers. We prefix with ``_f_`` and replace hyphens with underscores.
A reverse mapping is returned so the engine can map back to real field IDs.
def _substitute_field_refs(
formula: str,
) -> tuple[str, dict[str, str], dict[str, tuple[str, str]]]:
"""Replace field references with safe Python identifiers.
Handles two syntaxes:
- ``{field_id}`` ``_f_<safe>`` (single-field, row or aggregate context)
- ``{rel_field_id.target_field_id}`` ``_ct_<safe_rel>__<safe_target>``
(cross-table reference, KTD-2, U3)
Returns:
Tuple of (substituted_formula, field_mapping, cross_table_mapping)
where field_mapping maps safe_name field_id and
cross_table_mapping maps safe_name (rel_field_id, target_field_id).
"""
mapping: dict[str, str] = {}
field_mapping: dict[str, str] = {}
cross_table_mapping: dict[str, tuple[str, str]] = {}
def _replace(match: re.Match[str]) -> str:
field_id = match.group(1)
# Convert UUID-style field_id to a valid Python identifier
safe_name = "_f_" + field_id.replace("-", "_")
mapping[safe_name] = field_id
def _replace_cross(match: re.Match[str]) -> str:
rel_field_id = match.group(1)
target_field_id = match.group(2)
safe_name = "_ct_" + rel_field_id.replace("-", "_") + "__" + target_field_id.replace("-", "_")
cross_table_mapping[safe_name] = (rel_field_id, target_field_id)
return safe_name
result = _FIELD_REF_RE.sub(_replace, formula)
return result, mapping
def _replace_single(match: re.Match[str]) -> str:
field_id = match.group(1)
safe_name = "_f_" + field_id.replace("-", "_")
field_mapping[safe_name] = field_id
return safe_name
# Cross-table refs first (they contain a dot, so won't match _FIELD_REF_RE)
result = _CROSS_TABLE_REF_RE.sub(_replace_cross, formula)
# Then single-field refs
result = _FIELD_REF_RE.sub(_replace_single, result)
return result, field_mapping, cross_table_mapping
# ── AST whitelist (KTD7) ──────────────────────────────────
@ -84,6 +119,8 @@ _ALLOWED_NODES: frozenset[type[ast.AST]] = frozenset(
ast.Constant,
ast.IfExp,
ast.Load,
ast.List,
ast.Tuple,
ast.Add,
ast.Sub,
ast.Mult,
@ -105,6 +142,22 @@ _ALLOWED_NODES: frozenset[type[ast.AST]] = frozenset(
)
def _ast_depth(node: ast.AST) -> int:
"""Compute the depth of an AST tree (KTD8).
Uses iterative BFS to avoid recursion limits on wide trees.
Returns the maximum nesting depth.
"""
if not hasattr(node, "__dict__") and not isinstance(node, list):
return 1
max_depth = 0
for child in ast.iter_child_nodes(node):
child_depth = _ast_depth(child)
if child_depth > max_depth:
max_depth = child_depth
return max_depth + 1
class _SecurityVisitor(ast.NodeVisitor):
"""Visit AST nodes, reject any not in the whitelist (KTD7)."""
@ -138,7 +191,7 @@ class _SecurityVisitor(ast.NodeVisitor):
def parse_formula(
formula: str, allowed_functions: set[str] | None = None
) -> tuple[ast.Expression, dict[str, str]]:
) -> tuple[ast.Expression, dict[str, str], dict[str, tuple[str, str]]]:
"""Parse a formula string into a safe AST.
Args:
@ -147,8 +200,10 @@ def parse_formula(
all functions are allowed (used for syntax-only validation).
Returns:
Tuple of (AST expression, field_ref_mapping) where
field_ref_mapping maps safe Python identifiers to original field IDs.
Tuple of (AST expression, field_ref_mapping, cross_table_mapping)
where field_ref_mapping maps safe Python identifiers to original
field IDs, and cross_table_mapping maps safe Python identifiers to
(relation_field_id, target_field_id) tuples for cross-table refs.
Raises:
FormulaParseError: Syntax error in formula.
@ -162,8 +217,8 @@ def parse_formula(
if not expr:
raise FormulaParseError("Empty formula")
# Substitute field references {field_id} → safe_name
substituted, field_mapping = _substitute_field_refs(expr)
# Substitute field references {field_id} → safe_name, {rel.target} → _ct_...
substituted, field_mapping, cross_table_mapping = _substitute_field_refs(expr)
try:
tree = ast.parse(substituted, mode="eval")
@ -179,7 +234,15 @@ def parse_formula(
visitor = _SecurityVisitor(allowed)
visitor.visit(tree)
return tree, field_mapping # type: ignore[return-value]
# KTD8: depth limit — reject formulas with AST deeper than MAX_AST_DEPTH
depth = _ast_depth(tree)
if depth > MAX_AST_DEPTH:
raise FormulaDepthExceededError(
f"Formula AST depth {depth} exceeds limit {MAX_AST_DEPTH}. "
"Simplify the formula (e.g. flatten nested IF/IFS chains)."
)
return tree, field_mapping, cross_table_mapping # type: ignore[return-value]
def evaluate_ast(
@ -275,6 +338,12 @@ def _eval_node(
args = [_eval_node(a, fields, functions) for a in node.args]
return functions[func_name](*args)
if isinstance(node, ast.List):
return [_eval_node(e, fields, functions) for e in node.elts]
if isinstance(node, ast.Tuple):
return tuple(_eval_node(e, fields, functions) for e in node.elts)
raise FormulaSecurityError(f"Disallowed node during evaluation: {type(node).__name__}")

View File

@ -33,7 +33,9 @@ class FieldType(str, Enum):
attachment = "attachment"
image = "image"
formula = "formula"
relation = "relation"
lookup = "lookup"
rollup = "rollup"
class FieldOwner(str, Enum):
@ -62,6 +64,59 @@ class RecalcStatus(str, Enum):
error = "error"
class RelationType(str, Enum):
"""Type of relation between two tables (R5).
- ``one_to_one`` / ``one_to_many``: stored as record JSONB ``["rec_id"]``
or ``["rec_id1", "rec_id2"]`` arrays (no junction table).
- ``many_to_many``: stored in ``bitable_relation_links`` junction table
(KTD-1) for indexed reverse queries.
"""
one_to_one = "one_to_one"
one_to_many = "one_to_many"
many_to_many = "many_to_many"
class AutomationTriggerType(str, Enum):
"""Supported automation trigger types (R13)."""
record_created = "record_created"
record_updated = "record_updated"
record_deleted = "record_deleted"
class AutomationActionType(str, Enum):
"""Supported automation action types (R14)."""
create_record = "create_record"
update_record = "update_record"
send_webhook = "send_webhook"
send_email = "send_email"
send_message = "send_message"
class AutomationStatus(str, Enum):
"""Status of an automation rule execution log (R17)."""
success = "success"
error = "error"
retrying = "retrying"
class CrossTableDepType(str, Enum):
"""Type of cross-table dependency (KTD-3).
- ``formula``: a formula field references a relation_field.target_field
- ``lookup``: a lookup field pulls from a target table
- ``rollup``: a rollup field aggregates a target table column
"""
formula = "formula"
lookup = "lookup"
rollup = "rollup"
class BitableFile(BaseModel):
"""Top-level container grouping related tables (多维表格文件).
@ -153,7 +208,9 @@ class Field(BaseModel):
``config`` varies by ``field_type``:
- select/multiselect: ``{"options": [{"label": "...", "value": "..."}]}``
- formula: ``{"formula_expr": "=SUM({field_abc})"}``
- lookup: ``{"lookup_target": {"table_id": "...", "field_id": "...", "filter_field_id": "...", "filter_value": "..."}}``
- relation: ``{"relation_type": "one_to_one|one_to_many|many_to_many", "target_table_id": "...", "is_bidirectional": bool, "reverse_field_id": "..." (optional)}``
- lookup: ``{"relation_field_id": "...", "target_field_id": "..."}``
- rollup: ``{"relation_field_id": "...", "target_field_id": "...", "aggregation": "sum|count|avg|min|max|unique"}``
"""
model_config = ConfigDict(from_attributes=True)
@ -193,7 +250,11 @@ class View(BaseModel):
class RecalcTask(BaseModel):
"""An asynchronous formula recalculation task."""
"""An asynchronous formula recalculation task.
``is_cross_table`` marks tasks triggered by a cross-table dependency
e.g. a record change in table A causes a rollup in table B to recalc.
"""
model_config = ConfigDict(from_attributes=True)
@ -205,3 +266,81 @@ class RecalcTask(BaseModel):
error_message: str | None = None
queued_at: datetime = PydanticField(default_factory=_utcnow)
completed_at: datetime | None = None
is_cross_table: bool = False
class RelationLink(BaseModel):
"""A row in the ``bitable_relation_links`` junction table (KTD-1, R9).
Used for many_to_many relations. One-to-one / one-to-many relations
store their link IDs directly in the record's JSONB ``values`` — those
do not create RelationLink rows.
"""
model_config = ConfigDict(from_attributes=True)
id: str
relation_field_id: str
source_record_id: str
target_record_id: str
created_at: datetime = PydanticField(default_factory=_utcnow)
class AutomationRule(BaseModel):
"""A saved automation rule (R13-R17).
``trigger_config`` shape varies by trigger type:
- record_created: ``{"type": "record_created"}``
- record_updated: ``{"type": "record_updated", "field_ids": ["f1"]}`` (field_ids optional)
- record_deleted: ``{"type": "record_deleted"}``
``action_config`` shape varies by action type (see AutomationActionType).
``webhook_token`` is set only for inbound-webhook triggered rules.
"""
model_config = ConfigDict(from_attributes=True)
id: str
table_id: str
name: str
trigger_config: dict[str, object] = PydanticField(default_factory=dict)
action_config: dict[str, object] = PydanticField(default_factory=dict)
enabled: bool = True
webhook_token: str | None = None
created_at: datetime = PydanticField(default_factory=_utcnow)
updated_at: datetime = PydanticField(default_factory=_utcnow)
class AutomationLog(BaseModel):
"""Execution log entry for an automation rule (R17)."""
model_config = ConfigDict(from_attributes=True)
id: str
automation_id: str
trigger_record_id: str | None = None
status: AutomationStatus = AutomationStatus.success
error_message: str | None = None
attempt: int = 1
started_at: datetime = PydanticField(default_factory=_utcnow)
completed_at: datetime | None = None
class CrossTableDep(BaseModel):
"""A materialized cross-table field dependency edge (KTD-3, R11).
``source_field_id`` is in ``source_table_id`` and depends on
``target_field_id`` in ``target_table_id``. When the target field's
value changes in any record, dependents are enqueued for cross-table
recalc (U6).
"""
model_config = ConfigDict(from_attributes=True)
id: str
source_table_id: str
source_field_id: str
target_table_id: str
target_field_id: str
dep_type: CrossTableDepType = CrossTableDepType.formula
created_at: datetime = PydanticField(default_factory=_utcnow)

View File

@ -18,10 +18,11 @@ from __future__ import annotations
import asyncio
import logging
from decimal import Decimal
from agentkit.bitable.db import BitableDB
from agentkit.bitable.formula.engine import FormulaEngine
from agentkit.bitable.models import FieldType, RecalcStatus, RecalcTask
from agentkit.bitable.models import Field, FieldType, Record, RecalcStatus, RecalcTask
from agentkit.bitable.repository import BitableRepository
from agentkit.bitable.service import BitableService
@ -32,6 +33,22 @@ _DEFAULT_REAPER_INTERVAL = 300 # 5 minutes
_DEFAULT_STALE_THRESHOLD = 600 # 10 minutes
def _to_float(v: object) -> float | None:
"""Normalize JSONB numeric values to float (handles Decimal from asyncpg).
PostgreSQL JSONB returns numbers as ``Decimal``; ``isinstance(v, (int, float))``
misses them. ``bool`` is a subclass of ``int`` excluded so True/False don't
pollute numeric aggregations.
"""
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, Decimal):
return float(v)
return None
class RecalcWorker:
"""Background worker that processes formula recalc tasks.
@ -181,29 +198,25 @@ class RecalcWorker:
called from the worker loop (atomic claim sets it). When called
synchronously via ``service.process_recalc_task``, this method
marks it calculating first (idempotent re-marking is harmless).
Handles three computed field types (U3/U5/U6):
- ``formula``: parsed by FormulaEngine; may reference cross-table
fields via ``{rel.target}`` syntax (resolved by _resolve_cross_table_values).
- ``lookup``: pulls a single related record's field value via a relation.
- ``rollup``: aggregates related records' field values (SUM/AVG/COUNT/MIN/MAX).
"""
# Idempotent: mark calculating (no-op if already calculating via claim).
await self._repo.update_recalc_status(task.id, RecalcStatus.calculating)
try:
field = await self._repo.get_field(task.field_id)
if field is None or field.field_type != FieldType.formula:
if field is None or field.field_type not in (
FieldType.formula,
FieldType.lookup,
FieldType.rollup,
):
await self._repo.update_recalc_status(
task.id, RecalcStatus.error, "Field not found or not a formula"
)
return
formula_expr = field.config.get("formula_expr", "")
if not formula_expr:
await self._repo.update_recalc_status(
task.id, RecalcStatus.error, "No formula_expr in field config"
)
return
engine = await self._get_or_build_engine(task.table_id)
if engine is None:
await self._repo.update_recalc_status(
task.id, RecalcStatus.error, "No formula fields in table"
task.id, RecalcStatus.error, "Field not found or not computed"
)
return
@ -214,18 +227,13 @@ class RecalcWorker:
)
return
deps = engine.get_dependencies(task.field_id)
column_values: dict[str, list[object]] = {}
for dep_field_id in deps:
column_values[dep_field_id] = await self._repo.get_column_values(
task.table_id, dep_field_id
)
result = engine.evaluate(
task.field_id,
row_values=record.values,
column_values=column_values,
)
if field.field_type == FieldType.lookup:
result = await self._evaluate_lookup(field, record)
elif field.field_type == FieldType.rollup:
result = await self._evaluate_rollup(field, record)
else:
# Formula — may have cross-table refs (U3)
result = await self._evaluate_formula(field, record, task.table_id)
await self._repo.set_formula_value(task.record_id, task.field_id, result)
await self._repo.update_recalc_status(task.id, RecalcStatus.done)
@ -234,6 +242,151 @@ class RecalcWorker:
logger.exception("RecalcWorker: error processing task %s", task.id)
await self._repo.update_recalc_status(task.id, RecalcStatus.error, str(e)[:500])
async def _evaluate_formula(self, field: Field, record: Record, table_id: str) -> object:
"""Evaluate a formula field (U1+U3). Resolves cross-table refs."""
formula_expr = field.config.get("formula_expr", "")
if not formula_expr:
raise ValueError("No formula_expr in field config")
engine = await self._get_or_build_engine(table_id)
if engine is None:
raise ValueError("No formula fields in table")
deps = engine.get_dependencies(field.id)
column_values: dict[str, list[object]] = {}
for dep_field_id in deps:
column_values[dep_field_id] = await self._repo.get_column_values(table_id, dep_field_id)
# U3: resolve cross-table references {rel.target}
cross_table_values = await self._resolve_cross_table_values(engine, field.id, record)
return engine.evaluate(
field.id,
row_values=record.values,
column_values=column_values,
cross_table_values=cross_table_values,
)
async def _evaluate_lookup(self, field: Field, record: Record) -> object:
"""Evaluate a lookup field (U5, R6).
Config: ``{relation_field_id, target_field_id}``
Resolves the related record(s) via the relation field, then reads
the target field value from the first related record.
"""
rel_field_id = str(field.config.get("relation_field_id", ""))
target_field_id = str(field.config.get("target_field_id", ""))
if not rel_field_id or not target_field_id:
raise ValueError("lookup field missing relation_field_id or target_field_id")
# Same forward/reverse logic as _evaluate_rollup
rel_field = await self._repo.get_field(rel_field_id)
reverse = rel_field is not None and rel_field.table_id != record.table_id
related_ids = await self._service.list_related_records(
relation_field_id=rel_field_id,
record_id=record.id,
reverse=reverse,
)
if not related_ids:
return None
# Lookup takes the first related record's value (one_to_one / one_to_many first)
target_record = await self._repo.get_record(related_ids[0])
if target_record is None:
return None
raw = target_record.values.get(target_field_id)
# Normalize Decimal → float for JSON serialization (set_formula_value uses json.dumps)
n = _to_float(raw)
return n if n is not None else raw
async def _evaluate_rollup(self, field: Field, record: Record) -> object:
"""Evaluate a rollup field (U5, R8).
Config: ``{relation_field_id, target_field_id, aggregation}``
Aggregation is one of: sum, avg, count, min, max, concat, count_distinct.
"""
rel_field_id = str(field.config.get("relation_field_id", ""))
target_field_id = str(field.config.get("target_field_id", ""))
aggregation = str(field.config.get("aggregation", "sum")).lower()
if not rel_field_id or not target_field_id:
raise ValueError("rollup field missing relation_field_id or target_field_id")
# Determine forward vs reverse lookup: if the record is on the same
# table as the relation field, it's a forward lookup (record is the
# source). Otherwise the record is on the target table — reverse lookup.
rel_field = await self._repo.get_field(rel_field_id)
if rel_field is not None and rel_field.table_id != record.table_id:
reverse = True
else:
reverse = False
related_ids = await self._service.list_related_records(
relation_field_id=rel_field_id,
record_id=record.id,
reverse=reverse,
)
if not related_ids:
return 0 if aggregation in ("sum", "count") else None
# Fetch target field values from all related records
values: list[object] = []
for rid in related_ids:
rec = await self._repo.get_record(rid)
if rec is not None:
values.append(rec.values.get(target_field_id))
# Apply aggregation
# ponytail: JSONB numbers come back as Decimal from asyncpg — _to_float
# normalizes them (and excludes bool which is an int subclass).
numeric_values = [n for v in values if (n := _to_float(v)) is not None]
non_none = [v for v in values if v is not None]
if aggregation == "sum":
return sum(numeric_values)
if aggregation == "avg":
return sum(numeric_values) / len(numeric_values) if numeric_values else 0
if aggregation == "count":
return len(non_none)
if aggregation == "count_distinct":
return len(set(non_none))
if aggregation == "min":
return min(numeric_values) if numeric_values else None
if aggregation == "max":
return max(numeric_values) if numeric_values else None
if aggregation == "concat":
return "".join(str(v) for v in non_none)
raise ValueError(f"Unknown rollup aggregation: {aggregation}")
async def _resolve_cross_table_values(
self, engine: FormulaEngine, field_id: str, record: Record
) -> dict[str, list[object]]:
"""Resolve cross-table references for a formula (U3).
For each ``{rel_field.target_field}`` ref in the formula:
1. Find related records via the relation field on this record.
2. Read target_field values from those related records.
3. Return mapping safe_name list of values (for aggregate context).
If the relation field has no links, returns empty lists for each ref.
"""
cross_table_mapping = engine.get_cross_table_mapping(field_id)
if not cross_table_mapping:
return {}
result: dict[str, list[object]] = {}
for safe_name, (rel_field_id, target_field_id) in cross_table_mapping.items():
related_ids = await self._service.list_related_records(
relation_field_id=rel_field_id,
record_id=record.id,
)
values: list[object] = []
for rid in related_ids:
rec = await self._repo.get_record(rid)
if rec is not None:
values.append(rec.values.get(target_field_id))
result[safe_name] = values
return result
async def _get_or_build_engine(self, table_id: str) -> FormulaEngine | None:
"""Get or build a FormulaEngine for a table.

View File

@ -11,27 +11,37 @@ import logging
import re
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, insert, select, text, update
from sqlalchemy import case, delete, func, insert, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from agentkit.bitable.db import (
AutomationLogModel,
AutomationModel,
BitableDB,
CrossTableDepModel,
FieldModel,
FileModel,
RecordModel,
RecalcQueueModel,
RelationLinkModel,
TableModel,
ViewModel,
_uuid_str,
)
from agentkit.bitable.models import (
AutomationLog,
AutomationRule,
AutomationStatus,
BitableFile,
CrossTableDep,
CrossTableDepType,
Field,
FieldOwner,
FieldType,
Record,
RecalcStatus,
RecalcTask,
RelationLink,
Table,
View,
ViewType,
@ -226,13 +236,16 @@ class BitableRepository:
if not re.match(r"^[a-f0-9-]{36}$", pk_field_id):
raise ValueError(f"Invalid pk_field_id format: {pk_field_id}")
index_name = f"ix_bitable_records_pk_{table_id.replace('-', '_')}"
# asyncpg does not support parameter binding in DDL statements (CREATE INDEX),
# so pk_field_id is interpolated directly. It is validated as a strict UUID
# (hex + hyphens, 36 chars) above, so SQL injection is not possible here.
sql = text(
f"CREATE UNIQUE INDEX IF NOT EXISTS {index_name} "
f"ON bitable.bitable_records (table_id, (values->>'{pk_field_id}')) "
f"WHERE values ? :pk_path"
f"WHERE values ? '{pk_field_id}'"
)
async with self._session_factory() as session:
await session.execute(sql, {"pk_path": pk_field_id})
await session.execute(sql)
await session.commit()
async def delete_table(self, table_id: str) -> bool:
@ -411,6 +424,27 @@ class BitableRepository:
await session.commit()
return Record.model_validate(entity) if entity else None
async def merge_record_values(
self, record_id: str, values: dict[str, object]
) -> Record | None:
"""Shallow-merge values into a record's JSONB (``values || :new``).
Unlike :meth:`update_record_values` (full replace), this preserves
existing keys not present in ``values``. Used by relation link
management to set a single field without clobbering the rest.
"""
async with self._session_factory() as session:
stmt = (
update(RecordModel)
.where(RecordModel.id == record_id)
.values(values=RecordModel.values.op("||")(values))
.returning(RecordModel)
)
result = await session.execute(stmt)
entity = result.scalars().first()
await session.commit()
return Record.model_validate(entity) if entity else None
async def delete_record(self, record_id: str) -> bool:
"""Delete a record."""
async with self._session_factory() as session:
@ -518,9 +552,24 @@ class BitableRepository:
# ── Recalc Queue ────────────────────────────────────────
async def enqueue_recalc(
self, table_id: str, record_id: str, field_id: str
self,
table_id: str,
record_id: str,
field_id: str,
is_cross_table: bool = False,
) -> RecalcTask | None:
"""Enqueue a recalc task. Returns None if duplicate (ON CONFLICT DO NOTHING)."""
"""Enqueue a recalc task.
If a task for ``(record_id, field_id)`` already exists:
- ``pending`` / ``calculating`` left as-is (avoid duplicate processing).
- ``done`` / ``error`` reset to ``pending`` so a new change can
trigger a fresh recalc. Without this, the unique constraint would
silently swallow the new request and the field would show stale data.
``is_cross_table`` marks tasks triggered by a cross-table dependency
(U6) distinguishing them from same-table recalc helps the worker
load the correct evaluation context.
"""
async with self._session_factory() as session:
stmt = (
pg_insert(RecalcQueueModel)
@ -530,8 +579,22 @@ class BitableRepository:
record_id=record_id,
field_id=field_id,
status=RecalcStatus.pending.value,
is_cross_table=is_cross_table,
)
.on_conflict_do_update(
constraint="uq_recalc_record_field",
set_={
"status": case(
(
RecalcQueueModel.status.in_(
[RecalcStatus.done.value, RecalcStatus.error.value]
),
RecalcStatus.pending.value,
),
else_=RecalcQueueModel.status,
),
},
)
.on_conflict_do_nothing(constraint="uq_recalc_record_field")
.returning(RecalcQueueModel)
)
result = await session.execute(stmt)
@ -951,3 +1014,381 @@ class BitableRepository:
)
result = await session.execute(stmt)
return [Record.model_validate(e) for e in result.scalars().all()]
# ── Relation Links (U4, KTD-1: many_to_many junction) ───
async def add_relation_link(
self,
relation_field_id: str,
source_record_id: str,
target_record_id: str,
) -> RelationLink:
"""Create a junction-table link for a many_to_many relation."""
async with self._session_factory() as session:
stmt = (
insert(RelationLinkModel)
.values(
id=_uuid_str(),
relation_field_id=relation_field_id,
source_record_id=source_record_id,
target_record_id=target_record_id,
)
.returning(RelationLinkModel)
)
result = await session.execute(stmt)
entity = result.scalar_one()
await session.commit()
return RelationLink.model_validate(entity)
async def remove_relation_link(
self,
relation_field_id: str,
source_record_id: str,
target_record_id: str,
) -> bool:
"""Remove a single junction-table link."""
async with self._session_factory() as session:
result = await session.execute(
delete(RelationLinkModel).where(
RelationLinkModel.relation_field_id == relation_field_id,
RelationLinkModel.source_record_id == source_record_id,
RelationLinkModel.target_record_id == target_record_id,
)
)
await session.commit()
return result.rowcount > 0
async def remove_all_relation_links(
self, relation_field_id: str, source_record_id: str | None = None
) -> int:
"""Remove all links for a relation field (optionally for one source record).
Used during cascade cleanup when a relation field is deleted, or when
a source record is deleted.
"""
async with self._session_factory() as session:
stmt = delete(RelationLinkModel).where(
RelationLinkModel.relation_field_id == relation_field_id
)
if source_record_id is not None:
stmt = stmt.where(
RelationLinkModel.source_record_id == source_record_id
)
result = await session.execute(stmt)
await session.commit()
return result.rowcount
async def list_relation_links(
self, relation_field_id: str, source_record_id: str
) -> list[str]:
"""List target_record_ids linked to source_record_id via relation_field_id."""
async with self._session_factory() as session:
stmt = (
select(RelationLinkModel.target_record_id)
.where(
RelationLinkModel.relation_field_id == relation_field_id,
RelationLinkModel.source_record_id == source_record_id,
)
.order_by(RelationLinkModel.created_at)
)
result = await session.execute(stmt)
return [r[0] for r in result.fetchall()]
async def list_reverse_relation_links(
self, relation_field_id: str, target_record_id: str
) -> list[str]:
"""List source_record_ids whose relation_field points at target_record_id.
Used by the cross-table recalc worker to find which records in the
source table depend on a changed target record (U6).
"""
async with self._session_factory() as session:
stmt = (
select(RelationLinkModel.source_record_id)
.where(
RelationLinkModel.relation_field_id == relation_field_id,
RelationLinkModel.target_record_id == target_record_id,
)
.order_by(RelationLinkModel.created_at)
)
result = await session.execute(stmt)
return [r[0] for r in result.fetchall()]
async def list_relation_links_for_field(
self, relation_field_id: str
) -> list[RelationLink]:
"""List all links for a relation field (used during cascade delete)."""
async with self._session_factory() as session:
stmt = (
select(RelationLinkModel)
.where(RelationLinkModel.relation_field_id == relation_field_id)
.order_by(RelationLinkModel.created_at)
)
result = await session.execute(stmt)
return [RelationLink.model_validate(e) for e in result.scalars().all()]
# ── Cross-table dependency graph (U6, KTD-3) ────────────
async def add_cross_table_dep(
self,
source_table_id: str,
source_field_id: str,
target_table_id: str,
target_field_id: str,
dep_type: CrossTableDepType = CrossTableDepType.formula,
) -> CrossTableDep | None:
"""Record a cross-table field dependency. Returns None if duplicate."""
async with self._session_factory() as session:
stmt = (
pg_insert(CrossTableDepModel)
.values(
id=_uuid_str(),
source_table_id=source_table_id,
source_field_id=source_field_id,
target_table_id=target_table_id,
target_field_id=target_field_id,
dep_type=dep_type.value,
)
.on_conflict_do_nothing(
constraint="uq_cross_table_dep_source_target"
)
.returning(CrossTableDepModel)
)
result = await session.execute(stmt)
entity = result.scalars().first()
await session.commit()
return CrossTableDep.model_validate(entity) if entity else None
async def remove_cross_table_deps_for_source(self, source_field_id: str) -> int:
"""Remove all dep edges where source_field_id is the dependent.
Used when a formula/lookup/rollup field is deleted or its config changes
caller re-adds the current edges afterwards.
"""
async with self._session_factory() as session:
result = await session.execute(
delete(CrossTableDepModel).where(
CrossTableDepModel.source_field_id == source_field_id
)
)
await session.commit()
return result.rowcount
async def find_cross_table_dependents(
self, target_table_id: str, target_field_id: str | None = None
) -> list[CrossTableDep]:
"""Find all (source_table, source_field) pairs that depend on target.
Called by the recalc worker after a record write: which fields in
OTHER tables will need recalc because this field's value changed?
"""
async with self._session_factory() as session:
stmt = select(CrossTableDepModel).where(
CrossTableDepModel.target_table_id == target_table_id
)
if target_field_id is not None:
stmt = stmt.where(CrossTableDepModel.target_field_id == target_field_id)
result = await session.execute(stmt)
return [CrossTableDep.model_validate(e) for e in result.scalars().all()]
async def list_cross_table_deps_for_source_table(
self, source_table_id: str
) -> list[CrossTableDep]:
"""List all dep edges originating from fields in source_table_id."""
async with self._session_factory() as session:
stmt = select(CrossTableDepModel).where(
CrossTableDepModel.source_table_id == source_table_id
)
result = await session.execute(stmt)
return [CrossTableDep.model_validate(e) for e in result.scalars().all()]
async def list_cross_table_deps_for_target(
self, target_table_id: str, target_field_id: str
) -> list[CrossTableDep]:
"""List all dep edges pointing at (target_table, target_field) (U6 R11).
Used by ``trigger_cross_table_recalc`` to find downstream source
fields that must be recomputed when a target field changes.
"""
async with self._session_factory() as session:
stmt = select(CrossTableDepModel).where(
CrossTableDepModel.target_table_id == target_table_id,
CrossTableDepModel.target_field_id == target_field_id,
)
result = await session.execute(stmt)
return [CrossTableDep.model_validate(e) for e in result.scalars().all()]
# ── Automations (U7, R16) ───────────────────────────────
async def create_automation(
self,
table_id: str,
name: str,
trigger_config: dict[str, object],
action_config: dict[str, object],
enabled: bool = True,
webhook_token: str | None = None,
) -> AutomationRule:
"""Create a new automation rule."""
async with self._session_factory() as session:
stmt = (
insert(AutomationModel)
.values(
id=_uuid_str(),
table_id=table_id,
name=name,
trigger_config=trigger_config,
action_config=action_config,
enabled=enabled,
webhook_token=webhook_token,
)
.returning(AutomationModel)
)
result = await session.execute(stmt)
entity = result.scalar_one()
await session.commit()
return AutomationRule.model_validate(entity)
async def get_automation(self, automation_id: str) -> AutomationRule | None:
async with self._session_factory() as session:
result = await session.execute(
select(AutomationModel).where(AutomationModel.id == automation_id)
)
entity = result.scalars().first()
return AutomationRule.model_validate(entity) if entity else None
async def get_automation_by_webhook_token(self, token: str) -> AutomationRule | None:
"""Look up an automation rule by its inbound webhook token.
Caller must use ``hmac.compare_digest`` when matching user-supplied
tokens; this method does an exact equality lookup against the DB
indexed column.
"""
async with self._session_factory() as session:
result = await session.execute(
select(AutomationModel).where(AutomationModel.webhook_token == token)
)
entity = result.scalars().first()
return AutomationRule.model_validate(entity) if entity else None
async def list_automations(
self, table_id: str, enabled_only: bool = False
) -> list[AutomationRule]:
async with self._session_factory() as session:
stmt = (
select(AutomationModel)
.where(AutomationModel.table_id == table_id)
.order_by(AutomationModel.created_at)
)
if enabled_only:
stmt = stmt.where(AutomationModel.enabled.is_(True))
result = await session.execute(stmt)
return [AutomationRule.model_validate(e) for e in result.scalars().all()]
async def update_automation(self, automation_id: str, **kwargs: object) -> AutomationRule | None:
async with self._session_factory() as session:
stmt = (
update(AutomationModel)
.where(AutomationModel.id == automation_id)
.values(**kwargs)
.returning(AutomationModel)
)
result = await session.execute(stmt)
entity = result.scalars().first()
await session.commit()
return AutomationRule.model_validate(entity) if entity else None
async def delete_automation(self, automation_id: str) -> bool:
async with self._session_factory() as session:
# Cascade: delete logs first, then the rule.
await session.execute(
delete(AutomationLogModel).where(AutomationLogModel.automation_id == automation_id)
)
result = await session.execute(
delete(AutomationModel).where(AutomationModel.id == automation_id)
)
await session.commit()
return result.rowcount > 0
async def list_automations_for_trigger(
self,
table_id: str,
trigger_type: str,
) -> list[AutomationRule]:
"""List enabled automations in a table that match a trigger type.
Uses JSONB containment: ``trigger_config->>'type' = :ttype``. The
trigger_type arg is parameterized.
"""
async with self._session_factory() as session:
sql = text(
"SELECT * FROM bitable.bitable_automations "
"WHERE table_id = :table_id AND enabled = TRUE "
"AND trigger_config->>'type' = :ttype "
"ORDER BY created_at"
)
result = await session.execute(
sql, {"table_id": table_id, "ttype": trigger_type}
)
return [AutomationRule.model_validate(AutomationModel(**r._mapping)) for r in result.fetchall()]
# ── Automation Logs (U7, R17) ───────────────────────────
async def create_automation_log(
self,
automation_id: str,
trigger_record_id: str | None,
status: AutomationStatus = AutomationStatus.success,
error_message: str | None = None,
attempt: int = 1,
) -> AutomationLog:
async with self._session_factory() as session:
stmt = (
insert(AutomationLogModel)
.values(
id=_uuid_str(),
automation_id=automation_id,
trigger_record_id=trigger_record_id,
status=status.value,
error_message=error_message,
attempt=attempt,
)
.returning(AutomationLogModel)
)
result = await session.execute(stmt)
entity = result.scalar_one()
await session.commit()
return AutomationLog.model_validate(entity)
async def update_automation_log(
self,
log_id: str,
status: AutomationStatus,
error_message: str | None = None,
) -> None:
async with self._session_factory() as session:
kwargs: dict[str, object] = {"status": status.value}
if error_message is not None:
kwargs["error_message"] = error_message
if status in (AutomationStatus.success, AutomationStatus.error):
kwargs["completed_at"] = func.now()
stmt = (
update(AutomationLogModel)
.where(AutomationLogModel.id == log_id)
.values(**kwargs)
)
await session.execute(stmt)
await session.commit()
async def list_automation_logs(
self, automation_id: str, limit: int = 50
) -> list[AutomationLog]:
async with self._session_factory() as session:
stmt = (
select(AutomationLogModel)
.where(AutomationLogModel.automation_id == automation_id)
.order_by(AutomationLogModel.started_at.desc())
.limit(limit)
)
result = await session.execute(stmt)
return [AutomationLog.model_validate(e) for e in result.scalars().all()]

View File

@ -8,6 +8,7 @@ Routes and CLI call this layer — never the repository directly.
from __future__ import annotations
import asyncio
import logging
import os
from datetime import datetime, timezone
@ -17,12 +18,15 @@ from typing import TYPE_CHECKING, TypeAlias
from agentkit.bitable.db import BitableDB
from agentkit.bitable.models import (
DEFAULT_FIELD_TEMPLATES,
AutomationLog,
AutomationRule,
BitableFile,
Field,
FieldOwner,
FieldType,
Record,
RecalcTask,
RelationType,
Table,
View,
ViewType,
@ -37,6 +41,18 @@ if TYPE_CHECKING:
def invalidate_engine(self, table_id: str) -> None: ...
class _AutomationEngine(Protocol):
"""Structural type for the automation engine's trigger-dispatch surface."""
async def fire_trigger(
self,
table_id: str,
trigger_type: object,
record_id: str | None = None,
changed_field_ids: list[str] | None = None,
record_values: dict[str, object] | None = None,
) -> int: ...
logger = logging.getLogger(__name__)
@ -75,6 +91,8 @@ class BitableService:
self._db = db
self._repo = BitableRepository(db)
self._recalc_worker: _RecalcWorker | None = None # set via set_recalc_worker
self._automation_engine: _AutomationEngine | None = None # set via set_automation_engine
self._pending_trigger_tasks: set[asyncio.Task[object]] = set()
@property
def repo(self) -> BitableRepository:
@ -89,11 +107,53 @@ class BitableService:
"""
self._recalc_worker = worker
def set_automation_engine(self, engine: _AutomationEngine) -> None:
"""Register the AutomationEngine for trigger dispatch (U7).
Called after both service and engine are constructed. When unset,
record writes skip trigger evaluation automations are silent.
"""
self._automation_engine = engine
def _invalidate_engine_cache(self, table_id: str) -> None:
"""Invalidate the worker's cached formula engine for a table (P1 #5)."""
if self._recalc_worker is not None:
self._recalc_worker.invalidate_engine(table_id)
def _fire_automation_trigger(
self,
table_id: str,
trigger_type: str,
record_id: str | None = None,
changed_field_ids: list[str] | None = None,
record_values: dict[str, object] | None = None,
) -> None:
"""Fire automation triggers (U7). Best-effort — errors are logged, not raised."""
if self._automation_engine is None:
return
from agentkit.bitable.models import AutomationTriggerType
try:
trig = AutomationTriggerType(trigger_type)
except ValueError:
return
# Schedule the trigger fire — don't block the record write.
# Track the task so exceptions surface in the app logger instead of
# being silently dropped as "Task exception was never retrieved".
import asyncio
task = asyncio.create_task(
self._automation_engine.fire_trigger(
table_id=table_id,
trigger_type=trig,
record_id=record_id,
changed_field_ids=changed_field_ids,
record_values=record_values,
)
)
self._pending_trigger_tasks.add(task)
task.add_done_callback(self._pending_trigger_tasks.discard)
# ── Files (R1) ─────────────────────────────────────────
async def create_file(
@ -212,8 +272,8 @@ class BitableService:
config=config or {},
owner=owner,
)
# New formula field changes the table's DAG — invalidate cached engine (P1 #5).
if field_type == FieldType.formula:
# New computed field (formula/lookup/rollup) changes the table's DAG — invalidate cached engine (P1 #5).
if field_type in (FieldType.formula, FieldType.lookup, FieldType.rollup):
self._invalidate_engine_cache(table_id)
return field
@ -310,6 +370,12 @@ class BitableService:
values[f.id] = now_iso
record = await self._repo.create_record(table_id, values)
await self._trigger_recalc_for_affected_fields(table_id, record.id)
self._fire_automation_trigger(
table_id=table_id,
trigger_type="record_created",
record_id=record.id,
record_values=dict(record.values),
)
return record
async def create_records_batch(
@ -368,10 +434,22 @@ class BitableService:
)
async def update_record_values(self, record_id: str, values: BitableRecord) -> Record | None:
"""Update a record's values (full replace). Triggers recalc for affected formulas."""
record = await self._repo.update_record_values(record_id, values)
"""Update specific field values in a record (shallow merge, not full replace).
Triggers recalc for affected formula fields on this table AND on tables
that reference this record via relations (cross-table rollup/lookup).
"""
record = await self._repo.merge_record_values(record_id, values)
if record is not None:
await self._trigger_recalc_for_affected_fields(record.table_id, record.id)
await self._trigger_cross_table_recalc(record)
self._fire_automation_trigger(
table_id=record.table_id,
trigger_type="record_updated",
record_id=record.id,
changed_field_ids=list(values.keys()),
record_values=dict(record.values),
)
return record
async def delete_record(self, record_id: str) -> bool:
@ -380,7 +458,15 @@ class BitableService:
if record is None:
return False
await self._cleanup_attachment_files(record.table_id, [record])
return await self._repo.delete_record(record_id)
deleted = await self._repo.delete_record(record_id)
if deleted:
self._fire_automation_trigger(
table_id=record.table_id,
trigger_type="record_deleted",
record_id=record_id,
record_values=dict(record.values),
)
return deleted
async def delete_records_by_table(self, table_id: str) -> int:
"""Delete all records in a table and clean up attachment/image files.
@ -576,20 +662,47 @@ class BitableService:
# ── Recalc (U3: formula recalc pipeline) ────────────────
async def _trigger_recalc_for_affected_fields(self, table_id: str, record_id: str) -> None:
"""Detect formula fields affected by a record write and enqueue recalc.
"""Detect computed fields affected by a record write and enqueue recalc.
Finds all formula fields in the table, checks which ones depend on
the fields that were just written, and enqueues recalc tasks.
For simplicity (v1), we enqueue recalc for ALL formula fields in the
table for this record the worker will evaluate them in topological
order. The ON CONFLICT DO NOTHING constraint deduplicates.
Finds all formula/lookup/rollup fields in the table and enqueues
recalc tasks. The worker evaluates them in topological order.
ON CONFLICT DO NOTHING deduplicates.
"""
fields = await self._repo.list_fields(table_id)
formula_fields = [f for f in fields if f.field_type == FieldType.formula]
computed_fields = [
f
for f in fields
if f.field_type in (FieldType.formula, FieldType.lookup, FieldType.rollup)
]
for f in formula_fields:
for f in computed_fields:
await self._repo.enqueue_recalc(table_id, record_id, f.id)
async def _trigger_cross_table_recalc(self, record: Record) -> None:
"""Trigger recalc on records that depend on ``record`` via relations.
When a record's values change, rollup/lookup fields on RELATED records
(target table) may need recalc. For each relation field on this record's
table, find the target records this record links to and trigger their
recalc.
"""
fields = await self._repo.list_fields(record.table_id)
relation_fields = [f for f in fields if f.field_type == FieldType.relation]
for rf in relation_fields:
target_table_id = rf.config.get("target_table_id")
if not isinstance(target_table_id, str):
continue
val = record.values.get(rf.id)
target_ids: list[str] = []
if isinstance(val, str):
target_ids = [val]
elif isinstance(val, list):
target_ids = [v for v in val if isinstance(v, str)]
for tid in target_ids:
await self._trigger_recalc_for_affected_fields(target_table_id, tid)
async def trigger_recalc(
self, table_id: str, record_id: str, field_id: str
) -> RecalcTask | None:
@ -619,3 +732,575 @@ class BitableService:
worker = RecalcWorker(self._db, self)
await worker.process_task(task)
worker.invalidate_engine(task.table_id)
# ── Relations (U4: R5, R7, R8) ──────────────────────────
async def create_relation_field(
self,
table_id: str,
name: str,
target_table_id: str,
relation_type: RelationType = RelationType.one_to_many,
is_bidirectional: bool = False,
reverse_field_name: str | None = None,
) -> Field:
"""Create a relation field linking this table to a target table.
For bidirectional relations, a reverse relation field is also created
on the target table. The reverse field's ID is stored in this field's
config as ``reverse_field_id``.
Args:
table_id: Source table (where the field is created).
name: Field name on the source table.
target_table_id: Target table to link to.
relation_type: one_to_one, one_to_many, or many_to_many.
is_bidirectional: If True, create a reverse field on the target table.
reverse_field_name: Name for the reverse field (defaults to "<src_table> ↩").
Returns:
The created relation Field on the source table.
"""
# Accept both RelationType enum and string for API ergonomics
if isinstance(relation_type, str):
relation_type = RelationType(relation_type)
config: dict[str, object] = {
"relation_type": relation_type.value,
"target_table_id": target_table_id,
"is_bidirectional": is_bidirectional,
}
field = await self._repo.create_field(
table_id=table_id,
name=name,
field_type=FieldType.relation,
config=config,
owner=FieldOwner.user,
)
if is_bidirectional:
# Create reverse field on target table
src_table = await self._repo.get_table(table_id)
reverse_name = reverse_field_name or f"{src_table.name if src_table else 'Source'}"
reverse_config: dict[str, object] = {
"relation_type": (
RelationType.one_to_many.value
if relation_type == RelationType.one_to_many
else relation_type.value
),
"target_table_id": table_id,
"is_bidirectional": True,
"reverse_field_id": field.id,
}
reverse_field = await self._repo.create_field(
table_id=target_table_id,
name=reverse_name,
field_type=FieldType.relation,
config=reverse_config,
owner=FieldOwner.user,
)
# Link the two fields via reverse_field_id
await self._repo.update_field(
field.id,
config={**config, "reverse_field_id": reverse_field.id},
)
field = await self._repo.get_field(field.id) or field
return field
async def add_relation_link(
self,
relation_field_id: str,
source_record_id: str,
target_record_id: str,
) -> bool:
"""Link two records via a relation field.
For many_to_many: creates a junction table entry (idempotent).
For one_to_one / one_to_many: appends target_record_id to the
source record's JSONB values for this field.
Returns True if a new link was created, False if it already existed.
"""
field = await self._repo.get_field(relation_field_id)
if field is None or field.field_type != FieldType.relation:
raise ValueError(f"Field {relation_field_id} is not a relation field")
relation_type_str = field.config.get("relation_type", "one_to_many")
relation_type = RelationType(relation_type_str)
if relation_type == RelationType.many_to_many:
# Junction table entry (idempotent via ON CONFLICT DO NOTHING)
added = await self._repo.add_relation_link(
relation_field_id=relation_field_id,
source_record_id=source_record_id,
target_record_id=target_record_id,
)
if added:
# Trigger recalc on both sides
src_record = await self._repo.get_record(source_record_id)
if src_record is not None:
await self._trigger_recalc_for_affected_fields(
src_record.table_id, source_record_id
)
target_table_id = field.config.get("target_table_id")
if isinstance(target_table_id, str):
await self._trigger_recalc_for_affected_fields(
target_table_id, target_record_id
)
return added
# one_to_one / one_to_many: store in record JSONB
record = await self._repo.get_record(source_record_id)
if record is None:
raise ValueError(f"Source record {source_record_id} not found")
current = record.values.get(relation_field_id)
if relation_type == RelationType.one_to_one:
new_value: object = target_record_id
else: # one_to_many
existing: list[str] = list(current) if isinstance(current, list) else []
if target_record_id in existing:
return False
existing.append(target_record_id)
new_value = existing
await self._repo.merge_record_values(source_record_id, {relation_field_id: new_value})
# For bidirectional relations, update the reverse field too
reverse_field_id = field.config.get("reverse_field_id")
if reverse_field_id and isinstance(reverse_field_id, str):
reverse_field = await self._repo.get_field(reverse_field_id)
if reverse_field is not None:
reverse_record = await self._repo.get_record(target_record_id)
if reverse_record is not None:
rev_current = reverse_record.values.get(reverse_field_id)
rev_existing: list[str] = (
list(rev_current) if isinstance(rev_current, list) else []
)
if source_record_id not in rev_existing:
rev_existing.append(source_record_id)
await self._repo.merge_record_values(
target_record_id,
{reverse_field_id: rev_existing},
)
# Trigger recalc on both source and target records — relation links
# affect lookup/rollup/formula fields on both sides.
await self._trigger_recalc_for_affected_fields(record.table_id, source_record_id)
target_table_id = field.config.get("target_table_id")
if isinstance(target_table_id, str):
await self._trigger_recalc_for_affected_fields(target_table_id, target_record_id)
return True
async def remove_relation_link(
self,
relation_field_id: str,
source_record_id: str,
target_record_id: str,
) -> bool:
"""Remove a link between two records.
Returns True if a link was removed, False if no link existed.
For bidirectional relations, the reverse field on the target record is
also cleaned up (mirrors ``add_relation_link``).
"""
field = await self._repo.get_field(relation_field_id)
if field is None or field.field_type != FieldType.relation:
raise ValueError(f"Field {relation_field_id} is not a relation field")
relation_type_str = field.config.get("relation_type", "one_to_many")
relation_type = RelationType(relation_type_str)
if relation_type == RelationType.many_to_many:
removed = await self._repo.remove_relation_link(
relation_field_id=relation_field_id,
source_record_id=source_record_id,
target_record_id=target_record_id,
)
if removed:
await self._cleanup_reverse_link(field, source_record_id, target_record_id)
return removed
# one_to_one / one_to_many: remove from record JSONB
record = await self._repo.get_record(source_record_id)
if record is None:
return False
current = record.values.get(relation_field_id)
if relation_type == RelationType.one_to_one:
if current == target_record_id:
await self._repo.merge_record_values(source_record_id, {relation_field_id: None})
await self._cleanup_reverse_link(field, source_record_id, target_record_id)
return True
return False
# one_to_many: remove from list
if not isinstance(current, list):
return False
if target_record_id not in current:
return False
new_list = [r for r in current if r != target_record_id]
await self._repo.merge_record_values(source_record_id, {relation_field_id: new_list})
await self._cleanup_reverse_link(field, source_record_id, target_record_id)
return True
async def _cleanup_reverse_link(
self, field: Field, source_record_id: str, target_record_id: str
) -> None:
"""Remove source_record_id from the target record's reverse field.
Mirrors the reverse-update block in ``add_relation_link`` so removal
stays symmetric for bidirectional relations.
"""
reverse_field_id = field.config.get("reverse_field_id")
if not (reverse_field_id and isinstance(reverse_field_id, str)):
return
reverse_field = await self._repo.get_field(reverse_field_id)
if reverse_field is None:
return
target_record = await self._repo.get_record(target_record_id)
if target_record is None:
return
rev_current = target_record.values.get(reverse_field_id)
if isinstance(rev_current, list):
if source_record_id in rev_current:
rev_new = [r for r in rev_current if r != source_record_id]
await self._repo.merge_record_values(target_record_id, {reverse_field_id: rev_new})
elif rev_current == source_record_id:
await self._repo.merge_record_values(target_record_id, {reverse_field_id: None})
async def list_related_records(
self,
relation_field_id: str,
record_id: str,
reverse: bool = False,
) -> list[str]:
"""Get the record IDs related to ``record_id`` via a relation field.
Args:
relation_field_id: The relation field.
record_id: The source record ID.
reverse: If True, find records that point TO this record
(reverse lookup via junction table or reverse field).
Returns:
List of related record IDs.
"""
field = await self._repo.get_field(relation_field_id)
if field is None or field.field_type != FieldType.relation:
raise ValueError(f"Field {relation_field_id} is not a relation field")
relation_type_str = field.config.get("relation_type", "one_to_many")
relation_type = RelationType(relation_type_str)
if relation_type == RelationType.many_to_many:
if reverse:
links = await self._repo.list_reverse_relation_links(
relation_field_id=relation_field_id,
target_record_id=record_id,
)
else:
links = await self._repo.list_relation_links(
relation_field_id=relation_field_id,
source_record_id=record_id,
)
return [
link.target_record_id if not reverse else link.source_record_id for link in links
]
# one_to_one / one_to_many: read from record JSONB
if reverse:
# Find records in the SOURCE table (where the relation field lives)
# that reference this target record via the relation field.
source_table_id = field.table_id
# ponytail: O(n) scan of source table records. Ceiling: add a
# reverse index or use the reverse_field_id for direct lookup.
source_records, _ = await self._repo.list_records(source_table_id, limit=10000)
result: list[str] = []
for rec in source_records:
val = rec.values.get(relation_field_id)
if val == record_id:
result.append(rec.id)
elif isinstance(val, list) and record_id in val:
result.append(rec.id)
return result
record = await self._repo.get_record(record_id)
if record is None:
return []
current = record.values.get(relation_field_id)
if current is None:
return []
if isinstance(current, list):
return [str(r) for r in current]
return [str(current)]
async def delete_relation_field(self, field_id: str) -> bool:
"""Delete a relation field and cascade-clean junction entries.
Removes all relation links associated with this field, then deletes
the field itself. For bidirectional relations, the reverse field's
``reverse_field_id`` config is cleared.
"""
field = await self._repo.get_field(field_id)
if field is None or field.field_type != FieldType.relation:
return False
# Clean up junction table entries (many_to_many only)
await self._repo.remove_all_relation_links(field_id)
# Clean up record JSONB values (all relation types)
await self._repo.remove_field_from_records(field.table_id, field_id)
# Clear reverse field's reference
reverse_field_id = field.config.get("reverse_field_id")
if reverse_field_id and isinstance(reverse_field_id, str):
reverse_field = await self._repo.get_field(reverse_field_id)
if reverse_field is not None:
updated_config = {
k: v for k, v in reverse_field.config.items() if k != "reverse_field_id"
}
updated_config["is_bidirectional"] = False
await self._repo.update_field(reverse_field_id, config=updated_config)
return await self._repo.delete_field(field_id)
# ── Lookup & Rollup (U5: R6, R8) ───────────────────────
async def create_lookup_field(
self,
table_id: str,
name: str,
relation_field_id: str,
target_field_id: str,
) -> Field:
"""Create a lookup field that pulls a value from a related record.
Config: ``{relation_field_id, target_field_id}``.
The recalc worker resolves the relation reads target_field from
the first related record.
Args:
table_id: Table to create the field on.
name: Field name.
relation_field_id: The relation field on this table to follow.
target_field_id: The field on the target table to read.
"""
config: dict[str, object] = {
"relation_field_id": relation_field_id,
"target_field_id": target_field_id,
}
return await self._repo.create_field(
table_id=table_id,
name=name,
field_type=FieldType.lookup,
config=config,
owner=FieldOwner.user,
)
async def create_rollup_field(
self,
table_id: str,
name: str,
relation_field_id: str,
target_field_id: str,
aggregation: str = "sum",
) -> Field:
"""Create a rollup field that aggregates related records' values.
Config: ``{relation_field_id, target_field_id, aggregation}``.
Supported aggregations: sum, avg, count, count_distinct, min, max, concat.
Args:
table_id: Table to create the field on.
name: Field name.
relation_field_id: The relation field on this table to follow.
target_field_id: The field on the target table to aggregate.
aggregation: Aggregation function name.
"""
valid_aggs = {"sum", "avg", "count", "count_distinct", "min", "max", "concat"}
if aggregation not in valid_aggs:
raise ValueError(f"Invalid aggregation: {aggregation}. Must be one of {valid_aggs}")
config: dict[str, object] = {
"relation_field_id": relation_field_id,
"target_field_id": target_field_id,
"aggregation": aggregation,
}
return await self._repo.create_field(
table_id=table_id,
name=name,
field_type=FieldType.rollup,
config=config,
owner=FieldOwner.user,
)
# ── Cross-table dependency tracking (U6: R10, R11, R12) ─
async def register_cross_table_dep(
self,
source_table_id: str,
source_field_id: str,
target_table_id: str,
target_field_id: str,
dep_type: str,
) -> None:
"""Record a cross-table dependency edge (R10).
When a target field changes, downstream formulas/lookups/rollups in
the source table must be recomputed. This materialized dep table
avoids scanning all formulas on every write.
"""
from agentkit.bitable.models import CrossTableDepType
try:
dep_type_enum = CrossTableDepType(dep_type)
except ValueError as e:
raise ValueError(f"Invalid dep_type: {dep_type}") from e
await self._repo.add_cross_table_dep(
source_table_id=source_table_id,
source_field_id=source_field_id,
target_table_id=target_table_id,
target_field_id=target_field_id,
dep_type=dep_type_enum,
)
async def trigger_cross_table_recalc(
self, target_table_id: str, target_field_id: str, target_record_ids: list[str]
) -> int:
"""Enqueue recalc for downstream fields when a target field changes (R11).
Finds all source fields that depend on (target_table, target_field),
then for each source field, finds records in the source table whose
relation field links to any of the changed target records, and
enqueues recalc for those source records.
Returns the number of recalc tasks enqueued.
"""
deps = await self._repo.list_cross_table_deps_for_target(
target_table_id=target_table_id,
target_field_id=target_field_id,
)
if not deps:
return 0
count = 0
for dep in deps:
# Find source records whose relation field points to changed target records
source_records = await self._find_source_records_for_targets(
dep.source_field_id, target_record_ids
)
for record_id in source_records:
await self._repo.enqueue_recalc(dep.source_table_id, record_id, dep.source_field_id)
count += 1
return count
async def _find_source_records_for_targets(
self, relation_field_id: str, target_record_ids: list[str]
) -> list[str]:
"""Find source records linked to any of the target records via a relation field.
For many_to_many: queries the junction table.
For one_to_one/one_to_many: scans source table records (O(n), ponytail ceiling).
"""
if not target_record_ids:
return []
field = await self._repo.get_field(relation_field_id)
if field is None or field.field_type != FieldType.relation:
return []
relation_type_str = field.config.get("relation_type", "one_to_many")
if relation_type_str == "many_to_many":
# Junction table: find source_record_ids for any of the target_record_ids
source_ids: list[str] = []
for tid in target_record_ids:
links = await self._repo.list_reverse_relation_links(
relation_field_id=relation_field_id,
target_record_id=tid,
)
source_ids.extend(link.source_record_id for link in links)
return list(set(source_ids))
# one_to_one / one_to_many: scan source table records for matching values
target_set = set(target_record_ids)
src_records, _ = await self._repo.list_records(field.table_id, limit=10000)
result: list[str] = []
for rec in src_records:
val = rec.values.get(relation_field_id)
if val is None:
continue
if isinstance(val, list):
if any(str(v) in target_set for v in val):
result.append(rec.id)
elif str(val) in target_set:
result.append(rec.id)
return result
# ── Automation rules (U7: R13-R17) ─────────────────────
async def create_automation(
self,
table_id: str,
name: str,
trigger_config: dict[str, object],
action_config: dict[str, object],
enabled: bool = True,
generate_webhook: bool = False,
) -> AutomationRule:
"""Create an automation rule.
Args:
table_id: Table to watch for triggers.
name: Rule name.
trigger_config: Trigger definition (type + optional field_ids).
action_config: Action definition (type + params).
enabled: Whether the rule is active.
generate_webhook: If True, generate a webhook_token for inbound triggering.
"""
webhook_token = None
if generate_webhook:
from agentkit.bitable.automation import generate_webhook_token
webhook_token = generate_webhook_token()
return await self._repo.create_automation(
table_id=table_id,
name=name,
trigger_config=trigger_config,
action_config=action_config,
enabled=enabled,
webhook_token=webhook_token,
)
async def get_automation(self, automation_id: str) -> AutomationRule | None:
return await self._repo.get_automation(automation_id)
async def list_automations(
self, table_id: str, enabled_only: bool = False
) -> list[AutomationRule]:
"""List automation rules for a table."""
return await self._repo.list_automations(table_id=table_id, enabled_only=enabled_only)
async def update_automation(
self, automation_id: str, **kwargs: object
) -> AutomationRule | None:
return await self._repo.update_automation(automation_id, **kwargs)
async def delete_automation(self, automation_id: str) -> bool:
return await self._repo.delete_automation(automation_id)
async def list_automation_logs(
self, automation_id: str, limit: int = 50
) -> list[AutomationLog]:
return await self._repo.list_automation_logs(automation_id, limit=limit)
async def handle_webhook(self, token: str, payload: dict[str, object]) -> bool:
"""Handle an inbound webhook — delegates to the automation engine (R16).
Returns True if a rule matched the token.
"""
if self._automation_engine is None:
return False
return await self._automation_engine.handle_webhook(token, payload)

View File

@ -1,5 +1,7 @@
"""LLM Gateway - 统一 LLM 调用入口"""
from __future__ import annotations
import asyncio
import logging
import os

View File

@ -823,3 +823,309 @@ async def download_file(
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(file_path, filename=safe_filename)
# ── Relation / Lookup / Rollup endpoints (U4/U5, R5-R8) ───
class RelationFieldCreate(BaseModel):
"""Request body for creating a relation field."""
name: str
target_table_id: str
relation_type: str = "one_to_many" # one_to_one | one_to_many | many_to_many
is_bidirectional: bool = False
reverse_field_name: str | None = None
class RelationLinkCreate(BaseModel):
"""Request body for linking two records via a relation field."""
source_record_id: str
target_record_id: str
class LookupFieldCreate(BaseModel):
"""Request body for creating a lookup field."""
name: str
relation_field_id: str
target_field_id: str
class RollupFieldCreate(BaseModel):
"""Request body for creating a rollup field."""
name: str
relation_field_id: str
target_field_id: str
aggregation: str = "sum" # sum|avg|count|count_distinct|min|max|concat
@router.post("/tables/{table_id}/relation-fields", status_code=201)
async def create_relation_field(
table_id: str,
body: RelationFieldCreate,
user: dict = Depends(require_bitable_auth),
service: BitableService = Depends(_get_service),
) -> dict:
"""Create a relation field linking this table to a target table (R5)."""
await _check_table_ownership(service, table_id, user)
# Also verify ownership of the target table to prevent IDOR via relation
await _check_table_ownership(service, body.target_table_id, user)
from agentkit.bitable.models import RelationType
try:
rel_type = RelationType(body.relation_type)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid relation_type: {e}") from e
field = await service.create_relation_field(
table_id=table_id,
name=body.name,
target_table_id=body.target_table_id,
relation_type=rel_type,
is_bidirectional=body.is_bidirectional,
reverse_field_name=body.reverse_field_name,
)
return {"field": field.model_dump(mode="json")}
@router.post("/fields/{field_id}/links", status_code=201)
async def add_relation_link(
field_id: str,
body: RelationLinkCreate,
user: dict = Depends(require_bitable_auth),
service: BitableService = Depends(_get_service),
) -> dict:
"""Link two records via a relation field (R7)."""
field = await service.get_field(field_id)
if field is None:
raise HTTPException(status_code=404, detail="Field not found")
await _check_table_ownership(service, field.table_id, user)
created = await service.add_relation_link(
relation_field_id=field_id,
source_record_id=body.source_record_id,
target_record_id=body.target_record_id,
)
return {"created": created}
@router.delete("/fields/{field_id}/links")
async def remove_relation_link(
field_id: str,
source_record_id: str,
target_record_id: str,
user: dict = Depends(require_bitable_auth),
service: BitableService = Depends(_get_service),
) -> dict:
"""Remove a link between two records (R7)."""
field = await service.get_field(field_id)
if field is None:
raise HTTPException(status_code=404, detail="Field not found")
await _check_table_ownership(service, field.table_id, user)
removed = await service.remove_relation_link(
relation_field_id=field_id,
source_record_id=source_record_id,
target_record_id=target_record_id,
)
return {"removed": removed}
@router.get("/fields/{field_id}/related-records")
async def list_related_records(
field_id: str,
record_id: str,
reverse: bool = False,
user: dict = Depends(require_bitable_auth),
service: BitableService = Depends(_get_service),
) -> dict:
"""List records related to ``record_id`` via a relation field (R7)."""
field = await service.get_field(field_id)
if field is None:
raise HTTPException(status_code=404, detail="Field not found")
await _check_table_ownership(service, field.table_id, user)
ids = await service.list_related_records(
relation_field_id=field_id,
record_id=record_id,
reverse=reverse,
)
return {"record_ids": ids}
@router.post("/tables/{table_id}/lookup-fields", status_code=201)
async def create_lookup_field(
table_id: str,
body: LookupFieldCreate,
user: dict = Depends(require_bitable_auth),
service: BitableService = Depends(_get_service),
) -> dict:
"""Create a lookup field (R6)."""
await _check_table_ownership(service, table_id, user)
field = await service.create_lookup_field(
table_id=table_id,
name=body.name,
relation_field_id=body.relation_field_id,
target_field_id=body.target_field_id,
)
return {"field": field.model_dump(mode="json")}
@router.post("/tables/{table_id}/rollup-fields", status_code=201)
async def create_rollup_field(
table_id: str,
body: RollupFieldCreate,
user: dict = Depends(require_bitable_auth),
service: BitableService = Depends(_get_service),
) -> dict:
"""Create a rollup field (R8)."""
await _check_table_ownership(service, table_id, user)
try:
field = await service.create_rollup_field(
table_id=table_id,
name=body.name,
relation_field_id=body.relation_field_id,
target_field_id=body.target_field_id,
aggregation=body.aggregation,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"field": field.model_dump(mode="json")}
# ── Automation endpoints (U7, R13-R17) ────────────────────
class AutomationCreate(BaseModel):
"""Request body for creating an automation rule."""
name: str
trigger_config: dict[str, Any]
action_config: dict[str, Any]
enabled: bool = True
generate_webhook: bool = False
class AutomationUpdate(BaseModel):
"""Request body for updating an automation rule.
Only these fields may be modified ``table_id`` and ``webhook_token``
are intentionally excluded to prevent ownership bypass via field injection.
"""
name: str | None = None
trigger_config: dict[str, Any] | None = None
action_config: dict[str, Any] | None = None
enabled: bool | None = None
@router.post("/tables/{table_id}/automations", status_code=201)
async def create_automation(
table_id: str,
body: AutomationCreate,
user: dict = Depends(require_bitable_auth),
service: BitableService = Depends(_get_service),
) -> dict:
"""Create an automation rule (R13-R14)."""
await _check_table_ownership(service, table_id, user)
rule = await service.create_automation(
table_id=table_id,
name=body.name,
trigger_config=body.trigger_config,
action_config=body.action_config,
enabled=body.enabled,
generate_webhook=body.generate_webhook,
)
return {"automation": rule.model_dump(mode="json")}
@router.get("/tables/{table_id}/automations")
async def list_automations(
table_id: str,
enabled_only: bool = False,
user: dict = Depends(require_bitable_auth),
service: BitableService = Depends(_get_service),
) -> dict:
"""List automation rules for a table."""
await _check_table_ownership(service, table_id, user)
rules = await service.list_automations(table_id=table_id, enabled_only=enabled_only)
return {"automations": [r.model_dump(mode="json") for r in rules]}
@router.get("/automations/{automation_id}")
async def get_automation(
automation_id: str,
user: dict = Depends(require_bitable_auth),
service: BitableService = Depends(_get_service),
) -> dict:
"""Get an automation rule by ID."""
rule = await service.get_automation(automation_id)
if rule is None:
raise HTTPException(status_code=404, detail="Automation not found")
await _check_table_ownership(service, rule.table_id, user)
return {"automation": rule.model_dump(mode="json")}
@router.patch("/automations/{automation_id}")
async def update_automation(
automation_id: str,
body: AutomationUpdate,
user: dict = Depends(require_bitable_auth),
service: BitableService = Depends(_get_service),
) -> dict:
"""Update an automation rule (e.g. enable/disable, change action)."""
rule = await service.get_automation(automation_id)
if rule is None:
raise HTTPException(status_code=404, detail="Automation not found")
await _check_table_ownership(service, rule.table_id, user)
updates = body.model_dump(exclude_unset=True)
updated = await service.update_automation(automation_id, **updates)
return {"automation": updated.model_dump(mode="json") if updated else None}
@router.delete("/automations/{automation_id}", status_code=204)
async def delete_automation(
automation_id: str,
user: dict = Depends(require_bitable_auth),
service: BitableService = Depends(_get_service),
) -> None:
"""Delete an automation rule."""
rule = await service.get_automation(automation_id)
if rule is None:
raise HTTPException(status_code=404, detail="Automation not found")
await _check_table_ownership(service, rule.table_id, user)
await service.delete_automation(automation_id)
@router.get("/automations/{automation_id}/logs")
async def list_automation_logs(
automation_id: str,
limit: int = Query(50, ge=1, le=500),
user: dict = Depends(require_bitable_auth),
service: BitableService = Depends(_get_service),
) -> dict:
"""List execution logs for an automation rule (R17)."""
rule = await service.get_automation(automation_id)
if rule is None:
raise HTTPException(status_code=404, detail="Automation not found")
await _check_table_ownership(service, rule.table_id, user)
logs = await service.list_automation_logs(automation_id, limit=limit)
return {"logs": [log.model_dump(mode="json") for log in logs]}
@router.post("/webhooks/{token}")
async def inbound_webhook(
token: str,
body: dict[str, Any],
service: BitableService = Depends(_get_service),
) -> dict:
"""Handle an inbound webhook trigger (R16).
This endpoint is unauthenticated the token itself IS the auth.
The token is a 256-bit URL-safe random string from ``secrets.token_urlsafe``.
"""
matched = await service.handle_webhook(token, body)
if not matched:
# 404 to avoid leaking valid tokens (timing-safe: same response shape)
raise HTTPException(status_code=404, detail="Not found")
return {"accepted": True}

View File

@ -47,7 +47,10 @@ class BitableTool(Tool):
"ingest data from Excel files, databases, or API responses, and "
"query records. Actions: create_table, import_excel, "
"import_database, collect_api, upsert_records, query_records, "
"create_view, update_view, update_field, delete_view."
"create_view, update_view, update_field, delete_view, "
"create_relation, add_relation_link, remove_relation_link, "
"create_lookup_field, create_rollup_field, "
"create_automation, validate_formula."
),
input_schema={
"type": "object",
@ -65,6 +68,13 @@ class BitableTool(Tool):
"update_view",
"update_field",
"delete_view",
"create_relation",
"add_relation_link",
"remove_relation_link",
"create_lookup_field",
"create_rollup_field",
"create_automation",
"validate_formula",
],
"description": "Bitable operation to perform.",
},
@ -183,6 +193,13 @@ class BitableTool(Tool):
"update_view": self._update_view,
"update_field": self._update_field,
"delete_view": self._delete_view,
"create_relation": self._create_relation,
"add_relation_link": self._add_relation_link,
"remove_relation_link": self._remove_relation_link,
"create_lookup_field": self._create_lookup_field,
"create_rollup_field": self._create_rollup_field,
"create_automation": self._create_automation,
"validate_formula": self._validate_formula,
}
handler = handlers.get(action)
if handler is None:
@ -591,3 +608,131 @@ class BitableTool(Tool):
resp.raise_for_status()
# 204 No Content has an empty body; report a stable success shape.
return {"success": True, "deleted": True}
# ------------------------------------------------------------------
# U4/U5: Relation / Lookup / Rollup actions (R5-R8)
# ------------------------------------------------------------------
async def _create_relation(self, **kwargs) -> dict[str, object]:
table_id = kwargs.get("table_id")
if not table_id:
return {"success": False, "error": "Missing required field: table_id"}
client = await self._get_client()
resp = await client.post(
f"/tables/{table_id}/relation-fields",
json={
"name": kwargs.get("name", ""),
"target_table_id": kwargs.get("target_table_id", ""),
"relation_type": kwargs.get("relation_type", "one_to_many"),
"is_bidirectional": kwargs.get("is_bidirectional", False),
"reverse_field_name": kwargs.get("reverse_field_name"),
},
)
resp.raise_for_status()
return {"success": True, "field": resp.json()["field"]}
async def _add_relation_link(self, **kwargs) -> dict[str, object]:
field_id = kwargs.get("field_id")
if not field_id:
return {"success": False, "error": "Missing required field: field_id"}
client = await self._get_client()
resp = await client.post(
f"/fields/{field_id}/links",
json={
"source_record_id": kwargs.get("source_record_id", ""),
"target_record_id": kwargs.get("target_record_id", ""),
},
)
resp.raise_for_status()
return {"success": True, **resp.json()}
async def _remove_relation_link(self, **kwargs) -> dict[str, object]:
field_id = kwargs.get("field_id")
if not field_id:
return {"success": False, "error": "Missing required field: field_id"}
client = await self._get_client()
resp = await client.delete(
f"/fields/{field_id}/links",
params={
"source_record_id": kwargs.get("source_record_id", ""),
"target_record_id": kwargs.get("target_record_id", ""),
},
)
resp.raise_for_status()
return {"success": True, **resp.json()}
async def _create_lookup_field(self, **kwargs) -> dict[str, object]:
table_id = kwargs.get("table_id")
if not table_id:
return {"success": False, "error": "Missing required field: table_id"}
client = await self._get_client()
resp = await client.post(
f"/tables/{table_id}/lookup-fields",
json={
"name": kwargs.get("name", ""),
"relation_field_id": kwargs.get("relation_field_id", ""),
"target_field_id": kwargs.get("target_field_id", ""),
},
)
resp.raise_for_status()
return {"success": True, "field": resp.json()["field"]}
async def _create_rollup_field(self, **kwargs) -> dict[str, object]:
table_id = kwargs.get("table_id")
if not table_id:
return {"success": False, "error": "Missing required field: table_id"}
client = await self._get_client()
resp = await client.post(
f"/tables/{table_id}/rollup-fields",
json={
"name": kwargs.get("name", ""),
"relation_field_id": kwargs.get("relation_field_id", ""),
"target_field_id": kwargs.get("target_field_id", ""),
"aggregation": kwargs.get("aggregation", "sum"),
},
)
resp.raise_for_status()
return {"success": True, "field": resp.json()["field"]}
# ------------------------------------------------------------------
# U7: Automation actions (R13-R17)
# ------------------------------------------------------------------
async def _create_automation(self, **kwargs) -> dict[str, object]:
table_id = kwargs.get("table_id")
if not table_id:
return {"success": False, "error": "Missing required field: table_id"}
client = await self._get_client()
resp = await client.post(
f"/tables/{table_id}/automations",
json={
"name": kwargs.get("name", ""),
"trigger_config": kwargs.get("trigger_config", {}),
"action_config": kwargs.get("action_config", {}),
"enabled": kwargs.get("enabled", True),
"generate_webhook": kwargs.get("generate_webhook", False),
},
)
resp.raise_for_status()
return {"success": True, "automation": resp.json()["automation"]}
# ------------------------------------------------------------------
# U3: Formula validation (agent generates formula, validates before save)
# ------------------------------------------------------------------
async def _validate_formula(self, **kwargs) -> dict[str, object]:
"""Validate a formula expression via the server's parser.
Agent uses this to check a generated formula before persisting it
as a field config avoids saving broken formulas.
"""
formula_expr = kwargs.get("formula_expr")
if not formula_expr:
return {"success": False, "error": "Missing required field: formula_expr"}
client = await self._get_client()
resp = await client.post(
"/fields/validate-formula",
json={"formula_expr": formula_expr},
)
resp.raise_for_status()
return {"success": True, **resp.json()}

View File

@ -0,0 +1,227 @@
"""AE1 acceptance test: cross-table rollup recalculation end-to-end.
Validates the core v2 scenario from the plan:
Orders.amount changes -> Customers.total_amount (rollup SUM) recalculates.
This test requires PostgreSQL (marked with @pytest.mark.postgres).
It exercises the full stack: BitableService -> repository -> recalc_worker
-> FormulaEngine -> cross-table relation resolution -> rollup aggregation.
No mocks real PG, real async recalc queue, real cross-table deps.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from agentkit.bitable.models import FieldOwner, FieldType
pytestmark = [pytest.mark.postgres, pytest.mark.asyncio]
@pytest_asyncio.fixture
async def ae1_setup(bitable_service):
"""Set up Customers + Orders tables with a 1:N relation and rollup field.
Also wires a RecalcWorker into the service so rollup/formula fields
actually recalc when records change. Returns a dict with IDs.
"""
svc = bitable_service
# Wire a recalc worker (bitable_service fixture doesn't include one)
from agentkit.bitable.recalc_worker import RecalcWorker
worker = RecalcWorker(svc._db, svc)
svc.set_recalc_worker(worker)
# 1. Create Customers table
customers_table = await svc.create_table(name="Customers")
cust_id_field = await svc._repo.create_field(
table_id=customers_table.id,
name="cust_id",
field_type=FieldType.text,
config={},
owner=FieldOwner.user,
)
cust_name_field = await svc._repo.create_field(
table_id=customers_table.id,
name="name",
field_type=FieldType.text,
config={},
owner=FieldOwner.user,
)
await svc._repo.update_table(customers_table.id, primary_key_field_id=cust_id_field.id)
# 2. Create Orders table
orders_table = await svc.create_table(name="Orders")
ord_id_field = await svc._repo.create_field(
table_id=orders_table.id,
name="ord_id",
field_type=FieldType.text,
config={},
owner=FieldOwner.user,
)
ord_amount_field = await svc._repo.create_field(
table_id=orders_table.id,
name="amount",
field_type=FieldType.number,
config={},
owner=FieldOwner.user,
)
await svc._repo.update_table(orders_table.id, primary_key_field_id=ord_id_field.id)
# 3. Create 1:N relation field on Orders -> Customers
relation_field = await svc.create_relation_field(
table_id=orders_table.id,
name="customer",
target_table_id=customers_table.id,
relation_type="one_to_many",
)
# 4. Create rollup field on Customers: SUM(Orders.amount via relation)
rollup_field = await svc.create_rollup_field(
table_id=customers_table.id,
name="total_amount",
relation_field_id=relation_field.id,
target_field_id=ord_amount_field.id,
aggregation="sum",
)
return {
"customers_table": customers_table,
"orders_table": orders_table,
"cust_id_field": cust_id_field,
"cust_name_field": cust_name_field,
"ord_id_field": ord_id_field,
"ord_amount_field": ord_amount_field,
"relation_field": relation_field,
"rollup_field": rollup_field,
}
async def test_ae1_rollup_sum_recalc_on_insert(bitable_service, ae1_setup) -> None:
"""AE1: Inserting Orders updates Customers.total_amount via rollup SUM."""
svc = bitable_service
cust_fid = ae1_setup["cust_id_field"].id
ord_fid = ae1_setup["ord_id_field"].id
amt_fid = ae1_setup["ord_amount_field"].id
rel_fid = ae1_setup["relation_field"].id
rollup_fid = ae1_setup["rollup_field"].id
# Create a customer
cust = await svc.create_record(
table_id=ae1_setup["customers_table"].id,
values={cust_fid: "c1"},
)
# Create 3 orders linked to the customer
for i in range(3):
order = await svc.create_record(
table_id=ae1_setup["orders_table"].id,
values={ord_fid: f"o{i}", amt_fid: (i + 1) * 100},
)
await svc.add_relation_link(
relation_field_id=rel_fid,
source_record_id=order.id,
target_record_id=cust.id,
)
# Process recalc queue — claim and process all pending tasks
worker = svc._recalc_worker
if worker is None:
pytest.skip("recalc_worker not configured")
tasks = await worker._repo.claim_recalc_tasks(limit=50)
for task in tasks:
await worker.process_task(task)
# Read the customer record — total_amount should be 100+200+300 = 600
updated_cust = await svc._repo.get_record(cust.id)
assert updated_cust is not None
total = updated_cust.values.get(rollup_fid)
assert total == 600, f"Expected total_amount=600, got {total}"
async def test_ae1_rollup_updates_on_order_change(bitable_service, ae1_setup) -> None:
"""AE1: Updating an order's amount triggers rollup recalc on customer."""
svc = bitable_service
cust_fid = ae1_setup["cust_id_field"].id
ord_fid = ae1_setup["ord_id_field"].id
amt_fid = ae1_setup["ord_amount_field"].id
rel_fid = ae1_setup["relation_field"].id
rollup_fid = ae1_setup["rollup_field"].id
cust = await svc.create_record(
table_id=ae1_setup["customers_table"].id,
values={cust_fid: "c1"},
)
order = await svc.create_record(
table_id=ae1_setup["orders_table"].id,
values={ord_fid: "o1", amt_fid: 100},
)
await svc.add_relation_link(rel_fid, order.id, cust.id)
# Drain recalc queue
worker = svc._recalc_worker
if worker is None:
pytest.skip("recalc_worker not configured")
tasks = await worker._repo.claim_recalc_tasks(limit=50)
for task in tasks:
await worker.process_task(task)
# Verify initial sum
rec = await svc._repo.get_record(cust.id)
assert rec.values.get(rollup_fid) == 100
# Update the order amount
await svc.update_record_values(order.id, {amt_fid: 500})
# Drain again
tasks = await worker._repo.claim_recalc_tasks(limit=50)
for task in tasks:
await worker.process_task(task)
# Customer's total should now be 500
rec = await svc._repo.get_record(cust.id)
assert rec.values.get(rollup_fid) == 500, f"Expected 500 after update, got {rec.values.get(rollup_fid)}"
async def test_ae1_rollup_avg_aggregation(bitable_service, ae1_setup) -> None:
"""AE1: Rollup AVG aggregation across related records."""
svc = bitable_service
cust_fid = ae1_setup["cust_id_field"].id
ord_fid = ae1_setup["ord_id_field"].id
amt_fid = ae1_setup["ord_amount_field"].id
rel_fid = ae1_setup["relation_field"].id
# Change aggregation to avg — recreate rollup field
avg_rollup = await svc.create_rollup_field(
table_id=ae1_setup["customers_table"].id,
name="avg_amount",
relation_field_id=rel_fid,
target_field_id=amt_fid,
aggregation="avg",
)
cust = await svc.create_record(
table_id=ae1_setup["customers_table"].id,
values={cust_fid: "c1"},
)
for amt in [100, 200, 300]:
order = await svc.create_record(
table_id=ae1_setup["orders_table"].id,
values={ord_fid: f"o{amt}", amt_fid: amt},
)
await svc.add_relation_link(rel_fid, order.id, cust.id)
# Drain recalc queue
worker = svc._recalc_worker
if worker is None:
pytest.skip("recalc_worker not configured")
tasks = await worker._repo.claim_recalc_tasks(limit=50)
for task in tasks:
await worker.process_task(task)
rec = await svc._repo.get_record(cust.id)
avg = rec.values.get(avg_rollup.id)
assert avg == 200, f"Expected avg=200, got {avg}"

View File

@ -0,0 +1,436 @@
"""Unit tests for the automation engine (U7, R13-R17).
Tests the trigger evaluation, action dispatch, retry logic, and webhook
handling. These tests do NOT require PostgreSQL they mock the repo/service
interfaces to test the engine logic in isolation.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from agentkit.bitable.automation import AutomationEngine, generate_webhook_token
from agentkit.bitable.models import (
AutomationRule,
AutomationStatus,
AutomationTriggerType,
)
def _make_rule(
rule_id: str = "a1",
table_id: str = "t1",
trigger_type: str = "record_created",
action_type: str = "send_webhook",
enabled: bool = True,
webhook_token: str | None = None,
field_ids: list[str] | None = None,
action_config: dict | None = None,
) -> AutomationRule:
"""Build an AutomationRule for testing."""
trig_cfg: dict = {"type": trigger_type}
if field_ids:
trig_cfg["field_ids"] = field_ids
act_cfg = action_config or {"type": action_type, "url": "https://example.com/hook"}
return AutomationRule(
id=rule_id,
table_id=table_id,
name=f"Rule {rule_id}",
trigger_config=trig_cfg,
action_config=act_cfg,
enabled=enabled,
webhook_token=webhook_token,
)
def _make_mock_service() -> tuple[MagicMock, MagicMock]:
"""Create a mock service with a mock repo for the engine."""
service = MagicMock()
repo = MagicMock()
service.repo = repo
# Async methods on repo
repo.list_automations_for_trigger = AsyncMock(return_value=[])
repo.get_automation_by_webhook_token = AsyncMock(return_value=None)
repo.create_automation_log = AsyncMock(return_value="log1")
repo.update_automation_log = AsyncMock(return_value=None)
# Async methods on service
service.create_record = AsyncMock(return_value=MagicMock(id="r_new"))
service.update_record_values = AsyncMock(return_value=None)
return service, repo
# ---------------------------------------------------------------------------
# Trigger evaluation (R13)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fire_trigger_matches_record_created() -> None:
"""record_created trigger fires matching rules."""
service, repo = _make_mock_service()
rule = _make_rule(trigger_type="record_created")
repo.list_automations_for_trigger = AsyncMock(return_value=[rule])
engine = AutomationEngine(service)
matched = await engine.fire_trigger(
table_id="t1",
trigger_type=AutomationTriggerType.record_created,
record_id="r1",
record_values={"f1": "value"},
)
assert matched == 1
repo.list_automations_for_trigger.assert_awaited_once_with(
table_id="t1", trigger_type="record_created"
)
@pytest.mark.asyncio
async def test_fire_trigger_skips_disabled_rules() -> None:
"""Disabled rules don't fire."""
service, repo = _make_mock_service()
rule = _make_rule(enabled=False)
repo.list_automations_for_trigger = AsyncMock(return_value=[rule])
engine = AutomationEngine(service)
matched = await engine.fire_trigger(
table_id="t1",
trigger_type=AutomationTriggerType.record_created,
)
assert matched == 0
@pytest.mark.asyncio
async def test_fire_trigger_record_updated_field_filter() -> None:
"""record_updated with field_ids filter only fires on overlap."""
service, repo = _make_mock_service()
rule_watching_f1 = _make_rule(trigger_type="record_updated", field_ids=["f1"])
repo.list_automations_for_trigger = AsyncMock(return_value=[rule_watching_f1])
engine = AutomationEngine(service)
# Changed f2 only — rule watches f1 → no match
matched = await engine.fire_trigger(
table_id="t1",
trigger_type=AutomationTriggerType.record_updated,
changed_field_ids=["f2"],
)
assert matched == 0
# Changed f1 → match
matched = await engine.fire_trigger(
table_id="t1",
trigger_type=AutomationTriggerType.record_updated,
changed_field_ids=["f1", "f2"],
)
assert matched == 1
@pytest.mark.asyncio
async def test_fire_trigger_no_field_filter_matches_any() -> None:
"""record_updated without field_ids fires on any update."""
service, repo = _make_mock_service()
rule_no_filter = _make_rule(trigger_type="record_updated", field_ids=[])
repo.list_automations_for_trigger = AsyncMock(return_value=[rule_no_filter])
engine = AutomationEngine(service)
matched = await engine.fire_trigger(
table_id="t1",
trigger_type=AutomationTriggerType.record_updated,
changed_field_ids=["f_anything"],
)
assert matched == 1
# ---------------------------------------------------------------------------
# Webhook inbound (R16)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_handle_webhook_matches_token() -> None:
"""Inbound webhook fires the rule with matching token."""
service, repo = _make_mock_service()
rule = _make_rule(webhook_token="whk_test123")
repo.get_automation_by_webhook_token = AsyncMock(return_value=rule)
engine = AutomationEngine(service)
matched = await engine.handle_webhook("whk_test123", {"event": "test"})
assert matched is True
@pytest.mark.asyncio
async def test_handle_webhook_unknown_token() -> None:
"""Unknown webhook token returns False (no match)."""
service, repo = _make_mock_service()
repo.get_automation_by_webhook_token = AsyncMock(return_value=None)
engine = AutomationEngine(service)
matched = await engine.handle_webhook("whk_unknown", {"event": "test"})
assert matched is False
@pytest.mark.asyncio
async def test_handle_webhook_disabled_rule() -> None:
"""Disabled rules don't fire via webhook."""
service, repo = _make_mock_service()
rule = _make_rule(enabled=False, webhook_token="whk_test")
repo.get_automation_by_webhook_token = AsyncMock(return_value=rule)
engine = AutomationEngine(service)
matched = await engine.handle_webhook("whk_test", {})
assert matched is False
# ---------------------------------------------------------------------------
# Action execution (R14)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_action_create_record_resolves_trigger_placeholders() -> None:
"""create_record action resolves @trigger.X placeholders."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r_trigger",
"record_values": {"f_name": "Alice"},
"action_config": {
"type": "create_record",
"target_table_id": "t2",
"values": {
"f_target_name": "@trigger.f_name",
"f_static": "static_value",
},
},
}
await engine._execute_action(job)
service.create_record.assert_awaited_once()
args, kwargs = service.create_record.call_args
assert args[0] == "t2" # target_table_id
resolved = args[1]
assert resolved["f_target_name"] == "Alice"
assert resolved["f_static"] == "static_value"
@pytest.mark.asyncio
async def test_action_update_record_defaults_to_trigger_record() -> None:
"""update_record defaults target_record_id to the trigger record."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_updated",
"record_id": "r_trigger",
"record_values": {"f_status": "done"},
"action_config": {
"type": "update_record",
"values": {"f_log": "completed"},
},
}
await engine._execute_action(job)
service.update_record_values.assert_awaited_once_with("r_trigger", {"f_log": "completed"})
@pytest.mark.asyncio
async def test_action_send_webhook_posts_payload(httpx_mock, monkeypatch) -> None:
"""send_webhook POSTs JSON to the configured URL."""
# ponytail: uses httpx_mock fixture from pytest-httpx if available;
# otherwise this test is skipped.
pytest.importorskip("pytest_httpx")
# Bypass SSRF host check — this test validates the POST behavior, not the guard.
monkeypatch.setattr("agentkit.bitable.automation._assert_safe_host", lambda _host: None)
httpx_mock.add_response(url="https://example.com/hook", status_code=200)
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {"f1": "val"},
"action_config": {
"type": "send_webhook",
"url": "https://example.com/hook",
},
}
await engine._execute_action(job)
@pytest.mark.asyncio
async def test_action_unknown_type_raises() -> None:
"""Unknown action type raises ValueError."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {},
"action_config": {"type": "invalid_action"},
}
with pytest.raises(ValueError, match="Unknown action type"):
await engine._execute_action(job)
@pytest.mark.asyncio
async def test_webhook_ssrf_guard_blocks_loopback() -> None:
"""send_webhook rejects loopback URLs (SSRF protection, P1)."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {"f1": "val"},
"action_config": {
"type": "send_webhook",
"url": "http://127.0.0.1:8080/internal",
},
}
with pytest.raises(ValueError, match="Disallowed|unsafe|private|loopback"):
await engine._execute_action(job)
@pytest.mark.asyncio
async def test_webhook_ssrf_guard_blocks_private_ip() -> None:
"""send_webhook rejects RFC1918 private IPs (SSRF protection, P1)."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a2",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {},
"action_config": {
"type": "send_webhook",
"url": "http://10.0.0.1/admin",
},
}
with pytest.raises(ValueError, match="Disallowed|unsafe|private"):
await engine._execute_action(job)
@pytest.mark.asyncio
async def test_webhook_ssrf_guard_blocks_non_http_scheme() -> None:
"""send_webhook rejects non-HTTP(S) schemes (file://, gopher://, etc.)."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a3",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {},
"action_config": {
"type": "send_webhook",
"url": "file:///etc/passwd",
},
}
with pytest.raises(ValueError, match="scheme"):
await engine._execute_action(job)
# ---------------------------------------------------------------------------
# Retry logic (R15)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_retry_succeeds_on_second_attempt(monkeypatch) -> None:
"""Action that fails once then succeeds is logged correctly."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
# Make _execute_action fail once, then succeed
call_count = [0]
async def _flaky(job):
call_count[0] += 1
if call_count[0] == 1:
raise RuntimeError("transient failure")
monkeypatch.setattr(engine, "_execute_action", _flaky)
# Speed up retries
monkeypatch.setattr("agentkit.bitable.automation._RETRY_DELAYS", [0.0, 0.0, 0.0])
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {},
"action_config": {"type": "send_webhook", "url": "x"},
}
await engine._execute_with_retry(job)
assert call_count[0] == 2 # Failed once, succeeded on second
# Two log entries created (one per attempt)
assert repo.create_automation_log.await_count == 2
# Final log status is success
repo.update_automation_log.assert_awaited_with(
log_id="log1",
status=AutomationStatus.success,
completed_at=repo.update_automation_log.await_args.kwargs["completed_at"],
)
@pytest.mark.asyncio
async def test_retry_exhausted_after_3_attempts(monkeypatch) -> None:
"""Action that always fails is retried 3 times then logged as error."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
async def _always_fail(job):
raise RuntimeError("permanent failure")
monkeypatch.setattr(engine, "_execute_action", _always_fail)
monkeypatch.setattr("agentkit.bitable.automation._RETRY_DELAYS", [0.0, 0.0, 0.0])
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {},
"action_config": {"type": "send_webhook", "url": "x"},
}
await engine._execute_with_retry(job)
assert repo.create_automation_log.await_count == 3 # 3 attempts
# Last log status is error
last_call = repo.update_automation_log.await_args_list[-1]
assert last_call.kwargs["status"] == AutomationStatus.error
assert "permanent failure" in last_call.kwargs["error_message"]
# ---------------------------------------------------------------------------
# Webhook token generation (R16)
# ---------------------------------------------------------------------------
def test_generate_webhook_token_format() -> None:
"""Webhook tokens have the whk_ prefix and sufficient entropy."""
token = generate_webhook_token()
assert token.startswith("whk_")
# token_urlsafe(32) produces ~43 chars; with prefix, ~47
assert len(token) > 40
def test_generate_webhook_token_uniqueness() -> None:
"""Two generated tokens are different (non-deterministic)."""
tokens = {generate_webhook_token() for _ in range(10)}
assert len(tokens) == 10

View File

@ -491,17 +491,22 @@ def test_transform_records_missing_keys() -> None:
# ---------------------------------------------------------------------------
def test_action_enum_has_10_actions() -> None:
"""input_schema.action.enum lists all 10 actions (6 original + 4 new)."""
def test_action_enum_has_17_actions() -> None:
"""input_schema.action.enum lists all 17 actions (10 original + 7 U4/U5/U7/U3)."""
tool = BitableTool(base_url="http://test/api/v1/bitable")
actions = tool.input_schema["properties"]["action"]["enum"]
assert len(actions) == 10
for new_action in ("create_view", "update_view", "update_field", "delete_view"):
assert len(actions) == 17
for new_action in (
"create_view", "update_view", "update_field", "delete_view",
"create_relation", "add_relation_link", "remove_relation_link",
"create_lookup_field", "create_rollup_field",
"create_automation", "validate_formula",
):
assert new_action in actions
def test_execute_handlers_dict_has_10_actions() -> None:
"""execute() handlers dict contains all 10 action keys (KTD10)."""
def test_execute_handlers_dict_has_17_actions() -> None:
"""execute() handlers dict contains all 17 action keys (KTD10)."""
import re
src = open(
@ -510,8 +515,13 @@ def test_execute_handlers_dict_has_10_actions() -> None:
).read()
handlers_match = re.search(r"handlers\s*=\s*\{([^}]*)\}", src, re.DOTALL)
handler_keys = re.findall(r'"([a-z_]+)":\s*self\._', handlers_match.group(1))
assert len(handler_keys) == 10
for new_action in ("create_view", "update_view", "update_field", "delete_view"):
assert len(handler_keys) == 17
for new_action in (
"create_view", "update_view", "update_field", "delete_view",
"create_relation", "add_relation_link", "remove_relation_link",
"create_lookup_field", "create_rollup_field",
"create_automation", "validate_formula",
):
assert new_action in handler_keys

View File

@ -15,7 +15,7 @@ import pytest
async def test_init_creates_schema_and_all_tables(bitable_db) -> None:
"""init creates the bitable schema and all 7 tables (V2 adds bitable_files)."""
"""init creates the bitable schema and all 11 tables (V3 adds 4 new tables)."""
from sqlalchemy import text
async with bitable_db.engine.begin() as conn:
@ -27,7 +27,7 @@ async def test_init_creates_schema_and_all_tables(bitable_db) -> None:
)
assert result.fetchone() is not None
# All 7 tables present (V2 adds bitable_files)
# All 11 tables present (V2 adds bitable_files; V3 adds 4 new tables)
result = await conn.execute(
text(
"SELECT table_name FROM information_schema.tables "
@ -36,16 +36,85 @@ async def test_init_creates_schema_and_all_tables(bitable_db) -> None:
)
tables = {row[0] for row in result.fetchall()}
assert tables == {
"bitable_automation_logs",
"bitable_automations",
"bitable_cross_table_deps",
"bitable_fields",
"bitable_files",
"bitable_meta",
"bitable_records",
"bitable_recalc_queue",
"bitable_relation_links",
"bitable_tables",
"bitable_views",
}
async def test_v3_migration_adds_is_cross_table_column(bitable_db) -> None:
"""V3 migration adds is_cross_table boolean column on recalc_queue."""
from sqlalchemy import text
async with bitable_db.engine.begin() as conn:
result = await conn.execute(
text(
"SELECT column_name, data_type FROM information_schema.columns "
"WHERE table_schema = 'bitable' AND table_name = 'bitable_recalc_queue' "
"AND column_name = 'is_cross_table'"
)
)
row = result.fetchone()
assert row is not None
assert row[1] == "boolean"
async def test_v3_migration_creates_relation_links_indexes(bitable_db) -> None:
"""V3 creates the two covering indexes on bitable_relation_links."""
from sqlalchemy import text
async with bitable_db.engine.begin() as conn:
result = await conn.execute(
text(
"SELECT indexname FROM pg_indexes "
"WHERE schemaname = 'bitable' AND tablename = 'bitable_relation_links'"
)
)
indexes = {row[0] for row in result.fetchall()}
assert "ix_relation_links_field_source" in indexes
assert "ix_relation_links_field_target" in indexes
async def test_v3_migration_creates_cross_table_deps_unique_constraint(bitable_db) -> None:
"""V3 enforces (source_field_id, target_field_id) uniqueness on cross_table_deps."""
from sqlalchemy import text
async with bitable_db.engine.begin() as conn:
result = await conn.execute(
text(
"SELECT conname FROM pg_constraint "
"WHERE conrelid = 'bitable.bitable_cross_table_deps'::regclass "
"AND conname = 'uq_cross_table_dep_source_target'"
)
)
assert result.fetchone() is not None
async def test_v3_migration_creates_automation_webhook_token_partial_index(bitable_db) -> None:
"""V3 creates a partial index on webhook_token (WHERE NOT NULL)."""
from sqlalchemy import text
async with bitable_db.engine.begin() as conn:
result = await conn.execute(
text(
"SELECT indexdef FROM pg_indexes "
"WHERE schemaname = 'bitable' AND indexname = 'ix_automations_webhook_token'"
)
)
defn = result.fetchone()
assert defn is not None
# Partial index must include WHERE webhook_token IS NOT NULL
assert "webhook_token IS NOT NULL" in defn[0]
async def test_init_is_idempotent(bitable_db) -> None:
"""Calling init() twice does not raise and keeps schema intact."""
# bitable_db fixture already called init(); call again
@ -243,3 +312,218 @@ async def test_bitable_db_without_url_raises() -> None:
for key, val in zip(("DATABASE_URL", "AGENTKIT_DATABASE_URL"), saved):
if val is not None:
os.environ[key] = val
# ---------------------------------------------------------------------------
# V3 repository CRUD: relation_links / cross_table_deps / automations / logs
# ---------------------------------------------------------------------------
async def test_relation_links_crud(bitable_db) -> None:
"""RelationLink CRUD: add, list forward, list reverse, remove."""
from agentkit.bitable.repository import BitableRepository
repo = BitableRepository(bitable_db)
table_a = await repo.create_table(name="Students")
table_b = await repo.create_table(name="Courses")
# Relation field lives on table_a (Students).
rel_field = await repo.create_field(
table_id=table_a.id, name="Enrolled Courses", field_type="lookup"
)
rec_a1 = await repo.create_record(table_id=table_a.id)
rec_a2 = await repo.create_record(table_id=table_a.id)
rec_b1 = await repo.create_record(table_id=table_b.id)
rec_b2 = await repo.create_record(table_id=table_b.id)
# Add links: a1 → b1, a1 → b2, a2 → b1
await repo.add_relation_link(rel_field.id, rec_a1.id, rec_b1.id)
await repo.add_relation_link(rel_field.id, rec_a1.id, rec_b2.id)
await repo.add_relation_link(rel_field.id, rec_a2.id, rec_b1.id)
# Forward: a1 → [b1, b2]
forward = await repo.list_relation_links(rel_field.id, rec_a1.id)
assert set(forward) == {rec_b1.id, rec_b2.id}
# Reverse: which sources point at b1? → [a1, a2]
reverse = await repo.list_reverse_relation_links(rel_field.id, rec_b1.id)
assert set(reverse) == {rec_a1.id, rec_a2.id}
# Remove a1 → b1
removed = await repo.remove_relation_link(rel_field.id, rec_a1.id, rec_b1.id)
assert removed is True
forward_after = await repo.list_relation_links(rel_field.id, rec_a1.id)
assert forward_after == [rec_b2.id]
# remove_all for a source record cascades
await repo.add_relation_link(rel_field.id, rec_a1.id, rec_b1.id)
count = await repo.remove_all_relation_links(rel_field.id, source_record_id=rec_a1.id)
assert count >= 1
assert await repo.list_relation_links(rel_field.id, rec_a1.id) == []
# remove_all for the whole field
count = await repo.remove_all_relation_links(rel_field.id)
assert count >= 1 # a2→b1 still there
async def test_cross_table_deps_crud(bitable_db) -> None:
"""CrossTableDep CRUD: add (dedup), find dependents, remove by source."""
from agentkit.bitable.models import CrossTableDepType
from agentkit.bitable.repository import BitableRepository
repo = BitableRepository(bitable_db)
table_a = await repo.create_table(name="Orders")
table_b = await repo.create_table(name="Customers")
source_field = await repo.create_field(
table_id=table_b.id, name="total_amount", field_type="rollup"
)
target_field = await repo.create_field(table_id=table_a.id, name="amount", field_type="number")
# Add dep edge: source_field (in B) depends on target_field (in A)
dep = await repo.add_cross_table_dep(
source_table_id=table_b.id,
source_field_id=source_field.id,
target_table_id=table_a.id,
target_field_id=target_field.id,
dep_type=CrossTableDepType.rollup,
)
assert dep is not None
# Duplicate insert is a no-op
dup = await repo.add_cross_table_dep(
source_table_id=table_b.id,
source_field_id=source_field.id,
target_table_id=table_a.id,
target_field_id=target_field.id,
dep_type=CrossTableDepType.rollup,
)
assert dup is None
# Find dependents of table_a / target_field
deps = await repo.find_cross_table_dependents(table_a.id, target_field.id)
assert len(deps) == 1
assert deps[0].source_field_id == source_field.id
assert deps[0].dep_type == CrossTableDepType.rollup
# Remove by source
removed = await repo.remove_cross_table_deps_for_source(source_field.id)
assert removed == 1
assert await repo.find_cross_table_dependents(table_a.id, target_field.id) == []
async def test_automations_crud(bitable_db) -> None:
"""AutomationRule CRUD: create, get, list, update, delete (cascade logs)."""
from agentkit.bitable.repository import BitableRepository
repo = BitableRepository(bitable_db)
table = await repo.create_table(name="Tasks")
rule = await repo.create_automation(
table_id=table.id,
name="Notify on done",
trigger_config={"type": "record_updated", "field_ids": ["f_status"]},
action_config={"type": "send_webhook", "url": "https://example.com/hook"},
)
assert rule.enabled is True
assert rule.webhook_token is None
fetched = await repo.get_automation(rule.id)
assert fetched is not None and fetched.id == rule.id
rules = await repo.list_automations(table.id)
assert len(rules) == 1
# list_automations_for_trigger matches on JSONB trigger_config->>'type'
matching = await repo.list_automations_for_trigger(table.id, "record_updated")
assert len(matching) == 1
non_matching = await repo.list_automations_for_trigger(table.id, "record_created")
assert non_matching == []
# update (disable)
updated = await repo.update_automation(rule.id, enabled=False)
assert updated is not None and updated.enabled is False
# webhook token lookup
rule_with_token = await repo.create_automation(
table_id=table.id,
name="Inbound webhook",
trigger_config={"type": "record_created"},
action_config={"type": "create_record"},
webhook_token="tok_secret_123",
)
found = await repo.get_automation_by_webhook_token("tok_secret_123")
assert found is not None and found.id == rule_with_token.id
# delete cascades logs (no logs here, but exercise the path)
deleted = await repo.delete_automation(rule.id)
assert deleted is True
assert await repo.get_automation(rule.id) is None
async def test_automation_logs_crud(bitable_db) -> None:
"""AutomationLog CRUD: create, update, list."""
from agentkit.bitable.models import AutomationStatus
from agentkit.bitable.repository import BitableRepository
repo = BitableRepository(bitable_db)
table = await repo.create_table(name="Tasks")
rule = await repo.create_automation(
table_id=table.id,
name="Auto",
trigger_config={"type": "record_created"},
action_config={"type": "send_message"},
)
log = await repo.create_automation_log(
automation_id=rule.id,
trigger_record_id="rec_1",
status=AutomationStatus.retrying,
attempt=1,
)
assert log.status == AutomationStatus.retrying
await repo.update_automation_log(
log.id, AutomationStatus.success, error_message=None
)
logs = await repo.list_automation_logs(rule.id)
assert len(logs) == 1
assert logs[0].status == AutomationStatus.success
assert logs[0].completed_at is not None
async def test_enqueue_recalc_with_is_cross_table(bitable_db) -> None:
"""enqueue_recalc sets is_cross_table flag when requested (U6)."""
from sqlalchemy import text
from agentkit.bitable.models import FieldType
from agentkit.bitable.repository import BitableRepository
repo = BitableRepository(bitable_db)
table = await repo.create_table(name="T")
field = await repo.create_field(table_id=table.id, name="f", field_type=FieldType.text)
record1 = await repo.create_record(table_id=table.id)
record2 = await repo.create_record(table_id=table.id)
# Same-table recalc — is_cross_table defaults to False
task = await repo.enqueue_recalc(table.id, record1.id, field.id)
assert task is not None
assert task.is_cross_table is False
# Cross-table recalc — explicit flag set on a different record
cross_task = await repo.enqueue_recalc(
table.id, record2.id, field.id, is_cross_table=True
)
assert cross_task is not None
assert cross_task.is_cross_table is True
# Verify the DB column actually stores the flag
async with bitable_db.engine.begin() as conn:
result = await conn.execute(
text(
"SELECT is_cross_table FROM bitable.bitable_recalc_queue "
"WHERE id = :id"
),
{"id": cross_task.id},
)
row = result.fetchone()
assert row is not None and row[0] is True

View File

@ -209,3 +209,52 @@ def test_engine_with_uuid_field_ids() -> None:
engine.add_formula("calc", f"={{{fid}}} * 2")
result = engine.evaluate("calc", row_values={fid: 21})
assert result == 42
# ---------------------------------------------------------------------------
# U3: Cross-table formula evaluation
# ---------------------------------------------------------------------------
def test_engine_evaluate_cross_table_aggregate() -> None:
"""=SUM({rel.target}) — cross-table aggregate via cross_table_values."""
engine = FormulaEngine()
engine.add_formula("cross_sum", "=SUM({f_rel.f_target})")
# The service layer pre-resolves cross-table refs to value lists
# Safe name is _ct_f_rel__f_target
cross_values = {"_ct_f_rel__f_target": [10, 20, 30]}
result = engine.evaluate(
"cross_sum", row_values={}, cross_table_values=cross_values
)
assert result == 60
def test_engine_cross_table_dep_added_to_dag() -> None:
"""Cross-table refs add the local relation field to the DAG."""
engine = FormulaEngine()
engine.add_formula("calc", "=SUM({f_rel.f_target})")
deps = engine.get_dependencies("calc")
# The relation field on this table is a dependency (so when the relation
# changes, this formula is recomputed)
assert "f_rel" in deps
def test_engine_mixed_cross_table_and_row() -> None:
"""={f_local} + SUM({f_rel.f_target}) — row + cross-table aggregate."""
engine = FormulaEngine()
engine.add_formula("mixed", "={f_local} + SUM({f_rel.f_target})")
cross_values = {"_ct_f_rel__f_target": [1, 2, 3]}
result = engine.evaluate(
"mixed", row_values={"f_local": 10}, cross_table_values=cross_values
)
assert result == 16
def test_engine_cross_table_missing_values_defaults_empty() -> None:
"""Missing cross_table_values default to empty list (no crash)."""
engine = FormulaEngine()
engine.add_formula("calc", "=SUM({f_rel.f_target})")
# No cross_table_values provided — should default to empty list
result = engine.evaluate("calc", row_values={})
# SUM of empty list = 0
assert result == 0

View File

@ -9,6 +9,8 @@ from __future__ import annotations
import pytest
from agentkit.bitable.formula.parser import (
MAX_AST_DEPTH,
FormulaDepthExceededError,
FormulaParseError,
FormulaSecurityError,
UnknownFunctionError,
@ -25,20 +27,20 @@ ALLOWED = {"SUM", "AVG", "COUNT", "MIN", "MAX", "ABS", "ROUND", "IF", "LEN", "CO
def test_parse_simple_arithmetic() -> None:
tree, mapping = parse_formula("=1+2*3", ALLOWED)
tree, mapping, _ = parse_formula("=1+2*3", ALLOWED)
assert mapping == {}
result = evaluate_ast(tree, {}, {})
assert result == 7
def test_parse_strips_equals_prefix() -> None:
tree1, _ = parse_formula("=1+1", ALLOWED)
tree2, _ = parse_formula("1+1", ALLOWED)
tree1, _, _ = parse_formula("=1+1", ALLOWED)
tree2, _, _ = parse_formula("1+1", ALLOWED)
assert evaluate_ast(tree1, {}, {}) == evaluate_ast(tree2, {}, {}) == 2
def test_parse_field_reference() -> None:
tree, mapping = parse_formula("={field_abc} + 1", ALLOWED)
tree, mapping, _ = parse_formula("={field_abc} + 1", ALLOWED)
assert "field_abc" in mapping.values()
# Safe name is prefixed with _f_
safe_name = next(k for k, v in mapping.items() if v == "field_abc")
@ -49,7 +51,7 @@ def test_parse_field_reference() -> None:
def test_parse_uuid_field_reference() -> None:
"""Field IDs are UUIDs with hyphens — must be substituted to safe names."""
fid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
tree, mapping = parse_formula(f"={{{fid}}} * 2", ALLOWED)
tree, mapping, _ = parse_formula(f"={{{fid}}} * 2", ALLOWED)
# The mapping should have a safe name → original UUID
assert fid in mapping.values()
# Evaluate using the safe name (prefixed with _f_)
@ -60,26 +62,26 @@ def test_parse_uuid_field_reference() -> None:
def test_parse_string_concatenation() -> None:
tree, _ = parse_formula('="hello" + " " + "world"', ALLOWED)
tree, _, _ = parse_formula('="hello" + " " + "world"', ALLOWED)
assert evaluate_ast(tree, {}, {}) == "hello world"
def test_parse_conditional_ifexp() -> None:
tree, _ = parse_formula("=1 if True else 2", ALLOWED)
tree, _, _ = parse_formula("=1 if True else 2", ALLOWED)
assert evaluate_ast(tree, {}, {}) == 1
def test_parse_comparison() -> None:
tree, mapping = parse_formula("={f} > 5", ALLOWED)
tree, mapping, _ = parse_formula("={f} > 5", ALLOWED)
safe_name = next(k for k, v in mapping.items() if v == "f")
assert evaluate_ast(tree, {safe_name: 10}, {}) is True
assert evaluate_ast(tree, {safe_name: 3}, {}) is False
def test_parse_boolean_ops() -> None:
tree, _ = parse_formula("=True and False", ALLOWED)
tree, _, _ = parse_formula("=True and False", ALLOWED)
assert evaluate_ast(tree, {}, {}) is False
tree2, _ = parse_formula("=True or False", ALLOWED)
tree2, _, _ = parse_formula("=True or False", ALLOWED)
assert evaluate_ast(tree2, {}, {}) is True
@ -89,14 +91,14 @@ def test_parse_boolean_ops() -> None:
def test_parse_function_call_sum() -> None:
tree, mapping = parse_formula("=SUM({f1})", ALLOWED)
tree, mapping, _ = parse_formula("=SUM({f1})", ALLOWED)
safe_name = next(k for k, v in mapping.items() if v == "f1")
result = evaluate_ast(tree, {safe_name: [1, 2, 3]}, {"SUM": sum})
assert result == 6
def test_parse_function_call_concat() -> None:
tree, mapping = parse_formula('=CONCAT({f1}, "-", {f2})', ALLOWED)
tree, mapping, _ = parse_formula('=CONCAT({f1}, "-", {f2})', ALLOWED)
safe_f1 = next(k for k, v in mapping.items() if v == "f1")
safe_f2 = next(k for k, v in mapping.items() if v == "f2")
result = evaluate_ast(
@ -106,7 +108,7 @@ def test_parse_function_call_concat() -> None:
def test_parse_nested_function_calls() -> None:
tree, _ = parse_formula("=ABS(-5) + ROUND(3.7, 0)", ALLOWED)
tree, _, _ = parse_formula("=ABS(-5) + ROUND(3.7, 0)", ALLOWED)
funcs = {"ABS": abs, "ROUND": round}
result = evaluate_ast(tree, {}, funcs)
assert result == 9 # 5 + 4
@ -179,7 +181,7 @@ def test_parse_error_empty_string() -> None:
def test_evaluate_unknown_field_ref_raises() -> None:
tree, _ = parse_formula("={nonexistent} + 1", ALLOWED)
tree, _, _ = parse_formula("={nonexistent} + 1", ALLOWED)
with pytest.raises(FormulaParseError, match="Unknown field reference"):
evaluate_ast(tree, {}, {})
@ -191,9 +193,117 @@ def test_evaluate_unknown_field_ref_raises() -> None:
def test_mixed_aggregate_and_row_context() -> None:
"""={f1} + SUM({f2}) — row f1 + column f2 sum."""
tree, mapping = parse_formula("={f1} + SUM({f2})", ALLOWED)
tree, mapping, _ = parse_formula("={f1} + SUM({f2})", ALLOWED)
safe_f1 = next(k for k, v in mapping.items() if v == "f1")
safe_f2 = next(k for k, v in mapping.items() if v == "f2")
# f1 is a row value (scalar), f2 is a column value (list)
result = evaluate_ast(tree, {safe_f1: 10, safe_f2: [1, 2, 3]}, {"SUM": sum})
assert result == 16 # 10 + 6
# ---------------------------------------------------------------------------
# KTD8: AST depth limit
# ---------------------------------------------------------------------------
def test_depth_limit_allows_normal_formula() -> None:
"""Normal formulas should pass the depth check."""
tree, _, _ = parse_formula("=1+2+3+4+5", ALLOWED)
assert tree is not None
def test_depth_limit_rejects_deeply_nested() -> None:
"""Deeply nested expressions should be rejected (KTD8)."""
# Build a formula deeper than MAX_AST_DEPTH: nested additions
# Each "x + " adds 1 level of BinOp depth
deep_formula = "=" + " + ".join(["1"] * (MAX_AST_DEPTH + 5))
with pytest.raises(FormulaDepthExceededError, match="depth"):
parse_formula(deep_formula, ALLOWED)
def test_depth_limit_allows_at_boundary() -> None:
"""Formula at exactly the limit should pass."""
# Build a formula with depth close to but not exceeding MAX_AST_DEPTH
# A simple sum of N numbers has depth ~N, but ast groups them differently.
# A safer test: shallow formula always passes.
tree, _, _ = parse_formula("=SUM({f1}) + IF({f2}, {f3}, {f4})", ALLOWED)
assert tree is not None
def test_list_literal_evaluated() -> None:
"""List literals [1, 2, 3] should be evaluated as Python lists."""
tree, _, _ = parse_formula("=[1, 2, 3]", ALLOWED)
result = evaluate_ast(tree, {}, {})
assert result == [1, 2, 3]
def test_tuple_literal_evaluated() -> None:
"""Tuple literals (1, 2, 3) should be evaluated as Python tuples."""
tree, _, _ = parse_formula("=(1, 2, 3)", ALLOWED)
result = evaluate_ast(tree, {}, {})
assert result == (1, 2, 3)
# ---------------------------------------------------------------------------
# U3: Cross-table references {rel.target}
# ---------------------------------------------------------------------------
def test_parse_cross_table_reference() -> None:
"""{rel_field.target_field} is parsed into a single Name node (KTD-2)."""
tree, field_mapping, cross_table_mapping = parse_formula("={f_rel.f_target}", ALLOWED)
# Single-field mapping should be empty
assert field_mapping == {}
# Cross-table mapping should have one entry
assert len(cross_table_mapping) == 1
safe_name, (rel_id, target_id) = next(iter(cross_table_mapping.items()))
assert rel_id == "f_rel"
assert target_id == "f_target"
assert safe_name.startswith("_ct_")
assert "__" in safe_name # separator between rel and target
def test_parse_cross_table_with_aggregate() -> None:
"""=SUM({rel.target}) — cross-table ref inside an aggregate function."""
tree, _, cross_table_mapping = parse_formula("=SUM({f_rel.f_target})", ALLOWED)
assert len(cross_table_mapping) == 1
safe_name, (rel_id, target_id) = next(iter(cross_table_mapping.items()))
assert rel_id == "f_rel"
assert target_id == "f_target"
# Should evaluate correctly when given a list of values
result = evaluate_ast(tree, {safe_name: [1, 2, 3]}, {"SUM": sum})
assert result == 6
def test_parse_mixed_single_and_cross_table_refs() -> None:
"""={f_local} + SUM({f_rel.f_target}) — mixed row + cross-table aggregate."""
tree, field_mapping, cross_table_mapping = parse_formula(
"={f_local} + SUM({f_rel.f_target})", ALLOWED
)
assert "f_local" in field_mapping.values()
assert len(cross_table_mapping) == 1
safe_local = next(k for k, v in field_mapping.items() if v == "f_local")
safe_cross = next(iter(cross_table_mapping.keys()))
result = evaluate_ast(
tree, {safe_local: 10, safe_cross: [1, 2, 3]}, {"SUM": sum}
)
assert result == 16
def test_parse_cross_table_uuid_field_ids() -> None:
"""Cross-table refs work with UUID-style field IDs containing hyphens."""
rel_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
target_id = "11111111-2222-3333-4444-555555555555"
tree, _, cross_table_mapping = parse_formula(f"={{{rel_id}.{target_id}}}", ALLOWED)
assert len(cross_table_mapping) == 1
_, (parsed_rel, parsed_target) = next(iter(cross_table_mapping.items()))
assert parsed_rel == rel_id
assert parsed_target == target_id
def test_cross_table_ref_does_not_match_single_field() -> None:
"""{rel.target} must not be partially matched by _FIELD_REF_RE."""
# The rel part should not leak into field_mapping
tree, field_mapping, cross_table_mapping = parse_formula("={f_rel.f_target}", ALLOWED)
assert field_mapping == {}
assert len(cross_table_mapping) == 1

View File

@ -0,0 +1,102 @@
"""Tests for aggregate and math formula functions (R1)."""
from __future__ import annotations
from agentkit.bitable.formula.functions.aggregate import (
AGGREGATE_FUNCTIONS_DICT,
AGGREGATE_FUNCTIONS_LIST,
)
def test_registry_has_12_functions() -> None:
assert len(AGGREGATE_FUNCTIONS_DICT) == 12
def test_aggregate_set_has_10_functions() -> None:
assert len(AGGREGATE_FUNCTIONS_LIST) == 10
assert "SUM" in AGGREGATE_FUNCTIONS_LIST
assert "SUMIF" in AGGREGATE_FUNCTIONS_LIST
assert "ABS" not in AGGREGATE_FUNCTIONS_LIST # ABS is scalar, not aggregate
assert "ROUND" not in AGGREGATE_FUNCTIONS_LIST
# ── migrated aggregate functions ────────────────────────
def test_sum() -> None:
assert AGGREGATE_FUNCTIONS_DICT["SUM"]([1, 2, 3, 4, 5]) == 15
assert AGGREGATE_FUNCTIONS_DICT["SUM"]([1, None, "", "3"]) == 4.0
def test_avg() -> None:
assert AGGREGATE_FUNCTIONS_DICT["AVG"]([1, 2, 3, 4, 5]) == 3.0
assert AGGREGATE_FUNCTIONS_DICT["AVG"]([]) == 0.0
def test_count() -> None:
assert AGGREGATE_FUNCTIONS_DICT["COUNT"]([1, 2, None, "", "x"]) == 3
def test_min() -> None:
assert AGGREGATE_FUNCTIONS_DICT["MIN"]([3, 1, 4, 1, 5]) == 1.0
assert AGGREGATE_FUNCTIONS_DICT["MIN"]([]) == 0
def test_max() -> None:
assert AGGREGATE_FUNCTIONS_DICT["MAX"]([3, 1, 4, 1, 5]) == 5.0
assert AGGREGATE_FUNCTIONS_DICT["MAX"]([]) == 0
# ── migrated scalar math ────────────────────────────────
def test_abs() -> None:
assert AGGREGATE_FUNCTIONS_DICT["ABS"](-5) == 5.0
assert AGGREGATE_FUNCTIONS_DICT["ABS"](5) == 5.0
assert AGGREGATE_FUNCTIONS_DICT["ABS"](None) is None
def test_round() -> None:
assert AGGREGATE_FUNCTIONS_DICT["ROUND"](3.14159, 2) == 3.14
assert AGGREGATE_FUNCTIONS_DICT["ROUND"](3.14159) == 3.0
assert AGGREGATE_FUNCTIONS_DICT["ROUND"](None) is None
# ── conditional aggregates ──────────────────────────────
def test_sumif_equality() -> None:
assert AGGREGATE_FUNCTIONS_DICT["SUMIF"]([1, 2, 3, 4, 5], 3) == 3.0
def test_sumif_greater_than() -> None:
assert AGGREGATE_FUNCTIONS_DICT["SUMIF"]([1, 2, 3, 4, 5], ">3") == 9.0 # 4+5
def test_countif_greater_than() -> None:
assert AGGREGATE_FUNCTIONS_DICT["COUNTIF"]([1, 2, 3, 4, 5], ">3") == 2
def test_averageif() -> None:
assert AGGREGATE_FUNCTIONS_DICT["AVERAGEIF"]([1, 2, 3, 4, 5], ">=3") == 4.0 # (3+4+5)/3
def test_sumifs() -> None:
values = [10, 20, 30, 40]
criteria_range = [1, 2, 1, 2]
# Sum values where criteria_range == 2 → 20 + 40 = 60
assert AGGREGATE_FUNCTIONS_DICT["SUMIFS"](values, criteria_range, 2) == 60.0
def test_countifs() -> None:
values = [10, 20, 30, 40]
criteria_range = [1, 2, 1, 2]
# Count values where criteria_range == 1 → 2
assert AGGREGATE_FUNCTIONS_DICT["COUNTIFS"](values, criteria_range, 1) == 2
def test_sumifs_length_mismatch() -> None:
import pytest
with pytest.raises(ValueError, match="equal length"):
AGGREGATE_FUNCTIONS_DICT["SUMIFS"]([1, 2, 3], [1, 2], 1)

View File

@ -0,0 +1,126 @@
"""Tests for datetime formula functions (R1)."""
from __future__ import annotations
from agentkit.bitable.formula.functions.datetime import (
DATETIME_FUNCTIONS,
_date,
_dateadd,
_datedif,
_day,
_eomonth,
_hour,
_minute,
_month,
_networkdays,
_now,
_second,
_today,
_weekday,
_year,
)
def test_registry_has_15_functions() -> None:
assert len(DATETIME_FUNCTIONS) == 15
assert "DATE" in DATETIME_FUNCTIONS
assert "NETWORKDAYS" in DATETIME_FUNCTIONS
def test_date_constructor() -> None:
result = _date(2026, 7, 6)
assert result == "2026-07-06T00:00:00"
def test_year_month_day() -> None:
d = _date(2026, 7, 6)
assert _year(d) == 2026
assert _month(d) == 7
assert _day(d) == 6
def test_hour_minute_second() -> None:
dt = "2026-07-06T14:30:45"
assert _hour(dt) == 14
assert _minute(dt) == 30
assert _second(dt) == 45
def test_weekday() -> None:
# 2026-07-06 is a Monday → ISO weekday 1
assert _weekday("2026-07-06") == 1
# 2026-07-12 is a Sunday → ISO weekday 7
assert _weekday("2026-07-12") == 7
def test_datedif_days() -> None:
assert _datedif("2026-07-01", "2026-07-06", "days") == 5
def test_datedif_months() -> None:
assert _datedif("2026-01-15", "2026-07-15", "months") == 6
def test_datedif_years() -> None:
assert _datedif("2020-01-01", "2026-01-01", "years") == 6
def test_dateadd_days() -> None:
assert _dateadd("2026-07-06", 5, "days") == "2026-07-11T00:00:00"
def test_dateadd_months() -> None:
assert _dateadd("2026-01-15", 1, "months") == "2026-02-15T00:00:00"
def test_dateadd_months_clamps_day() -> None:
# Jan 31 + 1 month → Feb 28 (2026 is not a leap year)
assert _dateadd("2026-01-31", 1, "months") == "2026-02-28T00:00:00"
def test_networkdays() -> None:
# 2026-07-06 (Mon) to 2026-07-10 (Fri) = 5 working days
assert _networkdays("2026-07-06", "2026-07-10") == 5
def test_networkdays_includes_weekend() -> None:
# 2026-07-06 (Mon) to 2026-07-12 (Sun) = 5 working days (weekend excluded)
assert _networkdays("2026-07-06", "2026-07-12") == 5
def test_networkdays_reversed_args() -> None:
# Should swap if start > end
assert _networkdays("2026-07-10", "2026-07-06") == 5
def test_eomonth() -> None:
# End of July 2026
assert _eomonth("2026-07-06", 0) == "2026-07-31T00:00:00"
def test_eomonth_next_month() -> None:
# End of August 2026
assert _eomonth("2026-07-06", 1) == "2026-08-31T00:00:00"
def test_today_returns_iso_string() -> None:
result = _today()
assert isinstance(result, str)
assert "T00:00:00" in result
def test_now_returns_iso_string() -> None:
result = _now()
assert isinstance(result, str)
assert "T" in result
def test_none_input_returns_none_for_accessors() -> None:
assert _year(None) is None
assert _month(None) is None
assert _day(None) is None
def test_empty_string_returns_none_for_accessors() -> None:
assert _year("") is None
assert _datedif("", "2026-07-06") is None

View File

@ -0,0 +1,141 @@
"""Tests for logical formula functions (R1)."""
from __future__ import annotations
from agentkit.bitable.formula.functions.logical import LOGICAL_FUNCTIONS
def test_registry_has_15_functions() -> None:
assert len(LOGICAL_FUNCTIONS) == 15
expected = {
"IF", "IFS", "SWITCH", "IFERROR", "COALESCE", "AND", "OR", "NOT",
"ISBLANK", "ISNUMBER", "ISTEXT", "ISDATE", "TRUE", "FALSE", "NULL",
}
assert set(LOGICAL_FUNCTIONS.keys()) == expected
# ── IF (migrated) ───────────────────────────────────────
def test_if_true() -> None:
assert LOGICAL_FUNCTIONS["IF"](1, "yes", "no") == "yes"
def test_if_false() -> None:
assert LOGICAL_FUNCTIONS["IF"](0, "yes", "no") == "no"
def test_if_default_false() -> None:
assert LOGICAL_FUNCTIONS["IF"](0, "yes") is None
# ── IFS ─────────────────────────────────────────────────
def test_ifs_first_match() -> None:
assert LOGICAL_FUNCTIONS["IFS"](1, "a", 0, "b", 1, "c") == "a"
def test_ifs_second_match() -> None:
assert LOGICAL_FUNCTIONS["IFS"](0, "a", 1, "b", 0, "c") == "b"
def test_ifs_no_match() -> None:
assert LOGICAL_FUNCTIONS["IFS"](0, "a", 0, "b") is None
# ── SWITCH ──────────────────────────────────────────────
def test_switch_match() -> None:
assert LOGICAL_FUNCTIONS["SWITCH"]("b", "a", 1, "b", 2, "c", 3) == 2
def test_switch_default() -> None:
assert LOGICAL_FUNCTIONS["SWITCH"]("x", "a", 1, "b", 2, "default") == "default"
def test_switch_no_match_no_default() -> None:
assert LOGICAL_FUNCTIONS["SWITCH"]("x", "a", 1, "b", 2) is None
# ── IFERROR ─────────────────────────────────────────────
def test_iferror_passes_non_error() -> None:
assert LOGICAL_FUNCTIONS["IFERROR"](42, "default") == 42
def test_iferror_returns_default_for_error() -> None:
error_dict = {"__error": "division by zero"}
assert LOGICAL_FUNCTIONS["IFERROR"](error_dict, "default") == "default"
# ── COALESCE ────────────────────────────────────────────
def test_coalesce_first_non_empty() -> None:
assert LOGICAL_FUNCTIONS["COALESCE"]("a", "b", "c") == "a"
def test_coalesce_skips_none_and_empty() -> None:
assert LOGICAL_FUNCTIONS["COALESCE"](None, "", "c") == "c"
def test_coalesce_all_empty() -> None:
assert LOGICAL_FUNCTIONS["COALESCE"](None, "", None) is None
# ── Boolean operators ───────────────────────────────────
def test_and() -> None:
assert LOGICAL_FUNCTIONS["AND"](1, 1, 1) is True
assert LOGICAL_FUNCTIONS["AND"](1, 0, 1) is False
def test_or() -> None:
assert LOGICAL_FUNCTIONS["OR"](0, 0, 1) is True
assert LOGICAL_FUNCTIONS["OR"](0, 0, 0) is False
def test_not() -> None:
assert LOGICAL_FUNCTIONS["NOT"](1) is False
assert LOGICAL_FUNCTIONS["NOT"](0) is True
# ── Predicates ──────────────────────────────────────────
def test_isblank() -> None:
assert LOGICAL_FUNCTIONS["ISBLANK"](None) is True
assert LOGICAL_FUNCTIONS["ISBLANK"]("") is True
assert LOGICAL_FUNCTIONS["ISBLANK"]("x") is False
def test_isnumber() -> None:
assert LOGICAL_FUNCTIONS["ISNUMBER"](42) is True
assert LOGICAL_FUNCTIONS["ISNUMBER"](3.14) is True
assert LOGICAL_FUNCTIONS["ISNUMBER"]("42") is False
assert LOGICAL_FUNCTIONS["ISNUMBER"](True) is False # bool excluded
def test_istext() -> None:
assert LOGICAL_FUNCTIONS["ISTEXT"]("hello") is True
assert LOGICAL_FUNCTIONS["ISTEXT"](42) is False
def test_isdate() -> None:
assert LOGICAL_FUNCTIONS["ISDATE"]("2026-07-06") is True
assert LOGICAL_FUNCTIONS["ISDATE"]("2026-07-06T10:30:00") is True
assert LOGICAL_FUNCTIONS["ISDATE"]("not a date") is False
assert LOGICAL_FUNCTIONS["ISDATE"](42) is False
# ── Constants ───────────────────────────────────────────
def test_true_false_null() -> None:
assert LOGICAL_FUNCTIONS["TRUE"]() is True
assert LOGICAL_FUNCTIONS["FALSE"]() is False
assert LOGICAL_FUNCTIONS["NULL"]() is None

View File

@ -0,0 +1,126 @@
"""Tests for text formula functions (R1)."""
from __future__ import annotations
from agentkit.bitable.formula.functions.text import TEXT_FUNCTIONS
# ── Registry ────────────────────────────────────────────
def test_registry_has_17_functions() -> None:
assert len(TEXT_FUNCTIONS) == 17
expected = {
"CONCAT", "LEN", "FIND", "MID", "LEFT", "RIGHT", "REPLACE", "SUBSTITUTE",
"UPPER", "LOWER", "TRIM", "REPT", "TEXT", "VALUE", "STARTSWITH", "ENDSWITH",
"SPLIT",
}
assert set(TEXT_FUNCTIONS.keys()) == expected
# ── migrated functions ──────────────────────────────────
def test_concat() -> None:
assert TEXT_FUNCTIONS["CONCAT"]("a", "b", "c") == "abc"
assert TEXT_FUNCTIONS["CONCAT"]("a", None, "c") == "ac"
def test_len() -> None:
assert TEXT_FUNCTIONS["LEN"]("hello") == 5
assert TEXT_FUNCTIONS["LEN"](None) == 0
assert TEXT_FUNCTIONS["LEN"](123) == 3
# ── find / extract ──────────────────────────────────────
def test_find() -> None:
assert TEXT_FUNCTIONS["FIND"]("hello world", "world") == 6
assert TEXT_FUNCTIONS["FIND"]("hello", "xyz") == -1
assert TEXT_FUNCTIONS["FIND"]("hello", "") == 0
def test_mid() -> None:
assert TEXT_FUNCTIONS["MID"]("hello", 1, 3) == "ell"
assert TEXT_FUNCTIONS["MID"]("hello", 0, 2) == "he"
assert TEXT_FUNCTIONS["MID"]("hello", -1, 2) == "he" # negative clamped
def test_left() -> None:
assert TEXT_FUNCTIONS["LEFT"]("hello", 3) == "hel"
assert TEXT_FUNCTIONS["LEFT"]("hello") == "h" # default n=1
assert TEXT_FUNCTIONS["LEFT"]("hello", 0) == ""
def test_right() -> None:
assert TEXT_FUNCTIONS["RIGHT"]("hello", 3) == "llo"
assert TEXT_FUNCTIONS["RIGHT"]("hello") == "o" # default n=1
# ── replace / substitute ────────────────────────────────
def test_replace() -> None:
# REPLACE("hello", 1, 3, "XYZ") → "h" + "XYZ" + "o" = "hXYZo"
# Replaces 3 chars starting at index 1: "ell" → "XYZ"
assert TEXT_FUNCTIONS["REPLACE"]("hello", 1, 3, "XYZ") == "hXYZo"
def test_substitute_all() -> None:
assert TEXT_FUNCTIONS["SUBSTITUTE"]("a-b-c", "-", "+") == "a+b+c"
def test_substitute_nth() -> None:
# Replace only the 2nd occurrence
assert TEXT_FUNCTIONS["SUBSTITUTE"]("a-b-c", "-", "+", 2) == "a-b+c"
# ── case / trim ─────────────────────────────────────────
def test_upper_lower() -> None:
assert TEXT_FUNCTIONS["UPPER"]("hello") == "HELLO"
assert TEXT_FUNCTIONS["LOWER"]("HELLO") == "hello"
def test_trim() -> None:
assert TEXT_FUNCTIONS["TRIM"](" hello world ") == "hello world"
# ── repeat / format / convert ───────────────────────────
def test_rept() -> None:
assert TEXT_FUNCTIONS["REPT"]("ab", 3) == "ababab"
assert TEXT_FUNCTIONS["REPT"]("ab", 0) == ""
def test_text_format() -> None:
assert TEXT_FUNCTIONS["TEXT"](3.14159, ".2f") == "3.14"
assert TEXT_FUNCTIONS["TEXT"](42) == "42"
def test_value() -> None:
assert TEXT_FUNCTIONS["VALUE"]("42") == 42
assert TEXT_FUNCTIONS["VALUE"]("3.14") == 3.14
assert TEXT_FUNCTIONS["VALUE"]("not a number") is None
assert TEXT_FUNCTIONS["VALUE"](None) is None
# ── predicates / split ──────────────────────────────────
def test_startswith() -> None:
assert TEXT_FUNCTIONS["STARTSWITH"]("hello world", "hello") is True
assert TEXT_FUNCTIONS["STARTSWITH"]("hello world", "world") is False
def test_endswith() -> None:
assert TEXT_FUNCTIONS["ENDSWITH"]("hello world", "world") is True
assert TEXT_FUNCTIONS["ENDSWITH"]("hello world", "hello") is False
def test_split() -> None:
assert TEXT_FUNCTIONS["SPLIT"]("a,b,c", ",") == ["a", "b", "c"]
assert TEXT_FUNCTIONS["SPLIT"]("hello", ",") == ["hello"]

View File

@ -12,12 +12,21 @@ import pytest
from pydantic import ValidationError
from agentkit.bitable.models import (
AutomationActionType,
AutomationLog,
AutomationRule,
AutomationStatus,
AutomationTriggerType,
CrossTableDep,
CrossTableDepType,
Field,
FieldOwner,
FieldType,
Record,
RecalcStatus,
RecalcTask,
RelationLink,
RelationType,
Table,
View,
ViewType,
@ -30,7 +39,7 @@ from agentkit.bitable.models import (
def test_field_type_values() -> None:
"""FieldType has the 9 supported types with correct string values."""
"""FieldType has the 11 supported types with correct string values (V3 adds relation + rollup)."""
expected = {
"text",
"number",
@ -40,7 +49,9 @@ def test_field_type_values() -> None:
"attachment",
"image",
"formula",
"relation",
"lookup",
"rollup",
}
assert {ft.value for ft in FieldType} == expected
@ -61,6 +72,40 @@ def test_recalc_status_lifecycle() -> None:
assert {rs.value for rs in RecalcStatus} == {"pending", "calculating", "done", "error"}
def test_relation_type_values() -> None:
"""RelationType covers single/bi/multi (R5)."""
assert {rt.value for rt in RelationType} == {
"one_to_one",
"one_to_many",
"many_to_many",
}
def test_automation_trigger_type_values() -> None:
"""AutomationTriggerType covers create/update/delete (R13)."""
assert {t.value for t in AutomationTriggerType} == {
"record_created",
"record_updated",
"record_deleted",
}
def test_automation_action_type_values() -> None:
"""AutomationActionType covers 5 action kinds (R14)."""
assert {a.value for a in AutomationActionType} == {
"create_record",
"update_record",
"send_webhook",
"send_email",
"send_message",
}
def test_cross_table_dep_type_values() -> None:
"""CrossTableDepType covers formula/lookup/rollup edges (KTD-3)."""
assert {d.value for d in CrossTableDepType} == {"formula", "lookup", "rollup"}
# ---------------------------------------------------------------------------
# Table
# ---------------------------------------------------------------------------
@ -301,3 +346,148 @@ def test_table_from_attributes() -> None:
table = Table.model_validate(_Row())
assert table.id == "t1"
assert table.name == "Orders"
# ---------------------------------------------------------------------------
# V3 models: RecalcTask.is_cross_table, RelationLink, AutomationRule,
# AutomationLog, CrossTableDep
# ---------------------------------------------------------------------------
def test_recalc_task_is_cross_table_default() -> None:
"""RecalcTask.is_cross_table defaults to False (same-table recalc)."""
task = RecalcTask(id="q1", table_id="t1", record_id="r1", field_id="f1")
assert task.is_cross_table is False
def test_recalc_task_cross_table_flag() -> None:
"""RecalcTask can carry is_cross_table=True (U6)."""
task = RecalcTask(
id="q1",
table_id="t1",
record_id="r1",
field_id="f1",
is_cross_table=True,
)
assert task.is_cross_table is True
def test_relation_link_construction() -> None:
"""RelationLink represents a junction-table row (KTD-1)."""
link = RelationLink(
id="rl1",
relation_field_id="f_rel",
source_record_id="r_src",
target_record_id="r_tgt",
)
assert link.relation_field_id == "f_rel"
assert link.source_record_id == "r_src"
assert link.target_record_id == "r_tgt"
assert isinstance(link.created_at, datetime)
def test_relation_link_round_trip() -> None:
"""RelationLink round-trips through JSON."""
link = RelationLink(
id="rl1",
relation_field_id="f_rel",
source_record_id="r_src",
target_record_id="r_tgt",
)
restored = RelationLink.model_validate(link.model_dump(mode="json"))
assert restored == link
def test_automation_rule_construction() -> None:
"""AutomationRule carries trigger_config + action_config + token."""
rule = AutomationRule(
id="a1",
table_id="t1",
name="Notify on done",
trigger_config={"type": "record_updated", "field_ids": ["f_status"]},
action_config={"type": "send_webhook", "url": "https://example.com"},
)
assert rule.enabled is True
assert rule.webhook_token is None
assert rule.trigger_config["type"] == "record_updated"
assert rule.action_config["url"] == "https://example.com"
def test_automation_rule_round_trip() -> None:
"""AutomationRule round-trips through JSON."""
rule = AutomationRule(
id="a1",
table_id="t1",
name="Auto",
trigger_config={"type": "record_created"},
action_config={"type": "send_message", "channel": "general"},
webhook_token="tok_123",
)
restored = AutomationRule.model_validate(rule.model_dump(mode="json"))
assert restored == rule
assert restored.webhook_token == "tok_123"
def test_automation_log_construction() -> None:
"""AutomationLog carries status + attempt + error_message."""
log = AutomationLog(
id="log1",
automation_id="a1",
trigger_record_id="r1",
status=AutomationStatus.retrying,
attempt=2,
error_message="transient",
)
assert log.status == AutomationStatus.retrying
assert log.attempt == 2
assert log.error_message == "transient"
assert log.completed_at is None
def test_cross_table_dep_construction() -> None:
"""CrossTableDep records a source_field → target_field edge (KTD-3)."""
dep = CrossTableDep(
id="d1",
source_table_id="t_src",
source_field_id="f_src",
target_table_id="t_tgt",
target_field_id="f_tgt",
dep_type=CrossTableDepType.rollup,
)
assert dep.dep_type == CrossTableDepType.rollup
assert dep.target_table_id == "t_tgt"
def test_field_rollup_config_shape() -> None:
"""Rollup field config carries relation_field_id + aggregation (KTD-7)."""
field = Field(
id="f1",
table_id="t1",
name="Total Amount",
field_type=FieldType.rollup,
config={
"relation_field_id": "f_rel",
"target_field_id": "f_amount",
"aggregation": "sum",
},
)
assert field.field_type == FieldType.rollup
assert field.config["aggregation"] == "sum"
def test_field_relation_config_shape() -> None:
"""Relation field (lookup with relation config) carries relation_type."""
field = Field(
id="f1",
table_id="t1",
name="Customer",
field_type=FieldType.lookup,
config={
"relation_type": "many_to_many",
"target_table_id": "t_customers",
"is_bidirectional": True,
"reverse_field_id": "f_reverse",
},
)
assert field.config["relation_type"] == "many_to_many"
assert field.config["is_bidirectional"] is True

View File

@ -192,8 +192,9 @@ async def test_crash_recovery_resets_calculating_tasks(
tasks = await bitable_service.get_pending_recalc_tasks()
assert len(tasks) == 0 # not pending, it's calculating
# Crash recovery
reset_count = await bitable_service.reset_stale_recalc_tasks()
# Crash recovery — use threshold=0 to reset all calculating tasks immediately
# (simulates worker startup: all calculating tasks are stale when worker was down)
reset_count = await bitable_service.reset_stale_recalc_tasks(stale_threshold=0.0)
assert reset_count == 1
# Now it should be pending again
@ -230,11 +231,14 @@ async def test_recalc_deduplication(bitable_service: BitableService) -> None:
record = await bitable_service.create_record(table_id=table.id, values={src.id: 10})
# The create_record already enqueued one task. Enqueue again manually.
# With ON CONFLICT DO UPDATE: a duplicate "pending" task is left as-is
# (not reset), so the existing task is returned — not None.
task2 = await bitable_service.trigger_recalc(table.id, record.id, calc.id)
# Should return None (duplicate, ON CONFLICT DO NOTHING)
assert task2 is None
# task2 may be the existing task (status still "pending") — not None
assert task2 is not None
assert task2.status == RecalcStatus.pending
# Only one pending task
# Only one pending task (no duplicate created)
tasks = await bitable_service.get_pending_recalc_tasks()
assert len(tasks) == 1

View File

@ -0,0 +1,220 @@
"""Tests for relation field CRUD service methods (U4: R5, R7, R8).
These tests require PostgreSQL (skipped when unavailable). They verify:
- create_relation_field creates fields with correct config
- add/remove_relation_link manages junction table entries (many_to_many)
- add/remove_relation_link manages record JSONB (one_to_one/one_to_many)
- bidirectional relations create reverse fields
- delete_relation_field cascades cleanup
"""
from __future__ import annotations
import pytest
from agentkit.bitable.models import FieldType, RelationType
@pytest.mark.asyncio
async def test_create_relation_field_one_to_many(bitable_service) -> None:
"""create_relation_field creates a relation field with correct config."""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Related B",
target_table_id=table_b.id,
relation_type=RelationType.one_to_many,
)
assert field.field_type == FieldType.relation
assert field.config["relation_type"] == "one_to_many"
assert field.config["target_table_id"] == table_b.id
assert field.config["is_bidirectional"] is False
@pytest.mark.asyncio
async def test_create_relation_field_bidirectional(bitable_service) -> None:
"""Bidirectional relation creates a reverse field on the target table."""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Related B",
target_table_id=table_b.id,
relation_type=RelationType.many_to_many,
is_bidirectional=True,
reverse_field_name="Reverse A",
)
assert field.config["is_bidirectional"] is True
assert "reverse_field_id" in field.config
reverse_field = await bitable_service.get_field(field.config["reverse_field_id"])
assert reverse_field is not None
assert reverse_field.name == "Reverse A"
assert reverse_field.config["target_table_id"] == table_a.id
@pytest.mark.asyncio
async def test_add_relation_link_one_to_many(bitable_service) -> None:
"""one_to_many relation stores links in record JSONB."""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Related B",
target_table_id=table_b.id,
relation_type=RelationType.one_to_many,
)
rec_a = await bitable_service.create_record(table_a.id, {})
rec_b1 = await bitable_service.create_record(table_b.id, {})
rec_b2 = await bitable_service.create_record(table_b.id, {})
# Add links
assert await bitable_service.add_relation_link(field.id, rec_a.id, rec_b1.id) is True
assert await bitable_service.add_relation_link(field.id, rec_a.id, rec_b2.id) is True
# Verify
related = await bitable_service.list_related_records(field.id, rec_a.id)
assert set(related) == {rec_b1.id, rec_b2.id}
# Idempotent: adding again returns False
assert await bitable_service.add_relation_link(field.id, rec_a.id, rec_b1.id) is False
@pytest.mark.asyncio
async def test_remove_relation_link_one_to_many(bitable_service) -> None:
"""remove_relation_link removes from record JSONB."""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Related B",
target_table_id=table_b.id,
relation_type=RelationType.one_to_many,
)
rec_a = await bitable_service.create_record(table_a.id, {})
rec_b = await bitable_service.create_record(table_b.id, {})
await bitable_service.add_relation_link(field.id, rec_a.id, rec_b.id)
assert await bitable_service.remove_relation_link(field.id, rec_a.id, rec_b.id) is True
assert await bitable_service.list_related_records(field.id, rec_a.id) == []
@pytest.mark.asyncio
async def test_add_relation_link_many_to_many(bitable_service) -> None:
"""many_to_many relation uses junction table."""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Tags",
target_table_id=table_b.id,
relation_type=RelationType.many_to_many,
)
rec_a = await bitable_service.create_record(table_a.id, {})
rec_b1 = await bitable_service.create_record(table_b.id, {})
rec_b2 = await bitable_service.create_record(table_b.id, {})
assert await bitable_service.add_relation_link(field.id, rec_a.id, rec_b1.id) is True
assert await bitable_service.add_relation_link(field.id, rec_a.id, rec_b2.id) is True
related = await bitable_service.list_related_records(field.id, rec_a.id)
assert set(related) == {rec_b1.id, rec_b2.id}
# Reverse lookup
reverse = await bitable_service.list_related_records(field.id, rec_b1.id, reverse=True)
assert rec_a.id in reverse
@pytest.mark.asyncio
async def test_delete_relation_field_cascades(bitable_service) -> None:
"""delete_relation_field removes junction entries and record values."""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Related B",
target_table_id=table_b.id,
relation_type=RelationType.many_to_many,
)
rec_a = await bitable_service.create_record(table_a.id, {})
rec_b = await bitable_service.create_record(table_b.id, {})
await bitable_service.add_relation_link(field.id, rec_a.id, rec_b.id)
assert await bitable_service.delete_relation_field(field.id) is True
assert await bitable_service.get_field(field.id) is None
@pytest.mark.asyncio
async def test_add_relation_link_non_relation_field_raises(bitable_service) -> None:
"""add_relation_link on a non-relation field raises ValueError."""
table_a = await bitable_service.create_table(name="TableA")
field = await bitable_service.create_field(
table_id=table_a.id, name="text_field", field_type=FieldType.text
)
with pytest.raises(ValueError, match="not a relation field"):
await bitable_service.add_relation_link(field.id, "rec1", "rec2")
@pytest.mark.asyncio
async def test_bidirectional_relation_link_cleanup(bitable_service) -> None:
"""remove_relation_link cleans up the reverse field on the target record.
For bidirectional relations, add_relation_link updates BOTH sides:
- Forward: source.values[forward_field] = [target]
- Reverse: target.values[reverse_field] = [source]
remove_relation_link must mirror this _cleanup_reverse_link removes
source from target.values[reverse_field]. Without this, the reverse
field retains stale references (asymmetric association).
"""
table_a = await bitable_service.create_table(name="TableA")
table_b = await bitable_service.create_table(name="TableB")
field = await bitable_service.create_relation_field(
table_id=table_a.id,
name="Related B",
target_table_id=table_b.id,
relation_type=RelationType.one_to_many,
is_bidirectional=True,
reverse_field_name="Reverse A",
)
reverse_field_id = field.config["reverse_field_id"]
rec_a = await bitable_service.create_record(table_a.id, {})
rec_b = await bitable_service.create_record(table_b.id, {})
# Add link: forward (A→B) and reverse (B→A) should both be populated
await bitable_service.add_relation_link(field.id, rec_a.id, rec_b.id)
rec_a_after = await bitable_service.get_record(rec_a.id)
rec_b_after = await bitable_service.get_record(rec_b.id)
assert rec_a_after.values.get(field.id) == [rec_b.id]
assert rec_b_after.values.get(reverse_field_id) == [rec_a.id]
# Remove link: both sides should be cleaned up
assert await bitable_service.remove_relation_link(field.id, rec_a.id, rec_b.id) is True
rec_a_final = await bitable_service.get_record(rec_a.id)
rec_b_final = await bitable_service.get_record(rec_b.id)
# Forward field: rec_b removed from list
forward_val = rec_a_final.values.get(field.id)
assert forward_val == [] or forward_val is None
# Reverse field: rec_a removed from list (this is the key assertion —
# without _cleanup_reverse_link, rec_a would still be here)
reverse_val = rec_b_final.values.get(reverse_field_id)
assert reverse_val == [] or reverse_val is None, (
f"Reverse field not cleaned up, still contains: {reverse_val}"
)