新增: 知识图谱Phase1数据层(父②) + 修复 idea list_by_query 占位符(父⑤⑤.1)
父② 数据层(V29): - migrate_v29: tasks +queue/parent_id/content_json + task_links 表+双索引(幂等对标 v14/v24) - TaskRecord 加 3 字段(15→18 列,INSERT/SELECT×6/from_row/struct 同步)+ 8 构造点补默认 - TaskLinkRepo(新文件,CRUD + 循环依赖 BFS depends_on 链检测,14 测) - task_repo list_by_query queue/parent_id 筛选 + get_children + 聚合计数(10 测) - queue DEFAULT 'todo' 向后兼容;task_links 软删保留(无 ON DELETE) idea_repo list_by_query 占位符修复(父⑤⑤.1 引入的潜伏 bug): - 加 deleted_at IS NULL 恒带后 where_clauses.len()+1 偏移 → 非空 status/keyword 查询 rusqlite 错误 - 改 params_vec.len()+1(对标 task_repo ②.2 同款修复,父② agent 发现) df-storage 96 lib + 11 集成测试全过。
This commit is contained in:
@@ -33,6 +33,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
|
||||
review_rounds: row.get("review_rounds")?,
|
||||
output_json: row.get("output_json")?,
|
||||
idea_id: row.get("idea_id")?,
|
||||
// 知识图谱 Phase 1 V29 三列(queue/parent_id/content_json),15→18 列同步之一。
|
||||
queue: row.get("queue")?,
|
||||
parent_id: row.get("parent_id")?,
|
||||
content_json: row.get("content_json")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
@@ -54,6 +58,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
|
||||
///
|
||||
/// 当前仅 status(P1 下沉)+ keyword(P2 LIKE)在视图链路使用;project_id/priority/assignee/
|
||||
/// order_by/limit/offset 为基建就绪(视图暂不用,P3 排序分页待数据量增长)。
|
||||
///
|
||||
/// 知识图谱 Phase 1 V29(对标设计 §2.1)新增 queue / parent_id 两个筛选维度:
|
||||
/// - `queue`:管理池筛选(backlog/todo/decision/active/done),看板视图按池分列的数据源;
|
||||
/// - `parent_id`:父任务筛选(Some(id)=某父的子任务;特殊语义 None 仅叶子 vs 全部 由调用方拼)。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TaskQuery {
|
||||
/// 项目 ID 过滤(SQL 下推,命中 idx_tasks_project_id)
|
||||
@@ -66,6 +74,14 @@ pub struct TaskQuery {
|
||||
pub assignee: Option<String>,
|
||||
/// 关键词搜索(P2):title/description LIKE %kw%,对齐知识库 search 的 LIKE 模式。
|
||||
pub keyword: Option<String>,
|
||||
/// 管理池过滤(知识图谱 Phase 1 V29):backlog/todo/decision/active/done。
|
||||
/// 看板视图按池分列的数据源。值合法性由上层 queue 语义校验兜底(非法值 DB 无匹配返回空)。
|
||||
#[serde(default)]
|
||||
pub queue: Option<String>,
|
||||
/// 父任务 ID 过滤(知识图谱 Phase 1 V29):Some(id) = 查某父任务的子任务;
|
||||
/// 查叶子任务(parent_id IS NULL)由专用方法 get_children 之外的语义决定,本字段只做等值匹配。
|
||||
#[serde(default)]
|
||||
pub parent_id: Option<String>,
|
||||
/// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。
|
||||
pub order_by: Option<String>,
|
||||
/// 分页上限(钳制 ≤500)。P3 基建就绪。
|
||||
@@ -103,22 +119,26 @@ impl_repo!(
|
||||
from_row => |row| task_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
params![
|
||||
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
||||
rec.review_rounds, rec.output_json, rec.idea_id, rec.created_at, rec.updated_at
|
||||
rec.review_rounds, rec.output_json, rec.idea_id,
|
||||
rec.queue, rec.parent_id, rec.content_json,
|
||||
rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
conn.execute(
|
||||
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, updated_at = ?13 WHERE id = ?14",
|
||||
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17",
|
||||
params![
|
||||
rec.project_id, rec.title, rec.description, rec.status, rec.priority,
|
||||
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
|
||||
rec.review_rounds, rec.output_json, rec.idea_id, rec.updated_at, rec.id
|
||||
rec.review_rounds, rec.output_json, rec.idea_id,
|
||||
rec.queue, rec.parent_id, rec.content_json,
|
||||
rec.updated_at, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -127,14 +147,14 @@ impl_repo!(
|
||||
impl TaskRepo {
|
||||
/// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active
|
||||
///
|
||||
/// 显式列出全部 15 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
|
||||
/// 显式列出全部 18 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
|
||||
/// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。
|
||||
pub async fn list_active(&self) -> Result<Vec<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| task_from_row(row))
|
||||
@@ -235,7 +255,7 @@ impl TaskRepo {
|
||||
}
|
||||
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE id = ?1")
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE id = ?1")
|
||||
.map_err(storage_err)?;
|
||||
let row = stmt
|
||||
.query_row(params![id], |row| task_from_row(row))
|
||||
@@ -257,7 +277,7 @@ impl TaskRepo {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pid], |row| task_from_row(row))
|
||||
@@ -284,7 +304,7 @@ impl TaskRepo {
|
||||
/// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致)
|
||||
/// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路)
|
||||
/// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭)
|
||||
/// - 显式列出全部 15 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列)
|
||||
/// - 显式列出全部 18 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列)
|
||||
///
|
||||
/// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。
|
||||
/// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集
|
||||
@@ -311,35 +331,51 @@ impl TaskRepo {
|
||||
let priority = query.priority;
|
||||
let assignee = query.assignee.clone();
|
||||
let keyword = query.keyword.clone();
|
||||
let queue = query.queue.clone();
|
||||
let parent_id = query.parent_id.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
|
||||
// ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = 已有子句数+1)──
|
||||
// ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = params_vec.len()+1)──
|
||||
// 占位符序号必须按「实际参数位置」(params_vec.len()+1)而非「子句数」(where_clauses.len()+1)
|
||||
// 编号:deleted_at IS NULL 是常量条件无占位符却占 where_clauses[0],用子句数编号会让首个
|
||||
// 真参数拿到 ?2 而 params_vec 只有 1 个元素 → rusqlite "needed 2, got 1"。
|
||||
// 用 params_vec.len()+1 保证 ?N 与参数位置严格对齐(N = 参数序号)。
|
||||
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。
|
||||
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(pid) = &project_id {
|
||||
where_clauses.push(format!("project_id = ?{}", where_clauses.len() + 1));
|
||||
where_clauses.push(format!("project_id = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(pid.clone()));
|
||||
}
|
||||
if let Some(s) = &status {
|
||||
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1));
|
||||
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(s.clone()));
|
||||
}
|
||||
if let Some(p) = priority {
|
||||
where_clauses.push(format!("priority = ?{}", where_clauses.len() + 1));
|
||||
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(p));
|
||||
}
|
||||
if let Some(a) = &assignee {
|
||||
where_clauses.push(format!("assignee = ?{}", where_clauses.len() + 1));
|
||||
where_clauses.push(format!("assignee = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(a.clone()));
|
||||
}
|
||||
// queue(知识图谱 Phase 1 V29):管理池等值过滤,看板视图按池分列数据源
|
||||
if let Some(q) = &queue {
|
||||
where_clauses.push(format!("queue = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(q.clone()));
|
||||
}
|
||||
// parent_id(知识图谱 Phase 1 V29):父任务等值过滤(查某父的子任务)
|
||||
if let Some(pid) = &parent_id {
|
||||
where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1));
|
||||
params_vec.push(Box::new(pid.clone()));
|
||||
}
|
||||
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
|
||||
if let Some(kw) = &keyword {
|
||||
let pat = format!("%{kw}%");
|
||||
let p1 = where_clauses.len() + 1;
|
||||
let p1 = params_vec.len() + 1;
|
||||
let p2 = p1 + 1;
|
||||
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
|
||||
params_vec.push(Box::new(pat.clone()));
|
||||
@@ -358,10 +394,13 @@ impl TaskRepo {
|
||||
};
|
||||
|
||||
// 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。
|
||||
// 显式列出全部 18 列(含 V29 queue/parent_id/content_json,不 SELECT deleted_at:
|
||||
// TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
|
||||
let sql = format!(
|
||||
"SELECT id, project_id, title, description, status, priority, branch_name, \
|
||||
assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, \
|
||||
created_at, updated_at FROM tasks WHERE {} ORDER BY {} DESC{}",
|
||||
queue, parent_id, content_json, created_at, updated_at \
|
||||
FROM tasks WHERE {} ORDER BY {} DESC{}",
|
||||
where_clauses.join(" AND "),
|
||||
order_col,
|
||||
limit_sql_bound
|
||||
@@ -393,6 +432,74 @@ impl TaskRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
|
||||
///
|
||||
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status
|
||||
/// 不走状态机,由子任务聚合计算。本方法取子任务列表供聚合规则消费。
|
||||
///
|
||||
/// 注:嵌套深度限制 1 级(无孙任务)由 IPC 层校验不进 DB 约束,本方法只做等值查询。
|
||||
pub async fn get_children(&self, parent_id: &str) -> Result<Vec<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let pid = parent_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND parent_id = ?1 ORDER BY created_at ASC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pid], |row| task_from_row(row))
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 父任务聚合:按 status 分组统计子任务计数(对标设计 §2.1 聚合规则)。
|
||||
///
|
||||
/// 父任务 status 由子任务聚合计算(不走状态机),聚合规则:
|
||||
/// - 全 todo → 父 todo
|
||||
/// - 任一 in_progress → 父 in_progress
|
||||
/// - 任一 blocked → 父 blocked
|
||||
/// - 全 done/cancelled → 父 done
|
||||
///
|
||||
/// 本方法返回 `Vec<(status, count)>`(SQL GROUP BY 一次查询,数据量小 ~50 无压力),
|
||||
/// 聚合规则的具体判定由调用方(commands 层)实现 —— 本层只提供原始计数,不持有
|
||||
/// 业务聚合逻辑(CRUD 层只懂表/列语义,对标 B-260616-16 跨表校验下沉思路)。
|
||||
pub async fn count_children_by_status(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
) -> Result<Vec<(String, i64)>> {
|
||||
let conn = self.conn.clone();
|
||||
let pid = parent_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT status, COUNT(*) AS cnt FROM tasks \
|
||||
WHERE deleted_at IS NULL AND parent_id = ?1 \
|
||||
GROUP BY status",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![pid], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。
|
||||
///
|
||||
/// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id),
|
||||
@@ -402,7 +509,7 @@ impl TaskRepo {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
|
||||
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| task_from_row(row))
|
||||
@@ -417,3 +524,215 @@ impl TaskRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — 知识图谱 Phase 1:queue/parent_id 筛选 + get_children(内存 DB)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::models::ProjectRecord;
|
||||
|
||||
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
|
||||
fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord {
|
||||
trec_full(id, queue, parent_id, "todo")
|
||||
}
|
||||
|
||||
/// 全参 fixture(聚合测试需自定义 status 时用)。
|
||||
fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: &str) -> TaskRecord {
|
||||
TaskRecord {
|
||||
id: id.to_string(),
|
||||
project_id: "proj-1".to_string(),
|
||||
title: format!("task-{id}"),
|
||||
description: String::new(),
|
||||
status: status.to_string(),
|
||||
priority: 1,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
queue: queue.to_string(),
|
||||
parent_id: parent_id.map(|s| s.to_string()),
|
||||
content_json: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造内存 DB + 占位 project(满足 tasks.project_id FK,PRAGMA foreign_keys=ON)。
|
||||
async fn setup() -> TaskRepo {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let project_repo = ProjectRepo::new(&db);
|
||||
project_repo
|
||||
.insert(ProjectRecord {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
TaskRepo::new(&db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_query_queue_filter() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("t1", "backlog", None)).await.unwrap();
|
||||
repo.insert(trec("t2", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("t3", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("t4", "done", None)).await.unwrap();
|
||||
|
||||
// queue=todo → 只返回 t2/t3
|
||||
let q = TaskQuery {
|
||||
queue: Some("todo".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let res = repo.list_by_query(&q).await.unwrap();
|
||||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert!(ids.contains(&"t2"));
|
||||
assert!(ids.contains(&"t3"));
|
||||
|
||||
// queue=backlog → 只返回 t1
|
||||
let q = TaskQuery {
|
||||
queue: Some("backlog".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let res = repo.list_by_query(&q).await.unwrap();
|
||||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["t1"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_query_parent_id_filter() {
|
||||
let repo = setup().await;
|
||||
// parent 父任务(叶子,parent_id=None)+ 3 子任务(parent_id=parent)
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
|
||||
repo.insert(trec("c2", "todo", Some("parent"))).await.unwrap();
|
||||
repo.insert(trec("orphan", "todo", None)).await.unwrap();
|
||||
|
||||
// parent_id=parent → 只返回 c1/c2(父任务自身不匹配)
|
||||
let q = TaskQuery {
|
||||
parent_id: Some("parent".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let res = repo.list_by_query(&q).await.unwrap();
|
||||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert!(ids.contains(&"c1"));
|
||||
assert!(ids.contains(&"c2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_query_queue_and_parent_id_combined() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
|
||||
repo.insert(trec("c2", "backlog", Some("parent"))).await.unwrap();
|
||||
|
||||
// queue=todo AND parent_id=parent → 只 c1(c2 是 backlog)
|
||||
let q = TaskQuery {
|
||||
queue: Some("todo".to_string()),
|
||||
parent_id: Some("parent".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let res = repo.list_by_query(&q).await.unwrap();
|
||||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["c1"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_query_empty_returns_all_active() {
|
||||
// 空 query(全 None)→ 等价 list_active 全量未删,向后兼容
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("t1", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("t2", "backlog", None)).await.unwrap();
|
||||
|
||||
let res = repo.list_by_query(&TaskQuery::default()).await.unwrap();
|
||||
assert_eq!(res.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_children_returns_only_direct_children() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
|
||||
repo.insert(trec("c2", "in_progress", Some("parent"))).await.unwrap();
|
||||
repo.insert(trec("orphan", "todo", None)).await.unwrap();
|
||||
|
||||
let children = repo.get_children("parent").await.unwrap();
|
||||
let ids: Vec<_> = children.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert!(ids.contains(&"c1"));
|
||||
assert!(ids.contains(&"c2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_children_empty_when_no_children() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
let children = repo.get_children("parent").await.unwrap();
|
||||
assert!(children.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_children_by_status_groups() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
// 4 个子任务:status 分布 todo×2 / in_progress×1 / done×1(queue 统一 todo)
|
||||
repo.insert(trec_full("c1", "todo", Some("parent"), "todo"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert(trec_full("c2", "todo", Some("parent"), "todo"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert(trec_full("c3", "todo", Some("parent"), "in_progress"))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert(trec_full("c4", "todo", Some("parent"), "done"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let counts = repo.count_children_by_status("parent").await.unwrap();
|
||||
// 转 map 便于断言(顺序由 GROUP BY 决定,不依赖)。父任务自身不计入(parent_id 非自身)。
|
||||
let map: std::collections::HashMap<String, i64> = counts.into_iter().collect();
|
||||
assert_eq!(map.get("todo"), Some(&2));
|
||||
assert_eq!(map.get("in_progress"), Some(&1));
|
||||
assert_eq!(map.get("done"), Some(&1));
|
||||
assert!(!map.contains_key("cancelled"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_children_by_status_empty_when_no_children() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
let counts = repo.count_children_by_status("parent").await.unwrap();
|
||||
assert!(counts.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_children_excludes_soft_deleted() {
|
||||
// 软删子任务不进 get_children 结果(deleted_at IS NULL 过滤)
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
|
||||
repo.insert(trec("c2", "todo", Some("parent"))).await.unwrap();
|
||||
repo.soft_delete("c2").await.unwrap();
|
||||
|
||||
let children = repo.get_children("parent").await.unwrap();
|
||||
let ids: Vec<_> = children.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["c1"], "软删子任务应被过滤");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user