1. 问题现象与背景分析
最近在FastAdmin框架(基于ThinkPHP5)开发时遇到一个典型问题:在进行关联模型查询时,软删除(soft delete)功能意外失效。具体表现为使用with()或join()关联查询时,即使主表数据已被软删除,仍然能被查询出来。
这种情况在管理后台开发中尤为常见。比如我们有个文章表(article)和分类表(category),文章表设置了软删除字段delete_time,但当通过分类关联查询文章时,已被删除的文章仍然会出现在结果集中。
2. 软删除机制原理解析
2.1 ThinkPHP5的软删除实现
ThinkPHP5通过SoftDeletetrait实现软删除功能,核心逻辑是:
- 数据表需要
delete_time字段(默认名,可配置) - 删除操作变为更新操作,将
delete_time设为当前时间 - 查询时自动加上
delete_time IS NULL条件
// 典型用法 use traits\model\SoftDelete; class Article extends Model { use SoftDelete; protected $deleteTime = 'delete_time'; }2.2 关联查询的特殊性
当进行关联查询时,ThinkPHP5会生成类似这样的SQL:
SELECT * FROM category LEFT JOIN article ON category.id = article.category_id WHERE category.id = 1问题在于:软删除的自动条件article.delete_time IS NULL没有被自动加上。
3. 解决方案深度剖析
3.1 方案一:全局设置关联软删除(推荐)
修改关联模型定义,显式声明需要应用软删除:
// Category模型中 public function articles() { return $this->hasMany('Article')->withTrashed(false); }关键点:
withTrashed(false)表示关联查询也应用软删除规则- 需要在所有关联定义中添加此设置
- FastAdmin中建议在对应的模型文件中修改
3.2 方案二:查询时临时设置
在具体查询时动态指定:
Category::with(['articles' => function($query){ $query->where('article.delete_time', null); }])->select();优势:
- 灵活性高,可针对不同场景设置
- 不影响其他关联查询
3.3 方案三:修改底层逻辑(高级)
继承修改Query类:
// 新建extend/traits/SoftDelete.php trait SoftDelete { protected function base($query) { if ($query->getOptions('with')) { foreach ($query->getOptions('with') as $relation) { $this->checkRelationSoftDelete($query, $relation); } } return parent::base($query); } }注意:此方案需要较强的框架理解能力,不建议新手直接使用
4. FastAdmin环境下的特殊处理
4.1 后台列表关联查询
FastAdmin的CRUD控制器中,修改index方法:
protected function indexBuilder() { return $this->model ->with(['articles' => function($query){ $query->whereNull('delete_time'); }]) ->where($this->getWhere()); }4.2 表格渲染适配
修改对应的index.html模板:
{foreach $row.articles as $article} {if !$article.delete_time} <!-- 正常显示内容 --> {/if} {/foreach}5. 常见问题排查指南
5.1 检查清单
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 关联查询返回已删除数据 | 未设置withTrashed | 方案一或方案二 |
| 部分关联数据缺失 | 误用withTrashed(true) | 检查关联定义 |
| 分页总数不正确 | 统计时未过滤软删除 | 使用fetchSql调试 |
5.2 调试技巧
- 获取最终SQL:
echo $model->fetchSql(true)->with('articles')->find();- 检查模型继承链:
print_r(class_parents($model));- 验证软删除字段:
dump($model->getDeleteTimeField());6. 性能优化建议
- 索引优化:
ALTER TABLE `article` ADD INDEX `idx_category_delete` (`category_id`, `delete_time`);- 关联查询替代方案:
// 代替with,使用join+where组合 $list = Category::alias('c') ->join('article a', 'c.id = a.category_id AND a.delete_time IS NULL') ->select();- 缓存策略:
$result = Cache::remember('category_list', function(){ return Category::with(['articles' => function($query){ $query->whereNull('delete_time'); }])->select(); }, 3600);7. 扩展应用场景
7.1 多层级关联处理
当存在多层关联时(如分类→文章→评论),需要逐级设置:
Category::with(['articles.comments' => function($query){ $query->whereNull('delete_time'); }])->select();7.2 动态软删除条件
根据不同业务场景动态调整:
$withDeleted = input('param.show_deleted'); Article::with(['comments' => function($query) use ($withDeleted){ if (!$withDeleted) { $query->whereNull('delete_time'); } }]);7.3 与其他查询条件组合
复杂查询示例:
Article::with(['user' => function($query){ $query->whereNull('delete_time') ->where('status', 'normal') ->field('id,nickname'); }])->where('create_time', '>', '2023-01-01') ->order('view_count DESC') ->select();8. 最佳实践总结
- 一致性原则:
- 所有关联模型统一使用软删除
- 数据库字段命名规范统一(建议都用delete_time)
- 代码规范建议:
/** * 关联文章 * @return \think\model\relation\HasMany */ public function articles() { return $this->hasMany('Article') ->whereNull('delete_time') ->field('id,category_id,title'); }- 测试用例设计:
public function testSoftDeleteWithRelation() { $article = Article::find(1); $article->delete(); // 软删除 $category = Category::with('articles')->find(1); $this->assertEmpty($category->articles); // 应返回空数组 }在实际项目开发中,我发现这个问题最容易在以下场景被忽略:
- 从简单查询升级到关联查询时
- 接手他人代码进行功能扩展时
- 快速开发原型阶段忽略细节时
建议在项目初期就建立完善的模型关联测试套件,特别是对数据状态的测试要全面覆盖软删除场景。