'*', 'where' => [], 'orderBy' => null, 'limit' => null, 'offset' => null, 'trashed' => 'without' ]; /** Registered model-event listeners, keyed by "ClassName@event". */ private static array $eventListeners = []; private static array $validOperators = ['=', '!=', '>', '<', '>=', '<=', 'LIKE', 'NOT LIKE', 'IN']; /** Cache of "Class::method" => whether it may be resolved as a relation. */ private static array $relationMethods = []; /** While true, hasMany/hasOne/belongsTo describe the relation instead of querying it. */ private static bool $describingRelation = false; private string $cacheGroup = 'softminal_cache_'; private int $cacheTtl = 60; public static function table(): string { global $wpdb; $instance = new static(); return $wpdb->prefix . SOFTMINAL_CACHE_TABLE_PREFIX . $instance->table; } public static function query() { return new static(); } /** * Route undefined instance calls to local scopes (scopeXxx methods). */ public function __call($method, $parameters) { $scope = 'scope' . ucfirst($method); if (method_exists($this, $scope)) { // Laravel convention: the query builder is passed as the first argument. $result = $this->{$scope}($this, ...$parameters); return $result === null ? $this : $result; } throw new \BadMethodCallException( 'Call to undefined method ' . esc_html(static::class . '::' . $method) . '()' ); } /** * Route undefined static calls to a fresh instance (enables Model::scopeName() * and Model::whereX() style entry points). */ public static function __callStatic($method, $parameters) { return (new static())->{$method}(...$parameters); } /* ----------------------------------------------------------------- * Soft-delete query scopes * ----------------------------------------------------------------- */ public function withTrashed() { $this->query['trashed'] = 'with'; return $this; } public function onlyTrashed() { $this->query['trashed'] = 'only'; return $this; } public function withoutTrashed() { $this->query['trashed'] = 'without'; return $this; } /** * The where set actually applied to a query, including the soft-delete * constraint when enabled. Never mutates $this->query['where']. */ private function activeWheres(): array { $wheres = $this->query['where']; if (!$this->softDeletes) { return $wheres; } $mode = $this->query['trashed'] ?? 'without'; if ($mode === 'without') { array_unshift($wheres, [ 'column' => $this->deletedAtColumn, 'operator' => 'IS NULL', 'value' => null, 'boolean' => 'AND', 'scope' => true ]); } elseif ($mode === 'only') { $wheres[] = [ 'column' => $this->deletedAtColumn, 'operator' => 'IS NOT NULL', 'value' => null, 'boolean' => 'AND', 'scope' => true ]; } return $wheres; } public function with($relations) { if (is_string($relations)) { $relations = [$relations]; } $this->with = $relations; return $this; } protected function hasMany($related, $foreignKey, $localKey = null) { $localKey = $localKey ?: $this->primaryKey; if (self::$describingRelation) { return ['__relation' => true, 'many' => true, 'related' => $related, 'relatedKey' => $foreignKey, 'localKey' => $localKey]; } $localValue = $this->attributes[$localKey] ?? null; if ($localValue === null) { return new Collection([]); } return $related::where($foreignKey, $localValue)->get(); } protected function hasOne($related, $foreignKey, $localKey = null) { $localKey = $localKey ?: $this->primaryKey; if (self::$describingRelation) { return ['__relation' => true, 'many' => false, 'related' => $related, 'relatedKey' => $foreignKey, 'localKey' => $localKey]; } $localValue = $this->attributes[$localKey] ?? null; if ($localValue === null) { return null; } return $related::where($foreignKey, $localValue)->first(); } protected function belongsTo($related, $foreignKey, $ownerKey = 'id') { if (self::$describingRelation) { return ['__relation' => true, 'many' => false, 'related' => $related, 'relatedKey' => $ownerKey, 'localKey' => $foreignKey]; } $foreignValue = $this->attributes[$foreignKey] ?? null; if ($foreignValue === null) { return null; } return $related::where($ownerKey, $foreignValue)->first(); } public function load($relation) { if (!$this->isRelationMethod((string) $relation)) { return null; } $this->relations[$relation] = $this->$relation(); return $this->relations[$relation]; } /** * Eager-load $this->with onto a result set with one query per relation * (WHERE key IN (...)) instead of one query per row. Relations that are * not a plain hasMany/hasOne/belongsTo fall back to per-row loading. */ private function eagerLoadRelations(Collection $models): void { $items = $models->all(); if (empty($items)) { return; } foreach ((array) $this->with as $name) { $name = (string) $name; $descriptor = reset($items)->describeRelation($name); if ($descriptor === null) { foreach ($items as $model) { $model->load($name); } continue; } $localKey = $descriptor['localKey']; $relatedKey = $descriptor['relatedKey']; $keys = []; foreach ($items as $model) { $value = $model->attributes[$localKey] ?? null; if ($value !== null && $value !== '') { $keys[(string) $value] = $value; } } $grouped = []; if (!empty($keys)) { $related = $descriptor['related']; foreach ($related::query()->whereIn($relatedKey, array_values($keys))->get() as $row) { $grouped[(string) ($row->attributes[$relatedKey] ?? '')][] = $row; } } foreach ($items as $model) { $value = $model->attributes[$localKey] ?? null; $rows = $value === null ? [] : ($grouped[(string) $value] ?? []); $model->relations[$name] = $descriptor['many'] ? new Collection($rows) : ($rows[0] ?? null); } } } /** * The hasMany/hasOne/belongsTo definition behind a relation method, or * null when the method does anything else (then it cannot be batched). */ private function describeRelation(string $name): ?array { if (!$this->isRelationMethod($name)) { return null; } self::$describingRelation = true; try { $result = $this->$name(); } catch (\Throwable $e) { $result = null; } finally { self::$describingRelation = false; } return is_array($result) && !empty($result['__relation']) ? $result : null; } public function loadRelations(array $relations) { foreach ($relations as $relation) { $this->load($relation); } return $this; } private function getCacheVersion(): int { $key = 'model_cache_ver:' . static::table(); $ver = wp_cache_get($key, $this->cacheGroup); if (!$ver) { // Persist the initial version so a later bumpCacheVersion() actually increments it // (otherwise the bump reads "missing" as 0 and re-sets 1, leaving stale reads cached). wp_cache_set($key, 1, $this->cacheGroup, DAY_IN_SECONDS); return 1; } return (int) $ver; } private static function bumpCacheVersion(): void { $instance = new static(); $key = 'model_cache_ver:' . static::table(); $ver = (int) wp_cache_get($key, $instance->cacheGroup); wp_cache_set($key, $ver + 1, $instance->cacheGroup, DAY_IN_SECONDS); } /** * Invalidate every cached read for this model's table. Call after raw * $wpdb writes, which the model cannot observe. */ public static function flushCache(): void { static::bumpCacheVersion(); } private function cacheKey(string $suffix): string { return $suffix . ':v' . $this->getCacheVersion(); } private function cacheKeyForSql(string $sql, array $params = []): string { return $this->cacheKey('sql:' . md5(static::table() . '|' . $sql . '|' . wp_json_encode($params))); } public static function all() { global $wpdb; $instance = new static(); $safeTable = esc_sql(static::table()); $cacheKey = $instance->cacheKey('all:' . md5($safeTable)); $cached = wp_cache_get($cacheKey, $instance->cacheGroup); if ($cached !== false) { return new Collection(array_map(function ($item) { return static::hydrate($item); }, $cached ?: [])); } $safeTable = esc_sql(static::table()); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is from static::table(), not user input $results = $wpdb->get_results( "SELECT * FROM `{$safeTable}`", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is from static::table(), not user input ARRAY_A ); wp_cache_set($cacheKey, $results ?: [], $instance->cacheGroup, $instance->cacheTtl); return new Collection(array_map(function ($item) { return static::hydrate($item); }, $results ?: [])); } public function toArray(): array { $attributes = $this->attributes; foreach ($attributes as $key => $value) { if ($value instanceof \DateTime) { $attributes[$key] = $value->format('Y-m-d H:i:s'); } elseif (is_object($value) && method_exists($value, 'toArray')) { $attributes[$key] = $value->toArray(); } } foreach ($this->relations as $key => $relation) { if ($relation instanceof Model) { $attributes[$key] = $relation->toArray(); } elseif ($relation instanceof Collection) { $attributes[$key] = $relation->toArray(); } elseif (is_array($relation)) { $attributes[$key] = array_map(function ($item) { return $item instanceof Model ? $item->toArray() : $item; }, $relation); } } // Apply accessors to any raw attribute that defines a getXxxAttribute(). foreach ($attributes as $key => $value) { if ($this->hasGetMutator($key)) { $attributes[$key] = $this->{'get' . $this->studly($key) . 'Attribute'}($value); } } // Append computed accessor-only attributes. foreach ($this->appends as $key) { $raw = $this->attributes[$key] ?? null; $attributes[$key] = $this->hasGetMutator($key) ? $this->{'get' . $this->studly($key) . 'Attribute'}($raw) : $raw; } foreach ($this->hidden as $key) { unset($attributes[$key]); } return $attributes; } public function toJson(): string { return wp_json_encode($this->toArray()); } public static function where($column, $operator = null, $value = null) { $instance = new static(); return $instance->andWhere($column, $operator, $value); } public function andWhere($column, $operator = null, $value = null, $boolean = 'AND') { if ($value === null) { // where('col', null) / where('col', '=', null) / where('col', '!=', null) // are NULL checks; "= ''" would never match a NULL column. if ($operator === null || $operator === '=') { return $this->whereNull($column, $boolean); } if ($operator === '!=') { return $this->whereNotNull($column, $boolean); } $value = $operator; $operator = '='; } $operator = strtoupper((string) $operator); if (!in_array($operator, self::$validOperators, true)) { throw new \InvalidArgumentException('Invalid operator: ' . esc_html($operator)); } if (in_array($operator, ['LIKE', 'NOT LIKE'], true)) { global $wpdb; $value = (string) $value; // A caller-supplied wildcard is honoured; otherwise the value is escaped so // "%" or "_" inside it match literally, then wrapped for a contains-search. // For user input use whereContains(), which always escapes. if (strpos($value, '%') === false) { $value = '%' . $wpdb->esc_like($value) . '%'; } } $this->query['where'][] = [ 'column' => (string) $column, 'operator' => $operator, 'value' => $value, 'boolean' => $boolean ]; return $this; } /** * Match rows whose column contains $value as literal text. Safe for user * input: %, _ and \ in the value are escaped, never treated as wildcards. */ public function whereContains($column, $value, $boolean = 'AND') { global $wpdb; $this->query['where'][] = [ 'column' => (string) $column, 'operator' => 'LIKE', 'value' => '%' . $wpdb->esc_like((string) $value) . '%', 'boolean' => $boolean ]; return $this; } public function orWhere($column, $operator = null, $value = null) { return $this->andWhere($column, $operator, $value, 'OR'); } public function whereIn($column, array $values, $boolean = 'AND') { $this->query['where'][] = [ 'column' => (string) $column, 'operator' => 'IN', 'value' => $values, 'boolean' => $boolean ]; return $this; } public function whereNull($column, $boolean = 'AND') { $this->query['where'][] = [ 'column' => (string) $column, 'operator' => 'IS NULL', 'value' => null, 'boolean' => $boolean ]; return $this; } public function whereNotNull($column, $boolean = 'AND') { $this->query['where'][] = [ 'column' => (string) $column, 'operator' => 'IS NOT NULL', 'value' => null, 'boolean' => $boolean ]; return $this; } public function whereBetween($column, array $values, $boolean = 'AND') { $this->query['where'][] = [ 'column' => (string) $column, 'operator' => 'BETWEEN', 'value' => $values, 'boolean' => $boolean ]; return $this; } public function select($columns) { $this->query['select'] = is_array($columns) ? $columns : [$columns]; return $this; } public function orderBy($column, $direction = 'ASC') { $this->query['orderBy'] = [ 'column' => (string) $column, 'direction' => strtoupper((string) $direction) ]; return $this; } public function limit($limit) { $this->query['limit'] = (int) $limit; return $this; } public function offset($offset) { $this->query['offset'] = (int) $offset; return $this; } public function paginate($perPage = 15, $page = 1) { global $wpdb; // Clamp: per_page 0 would drop the LIMIT, page 0 would give a negative OFFSET. $perPage = max(1, (int) $perPage); $page = max(1, (int) $page); $this->query['limit'] = $perPage; $this->query['offset'] = ($page - 1) * $perPage; $safeTable = esc_sql(static::table()); $params = []; $activeWheres = $this->activeWheres(); $whereSql = ''; if (!empty($activeWheres)) { [$whereSql, $whereParams] = $this->buildWhereClause($activeWheres); $params = $whereParams; } $cacheKey = $this->cacheKeyForSql("COUNT:{$safeTable}{$whereSql}", $params); $cachedTotal = wp_cache_get($cacheKey, $this->cacheGroup); if ($cachedTotal === false) { $safeTable = esc_sql(static::table()); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is from static::table(), not user input $countSql = "SELECT COUNT(*) FROM `{$safeTable}`" . $whereSql; if (!empty($params)) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL built from internal model; user values via $wpdb->prepare $total = (int) $wpdb->get_var($wpdb->prepare($countSql, $params)); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user params; table via esc_sql $total = (int) $wpdb->get_var($countSql); } wp_cache_set($cacheKey, $total, $this->cacheGroup, $this->cacheTtl); } else { $total = (int) $cachedTotal; } $results = $this->get(); return [ 'data' => $results, 'meta' => [ 'current_page' => (int) $page, 'per_page' => (int) $perPage, 'total' => $total, 'last_page' => (int) ceil($total / max(1, (int) $perPage)), 'has_more' => ((int) $page * (int) $perPage) < $total ] ]; } public function get($columns = ['*']) { global $wpdb; if ($columns === ['*'] && !empty($this->query['select'])) { $columns = $this->query['select']; } $selectClause = $this->normalizeSelect($columns); $safeTable = esc_sql(static::table()); $params = []; $activeWheres = $this->activeWheres(); $whereSql = ''; if (!empty($activeWheres)) { [$whereSql, $whereParams] = $this->buildWhereClause($activeWheres); $params = array_merge($params, $whereParams); } $orderSql = ''; if ($this->query['orderBy']) { $orderCol = esc_sql(sanitize_key($this->query['orderBy']['column'])); $dir = strtoupper($this->query['orderBy']['direction']) === 'DESC' ? 'DESC' : 'ASC'; $orderSql = " ORDER BY `{$orderCol}` {$dir}"; } $limitSql = ''; if ($this->query['limit']) { $limitSql .= ' LIMIT %d'; $params[] = (int) $this->query['limit']; if ((int) $this->query['offset'] > 0) { $limitSql .= ' OFFSET %d'; $params[] = (int) $this->query['offset']; } } elseif ((int) $this->query['offset'] > 0) { // MySQL has no OFFSET without LIMIT; use its documented "all rows" limit. $limitSql .= ' LIMIT 18446744073709551615 OFFSET %d'; $params[] = (int) $this->query['offset']; } $safeTable = esc_sql(static::table()); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name and select clause are from internal model, not user input $baseSql = "SELECT {$selectClause} FROM `{$safeTable}`{$whereSql}{$orderSql}{$limitSql}"; $cacheKey = $this->cacheKeyForSql($baseSql, $params); $cached = wp_cache_get($cacheKey, $this->cacheGroup); if ($cached !== false) { $models = array_map(function ($item) { return static::hydrate($item); }, $cached ?: []); $collection = new Collection($models); if (!empty($this->with)) { $this->eagerLoadRelations($collection); } return $collection; } if (!empty($params)) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL built from internal model; user values via $wpdb->prepare $results = $wpdb->get_results($wpdb->prepare($baseSql, $params), ARRAY_A); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user params; table via esc_sql $results = $wpdb->get_results($baseSql, ARRAY_A); } wp_cache_set($cacheKey, $results ?: [], $this->cacheGroup, $this->cacheTtl); $models = array_map(function ($item) { return static::hydrate($item); }, $results ?: []); $collection = new Collection($models); if (!empty($this->with)) { $this->eagerLoadRelations($collection); } return $collection; } protected static function hydrate($attributes) { $model = new static(); $model->attributes = $attributes; if (isset($model->casts)) { foreach ($model->casts as $key => $type) { if (array_key_exists($key, $attributes)) { switch ($type) { case 'json': if (is_string($attributes[$key])) { $model->attributes[$key] = json_decode($attributes[$key], true); } break; case 'datetime': if (is_string($attributes[$key])) { $model->attributes[$key] = strtotime($attributes[$key]); } break; case 'boolean': $model->attributes[$key] = (bool) $attributes[$key]; break; } } } } $model->exists = true; $model->syncOriginal(); return $model; } /** * Find by primary key. Soft-deleted rows are excluded; use * findWithTrashed() to include them. */ public static function find($id) { return static::findByKey($id, false); } public static function findWithTrashed($id) { return static::findByKey($id, true); } private static function findByKey($id, bool $withTrashed) { global $wpdb; $instance = new static(); $safeTable = esc_sql(static::table()); $safeKey = esc_sql(sanitize_key($instance->primaryKey)); $id = $instance->castKey($id); $cacheKey = $instance->cacheKey('find:' . md5($safeTable . '|' . $id)); $cachedRow = wp_cache_get($cacheKey, $instance->cacheGroup); if ($cachedRow === false) { $sql = $instance->keyType === 'int' ? "SELECT * FROM `{$safeTable}` WHERE `{$safeKey}` = %d" : "SELECT * FROM `{$safeTable}` WHERE `{$safeKey}` = %s"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table and key column are from the model definition, not user input $cachedRow = $wpdb->get_row( $wpdb->prepare($sql, $id), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table and key column are from the model definition; id bound via prepare ARRAY_A ) ?: []; wp_cache_set($cacheKey, $cachedRow, $instance->cacheGroup, $instance->cacheTtl); } if (empty($cachedRow)) { return null; } if (!$withTrashed && $instance->softDeletes && !empty($cachedRow[$instance->deletedAtColumn])) { return null; } return static::hydrate($cachedRow); } private function keyFormat(): string { return $this->keyType === 'int' ? '%d' : '%s'; } private function castKey($id) { return $this->keyType === 'int' ? (int) $id : (string) $id; } public function save() { global $wpdb; $safeTable = esc_sql(static::table()); $keyName = $this->primaryKey; // Auto-increment keys: a key value means the row exists. String keys // are assigned before the first insert, so rely on $exists instead. $hasKey = isset($this->attributes[$keyName]) && $this->attributes[$keyName]; $isUpdate = $this->keyType === 'int' ? (bool) $hasKey : ($hasKey && $this->exists); $this->changes = $this->getDirty(); if ($this->fireModelEvent('saving') === false) { return false; } if ($isUpdate) { if ($this->fireModelEvent('updating') === false) { return false; } } elseif ($this->fireModelEvent('creating') === false) { return false; } // Persist every attribute that passes the fillable/guarded check, so // $guarded-only models (empty $fillable) write their columns too. // Guarded attributes are written only when changed: fill() never lets // them in, so a change came from forceFill() or a mutator. $dirty = $this->getDirty(); $attributes = []; foreach ($this->attributes as $field => $value) { if ($field === $keyName) { continue; } if ($this->isFillable((string) $field) || array_key_exists($field, $dirty)) { $attributes[$field] = $value; } } foreach ($this->casts as $key => $type) { if (isset($attributes[$key])) { switch ($type) { case 'json': if (is_array($attributes[$key]) || is_object($attributes[$key])) { $attributes[$key] = wp_json_encode($attributes[$key]); } break; case 'datetime': if ($attributes[$key] instanceof \DateTimeInterface) { $attributes[$key] = $attributes[$key]->format('Y-m-d H:i:s'); } elseif (is_int($attributes[$key])) { // hydrate() turns the column into a Unix timestamp; // turn it back so a load-then-save round trips. $attributes[$key] = gmdate('Y-m-d H:i:s', $attributes[$key]); } elseif ($attributes[$key] === false) { // Unparseable stored value: leave the column alone. unset($attributes[$key]); } break; case 'boolean': $attributes[$key] = (int) (bool) $attributes[$key]; break; } } } if ($this->timestamps) { $now = current_time('mysql'); // Write to the row AND the in-memory model, so a freshly created() // instance already carries its timestamps without a re-fetch. $attributes['updated_at'] = $now; $this->attributes['updated_at'] = $now; if (!$isUpdate) { $attributes['created_at'] = $now; $this->attributes['created_at'] = $now; } } if ($isUpdate) { $updated = true; if (!empty($attributes)) { $format = array_fill(0, count($attributes), '%s'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $updated = $wpdb->update( $safeTable, $attributes, [$keyName => $this->castKey($this->attributes[$keyName])], $format, [$this->keyFormat()] ); } if ($updated !== false) { static::bumpCacheVersion(); $this->exists = true; $this->syncOriginal(); $this->fireModelEvent('updated'); $this->fireModelEvent('saved'); return true; } return false; } // A pre-assigned key (uuid, slug) is part of the insert. if ($hasKey) { $attributes[$keyName] = $this->attributes[$keyName]; } $format = array_fill(0, count($attributes), '%s'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $inserted = $wpdb->insert($safeTable, $attributes, $format); if ($inserted) { if (!$hasKey) { $this->attributes[$keyName] = (int) $wpdb->insert_id; } static::bumpCacheVersion(); $this->exists = true; $this->syncOriginal(); $this->fireModelEvent('created'); $this->fireModelEvent('saved'); return true; } return false; } public function __get($key) { if (array_key_exists($key, $this->relations)) { return $this->relations[$key]; } if ($this->hasGetMutator($key)) { $value = array_key_exists($key, $this->attributes) ? $this->attributes[$key] : null; return $this->{'get' . $this->studly($key) . 'Attribute'}($value); } if (array_key_exists($key, $this->attributes)) { return $this->attributes[$key]; } if ($this->isRelationMethod((string) $key)) { $this->relations[$key] = $this->$key(); return $this->relations[$key]; } return null; } /** * Whether $name may be resolved as a relation by property access, load() * or with(). Reading a property must never run save()/delete()/etc., so * anything the base Model defines is excluded, as are static methods, * methods that need arguments, scopes and accessors/mutators. */ private function isRelationMethod(string $name): bool { $cacheKey = static::class . '::' . $name; if (isset(self::$relationMethods[$cacheKey])) { return self::$relationMethods[$cacheKey]; } $allowed = false; if ($name !== '' && method_exists($this, $name) && !method_exists(self::class, $name)) { $method = new \ReflectionMethod($this, $name); $allowed = !$method->isStatic() && $method->getNumberOfRequiredParameters() === 0 && stripos($name, 'scope') !== 0 && !preg_match('/^(get|set).+Attribute$/i', $name); } return self::$relationMethods[$cacheKey] = $allowed; } public function __set($key, $value) { if ($this->hasSetMutator($key)) { $this->{'set' . $this->studly($key) . 'Attribute'}($value); return; } // $fillable/$guarded protect mass assignment (fill/create/update). // Setting one named attribute in code is always allowed. $this->attributes[$key] = $value; } public function __isset($key) { return array_key_exists($key, $this->attributes) || array_key_exists($key, $this->relations) || $this->hasGetMutator($key); } public static function create(array $attributes) { $model = new static(); $model->fill($attributes); $model->save(); return $model; } public static function firstOrCreate(array $search, array $additional = []) { $query = new static(); foreach ($search as $column => $value) { $query = $query->andWhere($column, '=', $value); } $existing = $query->first(); if ($existing) { return $existing; } return static::create(array_merge($search, $additional)); } public static function updateOrCreate(array $search, array $attributes = []) { $query = new static(); foreach ($search as $column => $value) { $query = $query->andWhere($column, '=', $value); } $existing = $query->first(); if ($existing) { $id = $existing->attributes[$existing->primaryKey] ?? null; if ($id) { static::update($id, $attributes); return static::find($id); } } return static::create(array_merge($search, $attributes)); } public function delete() { global $wpdb; if (!isset($this->attributes[$this->primaryKey])) { return false; } if ($this->fireModelEvent('deleting') === false) { return false; } $id = $this->castKey($this->attributes[$this->primaryKey]); $safeTable = esc_sql(static::table()); // Soft delete: stamp deleted_at instead of removing the row. if ($this->softDeletes) { $now = current_time('mysql'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $updated = $wpdb->update( $safeTable, [$this->deletedAtColumn => $now], [$this->primaryKey => $id], ['%s'], [$this->keyFormat()] ); if ($updated !== false) { $this->attributes[$this->deletedAtColumn] = $now; static::bumpCacheVersion(); $this->syncOriginal(); $this->fireModelEvent('deleted'); return true; } return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $result = $wpdb->delete( $safeTable, [$this->primaryKey => $id], [$this->keyFormat()] ); if ($result !== false) { static::bumpCacheVersion(); $this->exists = false; $this->fireModelEvent('deleted'); } return $result !== false; } /** * Permanently remove the row even when soft deletes are enabled. */ public function forceDelete() { global $wpdb; if (!isset($this->attributes[$this->primaryKey])) { return false; } if ($this->fireModelEvent('deleting') === false) { return false; } $id = $this->castKey($this->attributes[$this->primaryKey]); $safeTable = esc_sql(static::table()); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $result = $wpdb->delete( $safeTable, [$this->primaryKey => $id], [$this->keyFormat()] ); if ($result !== false) { static::bumpCacheVersion(); $this->exists = false; $this->fireModelEvent('deleted'); } return $result !== false; } /** * Restore a soft-deleted row (clear deleted_at). */ public function restore() { global $wpdb; if (!$this->softDeletes || !isset($this->attributes[$this->primaryKey])) { return false; } if ($this->fireModelEvent('restoring') === false) { return false; } $id = $this->castKey($this->attributes[$this->primaryKey]); $safeTable = esc_sql(static::table()); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $updated = $wpdb->update( $safeTable, [$this->deletedAtColumn => null], [$this->primaryKey => $id], ['%s'], [$this->keyFormat()] ); if ($updated !== false) { $this->attributes[$this->deletedAtColumn] = null; static::bumpCacheVersion(); $this->syncOriginal(); $this->fireModelEvent('restored'); return true; } return false; } /** * Whether this instance is currently soft-deleted. */ public function trashed(): bool { return $this->softDeletes && !empty($this->attributes[$this->deletedAtColumn]); } /** * Reload this instance's attributes from the database (picks up column * defaults and server-side values set outside the model). No-op when the * model has not been saved yet. */ public function refresh() { $id = $this->attributes[$this->primaryKey] ?? null; if (!$id) { return $this; } // Primary-key lookup: not subject to the soft-delete scope, so a // trashed row still refreshes correctly. $fresh = static::findUncached($id); if ($fresh) { $this->attributes = $fresh->attributes; $this->relations = []; $this->exists = true; $this->syncOriginal(); } return $this; } /** * A new instance loaded from the database, leaving this one untouched. * Returns null for unsaved models or rows that no longer exist. */ public function fresh() { $id = $this->attributes[$this->primaryKey] ?? null; if (!$id) { return null; } return static::findUncached($id); } /** * Primary-key read that bypasses the object cache. refresh()/fresh() exist * to observe the database's current state (defaults, external writes), so * serving them from the 60s model cache would defeat the point. */ private static function findUncached($id) { global $wpdb; $instance = new static(); $safeTable = esc_sql(static::table()); $safeKey = esc_sql(sanitize_key($instance->primaryKey)); $sql = $instance->keyType === 'int' ? "SELECT * FROM `{$safeTable}` WHERE `{$safeKey}` = %d" : "SELECT * FROM `{$safeTable}` WHERE `{$safeKey}` = %s"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table and key column are from the model definition, not user input; uncached by design $row = $wpdb->get_row( $wpdb->prepare($sql, $instance->castKey($id)), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table and key column are from the model definition; id bound via prepare ARRAY_A ); return $row ? static::hydrate($row) : null; } /** * Delete by primary key through the model, so soft deletes and the * deleting/deleted events apply. Returns false when no such row exists. */ public static function findByIdAndDelete($id) { $model = static::findWithTrashed($id); if (!$model) { return false; } return $model->delete(); } public static function update($id, array $attributes) { $model = static::find($id); if (!$model) { return false; } $model->fill($attributes); $saved = $model->save(); if ($saved) { static::bumpCacheVersion(); } return $saved; } public function first() { $this->query['limit'] = 1; $results = $this->get(); return !empty($results) ? $results[0] : null; } public function count() { global $wpdb; $params = []; $activeWheres = $this->activeWheres(); $whereSql = ''; if (!empty($activeWheres)) { [$whereSql, $whereParams] = $this->buildWhereClause($activeWheres); $params = $whereParams; } $safeTable = esc_sql(static::table()); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is from static::table(), not user input $countSql = "SELECT COUNT(*) FROM `{$safeTable}`" . $whereSql; if (!empty($params)) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL built from internal model; user values via $wpdb->prepare return (int) $wpdb->get_var($wpdb->prepare($countSql, $params)); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user params; table via esc_sql return (int) $wpdb->get_var($countSql); } public function exists() { return $this->count() > 0; } /* ----------------------------------------------------------------- * Aggregates (respect where + soft-delete constraints, uncached) * ----------------------------------------------------------------- */ public function sum($column) { return $this->aggregate('SUM', $column); } public function avg($column) { return $this->aggregate('AVG', $column); } public function min($column) { return $this->aggregate('MIN', $column); } public function max($column) { return $this->aggregate('MAX', $column); } protected function aggregate(string $function, string $column) { global $wpdb; $function = strtoupper($function); if (!in_array($function, ['SUM', 'AVG', 'MIN', 'MAX'], true)) { throw new \InvalidArgumentException('Invalid aggregate function: ' . esc_html($function)); } $safeColumn = esc_sql(sanitize_key($column)); if ($safeColumn === '') { return null; } $params = []; $activeWheres = $this->activeWheres(); $whereSql = ''; if (!empty($activeWheres)) { [$whereSql, $whereParams] = $this->buildWhereClause($activeWheres); $params = $whereParams; } $safeTable = esc_sql(static::table()); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table, function and column are from internal model, not user input $sql = "SELECT {$function}(`{$safeColumn}`) FROM `{$safeTable}`" . $whereSql; if (!empty($params)) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL built from internal model; user values via $wpdb->prepare $result = $wpdb->get_var($wpdb->prepare($sql, $params)); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- No user params; table via esc_sql $result = $wpdb->get_var($sql); } if ($result === null) { return $function === 'SUM' ? 0 : null; } // Cast numeric aggregates to a real number. if ($function === 'SUM' || $function === 'AVG') { return $result + 0; } return $result; } /** * Get a single column value from the first matching row. */ public function value(string $column) { $row = $this->select($column)->first(); return $row ? $row->{$column} : null; } /* ----------------------------------------------------------------- * Chunked / streaming processing * ----------------------------------------------------------------- */ /** * Process results in fixed-size pages. Return false from $callback to stop. * Uses offset paging — for large mutating jobs prefer stable ordering. */ public function chunk(int $count, callable $callback): bool { if ($count < 1) { return false; } $page = 1; do { $clone = clone $this; $clone->query['limit'] = $count; $clone->query['offset'] = ($page - 1) * $count; $results = $clone->get(); $resultCount = $results->count(); if ($resultCount === 0) { break; } if ($callback($results, $page) === false) { return false; } $page++; } while ($resultCount === $count); return true; } /** * Iterate every matching model one at a time, transparently paging. */ public function each(callable $callback, int $count = 500): bool { return $this->chunk($count, function ($models) use ($callback) { foreach ($models as $key => $model) { if ($callback($model, $key) === false) { return false; } } return true; }); } /** * A lazy generator over all matching models. */ public function cursor(int $chunkSize = 500): \Generator { $page = 1; do { $clone = clone $this; $clone->query['limit'] = $chunkSize; $clone->query['offset'] = ($page - 1) * $chunkSize; $results = $clone->get(); $resultCount = $results->count(); foreach ($results as $model) { yield $model; } $page++; } while ($resultCount === $chunkSize); } /* ----------------------------------------------------------------- * Mass assignment * ----------------------------------------------------------------- */ public function fill(array $attributes) { if (!empty($attributes) && empty($this->fillable) && $this->guarded === ['*']) { // Nothing can be filled: almost always a model that still needs // its $fillable list. Make that visible instead of silent. do_action('softminal_cache_mass_assignment_blocked', static::class, array_keys($attributes)); if (defined('WP_DEBUG') && WP_DEBUG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-only diagnostics error_log('[softminal_cache_model] ' . static::class . ' has no $fillable columns; fill() ignored: ' . implode(', ', array_keys($attributes))); } return $this; } foreach ($attributes as $key => $value) { $key = (string) $key; // The guard comes first: a set-mutator must not open a guarded key. if (!$this->isFillable($key)) { continue; } // The primary key is mass assignable only when $fillable lists it; // otherwise create(['id' => 5]) would overwrite an existing row. if ($key === $this->primaryKey && !in_array($key, $this->fillable, true)) { continue; } $this->setRawAttribute($key, $value); } return $this; } /** * Set attributes bypassing $fillable/$guarded. For trusted, server-side * values only (owner ids, roles, tokens) -- never pass request input. */ public function forceFill(array $attributes) { foreach ($attributes as $key => $value) { $this->setRawAttribute((string) $key, $value); } return $this; } private function setRawAttribute(string $key, $value): void { if ($this->hasSetMutator($key)) { $this->{'set' . $this->studly($key) . 'Attribute'}($value); return; } $this->attributes[$key] = $value; } public function isFillable(string $key): bool { if (!empty($this->fillable)) { return in_array($key, $this->fillable, true); } if ($this->guarded === ['*'] || in_array($key, $this->guarded, true)) { return false; } return true; } public static function firstOrNew(array $search, array $additional = []) { $query = new static(); foreach ($search as $column => $value) { $query = $query->andWhere($column, '=', $value); } $existing = $query->first(); if ($existing) { return $existing; } $model = new static(); $model->fill(array_merge($search, $additional)); return $model; } /* ----------------------------------------------------------------- * Dirty tracking * ----------------------------------------------------------------- */ public function syncOriginal() { $this->original = $this->attributes; return $this; } public function getOriginal($key = null, $default = null) { if ($key === null) { return $this->original; } return $this->original[$key] ?? $default; } public function getAttributes(): array { return $this->attributes; } public function getDirty(): array { $dirty = []; foreach ($this->attributes as $key => $value) { if (!array_key_exists($key, $this->original) || $this->original[$key] !== $value) { $dirty[$key] = $value; } } return $dirty; } public function isDirty($attributes = null): bool { $dirty = $this->getDirty(); if ($attributes === null) { return !empty($dirty); } foreach ((array) $attributes as $attribute) { if (array_key_exists($attribute, $dirty)) { return true; } } return false; } public function isClean($attributes = null): bool { return !$this->isDirty($attributes); } /** * Whether the given attribute(s) changed during the most recent save(). */ public function wasChanged($attributes = null): bool { if ($attributes === null) { return !empty($this->changes); } foreach ((array) $attributes as $attribute) { if (array_key_exists($attribute, $this->changes)) { return true; } } return false; } /* ----------------------------------------------------------------- * Model events * ----------------------------------------------------------------- */ public static function on(string $event, callable $callback): void { self::$eventListeners[static::class . '@' . $event][] = $callback; } public static function creating(callable $callback): void { self::on('creating', $callback); } public static function created(callable $callback): void { self::on('created', $callback); } public static function updating(callable $callback): void { self::on('updating', $callback); } public static function updated(callable $callback): void { self::on('updated', $callback); } public static function saving(callable $callback): void { self::on('saving', $callback); } public static function saved(callable $callback): void { self::on('saved', $callback); } public static function deleting(callable $callback): void { self::on('deleting', $callback); } public static function deleted(callable $callback): void { self::on('deleted', $callback); } public static function restoring(callable $callback): void { self::on('restoring', $callback); } public static function restored(callable $callback): void { self::on('restored', $callback); } /** * Fire a model event. Returns false if any listener halted the operation. */ protected function fireModelEvent(string $event) { $key = static::class . '@' . $event; if (!empty(self::$eventListeners[$key])) { foreach (self::$eventListeners[$key] as $listener) { if ($listener($this) === false) { return false; } } } // WordPress action hooks for external integrations. do_action('softminal_cache_model_' . $event, $this); do_action('softminal_cache_model_' . $event . '.' . static::class, $this); return true; } /* ----------------------------------------------------------------- * Accessor / mutator helpers * ----------------------------------------------------------------- */ protected function hasGetMutator(string $key): bool { return $key !== '' && method_exists($this, 'get' . $this->studly($key) . 'Attribute'); } protected function hasSetMutator(string $key): bool { return $key !== '' && method_exists($this, 'set' . $this->studly($key) . 'Attribute'); } protected function studly(string $value): string { return str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $value))); } private function buildWhereClause(?array $wheres = null): array { $wheres = $wheres ?? $this->activeWheres(); $scopeParts = []; $sqlParts = []; $booleans = []; $params = []; foreach ($wheres as $where) { $column = esc_sql(sanitize_key((string) ($where['column'] ?? ''))); $operator = strtoupper((string) ($where['operator'] ?? '=')); $value = $where['value'] ?? null; $boolean = strtoupper((string) ($where['boolean'] ?? 'AND')); if ($column === '') { continue; } // Soft-delete constraint: kept apart so an orWhere() cannot escape it. if (!empty($where['scope'])) { $scopeParts[] = "`{$column}` " . ($operator === 'IS NOT NULL' ? 'IS NOT NULL' : 'IS NULL'); continue; } if (!in_array($boolean, ['AND', 'OR'], true)) { $boolean = 'AND'; } $booleans[] = $boolean; if ($operator === 'IS NULL') { $sqlParts[] = "`{$column}` IS NULL"; continue; } if ($operator === 'IS NOT NULL') { $sqlParts[] = "`{$column}` IS NOT NULL"; continue; } if ($operator === 'BETWEEN') { $betweenValues = is_array($value) ? $value : []; $sqlParts[] = "`{$column}` BETWEEN %s AND %s"; $params[] = (string) ($betweenValues[0] ?? ''); $params[] = (string) ($betweenValues[1] ?? ''); continue; } if ($operator === 'IN') { $values = is_array($value) ? $value : [$value]; $values = array_values(array_filter($values, function ($v) { return $v !== null && $v !== ''; })); if (empty($values)) { $sqlParts[] = '1=0'; continue; } $placeholders = implode(', ', array_fill(0, count($values), '%s')); $sqlParts[] = "`{$column}` IN ({$placeholders})"; foreach ($values as $v) { $params[] = (string) $v; } continue; } $sqlParts[] = "`{$column}` {$operator} %s"; $params[] = (string) $value; } $userSql = ''; foreach ($sqlParts as $i => $part) { if ($i > 0) { $userSql .= ' ' . $booleans[$i] . ' '; } $userSql .= $part; } if (empty($scopeParts)) { return $userSql === '' ? ['', []] : [' WHERE ' . $userSql, $params]; } $scopeSql = implode(' AND ', $scopeParts); if ($userSql === '') { return [' WHERE ' . $scopeSql, []]; } return [' WHERE ' . $scopeSql . ' AND (' . $userSql . ')', $params]; } private function normalizeSelect($columns): string { if (is_string($columns)) { $columns = trim($columns); return ($columns === '*') ? '*' : '`' . esc_sql(sanitize_key($columns)) . '`'; } if (!is_array($columns) || empty($columns)) { return '*'; } if (count($columns) === 1 && $columns[0] === '*') { return '*'; } $safe = []; foreach ($columns as $col) { $col = (string) $col; $key = esc_sql(sanitize_key($col)); if ($key !== '') { $safe[] = "`{$key}`"; } } return !empty($safe) ? implode(', ', $safe) : '*'; } }