读写分离 / 脚手架 / 示例闭环 / 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)。