, <, >=, <= * Array values become IN/NOT IN clauses, null checks IS NULL/IS NOT NULL */ abstract class mfBaseModelV2 { protected static string $__tableName = ''; protected static string $__primaryKey = 'id'; protected static ?array $__databaseConfig = null; protected static array $__journalFieldMap = []; protected static bool $__enableJournaling = true; private static array $__db_instances = []; protected ?FronkDB $__db = null; protected ?mfLoghandler $__log = null; private ?stdClass $__originalData = null; private bool $__isLoaded = false; public function __construct(int|string $id = null) { static::__init_db(); $this->__db = self::$__db_instances[static::class]; $this->__log = mfLoghandler::singleton(); $this->__originalData = new stdClass(); if ($id !== null) $this->__load($id); } protected static function __init_db(): void { if (isset(self::$__db_instances[static::class])) return; if (empty(static::$__tableName)) { throw new Exception('$__tableName must be set in ' . get_called_class()); } self::$__db_instances[static::class] = static::$__databaseConfig !== null ? FronkDB::singleton( static::$__databaseConfig['host'], static::$__databaseConfig['user'], static::$__databaseConfig['pass'], static::$__databaseConfig['name'] ) : FronkDB::singleton(); } protected static function __getDb(): FronkDB { static::__init_db(); return self::$__db_instances[static::class]; } public static function get(int|string $id): ?static { static::__init_db(); $model = new static(); return $model->__load($id) ? $model : null; } public static function getFirst(array $filter = [], array $order = []): ?static { $results = static::getAll($filter, 1, 0, $order); return $results[0] ?? null; } public static function getAll(array $filter = [], int $limit = null, int $offset = 0, array $order = []): array { static::__init_db(); $db = self::$__db_instances[static::class]; $table = static::$__tableName; $whereSql = static::__buildFilterSql($filter); $orderSql = ""; if (!empty($order['column'])) { $dir = (strtoupper($order['dir'] ?? '') === 'DESC') ? 'DESC' : 'ASC'; $orderSql = "ORDER BY `" . $db->escape($order['column']) . "` $dir"; } $limitSql = $limit !== null ? "LIMIT " . (int)$offset . ", " . (int)$limit : ""; $res = $db->query("SELECT * FROM `$table` $whereSql $orderSql $limitSql"); $items = []; if ($db->num_rows($res)) { while ($data = $db->fetch_object($res)) { $model = new static(); $model->__populate($data); $model->__isLoaded = true; $items[] = $model; } } return $items; } public static function search(array $filter = [], int $limit = null, int $offset = 0, array $order = []): array { return static::getAll($filter, $limit, $offset, $order); } public static function count(array $filter = []): int { static::__init_db(); $db = self::$__db_instances[static::class]; $whereSql = static::__buildFilterSql($filter); $res = $db->query("SELECT COUNT(*) as cnt FROM `" . static::$__tableName . "` $whereSql"); return $db->num_rows($res) ? (int)$db->fetch_object($res)->cnt : 0; } public function isLoaded(): bool { return $this->__isLoaded; } public function getId(): int|string|null { return $this->{static::$__primaryKey} ?? null; } public function save(): bool { try { $isInsert = !$this->__isLoaded; $userId = $this->__getUserId(); $now = time(); if (property_exists($this, 'edit')) $this->edit = $now; if (property_exists($this, 'edit_by')) $this->edit_by = $userId; if ($isInsert) { if (property_exists($this, 'create')) $this->create = $now; if (property_exists($this, 'create_by')) $this->create_by = $userId; } $errors = $this->validate(); if (!empty($errors)) { $this->__log->warn('Validation failed: ' . implode(', ', $errors)); return false; } if (!$this->beforeSave($isInsert)) return false; $data = $this->__getPublicData(); $changes = $this->__getChangedFields($data); $pk = static::$__primaryKey; if (!$isInsert && empty($changes)) return true; if ($isInsert) { if (array_key_exists($pk, $data) && $data[$pk] === null) unset($data[$pk]); if (!$this->__db->insert(static::$__tableName, $data)) { throw new Exception("INSERT failed: " . $this->__db->getLastError()); } $this->{$pk} = $this->__db->insert_id; $this->__isLoaded = true; } else { $pkValue = $this->{$pk}; $updateData = []; foreach ($changes as $field => $change) $updateData[$field] = $change['new']; if (!empty($updateData)) { if (!$this->__db->update(static::$__tableName, $updateData, "`$pk` = '" . $this->__db->escape($pkValue) . "'")) { throw new Exception("UPDATE failed: " . $this->__db->getLastError()); } } } $this->__originalData = (object)$this->__getPublicData(); $this->afterSave($isInsert, $changes); if (static::$__enableJournaling) $this->__writeToJournal($changes, $isInsert); return true; } catch (Exception $e) { $this->__log->error("mfBaseModelV2 save() error: " . $e->getMessage()); return false; } } public function delete(): bool { if (!$this->__isLoaded) return false; $pk = static::$__primaryKey; $pkValue = $this->{$pk}; if ($this->__db->delete(static::$__tableName, "`$pk` = '" . $this->__db->escape($pkValue) . "'")) { if (static::$__enableJournaling) $this->__writeToJournal([], false, true); $this->__isLoaded = false; return true; } return false; } public function getJournalHistory(): array { $journalDb = FronkDB::singleton(); $pkValue = $this->{static::$__primaryKey} ?? null; if ($pkValue === null) return []; $modelName = $journalDb->escape(get_called_class()); $recordId = $journalDb->escape($pkValue); $res = $journalDb->query("SELECT * FROM `Journal` WHERE `model` = '$modelName' AND `record_id` = '$recordId' ORDER BY `timestamp` DESC"); $history = []; if ($journalDb->num_rows($res)) { while ($row = $journalDb->fetch_object($res)) { if ($row->field && isset(static::$__journalFieldMap[$row->field])) { $row->field_readable = static::$__journalFieldMap[$row->field]; } $history[] = $row; } } return $history; } public function toArray(): array { return $this->__getPublicData(); } public function toJson(): string { return json_encode($this->toArray()); } // Hooks public function validate(): array { return []; } protected function beforeSave(bool $isInsert): bool { return true; } protected function afterSave(bool $isInsert, array $changes): void {} // Internal methods private function __load(int|string $id): bool { $pk = static::$__primaryKey; $res = $this->__db->select(static::$__tableName, "*", "`$pk` = '" . $this->__db->escape($id) . "' LIMIT 1"); if ($this->__db->num_rows($res)) { $this->__populate($this->__db->fetch_object($res)); $this->__isLoaded = true; return true; } return false; } private function __populate(stdClass $data): void { $reflector = new ReflectionClass($this); foreach ($reflector->getProperties(ReflectionProperty::IS_PUBLIC) as $prop) { $name = $prop->getName(); if (!property_exists($data, $name)) continue; $type = $prop->getType()?->getName(); $value = $data->{$name}; $this->{$name} = $value === null ? null : match ($type) { 'int' => (int)$value, 'float' => (float)$value, 'bool' => (bool)$value, 'string' => (string)$value, default => $value, }; } $this->__originalData = (object)$this->__getPublicData(); } private function __getPublicData(): array { $data = []; $reflector = new ReflectionClass($this); foreach ($reflector->getProperties(ReflectionProperty::IS_PUBLIC) as $prop) { $name = $prop->getName(); $data[$name] = $prop->isInitialized($this) ? $this->{$name} : ($prop->hasDefaultValue() ? $prop->getDefaultValue() : null); } return $data; } private function __getChangedFields(array $currentData): array { $changes = []; if (!$this->__isLoaded || !$this->__originalData) { foreach ($currentData as $key => $value) $changes[$key] = ['old' => null, 'new' => $value]; return $changes; } foreach ($currentData as $key => $value) { if (!property_exists($this->__originalData, $key) || $this->__originalData->{$key} != $value) { $changes[$key] = ['old' => $this->__originalData->{$key} ?? null, 'new' => $value]; } } return $changes; } private function __writeToJournal(array $changes, bool $isInsert, bool $isDelete = false): void { try { $journalDb = FronkDB::singleton(); $baseData = [ 'user_id' => $this->__getUserId(), 'model' => get_called_class(), 'record_id' => $this->{static::$__primaryKey}, ]; if ($isDelete) { $journalDb->insert('Journal', $baseData + ['action' => 'delete']); } elseif ($isInsert) { $journalDb->insert('Journal', $baseData + ['action' => 'create']); } else { foreach ($changes as $field => $change) { $journalDb->insert('Journal', $baseData + [ 'action' => 'update', 'field' => $field, 'old_value' => is_array($change['old']) || is_object($change['old']) ? json_encode($change['old']) : $change['old'], 'new_value' => is_array($change['new']) || is_object($change['new']) ? json_encode($change['new']) : $change['new'], ]); } } } catch (Exception $e) { $this->__log->error("Journal write failed: " . $e->getMessage()); } } private function __getUserId(): ?int { try { $me = new User(); $me->loadMe(); return $me->id ?? null; } catch (Exception) { return null; } } /** * Builds WHERE clause from filter array. * Operators: =exact, !not, >, <, >=, <= * Arrays become IN/NOT IN, null becomes IS NULL/IS NOT NULL */ private static function __buildFilterSql(array $filter): string { $whereClauses = ["1=1"]; $db = self::$__db_instances[static::class]; $reflector = new ReflectionClass(static::class); foreach ($filter as $key => $value) { $column = $key; $operator = '='; $forceExact = false; // Parse operator from key prefix if (str_starts_with($key, '>=')) { $operator = '>='; $column = substr($key, 2); } elseif (str_starts_with($key, '<=')) { $operator = '<='; $column = substr($key, 2); } elseif (str_starts_with($key, '!')) { $operator = '!='; $column = substr($key, 1); } elseif (str_starts_with($key, '>')) { $operator = '>'; $column = substr($key, 1); } elseif (str_starts_with($key, '<')) { $operator = '<'; $column = substr($key, 1); } elseif (str_starts_with($key, '=')) { $operator = '='; $column = substr($key, 1); $forceExact = true; } if (!$reflector->hasProperty($column)) { mfLoghandler::singleton()->warn("Filter: Unknown property '$column' on " . static::class); continue; } // NULL handling if ($value === null) { $whereClauses[] = "`$column` " . ($operator === '!=' ? 'IS NOT NULL' : 'IS NULL'); continue; } // Array = IN/NOT IN if (is_array($value)) { $op = ($operator === '!=') ? 'NOT IN' : 'IN'; if (empty($value)) { $whereClauses[] = ($op === 'IN') ? "0=1" : "1=1"; continue; } $escaped = array_map(fn($v) => "'" . $db->escape($v) . "'", $value); $whereClauses[] = "`$column` $op (" . implode(',', $escaped) . ")"; continue; } // String lazy search vs exact/numeric $prop = $reflector->getProperty($column); $type = $prop->getType()?->getName() ?? 'string'; if ($type === 'string' && $operator === '=' && !$forceExact) { foreach (explode(' ', (string)$value) as $term) { if (empty($term)) continue; $whereClauses[] = "`$column` LIKE '%" . $db->escape($term) . "%'"; } } else { $whereClauses[] = "`$column` $operator '" . $db->escape($value) . "'"; } } return "WHERE " . implode(" AND ", $whereClauses); } }