mtphp 文档

读写分离 / 脚手架 / 示例闭环 / Query 进阶(Phase 4)

26.1 读写分离(写主读从,按语句类型自动路由)

Mtphp\Database\ReadWriteConnection 实现 Connector 契约,对上层(DB / Query / Model)完全透明:读走读库、写与事务走写库、事务内读强制走主(保证读己写可见)。

开启方式(config/database.php):默认单连接退化为旧行为,仅在配置 read 时启用。

// config/database.php
'read' => ['mysql_read'],   // 读连接名(可填多个,自动轮询负载)
// 'write' => 'mysql',      // 写连接名(默认即 default)

启用后,DB::table() / DB::select() 等读操作自动落到 mysql_read;insert/update/delete 与事务仍走 mysql。多读库时按轮询做负载均衡;读库为空则回落写库。

说明:本实现不做跨请求 sticky(避免 FPM 长生命周期连接把“写过即读主”状态泄漏到后续请求);需要“读己写”一致性时,把读写包在 DB::transaction() 内即可。

26.2 脚手架命令 make:seeder / make:controller

php bin/mtphp make:seeder UserSeeder              # 生成 app/Seeders/UserSeeder.php
php bin/mtphp make:seeder Users/AdminSeeder      # 支持子目录
php bin/mtphp make:controller User               # 生成 app/Controllers/UserController.php
php bin/mtphp make:controller Api/UserController # 支持子目录

生成的是最小可用骨架(含命名空间与 use、示例方法),已自动写入 bin/mtphp 命令清单。

26.3 示例闭环:迁移 → 填充 → 读取

框架自带一条示例迁移与填充,形成可跑闭环:

php bin/mtphp migrate     # 执行 database/migrations/2026_10_11_000001_create_users_table.php 建 users 表
php bin/mtphp seed       # 运行 App\Seeders\DatabaseSeeder → UserSeeder(先清后写,可重复执行)

随后访问 GET /demo/users 即可读取演示数据(无可用数据库时返回 422 风格的错误 JSON,提示先 migrate/seed,不致命)。database/migrations 下按文件名升序执行,迁移文件约定 return new class extends Migration { public function up():void{...} public function down():void{...} }。

26.4 Query 进阶:chunk / whereDate / 嵌套 orWhere 分组

// 大结果集分批(按主键游标,优于 LIMIT offset 偏移);回调返回 false 可提前终止
DB::table('order')->orderBy('id')->chunk(500, function (array $rows) {
    foreach ($rows as $row) { /* ... */ }
});

// 按日期部分比较
DB::table('order')->whereDate('created_at', '2026-10-11')->get();
DB::table('order')->whereDate('created_at', '>=', '2026-01-01')->get();

// 嵌套 OR 分组:(a = 1) AND (b = 2 OR c = 3)
DB::table('user')
    ->where('status', 1)
    ->whereGroup(fn ($q) => $q->where('role', 'admin')->orWhere('role', 'vip'))
    ->get();
// 等价的 OR 开头分组:
DB::table('user')->whereGroup(fn ($q) => $q->where('a',1)->orWhere('b',2), 'OR');

26.5 JSON 字段查询与全文检索

// JSON 文档内路径比较(跨数据库可移植:MySQL JSON_EXTRACT / SQLite json_extract)
// 路径可传 'name'(自动补 $.)或 '$.profile.age'(原样使用)
DB::table('user')->whereJson('meta', 'name', '=', 'neo')->get();
DB::table('user')->whereJson('meta', 'age', '>', 35)->get();   // 数值比较(自动 CAST 修正亲和性)
DB::table('user')->whereJson('meta', 'name', 'LIKE', '%o%')->get();

// 路径缺失 / 存在判断(键不存在时 json_extract 为 NULL)
DB::table('user')->whereJsonIsNull('meta', 'phone')->get();
DB::table('user')->whereJsonNotNull('meta', 'phone')->get();

// 全文检索(MySQL 目标:MATCH(col) AGAINST):普通列需 MySQL,SQLite 仅 FTS5 虚拟表支持
DB::table('post')->whereFullText(['title', 'body'], '框架 路由')->get();           // 自然语言模式
DB::table('post')->whereFullText(['title'], '框架 +路由', 'boolean')->get();       // 布尔模式

注:whereJson / whereJsonIsNull / whereFullText 同时适用于 DB::table() 与 Model::query()(继承同一 Query)。whereFullText 是 MySQL 原生能力,非 MySQL 后端执行会报语法错(与悲观锁策略一致,toSql() 始终可断言生成的 SQL 形态)。

26.6 子查询、集合操作与原生 HAVING

// EXISTS / NOT EXISTS 子查询(闭包 / Query 实例 / 原生 SQL 均可;闭包接收同连接新 Query)
DB::table('user')->whereExists(fn ($q) => $q->table('admins')->where('id', 1))->get();
DB::table('user')->whereNotExists(fn ($q) => $q->table('banned')->where('id', 1))->get();

// 集合操作 UNION(去重)/ UNION ALL(保留重复):分支可为 Query / 闭包 / 原生 SQL
DB::table('a')->where('x', 1)->union(DB::table('b')->where('x', 2))->get();
DB::table('a')->where('x', 1)->unionAll(fn ($q) => $q->table('a')->where('x', 1))->get();

// 原生 HAVING 片段(参数化防注入):配合 groupBy 做聚合后过滤
DB::table('order')->select('user_id', 'SUM(amount) AS total')
    ->groupBy('user_id')->havingRaw('SUM(amount) > ?', [1000])->get();

注:子查询与 UNION 分支共享主查询的连接与表前缀;绑定参数按“主查询 → 各分支”顺序自动合并。havingRaw 与 whereExists 同样适用于 Model::query()。

以上全部能力均配套零依赖单测(见 tests/,php tests/run.php 一键跑全量,全绿)。

26.7 Query 构造器补全(列间比较 / 追加选择 / 原生排序 / 连接 / 抽取 / 条件构造)

// 列间比较:WHERE a op b(两侧均为列名,不绑定占位符)
DB::table('cmp')->whereColumn('a', '>', 'b')->get();
DB::table('cmp')->whereColumn('a', '<', 'b')->orWhereColumn('a', '=', 'b')->get();

// 在已有 SELECT 上追加列(与 select() 的“替换”语义不同)
DB::table('user')->select('id')->addSelect('name')->where('id', 1)->first(); // ['id','name']

// 原生 ORDER BY(用于排序表达式 / 聚合 / 随机 / 自定义顺序;支持绑定)
DB::table('user')->orderByRaw('RAND()')->get();
DB::table('user')->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [3])->get();

// 连接:INNER(join)/ LEFT / RIGHT / CROSS
DB::table('user')->leftJoin('profile', 'user.id = profile.user_id')->get();   // 右表缺则补 NULL
DB::table('user')->rightJoin('profile', 'user.id = profile.user_id');          // 仅 SQL 生成
DB::table('user')->crossJoin('tag')->get();                                    // 笛卡尔积

// 抽取单列(或“列→键”映射)
DB::table('user')->orderBy('id')->pluck('name');        // ['alice','bob',...]
DB::table('user')->orderBy('id')->pluck('name', 'id');  // [1=>'alice', 2=>'bob', ...]

// 条件式构造:条件为真执行回调,否则(可选)执行 default,始终返回构造器自身
DB::table('user')->when($name, fn ($q) => $q->where('name', $name))->get();
DB::table('user')->when($age,  fn ($q) => $q->where('age','>',$age),
                                fn ($q) => $q->where('age','>',0))->get();

// 旁路钩子:执行副作用(记日志 / dump)后返回构造器自身,不改变查询
DB::table('user')->tap(fn ($q) => logger($q->toSql()))->get();

leftJoin / rightJoin / crossJoin 是 join($table,$on,$type) 的语义化封装(RIGHT 在 SQLite 下仅生成 SQL、不执行);orderByRaw 的绑定参数随主查询序列自动合并;whereColumn 片段原样透传不做反引号。以上方法均同时作用于 Model::query()(模型构造器继承自 Query)。

26.8 条件构造补全(嵌套分组 / whereIn 子查询 / 批量删除)

// 闭包嵌套 WHERE 分组:整体括号包裹,再与外层按 AND / OR 连接
DB::table('user')
    ->where('age', '>', 18)
    ->where(function ($q) {
        $q->where('vip', 1)->orWhere('score', '>', 100);
    });
// WHERE age > ? AND (vip = ? OR score > ?)

DB::table('user')->where('vip', 0)->orWhere(function ($q) {
    $q->where('score', '>', 150);
});
// WHERE vip = ? OR (score > ?)

// whereIn / whereNotIn 支持子查询(闭包 / Query 实例 / 原生 SQL)
DB::table('user')->whereIn('id', function ($q) {
    $q->table('vip')->select('user_id');
});
DB::table('user')->whereIn('id', DB::table('vip')->select('user_id'));
DB::table('post')->whereNotIn('user_id', fn ($q) => $q->table('banned')->select('id'));

// 按主键批量删除(数组 / 单值 / 逗号串;命中写前事件返回 false 时整批中止)
Post::destroy([1, 2, 3]);
Post::destroy(5);
Post::destroy('7,8,9');

where() / orWhere() 在首参为闭包时自动转为嵌套分组;whereIn / whereNotIn 的子查询形态与 whereExists 共用子查询编译(闭包接收同连接新 Query,自动带表前缀,绑定按主查询顺序合并)。Model::destroy() 复用已接通模型事件的删除路径。

26.9 派生表(FROM 子查询)/ orHaving / from 别名

// FROM 派生表:以子查询结果作为源表,须指定别名;闭包 / Query 实例 / 原生 SQL 均可
DB::table('tmp')->fromSub(function ($q) {
    $q->from('orders')->select('user_id', 'SUM(amount) AS total')->groupBy('user_id');
}, 'o')->where('o.total', '>', 100)->get();

$sub = DB::table('orders')->select('user_id', 'SUM(amount) AS total')->groupBy('user_id');
DB::table('tmp')->fromSub($sub, 'o')->where('o.total', '>=', 100)->get();

// 原生派生表(逃生舱):手写 SQL 作源表,参数化防注入
DB::table('tmp')->fromRaw(
    '(SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id)', [], 'o'
)->where('o.total', '<', 100)->get();

// HAVING 的“逻辑或”版本(语法同 having)
DB::table('orders')->select('user_id', 'SUM(amount) AS total')
    ->groupBy('user_id')->having('total', '>', 100)->orHaving('total', '<', 10)->get();

// from() 是 table() 的同义别名(FROM 子句的自然命名)
DB::table('user')->from('user')->where('id', 1)->first(); // 等价于 table('user')

派生表的子查询与主查询共享连接与表前缀,其绑定参数按“先子查询、后主查询”顺序自动合并;外层可用 where / orderBy 引用派生表别名列(如 o.total)。fromSub / fromRaw / orHaving / from 均同时作用于 Model::query()。from() 的加入同时补全了子查询闭包内更自然的 from() 写法。

26.10 模型作用域 + 多态关联 + 远程一对多

// —— 本地作用域 ——
class Post extends Model {
    public function scopePublished($q)            { $q->where('published', 1); }
    public function scopePopular($q, int $views)  { $q->where('views', '>=', $views); }
}
Post::published()->get();                 // 静态调用
Post::popular(100)->get();                // 带参数(>= 100)
Post::query()->published()->latest()->get();

// —— 全局作用域(每次查询自动套用)——
Post::addGlobalScope('adult', fn ($q) => $q->where('age', '>=', 18));
Post::all();                             // 自动附带 age >= 18
Post::withoutGlobalScope('adult')->get(); // 临时移除此作用域
Post::withoutGlobalScopes()->get();      // 临时移除全部(如后台总览)

// —— 多态关联(评论可被 Post / Video 共享)——
class Post extends Model {
    public function comments() { return $this->morphMany(Comment::class, 'commentable'); }
    public function tags()     { return $this->morphToMany(Tag::class, 'taggable', 'taggables', 'tag_id'); }
}
class Comment extends Model {
    public function commentable() { return $this->morphTo(); } // 反查出真正父模型
}
Post::with('comments')->get();           // 预加载(按 commentable_type 隔离)
Post::withCount('comments')->get();      // 附 comments_count
$comment = Comment::with('commentable')->find(1); // 按类型列分组取回父模型

// 多态类型映射(让 type 列存别名而非完整类名)
Model::setMorphMap(['post' => Post::class, 'video' => Video::class]);

// —— 远程一对多(国家 → 经用户 → 城市)——
class Country extends Model {
    public function cities() { return $this->hasManyThrough(City::class, User::class, 'country_id', 'user_id'); }
}
Country::with('cities')->get();          // 预加载(跨两跳)
Country::whereHas('cities', fn ($q) => $q->where('name', 'bj'))->get();

多态关联默认 type 列存模型完整类名;setMorphMap 可改为简短别名并自动维护反查索引。hasManyThrough 的中间表外键默认取「父 / 中间模型」的 foreignKeyName,也可用显式参数覆盖(如上例的 country_id / user_id)。