🏗️ FiberPHP ORM —— Active Record 模式,支持关联关系、预加载、属性转换、时间戳、查询作用域。

Maintainers

Package info

gitee.com/FiberPHP/orm

Issues

pkg:composer/fiberphp/orm

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

dev-master 2026-08-30 14:54 UTC

This package is auto-updated.

Last update: 2026-08-30 14:56:47 UTC


README

FiberPHP ORM —— 基于 fiberphp/database 连接池/SQL 抽象层的 Active Record 实现,支持属性处理、时间戳、全局作用域、关系映射与预加载,协程原生安全。

参考:命名风格借鉴 ThinkPHP 简短风格(Attr 获取器/修改器、Concern 名词 trait), 关系 API 保持行业惯例(hasOne/belongsTo/with 等)。

特性(P0,当前版本)

模块能力
Attributefillable/guarded 批量赋值、$casts 类型转换(json/int/bool/string)、获取器 getXxxAttr / 修改器 setXxxAttr
Timestampinsert/UPDATE 时自动填充 created_at / updated_at,值回写 attributes,子类可覆盖列名
Scopebase(Query) 全局作用域(多租户/SaaS 自动加条件,走 Context,天然协程隔离)+ scopeXxx() local scope
关系hasOne / hasMany / belongsTo / belongsToMany(多对多 + Pivot)/ hasManyThrough(跨表)/ morphOne / morphMany / morphTo(多态),外键与中间表名自动推断
预加载with() + Relation::eagerLoad() 实现,1+1 查询解决 N+1,批量查询后按外键映射回父实例
CRUDfind(id) / all() / save()(有 id→update,无→insert) / delete() / with() 链式
Event生命周期事件:creating/created/updating/updated/saving/saved/deleting/deleted/restoring/restored,基于 fiberphp/event,before 走 dispatch 可中断,after 走 emit 异常安全
ObserverObserverInterface + Observer 基类 + Model::observe() 批量注册 + OrmProvider 从 config 自动加载
SoftDeleteuse SoftDelete; 即启用,delete() 变软删除,restore()/forceDelete()/trashed()/withTrashed()/onlyTrashed(),全局作用域自动过滤
Collectionmap/filter/pluck/each/contains/first/toArray + groupBy/keyBy/sum/avg/max/min/find/flatMap/every/pipe
分页cursorPaginate(offset 游标,base64 编码偏移)+ keysetPaginate(keyset 游标,编码末行排序键值,级联 OR seek 翻页,并发写入不漏行不重复、性能与页码无关);均无需 COUNT,拉 perPage+1 探测 has_more,全局作用域 + with() 预加载一致生效
JSON 路径where('meta->>theme','dark') 跨方言路径取值查询(->> 文本 / -> JSON 值)+ whereJsonContains('meta->tags','php') 数组包含查询;由 Driver jsonPath()/jsonContains() 分发:MySQL ->>/json_contains、PG #>>/@>、SQLite json_extract/json_each,支持单层/嵌套路径
模型工厂Model::factory()->create()/make()/createMany() 配合 fakerphp/faker 生成假数据;工厂按约定 {Model}Factory 解析或 Factory::register() 注册,属性覆盖优先、修改器/转换器生效

快速开始

use FiberPHP\Orm\Model;
use FiberPHP\Orm\Query;

class User extends Model
{
    public const TABLE = 'users';
    public const PK = 'id';

    protected array $fillable = ['name', 'email', 'meta'];
    protected array $casts = ['meta' => 'json'];

    // 获取器(大写名字)
    protected function getNameAttr($value): string
    {
        return strtoupper($value);
    }

    // 修改器(小写邮箱)
    protected function setEmailAttr($value): string
    {
        return strtolower($value);
    }

    // 全局作用域:多租户只看当前租户
    public static function base(Query $query): void
    {
        $query->where('tenant_id', \FiberPHP\Context::get('tenant_id'));
    }

    // local scope
    public function scopeActive(Query $query): void
    {
        $query->where('status', 1);
    }

    // 关系
    public function profile() { return $this->hasOne(Profile::class); }
    public function posts()   { return $this->hasMany(Post::class); }
}

// 查单条(触发 base 过滤,结果触发获取器)
$user = User::find(1);
echo $user->name;  // 大写

// 预加载 posts(1+1 查询)
$users = User::with('posts')->active()->get();
foreach ($users as $u) {
    // $u->posts 已通过 eagerLoad 写入,无额外查询
    echo $u->posts->count();
}

// 插入(修改器 + created_at/updated_at 自动填充)
$u = new User();
$u->fill(['name' => 'Alice', 'email' => 'ALICE@EXAMPLE.COM']);
$u->save();
echo $u->id;
echo $u->email;  // alice@example.com(修改器生效)

// 更新 + 删除
$u->name = 'Alicia';
$u->save();
$u->delete();

生命周期事件

基于 fiberphp/event 实现,事件命名规范:

orm.{model_short}.{phase}
phase类型触发方式可中断写入
savingbeforedispatch + halt✅ 监听器返回 false 或抛异常
creatingbeforedispatch + halt
updatingbeforedispatch + halt
deletingbeforedispatch + halt
savedafteremit(异常安全)
createdafteremit
updatedafteremit
deletedafteremit

注册监听器

// 方式一:config/event.php 声明式
return [
    'orm.user.created' => [\App\Listener\UserAuditListener::class, 'onCreated'],
    'orm.user.*'      => fn($data) => logger()->info('user event', $data),
];

// 方式二:运行时注册
use FiberPHP\Event\Event;

Event::on('orm.user.saving', function (array $payload) {
    $model = $payload['model']; // User 实例
    // 返回 false 中断写入,抛异常也会中断
    if ($model->name === 'banned') {
        return false;
    }
});

触发时机

  • save()savingcreating/updating → 写入 → created/updatedsaved
  • delete()deleting → 删除 → deleted

观察者(Observer)

将一组生命周期方法封装为类,比闭包监听器更易维护。

注册方式

// 方式一:config/orm.php 声明式(OrmProvider::boot() 自动加载)
return [
    'observers' => [
        UserObserver::class  => [User::class, Admin::class],
        AuditObserver::class => [User::class, Order::class],
    ],
];

// 方式二:运行时注册
User::observe(UserObserver::class);

观察者类

use FiberPHP\Orm\Observer\Observer;
use FiberPHP\Orm\Model;

class UserObserver extends Observer
{
    public function creating(Model $model): ?bool
    {
        if ($model->email === 'banned@test.com') {
            return false; // before 类返回 false 中断写入
        }
        return null;
    }

    public function saved(Model $model): void
    {
        logger()->info('user saved', ['id' => $model->id]);
    }
}

触发顺序

fireModelEvent 先触发 fiberphp/event 监听器,再调用 Observer 方法。before 类中监听器或 Observer 返回 false 即中断写入。

多对多关系(belongsToMany)

// User 模型
class User extends Model
{
    public function roles()
    {
        return $this->belongsToMany(Role::class);
        // 约定:中间表 role_user,外键 user_id,关联外键 role_id
    }
}

// 自定义
return $this->belongsToMany(Role::class, 'user_role', 'user_id', 'role_id');

使用

// 懒加载
$user = User::find(1);
$roles = $user->roles;  // Collection<Role>

// 预加载
$users = User::with('roles')->get();

// pivot 数据访问
$role = $user->roles->first();
echo $role->pivot->user_id;  // 中间表行数据

软删除(SoftDelete)

模型 use SoftDelete; 即启用,delete() 变为 UPDATE deleted_at 而非真正 DELETE。

class User extends Model
{
    use SoftDelete;

    // 子类可覆盖列名
    public const DELETED_AT = 'deleted_at';
}

// 软删除(默认查询自动过滤 deleted_at IS NULL)
$user = User::find(1);
$user->delete();
User::find(1);  // → null(已被软删除过滤)

// 包含软删除记录
User::withTrashed()->find(1);  // → 找到

// 仅查软删除记录
User::onlyTrashed()->get();  // → Collection<软删除的 User>

// 恢复
$user->restore();
User::find(1);  // → 找到(已恢复)

// 强制删除(真正 DELETE)
$user->forceDelete();

// 判断是否已软删除
$user->trashed();  // → bool

// 事件:deleting → deleted(软删除)、restoring → restored(恢复)

多态关系(morphOne / morphMany / morphTo)

一个模型通过 {name}_type + {name}_id 列关联多种父模型。

// Post 拥有多个 Comment(morphMany)
class Post extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class, 'commentable');
        // 列:commentable_type='Post', commentable_id=post.id
    }
}

// Comment 属于 Post 或 User 等(morphTo)
class Comment extends Model
{
    public function commentable()
    {
        return $this->morphTo();
        // 方法名 'commentable' 即 morph name
        // 列:commentable_type, commentable_id
    }
}

使用

// 懒加载
$post = Post::find(1);
$post->comments;  // Collection<Comment>

$comment = Comment::find(1);
$comment->commentable;  // Post 实例(自动解析 commentable_type → 类名)

// 预加载
Post::with('comments')->get();
Comment::with('commentable')->get();  // 按类型分组批量查询

类型解析顺序

morphTo() 通过 commentable_type 值解析类名:

  1. morphMap() 静态映射表(子类覆盖)
  2. 同命名空间类名(CommentFiberPHP\Orm\Tests\CommentPost
  3. 作为完整类名直接加载

跨表关联(hasManyThrough)

A → B → C:A 通过中间模型 B 关联 C 的多条记录,无需在 C 上冗余 A 的外键。

// Country → User → Post
class Country extends Model
{
    public function posts()
    {
        return $this->hasManyThrough(Post::class, User::class);
        // 约定:users.country_id → countries.id
        //       posts.user_id    → users.id
    }
}

键约定

参数含义默认推断
firstKey中间表指向当前模型的外键(users.country_id)当前表名单数化 + _id
secondKey关联表指向中间模型的外键(posts.user_id)中间表名单数化 + _id
localKey当前模型主键(countries.id)static::PK
secondLocalKey中间模型主键(users.id)$through::PK

不规则复数:默认推断用 rtrim(TABLE, 's') 简单去尾 s,countries 会产出 countrie_id。 遇到 country→countries 等 ies 复数时,显式传 firstKeyhasManyThrough(Post::class, User::class, 'country_id')

使用

// 懒加载
$country = Country::find(1);
$country->posts;  // Collection<Post>

// 预加载(1+1 查询)
Country::with('posts')->get();

中间模型作用域

跨表查询走中间模型 B::query(),其全局作用域 base() 一致生效。 如 User::base() 过滤 tenant_id=1,则 Country::find(2)->posts 即使 C 有数据, 只要关联的 B 记录被作用域过滤掉,结果即为空——多租户/软删除在跨表关联中天然生效。

游标分页(cursorPaginate)

无需 COUNT 的游标分页,适合大数据集。每次按游标解码的起始偏移量拉取 perPage + 1 条, 多出的 1 条用于探测是否还有下一页;游标为 base64 编码的 JSON 偏移量,前端原样回传即可翻页。

// 首页
$paginator = User::query()->order('id', 'asc')->cursorPaginate(15);

$paginator->items();        // 当前页模型数组(已切片至 perPage)
$paginator->collection();   // 当前页 Collection
$paginator->hasMorePages(); // bool
$paginator->nextCursor();   // 下一页游标(末页为 null)
$paginator->previousCursor(); // 上一页游标(首页为 null)

// 翻下一页(前端把 next_cursor 回传)
$next = User::query()->order('id', 'asc')->cursorPaginate(15, $cursor);

// 序列化(data 为各模型 toArray)
$paginator->toArray();
// ['data' => [...], 'per_page' => 15, 'next_cursor' => '...', 'previous_cursor' => null, 'has_more' => true]

便捷入口

// 简单分页(无排序/预加载需求)
User::cursorPaginate(15);

// 需要排序或预加载时链式调用
User::with('posts')->order('id', 'asc')->cursorPaginate(15, $cursor);

设计要点

  • 全局作用域生效:分页取数前应用 base()/SoftDelete,与 get() 一致。
  • 预加载一致with() 在分页结果上同样避免 N+1。
  • 稳定排序:游标分页依赖确定顺序,调用前应 order() 指定排序列,否则翻页结果不确定。
  • offset 编码:当前实现以偏移量编码游标(非 keyset),适合中等规模数据;超大并发写入场景可后续扩展为基于排序列的 keyset 游标。

Keyset(seek)游标分页

适合高并发写入场景的分页:游标编码上一页末行的排序键值,翻页用 WHERE (键) OP (末行值) seek 跳过已读行——并发插入/删除不漏行不重复,且性能与页码无关(恒定走索引 seek),不像 LIMIT offset, n 随 offset 增大变慢。

// 默认按主键升序
$page = User::keysetPaginate(perPage: 10);

// 自定义排序(多列需末列唯一以稳定排序,如按时间倒序 + id 兜底)
$page = User::query()
    ->keysetPaginate(10, $cursor, ['created_at' => 'desc', 'id' => 'desc']);

// 预加载一致生效
$page = User::with('posts')->keysetPaginate(10, $cursor, ['id' => 'desc']);

// 翻页:首页 cursor 传 null,后续传上一页的 next_cursor
$next = User::keysetPaginate(10, $page->nextCursor(), ['id' => 'desc']);

// 序列化(data 为模型 toArray)
$page->toArray();  // ['data'=>..., 'per_page'=>10, 'next_cursor'=>..., 'previous_cursor'=>null, 'has_more'=>bool]

设计要点

  • 级联 OR seek:对排序列 [c1..cn] 与末行值 [v1..vn] 生成 (c1 OP1 v1) OR (c1=v1 AND c2 OP2 v2) OR ... OR (c1=v1 AND ... AND cn OPn vn), OPi 为 >(asc)或 <(desc)。通用形式,支持任意方向混合(含混合方向如 created_at DESC, id ASC)。
  • 游标值绑定:seek WHERE 的值经参数绑定(? 占位符),游标值虽来自库行仍走占位符防注入;列名经 parseKey 引用。
  • 末行键值编码nextCursor 取末行的原始库值getAttributes,未经获取器/转换器)以保证 seek 比较与库一致。
  • 首页与探测cursor=null 视作首页(不加 seek WHERE);拉 perPage+1 条,多出的 1 行探测 has_more,无需 COUNT。
  • 仅前向:仅支持 next_cursor 翻页;后向需反转排序方向,previous_cursor 恒为 null。
  • 排序列要求:多列排序须保证末列唯一(如 created_at, id,id 兜底)以稳定排序,避免相同键值边界漏行。
  • 选型:常规场景用 cursorPaginate(offset,支持双向翻页);高并发写入、深翻页、大数据集用 keysetPaginate

JSON 路径查询(方言感知)

JSON 字段(json cast 列)可用 field->>path / field->path 语法按路径取值查询,方言差异由各数据库 Driver 的 jsonPath() 承担,不在共享 Builder 硬编码:

驱动->>(文本,去引号,用于 = 比较)->(JSON 值,取子对象/数组)
MySQL`meta`->>'$.theme'`meta`->'$.theme'
PostgreSQL"meta" #>> '{theme}'"meta" #> '{theme}'
SQLitejson_extract("meta", '$.theme')json_extract("meta", '$.theme')
// 单层路径
User::query()->where('meta->>theme', 'dark')->get();

// 嵌套路径:meta->>prefs.locale 归一化为 prefs.locale → $.prefs.locale
User::query()->where('meta->>prefs.locale', 'en')->get();

// 数组下标 [n]
User::query()->where('meta->>tags.0', 'php')->get();

JSON 数组包含查询(whereJsonContains)

判断 JSON 数组(可选路径)是否包含某标量值,方言同样由驱动 jsonContains() 承担:

驱动实现
MySQLjson_contains(field, value[, '$.path'])
PostgreSQLfield @> value::jsonb(路径先 #>> 提取再转 jsonb)
SQLiteEXISTS (SELECT 1 FROM json_each(field[, '$.path']) WHERE "value" = json_extract(value, '$'))
// meta.tags 数组是否含 'php'
User::query()->whereJsonContains('meta->tags', 'php')->get();

// 整字段为 JSON 数组时省略路径
User::query()->whereJsonContains('meta', 'admin')->get();

// 数字成员同样可用(SQLite 经 json_extract 取去引号标量,不依赖列存类型)
User::query()->whereJsonContains('meta->scores', 20)->get();

// OR 版本
User::query()->whereOrJsonContains('meta->tags', 'php')->get();

设计要点

  • 单一入口Builder::parseKey() 识别 ->/->> 分支后委托 buildJsonPath()DriverInterface::jsonPath()whereJsonContains() 拆分路径后经 parseKey 引用字段并委托 DriverInterface::jsonContains()。路径归一化为点号分隔(a.b / tags.0 / [0]),各驱动转译为本地方言。
  • 文本 vs JSON 值->> 返回去引号文本(适合 = 比较),-> 返回 JSON 值(适合取子结构);SQLite 两者均用 json_extract(scalar 返回去引号值,复合类型返回 JSON 文本)。
  • NULL 安全:对 NULL 的 JSON 列,json_extract 返回 NULL,不误命中。
  • 作用域一致:路径查询与包含查询均经 where/whereRawbase() 全局作用域,与普通查询一致。

模型工厂(Factory + Faker)

用假数据构造并持久化模型,配合 fakerphp/faker 生成随机值(建议作为开发依赖安装:composer require --dev fakerphp/faker)。

// app/Factory/UserFactory.php
class UserFactory extends Factory
{
    protected string $model = User::class;

    protected function definition(): array
    {
        return [
            'name'  => $this->faker()->name(),
            'email' => $this->faker()->unique()->email(),
            'meta'  => ['theme' => $this->faker()->randomElement(['dark', 'light'])],
        ];
    }
}
$user  = User::factory()->create();                  // 构造并保存
$user  = User::factory()->make();                    // 仅构造不保存
$users = User::factory()->createMany(5);             // 批量保存(Collection)
$users = User::factory()->makeMany(5);               // 批量不保存
$user  = User::factory()->create(['name' => 'bob']);  // 属性覆盖(优先于 definition)

工厂解析

Model::factory()Factory::resolveFor(static::class),解析顺序:

  1. 显式注册:Factory::register(User::class, UserFactory::class)(命名空间与约定不符时用,建议在应用 Provider 中调用)
  2. 约定:同命名空间下的 {Model}Factory 类(如 App\UserApp\UserFactory

设计要点

  • Faker 懒加载$this->faker() 首次调用时按 $locale 创建 Faker\Generator 并按工厂实例缓存;未安装 fakerphp/faker 时抛异常提示安装。
  • 属性覆盖优先make()/create()$attributes 参数与 definition() 合并,传入值优先。
  • 修改器/获取器/转换器生效:工厂经 fill() 走完整 setAttribute 链,与手动构造一致。
  • 唯一性$this->faker()->unique()->email() 在同一工厂实例内累计去重,createMany() 自动产出唯一值。
  • 协程安全:工厂按单次使用设计,new() 每次返回新实例,Faker 生成器按实例缓存,避免跨协程共享有状态生成器。

协程安全设计

  • 实例不缓存静态:每次 find()/query() 返回全新实例,协程间零共享。
  • 关系缓存绑实例$user->posts 懒加载后写入当前实例的 $relations,实例销毁(协程结束)即自动清除。
  • 全局作用域走 Contextbase()FiberPHP\Context 读取 tenant_id,天然每协程独立。
  • DB 连接池:底层依赖 fiberphp/database 的 Workerman 协程连接池,多协程共享连接。

目录结构

orm/
├── src/
│   ├── Model.php                  # Active Record 基类
│   ├── Query.php                  # ORM 查询层(包装 database Query,加预加载/base)
│   ├── Collection.php             # 模型集合(groupBy/keyBy/sum/avg/...)
│   ├── CursorPaginator.php        # offset 游标分页器(继承底层,模型 Collection + toArray)
│   ├── KeysetPaginator.php        # keyset(seek)分页器(编码末行排序键值,级联 OR seek,前向翻页)
│   ├── Factory.php                # 模型工厂基类(make/create/createMany + Faker 懒加载 + 解析)
│   ├── Pivot.php                  # 中间表值对象(belongsToMany 附加)
│   ├── Concern/
│   │   ├── Attribute.php          # 属性处理(fillable/casts/获取器/修改器)
│   │   ├── Relation.php           # 关系入口(hasOne/hasMany/belongsTo/belongsToMany)
│   │   ├── Timestamp.php          # 时间戳自动填充
│   │   ├── Scope.php              # 全局/局部作用域
│   │   ├── Event.php              # 生命周期事件 + Observer 调度
│   │   └── SoftDelete.php         # 软删除(delete/restore/forceDelete/withTrashed)
│   ├── Relation/
│   │   ├── Relation.php           # 关系抽象基类(懒加载 + 预加载契约)
│   │   ├── HasOne.php
│   │   ├── HasMany.php
│   │   ├── BelongsTo.php
│   │   ├── BelongsToMany.php      # 多对多关系(pivot 表查询 + pivot 附加)
│   │   ├── HasManyThrough.php     # 跨表一对多(A → B → C)
│   │   ├── MorphOne.php           # 多态一对一
│   │   ├── MorphMany.php          # 多态一对多
│   │   └── MorphTo.php            # 多态从属(自动解析类型 → 类名)
│   ├── Observer/
│   │   ├── ObserverInterface.php  # 观察者契约(8 个生命周期方法)
│   │   └── Observer.php           # 观察者基类(空默认实现)
│   ├── OrmProvider.php            # boot() 从 config 加载 observers
│   └── Install.php
├── config/orm.php                 # observers 声明(P1 启用)
├── tests/
├── composer.json
└── README.md

后续规划

P0–P3 规划项均已完成,后续按需迭代。