学完这篇,你能从零写出一个两百行左右、能直接塞进项目的轻量 ORM 骨架,并且搞清楚 ActiveRecord 背后真正在跑的四件事:元数据、SQL 编译、结果水合、脏数据追踪。
第一步:先把约定定死,代码才好写
ORM 的复杂度一大半来自「不确定」。先立三条规矩,后面全部代码都靠它们省事:
- 类名
UserProfile 对应表 user_profiles(驼峰转蛇形、复数)
- 主键统一叫
id,类型自增整型
- 只有声明在
$fillable 里的字段允许被批量赋值
abstract class Model
{
protected static ?PDO $pdo = null;
protected static string $table = '';
protected array $attributes = []; // 当前值
protected array $original = []; // 数据库里的快照
protected bool $exists = false; // 是否已持久化
public static function table(): string
{
if (static::$table !== '') return static::$table;
$name = (new ReflectionClass(static::class))->getShortName();
return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $name)) . 's';
}
}
第二步:连接交给单例 PDO,只配一次
public static function pdo(): PDO
{
if (static::$pdo === null) {
static::$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 用真预处理
]);
}
return static::$pdo;
}
注意:ATTR_EMULATE_PREPARES => false 一定要关。开着模拟预处理时,PDO 会在本地拼字符串,整型参数悄悄被转成字符串,LIMIT ? 这类场景会直接报语法错误,很多人第一次写 ORM 就栽在这。
第三步:查询构建器——只攒条件,最后才编译 SQL
class Query
{
private array $wheres = [];
private array $bindings = [];
private string $order = '';
private int $limit = 0;
public function __construct(private string $model) {}
public function where(string $col, $op, $val = null): static
{
if (func_num_args() === 2) { $val = $op; $op = '='; }
$this->wheres[] = "`{$this->safeColumn($col)}` {$op} ?";
$this->bindings[] = $val;
return $this;
}
public function orderBy(string $col, string $dir = 'asc'): static
{
$dir = strtolower($dir) === 'desc' ? 'DESC' : 'ASC';
$this->order = " ORDER BY `{$this->safeColumn($col)}` {$dir}";
return $this;
}
private function safeColumn(string $col): string
{
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $col)) {
throw new InvalidArgumentException("非法字段名: {$col}");
}
return $col;
}
public function get(): array
{
$sql = 'SELECT * FROM `' . $this->model::table() . '`';
if ($this->wheres) $sql .= ' WHERE ' . implode(' AND ', $this->wheres);
$sql .= $this->order;
if ($this->limit) $sql .= ' LIMIT ' . $this->limit;
$stmt = Model::pdo()->prepare($sql);
$stmt->execute($this->bindings);
return array_map(fn($r) => $this->model::hydrate($r), $stmt->fetchAll());
}
}
Model 里加一个 __callStatic,User::where('id', 1)->get() 就能用了:
public static function __callStatic(string $name, array $args)
{
return (new Query(static::class))->$name(...$args);
}
注意:参数绑定只能保护「值」,保护不了「列名」和「排序方向」。所以上面必须用正则白名单卡死字段名,orderBy($request->get('sort')) 这种写法等于把注入口敞开。
第四步:结果水合——把数组行变成对象
public static function hydrate(array $row): static
{
$m = (new ReflectionClass(static::class))->newInstanceWithoutConstructor();
$m->attributes = $row;
$m->original = $row;
$m->exists = true;
return $m;
}
注意:一定用 newInstanceWithoutConstructor()。如果直接 new static(),模型里的构造函数逻辑(依赖注入、初始化默认值)会被每行记录触发一次,查 500 条就是 500 次副作用。hydrate 走的是「恢复对象」而不是「创建对象」。
配套两个魔术方法:
public function __get($name)
{
return array_key_exists($name, $this->attributes) ? $this->attributes[$name] : null;
}
public function __set($name, $value)
{
$this->attributes[$name] = $value;
}
第五步:save()——只更新真正变过的列
有了 $original 快照,脏检查就是一次数组比较:
public function save(): bool
{
$data = $this->attributes;
if ($this->exists) {
$changed = array_diff_assoc($data, $this->original);
if (!$changed) return true; // 没变化,不发 SQL
$sets = implode(', ', array_map(fn($c) => "`{$c}` = ?", array_keys($changed)));
$sql = 'UPDATE `' . static::table() . "` SET {$sets} WHERE id = ?";
$stmt = static::pdo()->prepare($sql);
$stmt->execute([...array_values($changed), $this->attributes['id']]);
} else {
$sql = 'INSERT INTO `' . static::table() . '` (`'
. implode('`,`', array_keys($data)) . '`) VALUES ('
. rtrim(str_repeat('?,', count($data)), ',') . ')';
$stmt = static::pdo()->prepare($sql);
$stmt->execute(array_values($data));
$this->attributes['id'] = static::pdo()->lastInsertId();
$this->exists = true;
}
$this->original = $this->attributes;
return true;
}
注意:array_diff_assoc 对 null 和 '0' 是严格按字符串比较的,如果字段有 tinyint(1) 存 '0'、代码里写成 0,会话里不会产生差异——但如果你把布尔和字符串混着用,可能会出现「明明改了却没更新」。稳妥做法是脏检查改用 !== 的严格比较循环。