diff --git a/docs/plans/2026-07-06-001-feat-bitable-formula-relations-v2-plan.md b/docs/plans/2026-07-06-001-feat-bitable-formula-relations-v2-plan.md new file mode 100644 index 0000000..8813cd7 --- /dev/null +++ b/docs/plans/2026-07-06-001-feat-bitable-formula-relations-v2-plan.md @@ -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→B,B 无反向字段)、双向关联(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 queue;2) 查跨表依赖图,找出表 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` action;2) 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` action;2) 生成公式 `={price} * {quantity}`;3) validate-formula 验证通过;4) 创建公式字段 `total_price`;5) 重算所有记录 + +### Dependencies / Assumptions + +- **假设:** 现有 PostgreSQL JSONB 存储模型可扩展支持关联引用(记录 ID 列表嵌入 JSONB 或 junction table) +- **假设:** 现有 recalc worker(FOR UPDATE SKIP LOCKED)可扩展到跨表依赖追踪,无需替换为消息队列 +- **依赖:** 现有 BitableTool REST API 边界(KTD5)保持不变,新 action 通过 HTTP 调用 +- **依赖:** 现有公式引擎 AST 白名单(KTD7)扩展,不引入 `eval()`/`exec()` + +### Sources / Research + +**竞品调研(2026 年数据):** +- 飞书多维表格:6 视图 + 仪表盘(15+)、26 字段类型、100+ 公式函数、工作流+AI Agent 节点、单/双向关联/跨表公式 +- Notion:11 视图、21-23 字段类型、80+ 公式(Formulas 2.0,let/lambda/map/filter)、4 触发器+4 动作自动化、单/双向/自引用/Rollup +- Baserow:6 视图、25+ 字段类型、100+ Excel 风格公式、Workflow Builder、Link/Lookup/Rollup/Count +- NocoDB:9 视图(含 Gantt/Timeline/Map)、40+ 字段类型、LTAR V2 统一 junction-table +- APITable:7 视图(含 Mindmap)、24 字段类型、OT 实时协作(ot-json0+Yjs)、Magic Link 双向关联 +- Twenty:3 视图、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
多对多] + AUTO[bitable_automations
+ bitable_automation_logs] + DEP[bitable_cross_table_deps
依赖图] + end + + subgraph "Formula Engine" + FE[FormulaEngine
DAG + topo sort] + FUNC[40+ functions
date/text/logical/agg/lookup] + XCTBL[Cross-table ref resolver
relation_field.target_field] + end + + subgraph "Relation Layer" + REL[RelationService
single/bi/multi/self-ref] + LK[Lookup evaluator] + RU[Rollup evaluator
sum/count/avg/min/max/unique] + end + + subgraph "Recalc Pipeline" + RW[RecalcWorker
FOR UPDATE SKIP LOCKED] + DG[DependencyGraph
field-level cross-table] + Q[recalc_queue
+ cross_table flag] + end + + subgraph "Automation Engine" + TRIG[Trigger detector
create/update/delete] + ACT[Action executor
5 actions + retry] + WH[Webhook in/out] + LOG[Execution logs] + end + + subgraph "Agent Surface" + BT[BitableTool
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 backoff(1s/2s/4s),3 次失败后标记 error。 + +**KTD-5. Agent 生成公式/自动化用"直接落地 + 可撤销"模式,不做确认弹窗。** +agent 生成结果直接创建字段/规则,但在响应中返回 `created_id` 和 `undo_token`。用户可在 30 秒内通过 `undo` action 撤销。理由:1) agent-native 架构强调低延迟;2) 确认弹窗打断 agent 流;3) 撤销比确认更符合人类直觉。`undo_token` 存 Redis(5 分钟 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) 早失败,避免构建无效 DAG;2) validate-formula 端点直接返回错误;3) 不污染 engine 的拓扑排序逻辑。 + +**KTD-9. Webhook 入站用独立路由前缀 `/api/v1/bitable/webhooks/{token}`。** +不用 JWT 认证,用 webhook-specific token(每规则唯一)。理由:1) 外部系统无法获取 JWT;2) 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 models(RelationLinkModel, 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, TIMESTAMP(15 个) + - `text.py` — FIND, MID, REPLACE, SUBSTITUTE, UPPER, LOWER, TRIM, LEFT, RIGHT, REPT, TEXT, VALUE, STARTSWITH, ENDSWITH, SPLIT(15 个) + - `logical.py` — IFS, SWITCH, IFERROR, ISBLANK, AND, OR, NOT, ISNUMBER, ISTEXT, ISDATE, COALESCE, NULL, TRUE, FALSE(14 个) + - `aggregate.py` — SUMIF, COUNTIF, AVERAGEIF, SUMIFS, COUNTIFS(5 个条件聚合,迁移现有 SUM/AVG/COUNT/MIN/MAX) + - `lookup.py` — LOOKUP, FILTER, MATCH, VLOOKUP, XLOOKUP(5 个,跨表引用支持在 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__` Name 节点 +- engine `evaluate` 签名扩展:新增 `relation_context: dict[str, list[dict[str, object]]]` 参数,key 为 relation_field_id,value 为关联记录列表 +- 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(通过 rollup,U5 实现)跨表重算 +- **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_type,formula → 现有逻辑;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_amount(rollup SUM)跨表重算 +- **Edge case:** 关联记录为空时 lookup 返回 None,rollup 返回 0;unique 聚合去重 +- **Error path:** relation_field 已删除 → 字段 config 标记 error;target_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 依赖 B,B 依赖 A)抛 `CircularReferenceError` +- **Error path:** 跨表任务失败 → 状态 error + error_message;stale 任务被 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 task(KTD-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 backoff(1s/2s/4s),3 次失败后 `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_message;webhook 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 存 Redis(5 分钟 TTL),undo 时删除字段 +- `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 聚焦 Web,Tauri 兼容性留给实现时验证。 + +### 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 V3,4 张新表;不影响其他 schema +- **部署**:无新依赖(ponytail: 复用现有);无新环境变量(webhook token 存 DB) +- **文档**:无需更新 AGENTS.md(bitable 模块自包含) + +### 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 跨表重算需端到端集成测试。 diff --git a/src/agentkit/bitable/automation.py b/src/agentkit/bitable/automation.py new file mode 100644 index 0000000..507c5ce --- /dev/null +++ b/src/agentkit/bitable/automation.py @@ -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) diff --git a/src/agentkit/bitable/db.py b/src/agentkit/bitable/db.py index bad017e..f34d4eb 100644 --- a/src/agentkit/bitable/db.py +++ b/src/agentkit/bitable/db.py @@ -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 (source→target) and reverse (target→source) 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 V2→V3 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) " diff --git a/src/agentkit/bitable/formula/__init__.py b/src/agentkit/bitable/formula/__init__.py index d108666..f5bfc0b 100644 --- a/src/agentkit/bitable/formula/__init__.py +++ b/src/agentkit/bitable/formula/__init__.py @@ -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", ] diff --git a/src/agentkit/bitable/formula/engine.py b/src/agentkit/bitable/formula/engine.py index 9e06e07..7123182 100644 --- a/src/agentkit/bitable/formula/engine.py +++ b/src/agentkit/bitable/formula/engine.py @@ -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 diff --git a/src/agentkit/bitable/formula/functions.py b/src/agentkit/bitable/formula/functions.py deleted file mode 100644 index 05009e7..0000000 --- a/src/agentkit/bitable/formula/functions.py +++ /dev/null @@ -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, -} diff --git a/src/agentkit/bitable/formula/functions/__init__.py b/src/agentkit/bitable/formula/functions/__init__.py new file mode 100644 index 0000000..6960180 --- /dev/null +++ b/src/agentkit/bitable/formula/functions/__init__.py @@ -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", +] diff --git a/src/agentkit/bitable/formula/functions/aggregate.py b/src/agentkit/bitable/formula/functions/aggregate.py new file mode 100644 index 0000000..d247b4e --- /dev/null +++ b/src/agentkit/bitable/formula/functions/aggregate.py @@ -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, +} diff --git a/src/agentkit/bitable/formula/functions/datetime.py b/src/agentkit/bitable/formula/functions/datetime.py new file mode 100644 index 0000000..a3269c8 --- /dev/null +++ b/src/agentkit/bitable/formula/functions/datetime.py @@ -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, +} diff --git a/src/agentkit/bitable/formula/functions/logical.py b/src/agentkit/bitable/formula/functions/logical.py new file mode 100644 index 0000000..d901652 --- /dev/null +++ b/src/agentkit/bitable/formula/functions/logical.py @@ -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, +} diff --git a/src/agentkit/bitable/formula/functions/lookup.py b/src/agentkit/bitable/formula/functions/lookup.py new file mode 100644 index 0000000..f3cf2f2 --- /dev/null +++ b/src/agentkit/bitable/formula/functions/lookup.py @@ -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, +} diff --git a/src/agentkit/bitable/formula/functions/text.py b/src/agentkit/bitable/formula/functions/text.py new file mode 100644 index 0000000..777e855 --- /dev/null +++ b/src/agentkit/bitable/formula/functions/text.py @@ -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, +} diff --git a/src/agentkit/bitable/formula/parser.py b/src/agentkit/bitable/formula/parser.py index e404e3b..c33fea6 100644 --- a/src/agentkit/bitable/formula/parser.py +++ b/src/agentkit/bitable/formula/parser.py @@ -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_`` (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_`` (single-field, row or aggregate context) + - ``{rel_field_id.target_field_id}`` → ``_ct___`` + (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__}") diff --git a/src/agentkit/bitable/models.py b/src/agentkit/bitable/models.py index c5e7d21..3c0f871 100644 --- a/src/agentkit/bitable/models.py +++ b/src/agentkit/bitable/models.py @@ -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) diff --git a/src/agentkit/bitable/recalc_worker.py b/src/agentkit/bitable/recalc_worker.py index d6b8497..e957f18 100644 --- a/src/agentkit/bitable/recalc_worker.py +++ b/src/agentkit/bitable/recalc_worker.py @@ -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. diff --git a/src/agentkit/bitable/repository.py b/src/agentkit/bitable/repository.py index afaee6d..e0cbffa 100644 --- a/src/agentkit/bitable/repository.py +++ b/src/agentkit/bitable/repository.py @@ -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()] diff --git a/src/agentkit/bitable/service.py b/src/agentkit/bitable/service.py index fa72ba3..f486e30 100644 --- a/src/agentkit/bitable/service.py +++ b/src/agentkit/bitable/service.py @@ -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 " ↩"). + + 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) diff --git a/src/agentkit/llm/gateway.py b/src/agentkit/llm/gateway.py index d82a728..6a47b22 100644 --- a/src/agentkit/llm/gateway.py +++ b/src/agentkit/llm/gateway.py @@ -1,5 +1,7 @@ """LLM Gateway - 统一 LLM 调用入口""" +from __future__ import annotations + import asyncio import logging import os diff --git a/src/agentkit/server/routes/bitable.py b/src/agentkit/server/routes/bitable.py index 63072af..e194198 100644 --- a/src/agentkit/server/routes/bitable.py +++ b/src/agentkit/server/routes/bitable.py @@ -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} diff --git a/src/agentkit/tools/bitable_tool.py b/src/agentkit/tools/bitable_tool.py index b6d760e..f9ff37c 100644 --- a/src/agentkit/tools/bitable_tool.py +++ b/src/agentkit/tools/bitable_tool.py @@ -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()} diff --git a/tests/unit/bitable/test_ae1_cross_table_recalc.py b/tests/unit/bitable/test_ae1_cross_table_recalc.py new file mode 100644 index 0000000..097175f --- /dev/null +++ b/tests/unit/bitable/test_ae1_cross_table_recalc.py @@ -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}" diff --git a/tests/unit/bitable/test_automation.py b/tests/unit/bitable/test_automation.py new file mode 100644 index 0000000..b01cc82 --- /dev/null +++ b/tests/unit/bitable/test_automation.py @@ -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 diff --git a/tests/unit/bitable/test_bitable_tool.py b/tests/unit/bitable/test_bitable_tool.py index 8b6b75b..0c27ea8 100644 --- a/tests/unit/bitable/test_bitable_tool.py +++ b/tests/unit/bitable/test_bitable_tool.py @@ -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 diff --git a/tests/unit/bitable/test_db.py b/tests/unit/bitable/test_db.py index 19026dc..3b374ef 100644 --- a/tests/unit/bitable/test_db.py +++ b/tests/unit/bitable/test_db.py @@ -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 diff --git a/tests/unit/bitable/test_formula_engine.py b/tests/unit/bitable/test_formula_engine.py index 1179b99..6610c1e 100644 --- a/tests/unit/bitable/test_formula_engine.py +++ b/tests/unit/bitable/test_formula_engine.py @@ -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 diff --git a/tests/unit/bitable/test_formula_parser.py b/tests/unit/bitable/test_formula_parser.py index a87df4c..ee3d867 100644 --- a/tests/unit/bitable/test_formula_parser.py +++ b/tests/unit/bitable/test_formula_parser.py @@ -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 diff --git a/tests/unit/bitable/test_functions_aggregate.py b/tests/unit/bitable/test_functions_aggregate.py new file mode 100644 index 0000000..a50f0dc --- /dev/null +++ b/tests/unit/bitable/test_functions_aggregate.py @@ -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) diff --git a/tests/unit/bitable/test_functions_datetime.py b/tests/unit/bitable/test_functions_datetime.py new file mode 100644 index 0000000..7f31063 --- /dev/null +++ b/tests/unit/bitable/test_functions_datetime.py @@ -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 diff --git a/tests/unit/bitable/test_functions_logical.py b/tests/unit/bitable/test_functions_logical.py new file mode 100644 index 0000000..6081f5d --- /dev/null +++ b/tests/unit/bitable/test_functions_logical.py @@ -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 diff --git a/tests/unit/bitable/test_functions_text.py b/tests/unit/bitable/test_functions_text.py new file mode 100644 index 0000000..481a576 --- /dev/null +++ b/tests/unit/bitable/test_functions_text.py @@ -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"] diff --git a/tests/unit/bitable/test_models.py b/tests/unit/bitable/test_models.py index cc330db..8bd4112 100644 --- a/tests/unit/bitable/test_models.py +++ b/tests/unit/bitable/test_models.py @@ -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 diff --git a/tests/unit/bitable/test_recalc.py b/tests/unit/bitable/test_recalc.py index 154593e..97e4f8a 100644 --- a/tests/unit/bitable/test_recalc.py +++ b/tests/unit/bitable/test_recalc.py @@ -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 diff --git a/tests/unit/bitable/test_relation_service.py b/tests/unit/bitable/test_relation_service.py new file mode 100644 index 0000000..58b9792 --- /dev/null +++ b/tests/unit/bitable/test_relation_service.py @@ -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}" + )