374 lines
14 KiB
PHP
374 lines
14 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Modern base model with typed properties and automatic journaling.
|
|
*
|
|
* Filter operators: =exact, !not, >, <, >=, <=
|
|
* 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);
|
|
}
|
|
}
|