Merge pull request #549 from arabcoders/dev

Updated unittest.
This commit is contained in:
Abdulmohsen
2024-09-11 20:57:01 +03:00
committed by GitHub
28 changed files with 2185 additions and 792 deletions

View File

@@ -187,12 +187,17 @@ return (function (): array {
PDO::class => [ PDO::class => [
'class' => function (): PDO { 'class' => function (): PDO {
$dbFile = Config::get('database.file'); $inTestMode = true === (defined('IN_TEST_MODE') && true === IN_TEST_MODE);
$changePerm = !file_exists($dbFile); $dsn = $inTestMode ? 'sqlite::memory:' : Config::get('database.dsn');
$pdo = new PDO(dsn: Config::get('database.dsn'), options: Config::get('database.options', [])); if (false === $inTestMode) {
$dbFile = Config::get('database.file');
$changePerm = !file_exists($dbFile);
}
if ($changePerm && inContainer() && 777 !== (int)(decoct(fileperms($dbFile) & 0777))) { $pdo = new PDO(dsn: $dsn, options: Config::get('database.options', []));
if (!$inTestMode && $changePerm && inContainer() && 777 !== (int)(decoct(fileperms($dbFile) & 0777))) {
@chmod($dbFile, 0777); @chmod($dbFile, 0777);
} }

View File

@@ -563,8 +563,6 @@ class ExportCommand extends Command
], ],
]); ]);
$this->db->singleTransaction();
$requests = []; $requests = [];
foreach ($backends as $backend) { foreach ($backends as $backend) {
@@ -601,6 +599,7 @@ class ExportCommand extends Command
} }
} }
$start = makeDate();
$this->logger->notice("SYSTEM: Sending '{total}' play state comparison requests.", [ $this->logger->notice("SYSTEM: Sending '{total}' play state comparison requests.", [
'total' => count($requests), 'total' => count($requests),
]); ]);
@@ -614,9 +613,25 @@ class ExportCommand extends Command
} }
} }
$this->logger->notice("SYSTEM: Sent '{total}' play state comparison requests.", [ $end = makeDate();
'total' => count($requests), $this->logger->notice(
]); "SYSTEM: Completed '{total}' play state comparison requests in '{time.duration}'s. Parsed '{responses.size}' of data.",
[
'total' => count($requests),
'time' => [
'start' => $start,
'end' => $end,
'duration' => $end->getTimestamp() - $start->getTimestamp(),
],
'memory' => [
'now' => getMemoryUsage(),
'peak' => getPeakMemoryUsage(),
],
'responses' => [
'size' => fsize((int)Message::get('response.size', 0)),
],
]
);
$this->logger->notice("Export mode ended for '{backends}'.", [ $this->logger->notice("Export mode ended for '{backends}'.", [
'backends' => implode(', ', array_keys($backends)), 'backends' => implode(', ', array_keys($backends)),

View File

@@ -381,8 +381,6 @@ class ImportCommand extends Command
], ],
]); ]);
$this->db->singleTransaction();
foreach ($list as $name => &$backend) { foreach ($list as $name => &$backend) {
$metadata = false; $metadata = false;
$opts = ag($backend, 'options', []); $opts = ag($backend, 'options', []);
@@ -472,21 +470,24 @@ class ImportCommand extends Command
$end = makeDate(); $end = makeDate();
$this->logger->notice("SYSTEM: Completed waiting on '{total}' requests in '{time.duration}s'.", [ $this->logger->notice(
'total' => number_format(count($queue)), "SYSTEM: Completed waiting on '{total}' requests in '{time.duration}'s. Parsed '{responses.size}' of data.",
'time' => [ [
'start' => $start, 'total' => number_format(count($queue)),
'end' => $end, 'time' => [
'duration' => $end->getTimestamp() - $start->getTimestamp(), 'start' => $start,
], 'end' => $end,
'memory' => [ 'duration' => $end->getTimestamp() - $start->getTimestamp(),
'now' => getMemoryUsage(), ],
'peak' => getPeakMemoryUsage(), 'memory' => [
], 'now' => getMemoryUsage(),
'responses' => [ 'peak' => getPeakMemoryUsage(),
'size' => fsize((int)Message::get('response.size', 0)), ],
], 'responses' => [
]); 'size' => fsize((int)Message::get('response.size', 0)),
],
]
);
$queue = $requestData = null; $queue = $requestData = null;

View File

@@ -54,6 +54,11 @@ final readonly class DataUtil implements JsonSerializable, Stringable
return new self(array_map($callback, $this->data)); return new self(array_map($callback, $this->data));
} }
public function filter(callable $callback): self
{
return new self(array_filter($this->data, $callback, ARRAY_FILTER_USE_BOTH));
}
public function with(string $key, mixed $value): self public function with(string $key, mixed $value): self
{ {
return new self(ag_set($this->data, $key, $value)); return new self(ag_set($this->data, $key, $value));
@@ -71,6 +76,6 @@ final readonly class DataUtil implements JsonSerializable, Stringable
public function __toString(): string public function __toString(): string
{ {
return json_encode($this->data); return json_encode($this->jsonSerialize());
} }
} }

View File

@@ -6,13 +6,13 @@ declare(strict_types=1);
namespace App\Libs\Database; namespace App\Libs\Database;
use App\Libs\Exceptions\DBLayerException; use App\Libs\Exceptions\DBLayerException;
use App\Libs\Exceptions\RuntimeException;
use Closure; use Closure;
use PDO; use PDO;
use PDOException; use PDOException;
use PDOStatement; use PDOStatement;
use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerAwareTrait;
use RuntimeException;
final class DBLayer implements LoggerAwareInterface final class DBLayer implements LoggerAwareInterface
{ {
@@ -20,6 +20,8 @@ final class DBLayer implements LoggerAwareInterface
private const int LOCK_RETRY = 4; private const int LOCK_RETRY = 4;
private int $retry;
private int $count = 0; private int $count = 0;
private string $driver; private string $driver;
@@ -53,128 +55,207 @@ final class DBLayer implements LoggerAwareInterface
public const string IS_JSON_EXTRACT = 'JSON_EXTRACT'; public const string IS_JSON_EXTRACT = 'JSON_EXTRACT';
public const string IS_JSON_SEARCH = 'JSON_SEARCH'; public const string IS_JSON_SEARCH = 'JSON_SEARCH';
public function __construct(private PDO $pdo) public function __construct(private readonly PDO $pdo, private array $options = [])
{ {
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if (is_string($driver)) { if (is_string($driver)) {
$this->driver = $driver; $this->driver = $driver;
} }
$this->retry = ag($this->options, 'retry', self::LOCK_RETRY);
} }
/**
* Execute a SQL statement and return the number of affected rows.
* The execution will be wrapped into {@link DBLayer::wrap()} method. to handle database locks.
*
* @param string $sql The SQL statement to execute.
* @param array $options An optional array of options to be passed to the callback function.
*
* @return int|false The number of affected rows, or false on failure.
*/
public function exec(string $sql, array $options = []): int|false public function exec(string $sql, array $options = []): int|false
{ {
try { $opts = [];
return $this->wrap(function (DBLayer $db) use ($sql, $options) {
$queryString = $sql;
$this->last = [ if (true === ag_exists($options, 'on_failure')) {
'sql' => $queryString, $opts['on_failure'] = $options['on_failure'];
'bind' => [],
];
return $db->pdo->exec($queryString);
});
} catch (PDOException $e) {
if ($e instanceof DBLayerException) {
throw $e;
}
throw (new DBLayerException($e->getMessage()))
->setInfo($sql, [], $e->errorInfo ?? [], $e->getCode())
->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile())
->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine())
->setOptions($options);
} }
return $this->wrap(function (DBLayer $db) use ($sql) {
$queryString = $sql;
$this->last = [
'sql' => $queryString,
'bind' => [],
];
return $db->pdo->exec($queryString);
}, $opts);
} }
public function query(string|PDOStatement $sql, array $bind = [], array $options = []): PDOStatement /**
* Execute an SQL statement and return the PDOStatement object.
*
* @param PDOStatement|string $sql The SQL statement to execute.
* @param array $bind The bind parameters for the SQL statement.
* @param array $options An optional array of options to be passed to the callback function.
*
* @return PDOStatement The returned results wrapped in a PDOStatement object.
*/
public function query(PDOStatement|string $sql, array $bind = [], array $options = []): PDOStatement
{ {
try { $opts = [];
return $this->wrap(function (DBLayer $db) use ($sql, $bind, $options) { if (true === ag_exists($options, 'on_failure')) {
$isStatement = $sql instanceof PDOStatement; $opts['on_failure'] = $options['on_failure'];
$queryString = $isStatement ? $sql->queryString : $sql;
$this->last = [
'sql' => $queryString,
'bind' => $bind,
];
$stmt = $isStatement ? $sql : $db->prepare($sql);
if (false === ($stmt instanceof PDOStatement)) {
throw new PDOException('Unable to prepare statement.');
}
$stmt->execute($bind);
if (false !== stripos($queryString, 'SQL_CALC_FOUND_ROWS')) {
if (false !== ($countStatement = $this->pdo->query('SELECT FOUND_ROWS();'))) {
$this->count = (int)$countStatement->fetch(PDO::FETCH_COLUMN);
}
}
return $stmt;
});
} catch (PDOException $e) {
if ($e instanceof DBLayerException) {
throw $e;
}
throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e))
->setInfo(
(true === ($sql instanceof PDOStatement)) ? $sql->queryString : $sql,
$bind,
$e->errorInfo ?? [],
$e->getCode()
)
->setFile($e->getTrace()[$options['tracer'] ?? 1]['file'] ?? $e->getFile())
->setLine($e->getTrace()[$options['tracer'] ?? 1]['line'] ?? $e->getLine())
->setOptions($options);
} }
return $this->wrap(function (DBLayer $db) use ($sql, $bind) {
$isStatement = $sql instanceof PDOStatement;
$queryString = $isStatement ? $sql->queryString : $sql;
$this->last = [
'sql' => $queryString,
'bind' => $bind,
];
$stmt = $isStatement ? $sql : $db->prepare($sql);
if (!empty($bind)) {
array_map(
fn($k, $v) => $stmt->bindValue($k, $v, is_int($v) ? PDO::PARAM_INT : PDO::PARAM_STR),
array_keys($bind),
$bind
);
}
$stmt->execute();
return $stmt;
}, $opts);
} }
/**
* Start a transaction.
*
* @return bool Returns true on success, false on failure.
*/
public function start(): bool public function start(): bool
{ {
if ($this->pdo->inTransaction()) { if (true === $this->pdo->inTransaction()) {
return false; return false;
} }
return $this->pdo->beginTransaction(); return $this->pdo->beginTransaction();
} }
/**
* Commit a transaction.
*
* @return bool Returns true on success, false on failure.
*/
public function commit(): bool public function commit(): bool
{ {
return $this->pdo->commit(); return $this->pdo->commit();
} }
/**
* Rollback a transaction.
*
* @return bool Returns true on success, false on failure.
*/
public function rollBack(): bool public function rollBack(): bool
{ {
return $this->pdo->rollBack(); return $this->pdo->rollBack();
} }
/**
* Checks if inside a transaction.
*
* @return bool Returns true if a transaction is currently active, false otherwise.
*/
public function inTransaction(): bool public function inTransaction(): bool
{ {
return $this->pdo->inTransaction(); return $this->pdo->inTransaction();
} }
/** /**
* @return bool * This method wraps db operations in a single transaction.
* @deprecated Use {@link self::start()} instead. *
* @param Closure<DBLayer> $callback The callback function to be executed.
* @param bool $auto (Optional) Whether to automatically start and commit the transaction.
* @param array $options (Optional) An optional array of options to be passed the wrapper.
*
* @return mixed The result of the callback function.
*/ */
public function beginTransaction(): bool public function transactional(Closure $callback, bool $auto = true, array $options = []): mixed
{ {
return $this->start(); $autoStartTransaction = true === $auto && false === $this->inTransaction();
if ($autoStartTransaction) {
$options['on_failure'] = function ($e) {
if ($this->inTransaction()) {
$this->rollBack();
}
throw $e;
};
}
return $this->wrap(function (DBLayer $db, array $options = []) use ($callback, $autoStartTransaction) {
if (true === $autoStartTransaction) {
$db->start();
}
$result = $callback($this, $options);
if (true === $autoStartTransaction) {
$db->commit();
}
$this->last = $db->getLastStatement();
return $result;
}, $options);
} }
public function prepare(string $sql, array $options = []): PDOStatement|false /**
* Prepare a statement for execution and return a PDOStatement object.
*
* @param string $sql The SQL statement to prepare.
* @param array $options holds options for {@link PDOStatement} options.
*
* @return PDOStatement The returned results wrapped in a PDOStatement object.
*/
public function prepare(string $sql, array $options = []): PDOStatement
{ {
return $this->pdo->prepare($sql, $options); $stmt = $this->pdo->prepare($sql, $options);
if (false === ($stmt instanceof PDOStatement)) {
throw new PDOException('Unable to prepare statement.');
}
return $stmt;
} }
/**
* Returns the ID of the last inserted row or sequence value.
*
* @return string|false return the last insert id or false on failure or not supported.
*/
public function lastInsertId(): string|false public function lastInsertId(): string|false
{ {
return $this->pdo->lastInsertId(); return $this->pdo->lastInsertId();
} }
/**
* Delete Statement.
*
* @param string $table The table name.
* @param array $conditions The conditions to be met. i.e. WHERE clause.
* @param array $options The options to be passed to the query method.
*
* @return PDOStatement The returned results wrapped in a PDOStatement object.
*/
public function delete(string $table, array $conditions, array $options = []): PDOStatement public function delete(string $table, array $conditions, array $options = []): PDOStatement
{ {
if (empty($conditions)) { if (empty($conditions)) {
@@ -188,9 +269,13 @@ final class DBLayer implements LoggerAwareInterface
$query[] = 'DELETE FROM ' . $this->escapeIdentifier($table, true) . ' WHERE'; $query[] = 'DELETE FROM ' . $this->escapeIdentifier($table, true) . ' WHERE';
$query[] = implode(' AND ', $cond['query']); $query[] = implode(' AND ', $cond['query']);
if (array_key_exists('limit', $options)) { // -- For some reason, the SQLite authors don't include this feature in the amalgamation.
// So it's effectively unavailable to most third-party drivers and programs that use the amalgamation
// to compile SQLite. As we don't control the build version of SQLite, we can't guarantee that this
// feature is available. So we'll just skip it for SQLite.
$ignoreSafety = 'sqlite' !== $this->driver || true === (bool)ag($options, 'ignore_safety', false);
if (array_key_exists('limit', $options) && $ignoreSafety) {
$_ = $this->limitExpr($options['limit']); $_ = $this->limitExpr($options['limit']);
$query[] = $_['query']; $query[] = $_['query'];
$bind = array_replace_recursive($bind, $_['bind']); $bind = array_replace_recursive($bind, $_['bind']);
} }
@@ -277,7 +362,16 @@ final class DBLayer implements LoggerAwareInterface
return $this->query(implode(' ', $query), $bind, $options); return $this->query(implode(' ', $query), $bind, $options);
} }
public function getCount(string $table, array $conditions = [], array $options = []): void /**
* Get the count of rows in a table.
*
* @param string $table The table name.
* @param array $conditions The conditions to be met. i.e. WHERE clause.
* @param array $options The options to be passed to the query method.
*
* @return int The number of rows based on the conditions.
*/
public function getCount(string $table, array $conditions = [], array $options = []): int
{ {
$bind = $query = []; $bind = $query = [];
@@ -308,8 +402,20 @@ final class DBLayer implements LoggerAwareInterface
} }
$this->count = (int)$this->query(implode(' ', $query), $bind, $options)->fetchColumn(); $this->count = (int)$this->query(implode(' ', $query), $bind, $options)->fetchColumn();
return $this->count;
} }
/**
* Update Statement.
*
* @param string $table The table name.
* @param array $changes The changes to be made. i.e. SET clause.
* @param array $conditions The conditions to be met. i.e. WHERE clause.
* @param array $options The options to be passed to the query method.
*
* @return PDOStatement The returned results wrapped in a PDOStatement object.
*/
public function update(string $table, array $changes, array $conditions, array $options = []): PDOStatement public function update(string $table, array $changes, array $conditions, array $options = []): PDOStatement
{ {
if (empty($changes)) { if (empty($changes)) {
@@ -341,7 +447,12 @@ final class DBLayer implements LoggerAwareInterface
$query[] = 'WHERE ' . implode(' AND ', $cond['query']); $query[] = 'WHERE ' . implode(' AND ', $cond['query']);
if (array_key_exists('limit', $options)) { // -- For some reason, the SQLite authors don't include this feature in the amalgamation.
// So it's effectively unavailable to most third-party drivers and programs that use the amalgamation
// to compile SQLite. As we don't control the build version of SQLite, we can't guarantee that this
// feature is available. So we'll just skip it for SQLite.
$ignoreSafety = 'sqlite' !== $this->driver || true === (bool)ag($options, 'ignore_safety', false);
if (array_key_exists('limit', $options) && $ignoreSafety) {
$_ = $this->limitExpr((int)$options['limit']); $_ = $this->limitExpr((int)$options['limit']);
$query[] = $_['query']; $query[] = $_['query'];
@@ -357,6 +468,15 @@ final class DBLayer implements LoggerAwareInterface
return $this->query(implode(' ', $query), $bind, $options); return $this->query(implode(' ', $query), $bind, $options);
} }
/**
* Insert Statement.
*
* @param string $table The table name.
* @param array $conditions Simple associative array of [column => value].
* @param array $options The options to be passed to the query method.
*
* @return PDOStatement The returned results wrapped in a PDOStatement object.
*/
public function insert(string $table, array $conditions, array $options = []): PDOStatement public function insert(string $table, array $conditions, array $options = []): PDOStatement
{ {
if (empty($conditions)) { if (empty($conditions)) {
@@ -387,31 +507,40 @@ final class DBLayer implements LoggerAwareInterface
return $this->query($queryString, $conditions, $options); return $this->query($queryString, $conditions, $options);
} }
/**
* Quote a string for use in a query.
*
* @param mixed $text The string to be quoted.
* @param int $type Provides a data type hint for drivers that have alternate quoting styles.
*
* @return string The quoted string.
*/
public function quote(mixed $text, int $type = PDO::PARAM_STR): string public function quote(mixed $text, int $type = PDO::PARAM_STR): string
{ {
return (string)$this->pdo->quote($text, $type); return (string)$this->pdo->quote($text, $type);
} }
public function escape(string $text): string /**
{ * Get the ID generated in the last query.
return mb_substr($this->quote($text), 1, -1, 'UTF-8'); *
} * @param string|null $name The name of the sequence object from which the ID should be returned.
* @return string The generated ID, or empty string if no ID was generated.
*/
public function id(string|null $name = null): string public function id(string|null $name = null): string
{ {
return false !== ($id = $this->pdo->lastInsertId($name)) ? $id : ''; return false !== ($id = $this->pdo->lastInsertId($name)) ? $id : '';
} }
/**
* Get the number of rows by using {@link DBLayer::getCount()}.
*
* @return int The total number of rows.
*/
public function totalRows(): int public function totalRows(): int
{ {
return $this->count; return $this->count;
} }
public function close(): bool
{
return true;
}
/** /**
* Make sure only valid characters make it in column/table names * Make sure only valid characters make it in column/table names
* *
@@ -456,11 +585,21 @@ final class DBLayer implements LoggerAwareInterface
return $text; return $text;
} }
/**
* Get the PDO driver name.
*
* @return string The driver name.
*/
public function getDriver(): string public function getDriver(): string
{ {
return $this->driver; return $this->driver;
} }
/**
* Get reference to the PDO object.
*
* @return PDO The PDO object.
*/
public function getBackend(): PDO public function getBackend(): PDO
{ {
return $this->pdo; return $this->pdo;
@@ -617,10 +756,22 @@ final class DBLayer implements LoggerAwareInterface
} }
$eBindName = '__db_ftS_' . random_int(1, 1000); $eBindName = '__db_ftS_' . random_int(1, 1000);
$keys[] = sprintf(
"MATCH(%s) AGAINST(%s)", $keys[] = str_replace(
implode(', ', array_map(fn($columns) => $this->escapeIdentifier($columns, true), $opt[1])), ['(column)', '(bind)', '(expr)'],
':' . $eBindName [
$eColumnName,
$eBindName,
implode(', ', array_map(fn($columns) => $this->escapeIdentifier($columns, true), $opt[1]))
],
(function ($driver) {
if ('sqlite' === $driver) {
return "(column) MATCH :(bind)";
}
return "MATCH((expr)) AGAINST(:(bind))";
})(
$this->driver
)
); );
$bind[$eBindName] = $opt[2]; $bind[$eBindName] = $opt[2];
@@ -643,7 +794,7 @@ final class DBLayer implements LoggerAwareInterface
break; break;
case self::IS_JSON_EXTRACT: case self::IS_JSON_EXTRACT:
if (!isset($opt[1], $opt[2], $opt[3])) { if (!isset($opt[1], $opt[2], $opt[3])) {
throw new RuntimeException('IS_JSON_CONTAINS: expects 3 parameters.'); throw new RuntimeException('IS_JSON_EXTRACT: expects 3 parameters.');
} }
$eBindName = '__db_je_' . random_int(1, 1000); $eBindName = '__db_je_' . random_int(1, 1000);
@@ -651,7 +802,7 @@ final class DBLayer implements LoggerAwareInterface
$keys[] = sprintf( $keys[] = sprintf(
"JSON_EXTRACT(%s, %s) %s %s", "JSON_EXTRACT(%s, %s) %s %s",
$this->escapeIdentifier($column, true), $this->escapeIdentifier($column, true),
$opt[1], $this->escapeIdentifier($opt[1], true),
$opt[2], $opt[2],
':' . $eBindName, ':' . $eBindName,
); );
@@ -672,6 +823,14 @@ final class DBLayer implements LoggerAwareInterface
]; ];
} }
/**
* Create an IN expression.
*
* @param string $key column name
* @param array $parameters array of values.
*
* @return array{bind: array<string, mixed>, query: string}, The bind and query.
*/
private function inExpr(string $key, array $parameters): array private function inExpr(string $key, array $parameters): array
{ {
$i = 0; $i = 0;
@@ -689,6 +848,13 @@ final class DBLayer implements LoggerAwareInterface
]; ];
} }
/**
* Create a GROUP BY expression.
*
* @param array $groupBy The columns to group by.
*
* @return array{query: string} The query.
*/
private function groupByExpr(array $groupBy): array private function groupByExpr(array $groupBy): array
{ {
$groupBy = array_map( $groupBy = array_map(
@@ -699,6 +865,13 @@ final class DBLayer implements LoggerAwareInterface
return ['query' => 'GROUP BY ' . implode(', ', $groupBy)]; return ['query' => 'GROUP BY ' . implode(', ', $groupBy)];
} }
/**
* Create an ORDER BY expression.
*
* @param array $orderBy The columns to order by.
*
* @return array{query: string} The query.
*/
private function orderByExpr(array $orderBy): array private function orderByExpr(array $orderBy): array
{ {
$sortBy = []; $sortBy = [];
@@ -712,6 +885,14 @@ final class DBLayer implements LoggerAwareInterface
return ['query' => 'ORDER BY ' . implode(', ', $sortBy)]; return ['query' => 'ORDER BY ' . implode(', ', $sortBy)];
} }
/**
* Create a LIMIT expression.
*
* @param int $limit The limit.
* @param int|null $start The start.
*
* @return array{bind: array<string, int>, query: string} The bind and query.
*/
private function limitExpr(int $limit, ?int $start = null): array private function limitExpr(int $limit, ?int $start = null): array
{ {
$bind = [ $bind = [
@@ -732,87 +913,85 @@ final class DBLayer implements LoggerAwareInterface
]; ];
} }
/**
* Get the last executed statement.
*
* @return array The last executed statement.
*/
public function getLastStatement(): array public function getLastStatement(): array
{ {
return $this->last; return $this->last;
} }
public function transactional(Closure $callback): mixed /**
* Wraps the given callback function with a retry mechanism to handle database locks.
*
* @param Closure{DBLayer,array} $callback The callback function to be executed.
* @param array $options An optional array of options to be passed to the callback function.
*
* @return mixed The result of the callback function.
*
* @throws DBLayerException If an error occurs while executing the callback function.
*/
private function wrap(Closure $callback, array $options = []): mixed
{ {
$autoStartTransaction = false === $this->inTransaction(); static $lastFailure = [];
$on_lock = ag($options, 'on_lock', null);
for ($i = 1; $i <= self::LOCK_RETRY; $i++) { $errorHandler = ag($options, 'on_failure', null);
try { $exception = null;
if (true === $autoStartTransaction) { if (false === ag_exists($options, 'attempt')) {
$this->start(); $options['attempt'] = 0;
}
$result = $callback($this);
if (true === $autoStartTransaction) {
$this->commit();
}
$this->last = $this->getLastStatement();
return $result;
} catch (DBLayerException $e) {
/** @noinspection PhpConditionAlreadyCheckedInspection */
if ($autoStartTransaction && $this->inTransaction()) {
$this->rollBack();
}
//-- sometimes sqlite is locked, therefore attempt to sleep until it's unlocked.
if (false !== stripos($e->getMessage(), 'database is locked')) {
// throw exception if happens self::LOCK_RETRY times in a row.
if ($i >= self::LOCK_RETRY) {
throw $e;
}
/** @noinspection PhpUnhandledExceptionInspection */
sleep(self::LOCK_RETRY + random_int(1, 3));
} else {
throw $e;
}
}
} }
/** try {
* We return in try or throw exception. return $callback($this, $options);
* As such this return should never be reached. } catch (PDOException $e) {
*/ $attempts = (int)ag($options, 'attempts', 0);
return null; if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) {
} if ($attempts >= $this->retry) {
private function wrap(Closure $callback): mixed
{
for ($i = 0; $i <= self::LOCK_RETRY; $i++) {
try {
return $callback($this);
} catch (PDOException $e) {
if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) {
if ($i >= self::LOCK_RETRY) {
throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e))
->setInfo($stnt->queryString, $bind, $e->errorInfo ?? [], $e->getCode())
->setFile($e->getFile())
->setLine($e->getLine());
}
$sleep = self::LOCK_RETRY + random_int(1, 3);
$this->logger?->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [
'sleep' => $sleep
]);
sleep($sleep);
} else {
throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e)) throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e))
->setInfo($stnt->queryString, $bind, $e->errorInfo ?? [], $e->getCode())
->setFile($e->getFile()) ->setFile($e->getFile())
->setLine($e->getLine()); ->setLine($e->getLine());
} }
$sleep = (int)ag($options, 'max_sleep', rand(1, 4));
$this->logger?->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [
'sleep' => $sleep
]);
$options['attempts'] = $attempts + 1;
if (null !== $on_lock) {
return $on_lock($e, $callback, $options);
}
sleep($sleep);
return $this->wrap($callback, $options);
} else {
$exception = $e;
if (null !== $errorHandler && ag($lastFailure, 'message') !== $e->getMessage()) {
$lastFailure = [
'code' => $e->getCode(),
'message' => $e->getMessage(),
'time' => time(),
];
return $errorHandler($e, $callback, $options);
}
if ($e instanceof DBLayerException) {
throw $e;
}
throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e))
->setInfo($this->last['sql'], $this->last['bind'], $e->errorInfo ?? [], $e->getCode())
->setFile($e->getFile())
->setLine($e->getLine());
}
} finally {
if (null === $exception) {
$lastFailure = [];
} }
} }
return false;
} }
} }

View File

@@ -179,13 +179,6 @@ interface DatabaseInterface
*/ */
public function getDBLayer(): DBLayer; public function getDBLayer(): DBLayer;
/**
* Enable single transaction mode.
*
* @return bool
*/
public function singleTransaction(): bool;
/** /**
* Wrap queries into single transaction. * Wrap queries into single transaction.
* *

View File

@@ -9,16 +9,14 @@ use App\Libs\Database\DatabaseInterface as iDB;
use App\Libs\Database\DBLayer; use App\Libs\Database\DBLayer;
use App\Libs\Entity\StateInterface as iState; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Exceptions\DBAdapterException as DBException; use App\Libs\Exceptions\DBAdapterException as DBException;
use App\Libs\Exceptions\DBLayerException;
use App\Libs\Options; use App\Libs\Options;
use Closure; use Closure;
use DateTimeInterface; use DateTimeInterface;
use PDO; use PDO;
use PDOException; use PDOException;
use PDOStatement; use PDOStatement;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface as iLogger;
use Random\RandomException; use Throwable;
use RuntimeException;
/** /**
* Class PDOAdapter * Class PDOAdapter
@@ -27,21 +25,11 @@ use RuntimeException;
*/ */
final class PDOAdapter implements iDB final class PDOAdapter implements iDB
{ {
/**
* @var int The number of times to retry acquiring a lock.
*/
private const int LOCK_RETRY = 4;
/** /**
* @var bool Whether the current operation is in a transaction. * @var bool Whether the current operation is in a transaction.
*/ */
private bool $viaTransaction = false; private bool $viaTransaction = false;
/**
* @var bool Whether the current operation is using a single transaction.
*/
private bool $singleTransaction = false;
/** /**
* @var array Adapter options. * @var array Adapter options.
*/ */
@@ -55,20 +43,14 @@ final class PDOAdapter implements iDB
'update' => null, 'update' => null,
]; ];
/**
* @var string The database driver to be used.
*/
private string $driver = 'sqlite';
/** /**
* Creates a new instance of the class. * Creates a new instance of the class.
* *
* @param LoggerInterface $logger The logger object used for logging. * @param iLogger $logger The logger object used for logging.
* @param DBLayer $db The PDO object used for database connections. * @param DBLayer $db The PDO object used for database connections.
*/ */
public function __construct(private LoggerInterface $logger, private readonly DBLayer $db) public function __construct(private iLogger $logger, private readonly DBLayer $db)
{ {
$this->driver = $this->db->getDriver();
} }
/** /**
@@ -83,7 +65,6 @@ final class PDOAdapter implements iDB
/** /**
* @inheritdoc * @inheritdoc
* @throws RandomException if an error occurs while generating a random number.
*/ */
public function insert(iState $entity): iState public function insert(iState $entity): iState
{ {
@@ -158,12 +139,20 @@ final class PDOAdapter implements iDB
); );
} }
$this->execute($this->stmt['insert'], $data); $this->db->query($this->stmt['insert'], $data, options: [
'on_failure' => function (Throwable $e) use ($entity) {
if (false === str_contains($e->getMessage(), '21 bad parameter or other API misuse')) {
throw $e;
}
$this->stmt['insert'] = null;
return $this->insert($entity);
}
]);
$entity->id = (int)$this->db->lastInsertId(); $entity->id = (int)$this->db->lastInsertId();
} catch (PDOException $e) { } catch (PDOException $e) {
$this->stmt['insert'] = null; $this->stmt['insert'] = null;
if (false === $this->viaTransaction && false === $this->singleTransaction) { if (false === $this->viaTransaction) {
$this->logger->error( $this->logger->error(
message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.", message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
@@ -193,7 +182,6 @@ final class PDOAdapter implements iDB
/** /**
* @inheritdoc * @inheritdoc
* @throws RandomException if an error occurs while generating a random number.
*/ */
public function get(iState $entity): iState|null public function get(iState $entity): iState|null
{ {
@@ -204,19 +192,7 @@ final class PDOAdapter implements iDB
} }
if (null !== $entity->id) { if (null !== $entity->id) {
$stmt = $this->query( $stmt = $this->db->query('SELECT * FROM state WHERE id = :id', ['id' => (int)$entity->id]);
r(
'SELECT * FROM state WHERE ${column} = ${id}',
context: [
'column' => iState::COLUMN_ID,
'id' => (int)$entity->id
],
opts: [
'tag_left' => '${',
'tag_right' => '}'
],
)
);
if (false !== ($item = $stmt->fetch(PDO::FETCH_ASSOC))) { if (false !== ($item = $stmt->fetch(PDO::FETCH_ASSOC))) {
$item = $entity::fromArray($item); $item = $entity::fromArray($item);
@@ -247,7 +223,6 @@ final class PDOAdapter implements iDB
/** /**
* @inheritdoc * @inheritdoc
* @throws RandomException if an error occurs while generating a random number.
*/ */
public function getAll(DateTimeInterface|null $date = null, array $opts = []): array public function getAll(DateTimeInterface|null $date = null, array $opts = []): array
{ {
@@ -271,13 +246,14 @@ final class PDOAdapter implements iDB
$sql .= ' WHERE ' . iState::COLUMN_UPDATED . ' > ' . $date->getTimestamp(); $sql .= ' WHERE ' . iState::COLUMN_UPDATED . ' > ' . $date->getTimestamp();
} }
if (null === ($opts['class'] ?? null) || false === ($opts['class'] instanceof iState)) { $fromClass = $opts['class'] ?? $this->options['class'] ?? null;
if (null === ($fromClass ?? null) || false === ($fromClass instanceof iState)) {
$class = Container::get(iState::class); $class = Container::get(iState::class);
} else { } else {
$class = $opts['class']; $class = $fromClass;
} }
foreach ($this->query($sql) as $row) { foreach ($this->db->query($sql) as $row) {
$arr[] = $class::fromArray($row); $arr[] = $class::fromArray($row);
} }
@@ -286,7 +262,6 @@ final class PDOAdapter implements iDB
/** /**
* @inheritdoc * @inheritdoc
* @throws RandomException if an error occurs while generating a random number.
*/ */
public function find(iState ...$items): array public function find(iState ...$items): array
{ {
@@ -305,13 +280,11 @@ final class PDOAdapter implements iDB
/** /**
* @inheritdoc * @inheritdoc
* @throws RandomException
*/ */
public function findByBackendId(string $backend, int|string $id, string|null $type = null): iState|null public function findByBackendId(string $backend, int|string $id, string|null $type = null): iState|null
{ {
$key = $backend . '.' . iState::COLUMN_ID; $key = $backend . '.' . iState::COLUMN_ID;
$cond = [ $cond = [
'id' => $id
]; ];
$type_sql = ''; $type_sql = '';
@@ -320,39 +293,33 @@ final class PDOAdapter implements iDB
$cond['type'] = $type; $cond['type'] = $type;
} }
$sql = "SELECT * FROM state WHERE {$type_sql} JSON_EXTRACT(" . iState::COLUMN_META_DATA . ",'$.{$key}') = :id LIMIT 1"; $sql = "SELECT * FROM state WHERE {$type_sql} JSON_EXTRACT(" . iState::COLUMN_META_DATA . ",'$.{$key}') = {id} LIMIT 1";
$stmt = $this->db->prepare($sql); $stmt = $this->db->query(r($sql, ['id' => is_int($id) ? $id : $this->db->quote($id)]), $cond);
if (false === $this->execute($stmt, $cond)) {
throw new DBException(
r("PDOAdapter: Failed to execute sql query. Statement '{sql}', Conditions '{cond}'.", [
'sql' => $sql,
'cond' => arrayToString($cond),
]), 61
);
}
if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) { if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) {
return null; return null;
} }
return Container::get(iState::class)::fromArray($row); $fromClass = $this->options['class'] ?? null;
} if (null === ($fromClass ?? null) || false === ($fromClass instanceof iState)) {
$class = Container::get(iState::class);
} else {
$class = $fromClass;
}
return $class::fromArray($row);
}
/** /**
* @inheritdoc * @inheritdoc
* @throws RandomException if an error occurs while generating a random number.
*/ */
public function update(iState $entity): iState public function update(iState $entity): iState
{ {
try { try {
if (null === ($entity->id ?? null)) { if (null === ($entity->id ?? null)) {
throw new DBException( throw new DBException(r("PDOAdapter: Unable to update '{title}' without primary key defined.", [
r("PDOAdapter: Unable to update '{title}' without primary key defined.", [ 'title' => $entity->getName() ?? 'Unknown'
'title' => $entity->getName() ?? 'Unknown' ]), 51);
]), 51
);
} }
if (true === $entity->isEpisode() && $entity->episode < 1) { if (true === $entity->isEpisode() && $entity->episode < 1) {
@@ -391,15 +358,21 @@ final class PDOAdapter implements iDB
} }
if (null === ($this->stmt['update'] ?? null)) { if (null === ($this->stmt['update'] ?? null)) {
$this->stmt['update'] = $this->db->prepare( $this->stmt['update'] = $this->db->prepare($this->pdoUpdate('state', iState::ENTITY_KEYS));
$this->pdoUpdate('state', iState::ENTITY_KEYS)
);
} }
$this->execute($this->stmt['update'], $data); $this->db->query($this->stmt['update'], $data, options: [
'on_failure' => function (Throwable $e) use ($entity) {
if (false === str_contains($e->getMessage(), '21 bad parameter or other API misuse')) {
throw $e;
}
$this->stmt['update'] = null;
return $this->update($entity);
}
]);
} catch (PDOException $e) { } catch (PDOException $e) {
$this->stmt['update'] = null; $this->stmt['update'] = null;
if (false === $this->viaTransaction && false === $this->singleTransaction) { if (false === $this->viaTransaction) {
$this->logger->error( $this->logger->error(
message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.", message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.",
context: [ context: [
@@ -429,7 +402,6 @@ final class PDOAdapter implements iDB
/** /**
* @inheritdoc * @inheritdoc
* @throws RandomException if an error occurs while generating a random number.
*/ */
public function remove(iState $entity): bool public function remove(iState $entity): bool
{ {
@@ -447,19 +419,7 @@ final class PDOAdapter implements iDB
$id = $entity->id; $id = $entity->id;
} }
$this->query( $this->db->query('DELETE FROM state WHERE id = :id', ['id' => (int)$id]);
r(
'DELETE FROM state WHERE ${column} = ${id}',
[
'column' => iState::COLUMN_ID,
'id' => (int)$id
],
opts: [
'tag_left' => '${',
'tag_right' => '}'
]
)
);
} catch (PDOException $e) { } catch (PDOException $e) {
$this->logger->error( $this->logger->error(
message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.", message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.",
@@ -488,7 +448,6 @@ final class PDOAdapter implements iDB
/** /**
* @inheritdoc * @inheritdoc
* @throws RandomException if an error occurs while generating a random number.
*/ */
public function commit(array $entities, array $opts = []): array public function commit(array $entities, array $opts = []): array
{ {
@@ -563,7 +522,7 @@ final class PDOAdapter implements iDB
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function migrateData(string $version, LoggerInterface|null $logger = null): mixed public function migrateData(string $version, iLogger|null $logger = null): mixed
{ {
return (new PDODataMigration($this->db, $logger ?? $this->logger))->automatic(); return (new PDODataMigration($this->db, $logger ?? $this->logger))->automatic();
} }
@@ -618,7 +577,7 @@ final class PDOAdapter implements iDB
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function setLogger(LoggerInterface $logger): iDB public function setLogger(iLogger $logger): iDB
{ {
$this->logger = $logger; $this->logger = $logger;
@@ -630,20 +589,6 @@ final class PDOAdapter implements iDB
return $this->db; return $this->db;
} }
/**
* @inheritdoc
*/
public function singleTransaction(): bool
{
$this->singleTransaction = true;
if (false === $this->db->inTransaction()) {
$this->db->start();
}
return $this->db->inTransaction();
}
/** /**
* @inheritdoc * @inheritdoc
*/ */
@@ -684,7 +629,7 @@ final class PDOAdapter implements iDB
*/ */
public function __destruct() public function __destruct()
{ {
if (true === $this->singleTransaction && true === $this->db->inTransaction()) { if (true === $this->db->inTransaction()) {
$this->db->commit(); $this->db->commit();
} }
@@ -755,7 +700,6 @@ final class PDOAdapter implements iDB
* @param iState $entity Entity get external ids from. * @param iState $entity Entity get external ids from.
* *
* @return iState|null Entity if found, null otherwise. * @return iState|null Entity if found, null otherwise.
* @throws RandomException if an error occurs while generating a random number.
*/ */
private function findByExternalId(iState $entity): iState|null private function findByExternalId(iState $entity): iState|null
{ {
@@ -809,17 +753,7 @@ final class PDOAdapter implements iDB
$sqlGuids = ' AND ( ' . implode(' OR ', $guids) . ' ) '; $sqlGuids = ' AND ( ' . implode(' OR ', $guids) . ' ) ';
$sql = "SELECT * FROM state WHERE " . iState::COLUMN_TYPE . " = :type {$sqlEpisode} {$sqlGuids} LIMIT 1"; $sql = "SELECT * FROM state WHERE " . iState::COLUMN_TYPE . " = :type {$sqlEpisode} {$sqlGuids} LIMIT 1";
$stmt = $this->db->query($sql, $cond);
$stmt = $this->db->prepare($sql);
if (false === $this->execute($stmt, $cond)) {
throw new DBException(
r("PDOAdapter: Failed to execute sql query. Statement '{sql}', Conditions '{cond}'.", [
'sql' => $sql,
'cond' => arrayToString($cond),
]), 61
);
}
if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) { if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) {
return null; return null;
@@ -827,150 +761,4 @@ final class PDOAdapter implements iDB
return $entity::fromArray($row); return $entity::fromArray($row);
} }
/**
* Executes a prepared SQL statement with optional parameters.
*
* @param PDOStatement $stmt The prepared statement to execute.
* @param array $cond An optional array of parameters to bind to the statement.
* @return bool True if the statement was successfully executed, false otherwise.
*
* @throws PDOException if an error occurs during the execution of the statement.
* @throws RandomException if an error occurs while generating a random number.
*/
private function execute(PDOStatement $stmt, array $cond = []): bool
{
return $this->wrap(fn(PDOAdapter $adapter) => $stmt->execute($cond));
}
/**
* Executes a SQL query on the database.
*
* @param string $sql The SQL query to be executed.
*
* @return PDOStatement|false The result of the query as a PDOStatement object.
* It will return false if the query fails.
*
* @throws PDOException If an error occurs while executing the query.
* @throws RandomException If an error occurs while generating a random number.
*/
private function query(string $sql): PDOStatement|false
{
return $this->wrap(fn(PDOAdapter $adapter) => $adapter->db->query($sql));
}
/**
* FOR DEBUGGING AND DISPLAY PURPOSES ONLY.
*
* @note Do not use it for anything.
* @param string $sql
* @param array $parameters
* @return string
*
* @internal This is for debugging purposes only.
*/
public function getRawSQLString(string $sql, array $parameters): string
{
$replacer = [];
foreach ($parameters as $key => $val) {
$replacer['/(\:' . preg_quote($key, '/') . ')(?:\b|\,)/'] = ctype_digit(
(string)$val
) ? (int)$val : '"' . $val . '"';
}
return preg_replace(array_keys($replacer), array_values($replacer), $sql);
}
/**
* Generates a valid identifier for a table or column.
*
* @param string $text The input text to be transformed into a valid identifier.
* @param bool $quote Indicates whether the generated identifier should be quoted.
* By default, it is set to true.
*
* @return string The generated identifier.
* @throws RuntimeException If the input text is not a valid ASCII name or does not meet the naming convention requirements.
*/
public function identifier(string $text, bool $quote = true): string
{
// table or column has to be valid ASCII name.
// this is opinionated, but we only allow [a-zA-Z0-9_] in column/table name.
if (!\preg_match('#\w#', $text)) {
throw new RuntimeException(
r("PDOAdapter: Invalid column/table '{ident}'. Column/table must be valid ASCII code.", [
'ident' => $text
])
);
}
// The first character cannot be [0-9]:
if (\preg_match('/^\d/', $text)) {
throw new RuntimeException(
r("PDOAdapter: Invalid column/table '{ident}'. Must begin with a letter or underscore.", [
'ident' => $text
]
)
);
}
return !$quote ? $text : match ($this->driver) {
'mssql' => '[' . $text . ']',
'mysql' => '`' . $text . '`',
default => '"' . $text . '"',
};
}
/**
* Wraps the given callback function with a retry mechanism to handle database locks.
*
* @param Closure $callback The callback function to be executed.
*
* @return mixed The result of the callback function.
*
* @throws DBLayerException If an error occurs while executing the callback function.
* @throws RandomException If an error occurs while generating a random number.
*/
private function wrap(Closure $callback): mixed
{
for ($i = 0; $i <= self::LOCK_RETRY; $i++) {
try {
return $callback($this);
} catch (PDOException $e) {
if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) {
if ($i >= self::LOCK_RETRY) {
throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e))
->setInfo(
ag($this->db->getLastStatement(), 'sql', ''),
ag($this->db->getLastStatement(), 'bind', []),
$e->errorInfo ?? [],
$e->getCode()
)
->setFile($e->getFile())
->setLine($e->getLine());
}
$sleep = self::LOCK_RETRY + random_int(1, 3);
$this->logger->warning("PDOAdapter: Database is locked. sleeping for '{sleep}s'.", [
'sleep' => $sleep
]);
sleep($sleep);
} else {
throw (new DBLayerException($e->getMessage(), (int)$e->getCode(), $e))
->setInfo(
ag($this->db->getLastStatement(), 'sql', ''),
ag($this->db->getLastStatement(), 'bind', []),
$e->errorInfo ?? [],
$e->getCode()
)
->setFile($e->getFile())
->setLine($e->getLine());
}
}
}
return false;
}
} }

View File

@@ -28,6 +28,19 @@ final class EnvFile
$this->data = parseEnvFile($this->file); $this->data = parseEnvFile($this->file);
} }
/**
* Return a new instance of the class with the same file.
* This method will not flush the current data into new instance.
* You must call persist() method to save the data. before calling this method.
*
* @param bool $create
* @return self
*/
public function newInstance(bool $create = false): self
{
return new self($this->file, create: $create);
}
/** /**
* Get the value of a configuration setting. * Get the value of a configuration setting.
* *

View File

@@ -1,212 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Libs;
use App\Libs\Attributes\Route\Route;
use FilesystemIterator;
use InvalidArgumentException;
use PhpToken;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionAttribute;
use ReflectionClass;
use RuntimeException;
use SplFileInfo;
use Throwable;
final readonly class Router
{
/**
* @param array $dirs List of directories to scan for php files.
*/
public function __construct(private array $dirs = [])
{
}
public function getDirs(): array
{
return $this->dirs;
}
public function generate(): array
{
$routes = [];
foreach ($this->dirs as $path) {
array_push($routes, ...$this->scanDirectory($path));
}
usort($routes, fn($a, $b) => strlen($a['path']) < strlen($b['path']) ? -1 : 1);
return $routes;
}
private function scanDirectory(string $dir): array
{
$classes = $routes = [];
/** @var array<SplFileInfo> $files */
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
foreach ($files as $file) {
if (!$file->isFile()) {
continue;
}
if (!$file->isReadable() || 'php' !== $file->getExtension()) {
continue;
}
$class = $this->parseFile((string)$file);
if (false === $class) {
continue;
}
array_push($classes, ...$class);
}
foreach ($classes as $className) {
if (!class_exists($className)) {
continue;
}
array_push($routes, ...$this->getRoutes(new ReflectionClass($className)));
}
return $routes;
}
protected function getRoutes(ReflectionClass $class): array
{
$routes = [];
$attributes = $class->getAttributes(Route::class, ReflectionAttribute::IS_INSTANCEOF);
$invokable = false;
foreach ($class->getMethods() as $method) {
if ($method->getName() === '__invoke') {
$invokable = true;
}
}
foreach ($attributes as $attribute) {
try {
$attributeClass = $attribute->newInstance();
if (!$attributeClass instanceof Route) {
continue;
}
} catch (Throwable) {
continue;
}
if (false === $invokable && !$attributeClass->isCli) {
throw new InvalidArgumentException(
r(
'Trying to route \'{route}\' to un-invokable class/method \'{callable}\'.',
[
'route' => $attributeClass->pattern,
'callable' => $class->getName()
]
)
);
}
$routes[] = [
'path' => $attributeClass->pattern,
'method' => $attributeClass->methods,
'callable' => $class->getName(),
'host' => $attributeClass->host,
'middlewares' => $attributeClass->middleware,
'name' => $attributeClass->name,
'port' => $attributeClass->port,
'scheme' => $attributeClass->scheme,
];
}
foreach ($class->getMethods() as $method) {
$attributes = $method->getAttributes(Route::class, ReflectionAttribute::IS_INSTANCEOF);
foreach ($attributes as $attribute) {
try {
$attributeClass = $attribute->newInstance();
if (!$attributeClass instanceof Route) {
continue;
}
} catch (Throwable) {
continue;
}
$call = $method->getName() === '__invoke' ? $class->getName() : [$class->getName(), $method->getName()];
$routes[] = [
'path' => $attributeClass->pattern,
'method' => $attributeClass->methods,
'callable' => $call,
'host' => $attributeClass->host,
'middlewares' => $attributeClass->middleware,
'name' => $attributeClass->name,
'port' => $attributeClass->port,
'scheme' => $attributeClass->scheme,
];
}
}
return $routes;
}
private function parseFile(string $file): array|false
{
$classes = [];
$namespace = '';
try {
$stream = new Stream($file, 'r');
$content = $stream->getContents();
$stream->close();
} catch (InvalidArgumentException $e) {
throw new RuntimeException(
r('Unable to read \'{file}\'. {error}', [
'file' => $file,
'error' => $e->getMessage(),
])
);
}
$tokens = PhpToken::tokenize($content);
$count = count($tokens);
foreach ($tokens as $i => $iValue) {
if ($iValue->getTokenName() === 'T_NAMESPACE') {
for ($j = $i + 1; $j < $count; $j++) {
if ($tokens[$j]->getTokenName() === 'T_NAME_QUALIFIED') {
$namespace = $tokens[$j]->text;
break;
}
}
}
if ($iValue->getTokenName() === 'T_CLASS') {
for ($j = $i + 1; $j < $count; $j++) {
if ($tokens[$j]->getTokenName() === 'T_WHITESPACE') {
continue;
}
if ($tokens[$j]->getTokenName() === 'T_STRING') {
$classes[] = $namespace . '\\' . $tokens[$j]->text;
} else {
break;
}
}
}
}
return count($classes) >= 1 ? $classes : false;
}
}

View File

@@ -10,11 +10,15 @@ use League\Route\Http\Exception\BadRequestException;
use League\Route\Http\Exception\NotFoundException; use League\Route\Http\Exception\NotFoundException;
use Psr\Http\Message\ResponseInterface as iResponse; use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest; use Psr\Http\Message\ServerRequestInterface as iRequest;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use SplFileInfo; use SplFileInfo;
use Throwable; use Throwable;
final class ServeStatic final class ServeStatic implements LoggerAwareInterface
{ {
use LoggerAwareTrait;
private finfo|null $mimeType = null; private finfo|null $mimeType = null;
private const array CONTENT_TYPE = [ private const array CONTENT_TYPE = [
@@ -39,7 +43,6 @@ final class ServeStatic
private const array MD_IMAGES = [ private const array MD_IMAGES = [
'/screenshots' => __DIR__ . '/../../', '/screenshots' => __DIR__ . '/../../',
]; ];
private array $looked = [];
public function __construct(private string|null $staticPath = null) public function __construct(private string|null $staticPath = null)
{ {
@@ -59,8 +62,6 @@ final class ServeStatic
*/ */
public function serve(iRequest $request): iResponse public function serve(iRequest $request): iResponse
{ {
$requestPath = $request->getUri()->getPath();
if (false === in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) { if (false === in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) {
throw new BadRequestException( throw new BadRequestException(
message: r("Method '{method}' is not allowed.", ['method' => $request->getMethod()]), message: r("Method '{method}' is not allowed.", ['method' => $request->getMethod()]),
@@ -68,6 +69,11 @@ final class ServeStatic
); );
} }
// -- as we alter the static path for .md files, we need to keep the original path
// -- do not mutate the original path. as it may be used in other requests.
$staticPath = $this->staticPath;
$requestPath = $request->getUri()->getPath();
if (array_key_exists($requestPath, self::MD_FILES)) { if (array_key_exists($requestPath, self::MD_FILES)) {
return $this->serveFile($request, new SplFileInfo(self::MD_FILES[$requestPath])); return $this->serveFile($request, new SplFileInfo(self::MD_FILES[$requestPath]));
} }
@@ -75,44 +81,36 @@ final class ServeStatic
// -- check if the request path is in the MD_IMAGES array // -- check if the request path is in the MD_IMAGES array
foreach (self::MD_IMAGES as $key => $value) { foreach (self::MD_IMAGES as $key => $value) {
if (str_starts_with($requestPath, $key)) { if (str_starts_with($requestPath, $key)) {
$this->staticPath = realpath($value); $staticPath = realpath($value);
break; break;
} }
} }
$filePath = fixPath($this->staticPath . $requestPath); if (false === ($realBasePath = realpath($staticPath))) {
throw new BadRequestException(
message: r("The static path '{path}' doesn't exists.", ['path' => $staticPath]),
code: Status::SERVICE_UNAVAILABLE->value
);
}
$filePath = fixPath($staticPath . $requestPath);
if (is_dir($filePath)) { if (is_dir($filePath)) {
$filePath = $filePath . '/index.html'; $filePath = $filePath . '/index.html';
} }
if (!file_exists($filePath)) { if (!file_exists($filePath)) {
$checkIndex = $this->deepIndexLookup($this->staticPath, $requestPath); $this->logger?->debug("File '{file}' is not found.", ['file' => $filePath]);
if (!file_exists($checkIndex)) { $checkIndex = fixPath($staticPath . $this->deepIndexLookup($staticPath, $requestPath));
throw new NotFoundException( if (false === file_exists($checkIndex) || false === is_file($checkIndex)) {
message: r( throw new NotFoundException(r("Path '{file}' is not found.", [
"File '{file}' is not found. {checkIndex} {looked}", 'file' => $requestPath,
[ ]), code: Status::NOT_FOUND->value);
'file' => $requestPath,
'checkIndex' => $checkIndex,
'looked' => $this->looked,
]
),
code: Status::NOT_FOUND->value
);
} }
$filePath = $checkIndex; $filePath = $checkIndex;
} }
if (false === ($realBasePath = realpath($this->staticPath))) {
throw new BadRequestException(
message: r("The static path '{path}' doesn't exists.", ['path' => $this->staticPath]),
code: Status::SERVICE_UNAVAILABLE->value
);
}
$filePath = realpath($filePath); $filePath = realpath($filePath);
if (false === $filePath || false === str_starts_with($filePath, $realBasePath)) { if (false === $filePath || false === str_starts_with($filePath, $realBasePath)) {
throw new BadRequestException( throw new BadRequestException(
message: r("Request '{file}' is invalid.", ['file' => $requestPath]), message: r("Request '{file}' is invalid.", ['file' => $requestPath]),
@@ -183,23 +181,21 @@ final class ServeStatic
// -- paths may look like /parent/id/child, do a deep lookup for index.html at each level // -- paths may look like /parent/id/child, do a deep lookup for index.html at each level
// return the first index.html found // return the first index.html found
$path = fixPath($path); $path = fixPath($path);
if ('/' === $path || empty($path)) {
if ('/' === $path) {
return $path; return $path;
} }
$paths = explode('/', $path); $paths = explode('/', $path);
$count = count($paths); $count = count($paths);
if ($count < 2) { $index = $count - 1;
if ($index < 2) {
return $path; return $path;
} }
$index = $count - 1;
for ($i = $index; $i > 0; $i--) { for ($i = $index; $i > 0; $i--) {
$check = $base . implode('/', array_slice($paths, 0, $i)) . '/index.html'; $check = implode('/', array_slice($paths, 0, $i)) . '/index.html';
$this->looked[] = $check; if (file_exists($base . $check)) {
if (file_exists($check)) {
return $check; return $check;
} }
} }

View File

@@ -16,13 +16,13 @@ use Symfony\Component\Process\Process;
*/ */
final class Server final class Server
{ {
public const CONFIG_HOST = 'host'; public const string CONFIG_HOST = 'host';
public const CONFIG_PORT = 'port'; public const string CONFIG_PORT = 'port';
public const CONFIG_ROOT = 'root'; public const string CONFIG_ROOT = 'root';
public const CONFIG_PHP = 'php'; public const string CONFIG_PHP = 'php';
public const CONFIG_ENV = 'env'; public const string CONFIG_ENV = 'env';
public const CONFIG_ROUTER = 'router'; public const string CONFIG_ROUTER = 'router';
public const CONFIG_THREADS = 'threads'; public const string CONFIG_THREADS = 'threads';
/** /**
* @var array $config The configuration settings for the server * @var array $config The configuration settings for the server

View File

@@ -36,6 +36,7 @@ class TestCase extends \PHPUnit\Framework\TestCase
* @param Throwable|string $exception Expected exception class * @param Throwable|string $exception Expected exception class
* @param string $exceptionMessage (optional) Exception message * @param string $exceptionMessage (optional) Exception message
* @param int|null $exceptionCode (optional) Exception code * @param int|null $exceptionCode (optional) Exception code
* @param callable{ TestCase, Throwable}|null $callback (optional) Custom callback to handle the exception
* @return void * @return void
*/ */
protected function checkException( protected function checkException(
@@ -44,6 +45,7 @@ class TestCase extends \PHPUnit\Framework\TestCase
Throwable|string $exception, Throwable|string $exception,
string $exceptionMessage = '', string $exceptionMessage = '',
int|null $exceptionCode = null, int|null $exceptionCode = null,
callable $callback = null,
): void { ): void {
$caught = null; $caught = null;
try { try {
@@ -51,13 +53,17 @@ class TestCase extends \PHPUnit\Framework\TestCase
} catch (Throwable $e) { } catch (Throwable $e) {
$caught = $e; $caught = $e;
} finally { } finally {
if (null !== $callback) {
$callback($this, $caught);
return;
}
if (null === $caught) { if (null === $caught) {
$this->fail($reason); $this->fail('No exception was thrown. ' . $reason);
} else { } else {
$this->assertInstanceOf( $this->assertInstanceOf(
is_object($exception) ? $exception::class : $exception, is_object($exception) ? $exception::class : $exception,
$caught, $caught,
$reason $reason . ' ' . $caught->getMessage(),
); );
if (!empty($exceptionMessage)) { if (!empty($exceptionMessage)) {
$this->assertStringContainsString($exceptionMessage, $caught->getMessage(), $reason); $this->assertStringContainsString($exceptionMessage, $caught->getMessage(), $reason);

View File

@@ -7,6 +7,8 @@ use App\Backends\Common\Cache as BackendCache;
use App\Backends\Common\ClientInterface as iClient; use App\Backends\Common\ClientInterface as iClient;
use App\Backends\Common\Context; use App\Backends\Common\Context;
use App\Libs\APIResponse; use App\Libs\APIResponse;
use App\Libs\Attributes\Route\Cli;
use App\Libs\Attributes\Route\Route;
use App\Libs\Attributes\Scanner\Attributes as AttributesScanner; use App\Libs\Attributes\Scanner\Attributes as AttributesScanner;
use App\Libs\Attributes\Scanner\Item as ScannerItem; use App\Libs\Attributes\Scanner\Item as ScannerItem;
use App\Libs\Config; use App\Libs\Config;
@@ -27,7 +29,6 @@ use App\Libs\Guid;
use App\Libs\Initializer; use App\Libs\Initializer;
use App\Libs\Options; use App\Libs\Options;
use App\Libs\Response; use App\Libs\Response;
use App\Libs\Router;
use App\Libs\Stream; use App\Libs\Stream;
use App\Libs\Uri; use App\Libs\Uri;
use App\Listeners\ProcessPushEvent; use App\Listeners\ProcessPushEvent;
@@ -1021,29 +1022,53 @@ if (false === function_exists('generateRoutes')) {
*/ */
function generateRoutes(string $type = 'cli', array $opts = []): array function generateRoutes(string $type = 'cli', array $opts = []): array
{ {
$dirs = [__DIR__ . '/../Commands'];
foreach (array_keys(Config::get('supported', [])) as $backend) {
$dir = r(__DIR__ . '/../Backends/{backend}/Commands', ['backend' => ucfirst($backend)]);
if (!file_exists($dir)) {
continue;
}
$dirs[] = $dir;
}
$routes_cli = (new Router($dirs))->generate();
$cache = $opts[iCache::class] ?? Container::get(iCache::class); $cache = $opts[iCache::class] ?? Container::get(iCache::class);
$routes_cli = $routes_http = [];
try { try {
$dirs = [__DIR__ . '/../Commands'];
foreach (array_keys(Config::get('supported', [])) as $backend) {
$dir = r(__DIR__ . '/../Backends/{backend}/Commands', ['backend' => ucfirst($backend)]);
if (!file_exists($dir)) {
continue;
}
$dirs[] = $dir;
}
foreach (AttributesScanner::scan($dirs, allowNonInvokable: true)->for(Cli::class) as $item) {
$routes_cli[] = [
'callable' => $item->getCallable(),
'path' => ag($item->data, 'pattern'),
'method' => ag($item->data, 'methods'),
'middleware' => ag($item->data, 'middleware'),
'host' => ag($item->data, 'host'),
'name' => ag($item->data, 'name'),
'port' => ag($item->data, 'port'),
'scheme' => ag($item->data, 'scheme'),
];
}
$cache->set('routes_cli', $routes_cli, new DateInterval('PT1H')); $cache->set('routes_cli', $routes_cli, new DateInterval('PT1H'));
} catch (\Psr\SimpleCache\InvalidArgumentException) { } catch (\Psr\SimpleCache\InvalidArgumentException) {
} }
$routes_http = (new Router([__DIR__ . '/../API']))->generate();
try { try {
$dirs = [__DIR__ . '/../API'];
foreach (AttributesScanner::scan($dirs, allowNonInvokable: false)->for(Route::class) as $item) {
$routes_http[] = [
'callable' => $item->getCallable(),
'path' => ag($item->data, 'pattern'),
'method' => ag($item->data, 'methods'),
'middleware' => ag($item->data, 'middleware'),
'host' => ag($item->data, 'host'),
'name' => ag($item->data, 'name'),
'port' => ag($item->data, 'port'),
'scheme' => ag($item->data, 'scheme'),
];
}
$cache->set('routes_http', $routes_http, new DateInterval('P1D')); $cache->set('routes_http', $routes_http, new DateInterval('P1D'));
} catch (\Psr\SimpleCache\InvalidArgumentException) { } catch (\Psr\SimpleCache\InvalidArgumentException) {
} }

View File

@@ -15,13 +15,9 @@ trait UsesBasicRepository
{ {
use UsesPaging; use UsesPaging;
protected DBLayer $db; public function __construct(private readonly DBLayer $db)
public function __construct(DBLayer $db)
{ {
$this->init($db); $this->init($this->db);
$this->db = $db;
if (empty($this->table)) { if (empty($this->table)) {
throw new RuntimeException('You must set table name in $this->table'); throw new RuntimeException('You must set table name in $this->table');

View File

@@ -0,0 +1,646 @@
<?php
/** @noinspection SqlResolve, SqlWithoutWhere */
declare(strict_types=1);
namespace Tests\Database;
use App\Libs\Config;
use App\Libs\Database\DBLayer;
use App\Libs\Database\PDO\PDOAdapter;
use App\Libs\Exceptions\DBLayerException;
use App\Libs\Exceptions\ErrorException;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\Guid;
use App\Libs\TestCase;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use PDO;
use PDOException;
use Throwable;
use TypeError;
class DBLayerTest extends TestCase
{
private DBLayer|null $db = null;
protected TestHandler|null $handler = null;
private function createDB(PDO $pdo): void
{
$pdo->exec('DROP TABLE IF EXISTS "test"');
$pdo->exec(
<<<SQL
CREATE TABLE "test" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" TEXT NULL,
"watched" INTEGER NULL DEFAULT 0,
"added_at" INTEGER NULL,
"updated_at" INTEGER NULL,
"json_data" JSON NULL,
"nullable" TEXT NULL
)
SQL
);
$pdo->exec('DROP TABLE IF EXISTS "fts_table"');
$pdo->exec('CREATE VIRTUAL TABLE "fts_table" USING fts5( name, json_data);');
}
public function setUp(): void
{
$this->handler = new TestHandler();
$logger = new Logger('logger');
$logger->pushHandler($this->handler);
Guid::setLogger($logger);
if (null === Config::get('database', null)) {
Config::init([
'database' => ag(require __DIR__ . '/../../config/config.php', 'database', [])
]);
}
$this->db = new DBLayer(new PDO(dsn: 'sqlite::memory:', options: Config::get('database.options', [])));
$this->createDB($this->db->getBackend());
foreach (Config::get('database.exec', []) as $cmd) {
$this->db->exec($cmd);
}
}
public function test_exec()
{
$this->checkException(
closure: fn() => $this->db->exec('SELECT * FROM movies'),
reason: 'Should throw an exception when an error occurs and no on_failure handler is set.',
exception: DBLayerException::class,
exceptionMessage: 'no such table',
);
$this->checkException(
closure: fn() => $this->db->exec(
sql: 'SELECT * FROM movies',
options: ['on_failure' => fn(Throwable $e) => throw new ErrorException('Error occurred')]
),
reason: 'the on_failure handler should be called when an error occurs.',
exception: ErrorException::class,
exceptionMessage: 'Error occurred',
);
$this->assertSame(0, $this->db->exec('DELETE FROM test'));
}
public function test_query()
{
$this->checkException(
closure: fn() => $this->db->query(sql: 'SELECT * FROM movies'),
reason: 'Should throw an exception when an error occurs and no on_failure handler is set.',
exception: DBLayerException::class,
exceptionMessage: 'no such table',
);
$this->checkException(
closure: fn() => $this->db->query(
sql: 'SELECT * FROM movies',
options: ['on_failure' => fn(Throwable $e) => throw new ErrorException('Error occurred')]
),
reason: 'the on_failure handler should be called when an error occurs.',
exception: ErrorException::class,
exceptionMessage: 'Error occurred',
);
$this->checkException(
closure: function () {
$options = Config::get('database.options', []);
$options[PDO::ATTR_ERRMODE] = PDO::ERRMODE_SILENT;
$pdo = new PDO(dsn: 'sqlite::memory:', options: $options);
$db = new DBLayer($pdo);
foreach (Config::get('database.exec', []) as $cmd) {
$this->db->exec($cmd);
}
(new PDOAdapter(new Logger('test'), $this->db))->migrations('up');
return $db->query(sql: 'SELECT * FROM test WHERE zid = :id');
},
reason: 'If PDO error mode is set to silent mode, failing to prepare a statement should still throw an exception.',
exception: DBLayerException::class,
exceptionMessage: 'Unable to prepare statement.',
);
}
public function test_transactions_operations()
{
$this->db->start();
$this->assertTrue($this->db->inTransaction(), 'Should be in transaction.');
$this->assertFalse($this->db->start(), 'Should not start a new transaction if we are already in one.');
$this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]);
$this->assertSame('1', $this->db->lastInsertId(), 'Should return last insert id.');
$this->db->rollBack();
$this->assertFalse($this->db->inTransaction(), 'Should not be in transaction.');
$this->db->start();
$this->assertCount(
0,
$this->db->select('sqlite_sequence')->fetchAll(),
'Should not have any records, as we rolled back.'
);
$this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]);
$this->assertSame('1', $this->db->lastInsertId(), 'Should return last insert id.');
$this->db->commit();
$this->assertFalse($this->db->inTransaction(), 'Should not be in transaction.');
$this->assertCount(
1,
$this->db->select('sqlite_sequence')->fetchAll(),
'Should have one record, as we committed.'
);
$this->checkException(
closure: fn() => $this->db->transactional(function (DBLayer $db) {
$this->db->insert('sqlite_sequence', ['name' => 'test2', 'seq' => 1]);
$db->insert('not_set', ['name' => 'test', 'seq' => 1]);
}),
reason: 'Should throw an exception when trying to commit without starting a transaction.',
exception: DBLayerException::class,
exceptionMessage: 'no such table',
);
$this->assertCount(
1,
$this->db->select('sqlite_sequence')->fetchAll(),
'Should have one record, as the previous transaction was rolled back.'
);
$ret = $this->db->transactional(function (DBLayer $db) {
return $db->insert('sqlite_sequence', ['name' => 'test2', 'seq' => 1]);
});
$this->assertSame(1, $ret->rowCount(), 'Should return the number of affected rows.');
}
public function test_insert()
{
$this->checkException(
closure: fn() => $this->db->insert('test', []),
reason: 'Should throw exception if conditions parameter is empty.',
exception: RuntimeException::class
);
}
public function test_delete()
{
$this->checkException(
closure: fn() => $this->db->delete('test', []),
reason: 'Should throw exception if conditions parameter is empty.',
exception: RuntimeException::class
);
$this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]);
try {
$this->assertSame(
1,
$this->db->delete('sqlite_sequence', ['name' => 'test'], options: [
'limit' => 1,
'ignore_safety' => true
])->rowCount(),
'Should return the number of affected rows.'
);
} catch (DBLayerException $e) {
if (str_contains($e->getMessage(), 'near "LIMIT": syntax error') && 'sqlite' === $this->db->getDriver()) {
$this->assertSame(
1,
$this->db->delete('sqlite_sequence', ['name' => 'test'])->rowCount(),
'Should return the number of affected rows.'
);
} else {
throw $e;
}
}
}
public function test_getCount()
{
$this->assertSame(
0,
$this->db->getCount('sqlite_sequence'),
'Should return the number of records in the table.'
);
$this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]);
$total = $this->db->getCount('sqlite_sequence', [
'seq' => [DBLayer::IS_HIGHER_THAN_OR_EQUAL, 1]
], options: [
'groupby' => ['name'],
'orderby' => ['name' => 'ASC'],
]);
$this->assertSame(1, $total, 'Should return the number of records in the table.');
$this->assertSame($total, $this->db->totalRows(), 'Should return the number of records in the table.');
$this->db->delete('sqlite_sequence', ['name' => 'test']);
$this->assertSame(
0,
$this->db->getCount('sqlite_sequence'),
'Should return the number of records in the table.'
);
}
public function test_update()
{
$this->checkException(
closure: fn() => $this->db->update('test', [], ['id' => 1]),
reason: 'Should throw exception if conditions parameter is empty.',
exception: RuntimeException::class
);
$this->checkException(
closure: fn() => $this->db->update('test', ['name' => 'test'], []),
reason: 'Should throw exception if conditions parameter is empty.',
exception: RuntimeException::class
);
$this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]);
$this->assertSame(
1,
$this->db->update('sqlite_sequence', ['seq' => 2], ['name' => 'test'])->rowCount(),
'Should return the number of affected rows.'
);
try {
$this->assertSame(
1,
$this->db->update('sqlite_sequence', ['seq' => 1], ['name' => 'test'], options: [
'limit' => 1,
'ignore_safety' => true
])->rowCount(),
'Should return the number of affected rows.'
);
} catch (DBLayerException $e) {
if (str_contains($e->getMessage(), 'near "LIMIT": syntax error') && 'sqlite' === $this->db->getDriver()) {
$this->assertSame(
1,
$this->db->update('sqlite_sequence', ['seq' => 1], ['name' => 'test'])->rowCount(),
'Should return the number of affected rows.'
);
} else {
throw $e;
}
}
}
public function test_quote()
{
if ('sqlite' === $this->db->getDriver()) {
$this->assertEquals("'test'", $this->db->quote('test'), "Should return 'test'.");
$this->assertSame("'''test'''", $this->db->quote("'test'"), "Should return ''''test''''.");
$this->assertSame("'\"test\"'", $this->db->quote('"test"'), "Should return '\"test\"'.");
}
}
public function test_id()
{
$this->db->insert('sqlite_sequence', ['name' => 'test', 'seq' => 1]);
$this->assertSame('1', $this->db->id('test'), 'Should return the last insert id.');
$this->assertSame('1', $this->db->id(), 'Should return the last insert id.');
}
public function test_escapeIdentifier()
{
$this->checkException(
closure: fn() => $this->db->escapeIdentifier(''),
reason: 'Should throw exception if the identifier is empty.',
exception: RuntimeException::class,
exceptionMessage: 'Column/table must be valid ASCII code'
);
$this->checkException(
closure: fn() => $this->db->escapeIdentifier('😊'),
reason: 'Should throw exception if the identifier contains non-ASCII characters.',
exception: RuntimeException::class,
exceptionMessage: 'Column/table must be valid ASCII code.'
);
$this->checkException(
closure: fn() => $this->db->escapeIdentifier('1foo'),
reason: 'Should throw exception if the identifier contains non-ASCII characters.',
exception: RuntimeException::class,
exceptionMessage: 'Must begin with a letter or underscore'
);
$this->assertSame('foo', $this->db->escapeIdentifier('foo'), 'Should return foo if quote is off.');
if ('sqlite' === $this->db->getDriver()) {
$this->assertSame('"foo"', $this->db->escapeIdentifier('foo', true), 'Should return "foo".');
$this->assertSame(
'""foo"."bar""',
$this->db->escapeIdentifier('"foo"."bar"', true),
'Should return ""foo"."bar"".'
);
}
}
public function test_getBackend()
{
$this->assertInstanceOf(PDO::class, $this->db->getBackend(), 'Should return the PDO instance.');
}
public function test_select()
{
$this->db->insert('test', [
'name' => 'test',
'watched' => 1,
'added_at' => 1,
'updated_at' => 2,
'json_data' => json_encode([
'my_id' => 1,
'my_name' => 'test',
'my_data' => [
'my_id' => 1,
'my_name' => 'test',
],
]),
]);
$this->db->insert('test', [
'name' => 'test2',
'watched' => 0,
'added_at' => 3,
'updated_at' => 4,
'json_data' => json_encode([
'my_id' => 2,
'my_name' => 'test2',
'my_data' => [
'my_id' => 2,
'my_name' => 'test2',
],
]),
]);
$data1 = $this->db->select('test', [], ['id' => 1])->fetch();
$data2 = $this->db->select('test', [], ['id' => 2])->fetch();
$this->checkException(
closure: fn() => $this->db->select('test', ['id' => 1], ['id' => 1]),
reason: 'Should throw TypeError exception if cols value is not a string.',
exception: TypeError::class,
exceptionMessage: 'must be of type string',
);
$this->checkException(
closure: fn() => $this->db->select('test', ['*'], ['id'], options: ['count' => true]),
reason: 'Should throw exception if conditions parameter is not an key/value pairs.',
exception: TypeError::class,
exceptionMessage: 'must be of type string',
);
$this->assertSame(
$data1,
$this->db->select('test', [], ['id' => 1])->fetch(),
'Should return the record with id 1.'
);
$this->assertSame(
$data2,
$this->db->select('test', [], ['id' => 2])->fetch(),
'Should return the record with id 2.'
);
$this->assertSame(
$data2,
$this->db->select('test', [], ['id' => 2], options: [
'orderby' => ['id' => 'DESC'],
'limit' => 1,
'start' => 0,
'groupby' => ['id'],
])->fetch(),
'Should return the record with id 2.'
);
}
public function test_lock_retry()
{
/** @noinspection PhpUnhandledExceptionInspection */
$random = random_int(1, 100);
$this->db->transactional(function (DBLayer $db, array $options = []) use ($random) {
// -- trigger database lock exception
if ((int)ag($options, 'attempts', 0) < 1) {
throw new PDOException('database is locked');
}
$db->insert('sqlite_sequence', ['name' => 'test-' . $random, 'seq' => 1]);
}, options: [
'max_sleep' => 0,
]);
$this->assertSame(1, $this->db->getCount('sqlite_sequence', ['name' => 'test-' . $random]));
$this->checkException(
closure: function () use ($random) {
$this->db->transactional(fn() => throw new PDOException('database is locked'), options: [
'max_sleep' => 0,
'max_attempts' => 1,
]);
},
reason: 'Should throw an exception when the maximum number of attempts is reached.',
exception: DBLayerException::class,
exceptionMessage: 'database is locked',
);
$this->checkException(
closure: function () use ($random) {
$this->db->transactional(fn() => throw new PDOException('database is locked'), options: [
'max_sleep' => 0,
'max_attempts' => 1,
'on_lock' => fn() => throw new DBLayerException('on_lock called'),
]);
},
reason: 'Should throw an exception when the maximum number of attempts is reached.',
exception: DBLayerException::class,
exceptionMessage: 'on_lock called',
);
}
public function test_condition_parser()
{
$this->db->insert('test', [
'name' => 'test',
'watched' => 1,
'added_at' => 1,
'updated_at' => 2,
'json_data' => json_encode([
'my_id' => 1,
'my_name' => 'test',
'my_data' => [
'my_id' => 1,
'my_name' => 'test',
],
]),
]);
$this->db->insert('test', [
'name' => 'test2',
'watched' => 0,
'added_at' => 3,
'updated_at' => 4,
'json_data' => json_encode([
'my_id' => 2,
'my_name' => 'test2',
'my_data' => [
'my_id' => 2,
'my_name' => 'test2',
],
]),
]);
$data1 = $this->db->select('test', [], ['id' => 1])->fetch();
$data2 = $this->db->select('test', [], ['id' => 2])->fetch();
$this->db->insert('fts_table', ['name' => $data1['name'], 'json_data' => $data1['json_data']]);
$this->db->insert('fts_table', ['name' => $data2['name'], 'json_data' => $data2['json_data']]);
$this->assertSame(
$data1,
$this->db->select('test', [], [
'id' => [DBLayer::IS_LOWER_THAN_OR_EQUAL, 1],
])->fetch(),
'Should return the record with id 1.'
);
$this->assertSame(
$data2,
$this->db->select('test', [], [
'id' => [DBLayer::IS_HIGHER_THAN_OR_EQUAL, 2],
])->fetch(),
'Should return the record with id 1.'
);
$this->assertSame(
$data1,
$this->db->select('test', [], [
'added_at' => [DBLayer::IS_BETWEEN, [1, 2]],
])->fetch(),
'Should return the record with id 1.'
);
$this->assertSame(
$data2,
$this->db->select('test', [], [
'added_at' => [DBLayer::IS_NOT_BETWEEN, [1, 2]],
])->fetch(),
'Should return the record with id 2.'
);
$this->assertSame(
$data1,
$this->db->select('test', [], [
'nullable' => [DBLayer::IS_NULL],
])->fetch(),
'Should return the record with id 1.'
);
$this->assertSame(
$data2,
$this->db->select('test', [], [
'name' => [DBLayer::IS_LIKE, 'test2'],
])->fetch(),
'Should return the record with id 2.'
);
$this->assertSame(
$data1,
$this->db->select('test', [], [
'name' => [DBLayer::IS_NOT_LIKE, 'test2'],
])->fetch(),
'Should return the record with id 1.'
);
$this->assertSame(
$data1,
$this->db->select('test', [], [
'id' => [DBLayer::IS_IN, [0, 1]],
])->fetch(),
'Should return the record with id 1.'
);
$this->assertSame(
$data2,
$this->db->select('test', [], [
'id' => [DBLayer::IS_NOT_IN, [0, 1]],
])->fetch(),
'Should return the record with id 2.'
);
try {
$this->assertSame(
$data2,
$this->db->select('test', [], ['json_data' => [DBLayer::IS_JSON_CONTAINS, '$.my_id', 2],])->fetch(),
'Should return the record with id 1.'
);
} catch (DBLayerException $e) {
if (str_contains($e->getMessage(), 'no such function') && 'sqlite' === $this->db->getDriver()) {
// -- pass as sqlite does not support json_contains
} else {
throw $e;
}
}
$this->checkException(
closure: fn() => $this->db->select('test', [], ['json_data' => [DBLayer::IS_JSON_CONTAINS, '$.my_id'],]
)->fetch(),
reason: 'Should throw an exception when json_contains receives less then expected parameters.',
exception: RuntimeException::class,
exceptionMessage: 'IS_JSON_CONTAINS: expects 2',
);
$this->assertSame(
$data2,
$this->db->select('test', [], ['json_data' => [DBLayer::IS_JSON_EXTRACT, '$.my_id', '>', 1]])->fetch(),
'Should return the record with id 2.'
);
$this->checkException(
closure: fn() => $this->db->select('test', [], ['json_data' => [DBLayer::IS_JSON_EXTRACT, '$.my_id', '>']]),
reason: 'Should throw an exception when json_extract receives less then expected parameters.',
exception: RuntimeException::class,
exceptionMessage: 'IS_JSON_EXTRACT: expects 3',
);
$this->checkException(
closure: fn() => $this->db->select('test', [], ['json_data' => ['NOT_SET', '$.my_id', '>']]),
reason: 'Should throw exception on unknown operator.',
exception: RuntimeException::class,
exceptionMessage: 'expr not implemented',
);
$this->assertSame(
'test',
$this->db->select('fts_table', [], [
'name' => [DBLayer::IS_MATCH_AGAINST, ['name'], 'test'],
])->fetch()['name'],
'Should return the record with id 2.'
);
$this->checkException(
closure: fn() => $this->db->select('fts_table', [], [
'name' => [DBLayer::IS_MATCH_AGAINST, ['name']],
])->fetch(),
reason: 'Should throw an exception when match against receives less then expected parameters.',
exception: RuntimeException::class,
exceptionMessage: 'IS_MATCH_AGAINST: expects 2',
);
$this->checkException(
closure: fn() => $this->db->select('fts_table', [], [
'name' => [DBLayer::IS_MATCH_AGAINST, 'name', 'test'],
])->fetch(),
reason: 'Should throw an exception when match against receives less then expected parameters.',
exception: RuntimeException::class,
exceptionMessage: 'IS_MATCH_AGAINST: expects parameter 1 to be array',
);
$this->checkException(
closure: fn() => $this->db->select('fts_table', [], [
'name' => [DBLayer::IS_MATCH_AGAINST, ['name'], ['test']],
])->fetch(),
reason: 'Should throw an exception when match against receives less then expected parameters.',
exception: RuntimeException::class,
exceptionMessage: 'IS_MATCH_AGAINST: expects parameter 2 to be string',
);
}
}

View File

@@ -1,24 +1,25 @@
<?php <?php
/** @noinspection PhpUnhandledExceptionInspection */
declare(strict_types=1); declare(strict_types=1);
namespace Tests\Database; namespace Tests\Database;
use App\Libs\Config;
use App\Libs\Container;
use App\Libs\Database\DatabaseInterface as iDB; use App\Libs\Database\DatabaseInterface as iDB;
use App\Libs\Database\DBLayer; use App\Libs\Database\DBLayer;
use App\Libs\Database\PDO\PDOAdapter; use App\Libs\Database\PDO\PDOAdapter;
use App\Libs\Entity\StateEntity; use App\Libs\Entity\StateEntity;
use App\Libs\Entity\StateInterface; use App\Libs\Entity\StateInterface as iState;
use App\Libs\Exceptions\DBAdapterException as DBException; use App\Libs\Exceptions\DBAdapterException as DBException;
use App\Libs\Guid; use App\Libs\Guid;
use App\Libs\Options;
use App\Libs\TestCase; use App\Libs\TestCase;
use DateTimeImmutable; use DateTimeImmutable;
use Error; use Error;
use Monolog\Handler\TestHandler; use Monolog\Handler\TestHandler;
use Monolog\Logger; use Monolog\Logger;
use PDO; use PDO;
use Random\RandomException;
use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\NullOutput;
@@ -48,32 +49,81 @@ class PDOAdapterTest extends TestCase
Guid::setLogger($logger); Guid::setLogger($logger);
$this->db = new PDOAdapter($logger, new DBLayer(new PDO('sqlite::memory:'))); $this->db = new PDOAdapter($logger, new DBLayer(new PDO('sqlite::memory:')));
$this->db->setOptions([
Options::DEBUG_TRACE => true,
'class' => new StateEntity([]),
]);
$this->db->setLogger($logger);
$this->db->migrations('up'); $this->db->migrations('up');
} }
public function test_insert_throw_exception_if_has_id(): void public function test_insert_throw_exception_if_has_id(): void
{ {
$this->expectException(DBException::class); $this->checkException(
$this->expectExceptionCode(21); closure: function () {
$item = new StateEntity($this->testEpisode); $item = new StateEntity($this->testEpisode);
$this->db->insert($item); $this->db->insert($item);
$this->db->insert($item); $this->db->insert($item);
},
reason: 'When inserting item with id, an exception should be thrown.',
exception: DBException::class,
exceptionMessage: 'primary key already defined',
exceptionCode: 21,
);
}
public function test_insert_conditions(): void
{
$this->checkException(
closure: function () {
$item = new StateEntity($this->testEpisode);
$item->type = 'invalid';
$this->db->insert($item);
},
reason: 'When inserting item with id, an exception should be thrown.',
exception: DBException::class,
exceptionMessage: 'Unexpected content type',
exceptionCode: 22,
);
$this->checkException(
closure: function () {
$item = new StateEntity($this->testEpisode);
$item->episode = 0;
$this->db->insert($item);
},
reason: 'When inserting episode item with episode number 0, an exception should be thrown.',
exception: DBException::class,
exceptionMessage: 'Unexpected episode number',
);
} }
public function test_insert_successful(): void public function test_insert_successful(): void
{ {
$item = $this->db->insert(new StateEntity($this->testEpisode)); $item = new StateEntity($this->testEpisode);
$item->created_at = 0;
$item->updated_at = 0;
$item->watched = 0;
$item = $this->db->insert($item);
$this->assertSame(1, $item->id, 'When inserting new item, id is set to 1 when db is empty.'); $this->assertSame(1, $item->id, 'When inserting new item, id is set to 1 when db is empty.');
$item = new StateEntity($this->testMovie);
$item->created_at = 0;
$item->updated_at = 0;
$item->watched = 0;
$item->setMetadata([
iState::COLUMN_META_DATA_PLAYED_AT => null,
]);
$item = $this->db->insert($item);
$this->assertSame(2, $item->id, 'When inserting new item, id is set to 1 when db is empty.');
} }
/**
* @throws RandomException
*/
public function test_get_conditions(): void public function test_get_conditions(): void
{ {
$test = $this->testEpisode; $test = $this->testEpisode;
foreach (StateInterface::ENTITY_ARRAY_KEYS as $key) { foreach (iState::ENTITY_ARRAY_KEYS as $key) {
if (null === ($test[$key] ?? null)) { if (null === ($test[$key] ?? null)) {
continue; continue;
} }
@@ -104,9 +154,13 @@ class PDOAdapterTest extends TestCase
public function test_getAll_call_without_initialized_container(): void public function test_getAll_call_without_initialized_container(): void
{ {
$this->expectException(Error::class); $this->db->setOptions(['class' => null]);
$this->expectExceptionMessage('Call to a member function'); $this->checkException(
$this->db->getAll(); closure: fn() => $this->db->getAll(),
reason: 'When calling getAll without initialized container, an exception should be thrown.',
exception: Error::class,
exceptionMessage: 'Call to a member function',
);
} }
public function test_getAll_conditions(): void public function test_getAll_conditions(): void
@@ -133,20 +187,34 @@ class PDOAdapterTest extends TestCase
); );
} }
public function test_update_call_without_id_exception(): void public function test_update_fail_conditions(): void
{ {
$this->expectException(DBException::class); $this->checkException(
$this->expectExceptionCode(51); closure: fn() => $this->db->update(new StateEntity($this->testEpisode)),
$item = new StateEntity($this->testEpisode); reason: 'When updating item without id, an exception should be thrown.',
exception: DBException::class,
exceptionMessage: 'without primary key',
exceptionCode: 51,
);
$this->db->update($item); $this->checkException(
closure: function () {
$item = new StateEntity($this->testEpisode);
$this->db->insert($item);
$item->episode = 0;
$this->db->update($item);
},
reason: 'When inserting episode item with episode number 0, an exception should be thrown.',
exception: DBException::class,
exceptionMessage: 'Unexpected episode number',
);
} }
public function test_update_conditions(): void public function test_update_conditions(): void
{ {
$test = $this->testEpisode; $test = $this->testEpisode;
foreach (StateInterface::ENTITY_ARRAY_KEYS as $key) { foreach (iState::ENTITY_ARRAY_KEYS as $key) {
if (null === ($test[$key] ?? null)) { if (null === ($test[$key] ?? null)) {
continue; continue;
} }
@@ -161,12 +229,23 @@ class PDOAdapterTest extends TestCase
$this->assertSame($item, $updatedItem, 'When updating item, same object is returned.'); $this->assertSame($item, $updatedItem, 'When updating item, same object is returned.');
$r = $this->db->get($item)->getAll(); $r = $this->db->get($item)->getAll();
$updatedItem->updated_at = $r[StateInterface::COLUMN_UPDATED_AT]; $updatedItem->updated_at = $r[iState::COLUMN_UPDATED_AT];
$this->assertSame( $this->assertSame(
$updatedItem->getAll(), $updatedItem->getAll(),
$r, $r,
'When updating item, getAll should return same values as the recorded item.' 'When updating item, getAll should return same values as the recorded item.'
); );
$updatedItem->watched = 0;
$item->setMetadata([
iState::COLUMN_META_DATA_PLAYED_AT => null,
]);
$item = $this->db->update($item);
$this->assertNull(
ag($item->getMetadata($item->via), iState::COLUMN_META_DATA_PLAYED_AT),
'When watched flag is set to 0, played_at metadata should be null.'
);
} }
public function test_remove_conditions(): void public function test_remove_conditions(): void
@@ -185,7 +264,7 @@ class PDOAdapterTest extends TestCase
'When db is not empty, remove returns true if record removed.' 'When db is not empty, remove returns true if record removed.'
); );
$this->assertInstanceOf( $this->assertInstanceOf(
StateInterface::class, iState::class,
$this->db->get($item2), $this->db->get($item2),
'When Record exists an instance of StateInterface is returned.' 'When Record exists an instance of StateInterface is returned.'
); );
@@ -205,6 +284,13 @@ class PDOAdapterTest extends TestCase
$this->db->remove($item3), $this->db->remove($item3),
'If record does not have id and/or pointers, return false.' 'If record does not have id and/or pointers, return false.'
); );
$item1 = new StateEntity($this->testEpisode);
$this->db->insert($item1);
$this->assertTrue(
$this->db->remove(new StateEntity($this->testEpisode)),
'When removing item with id, return true.'
);
} }
public function test_commit_conditions(): void public function test_commit_conditions(): void
@@ -218,8 +304,8 @@ class PDOAdapterTest extends TestCase
'Array<added, updated, failed> with count of each operation status.' 'Array<added, updated, failed> with count of each operation status.'
); );
$item1->guids['guid_anidb'] = StateInterface::TYPE_EPISODE . '/1'; $item1->guids['guid_anidb'] = iState::TYPE_EPISODE . '/1';
$item2->guids['guid_anidb'] = StateInterface::TYPE_MOVIE . '/1'; $item2->guids['guid_anidb'] = iState::TYPE_MOVIE . '/1';
$this->assertSame( $this->assertSame(
['added' => 0, 'updated' => 2, 'failed' => 0], ['added' => 0, 'updated' => 2, 'failed' => 0],
@@ -230,8 +316,156 @@ class PDOAdapterTest extends TestCase
public function test_migrations_call_with_wrong_direction_exception(): void public function test_migrations_call_with_wrong_direction_exception(): void
{ {
$this->expectException(DBException::class); $this->checkException(
$this->expectExceptionCode(91); closure: fn() => $this->db->migrations('not_dd'),
$this->db->migrations('not_dd'); reason: 'When calling migrations with wrong direction, an exception should be thrown.',
exception: DBException::class,
exceptionMessage: 'Unknown migration direction',
exceptionCode: 91,
);
}
public function test_commit_transaction_on__destruct(): void
{
$started = $this->db->getDBLayer()->start();
$this->assertTrue($started, 'Transaction should be started.');
$this->db->getDBLayer()->transactional(function () {
$this->db->insert(new StateEntity($this->testEpisode));
$this->db->insert(new StateEntity($this->testMovie));
}, auto: false);
$this->assertTrue($this->db->getDBLayer()->inTransaction(), 'Transaction should be still open.');
$this->db->__destruct();
$this->assertFalse($this->db->getDBLayer()->inTransaction(), 'Transaction should be closed.');
$this->assertCount(
2,
$this->db->getAll(),
'When transaction is committed, records should be found in db.'
);
}
public function test_find(): void
{
$item1 = new StateEntity($this->testEpisode);
$item2 = new StateEntity($this->testMovie);
$this->db->insert($item1);
$this->db->insert($item2);
$items = $this->db->find($item1, $item2, new StateEntity([]));
$this->assertCount(2, $items, 'Only items that are found should be returned.');
$this->assertSame($item1->id, array_values($items)[0]->id, 'When items are found, they should be returned.');
$this->assertSame($item2->id, array_values($items)[1]->id, 'When items are found, they should be returned.');
}
public function test_findByBackendId(): void
{
Container::init();
Container::add(iState::class, new StateEntity([]));
$this->db->setOptions(['class' => null]);
$item1 = new StateEntity($this->testEpisode);
$item2 = new StateEntity($this->testMovie);
$this->db->insert($item1);
$this->db->insert($item2);
$item1_db = $this->db->findByBackendId(
$item1->via,
ag($item1->getMetadata($item1->via), iState::COLUMN_ID),
$item1->type,
);
$this->assertCount(0, $item1_db->apply($item1)->diff(), 'When item is found, it should be returned.');
$this->assertNull(
$this->db->findByBackendId('not_set', 0, 'movie'),
'When item is not found, null should be returned.'
);
$this->db->setOptions(['class' => new StateEntity([])]);
$item2_db = $this->db->findByBackendId(
$item2->via,
ag($item2->getMetadata($item2->via), iState::COLUMN_ID),
$item2->type,
);
$this->assertCount(0, $item2_db->apply($item2)->diff(), 'When item is found, it should be returned.');
}
public function test_ensureIndex()
{
$this->assertTrue($this->db->ensureIndex(), 'When ensureIndex is called, it should return true.');
}
public function test_migrateData()
{
Config::init(require __DIR__ . '/../../config/config.php');
$this->assertFalse(
$this->db->migrateData(Config::get('database.version')),
'At this point we are starting with new database, so migration should be false.'
);
}
public function test_maintenance()
{
Config::init(require __DIR__ . '/../../config/config.php');
$this->assertTrue(
0 === $this->db->maintenance(),
'At this point we are starting with new database, so maintenance should be false.'
);
}
public function test_reset()
{
$this->assertTrue($this->db->reset(), 'When reset is called, it should return true. and reset the db.');
}
public function test_transaction()
{
$this->db->getDBLayer()->start();
$this->checkException(
closure: function () {
return $this->db->transactional(fn() => throw new \PDOException('test', 11));
},
reason: 'If we started transaction from outside the db, it shouldn\'t swallow the exception.',
exception: \PDOException::class,
exceptionMessage: 'test',
exceptionCode: 11,
);
$this->db->getDBLayer()->rollback();
$this->db->getDBLayer()->start();
$this->db->transactional(fn($db) => $db->insert(new StateEntity($this->testEpisode)));
$this->db->getDBLayer()->commit();
$this->checkException(
closure: function () {
return $this->db->transactional(fn() => throw new \PDOException('test', 11));
},
reason: 'The exception should be thrown after rollback.',
exception: \PDOException::class,
exceptionMessage: 'test',
exceptionCode: 11,
);
}
public function test_isMigrated()
{
Config::init(require __DIR__ . '/../../config/config.php');
$db = new PDOAdapter(new Logger('logger'), new DBLayer(new PDO('sqlite::memory:')));
$this->assertFalse(
$db->isMigrated(),
'At this point we are starting with new database, so migration should be false.'
);
$this->assertTrue(
0 === $db->migrations('up'),
'When migrations are run, it should return true.'
);
$this->assertTrue(
$db->isMigrated(),
'When migrations are run, it should return true.'
);
} }
} }

View File

@@ -0,0 +1,6 @@
html {
background-color: #f0f0f0;
font-family: Arial, sans-serif;
font-size: 16px;
color: #333;
}

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1 @@
const testFunc = () => 'test'

View File

@@ -0,0 +1,3 @@
{
"test": "test"
}

View File

@@ -0,0 +1 @@
# Test markdown

View File

@@ -0,0 +1 @@
test

View File

@@ -0,0 +1 @@
test_index.html

View File

@@ -0,0 +1 @@
test

205
tests/Libs/DataUtilTest.php Normal file
View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace Tests\Libs;
use App\Libs\DataUtil;
use App\Libs\TestCase;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\Http\Message\ServerRequestInterface as iRequest;
class DataUtilTest extends TestCase
{
private array $query = ['page' => 1, 'limit' => 10];
private array $post = [
'foo' => 'bar',
'baz' => 'kaz',
'sub' => ['key' => 'val'],
'bool' => true,
'int' => 1,
'float' => 1.1
];
private iRequest|null $request = null;
protected function setUp(): void
{
parent::setUp();
$factory = new Psr17Factory();
$creator = new ServerRequestCreator($factory, $factory, $factory, $factory);
$this->request = $creator->fromArrays(
server: [
'REQUEST_METHOD' => 'GET',
'SCRIPT_FILENAME' => realpath(__DIR__ . '/../../public/index.php'),
'REMOTE_ADDR' => '127.0.0.1',
'REQUEST_URI' => '/',
'SERVER_NAME' => 'localhost',
'SERVER_PORT' => 80,
'HTTP_USER_AGENT' => 'WatchState/0.0',
],
headers: [
'Accept' => 'application/json',
'Authorization' => 'Bearer api_test_token',
],
cookie: ['test' => 'cookie'],
get: $this->query,
post: $this->post,
files: [
'file' => [
'name' => 'test_servers.yaml',
'type' => 'text/plain',
'size' => filesize(__DIR__ . '/../Fixtures/test_servers.yaml'),
'error' => UPLOAD_ERR_OK,
'tmp_name' => __DIR__ . '/../Fixtures/test_servers.yaml',
],
]
);
}
public function test_DataUtil_fromArray(): void
{
$this->assertSame($this->query, DataUtil::fromArray($this->query)->getAll(), 'fromArray() returns all data');
$this->assertSame($this->post, DataUtil::fromArray($this->post)->getAll(), 'fromArray() returns all data');
}
public function test_DataUtil_fromRequest(): void
{
$this->assertSame(
$this->post,
DataUtil::fromRequest($this->request)->getAll(),
'fromRequest() returns all data post data, Default is without query params.'
);
$this->assertSame(
$this->post,
DataUtil::fromRequest($this->request, includeQueryParams: false)->getAll(),
'fromRequest() return only post data, without query params when includeQueryParams is explicitly set to false.'
);
$this->assertSame(
array_replace_recursive($this->query, $this->post),
DataUtil::fromRequest($this->request, includeQueryParams: true)->getAll(),
'fromRequest() returns all data including query params when includeQueryParams is explicitly set to true.'
);
}
public function test_DataUtil_get(): void
{
$obj = DataUtil::fromRequest($this->request, includeQueryParams: true);
$this->assertSame($this->query['page'], $obj->get('page'), 'get() returns the value of the key if it exists.');
$this->assertSame(
$this->query['limit'],
$obj->get('limit'),
'get() returns the value of the key if it exists.'
);
$this->assertNull($obj->get('not_set'), 'get() returns null if the key does not exist.');
$this->assertSame(
'default',
$obj->get('not_set', 'default'),
'get() returns the default value if the key does not exist.'
);
$this->assertIsArray($obj->get('sub'), 'get() returns an array if the key is an array.');
$this->assertIsBool($obj->get('bool'), 'get() returns a boolean if the key is a boolean.');
$this->assertIsInt($obj->get('int'), 'get() returns an integer if the key is an integer.');
$this->assertIsFloat($obj->get('float'), 'get() returns a float if the key is a float.');
$this->assertIsString($obj->get('foo'), 'get() returns a string if the key is a string.');
$this->assertSame(
ag($this->post, 'sub.key'),
$obj->get('sub.key'),
'get() returns the value of the key if it exists.'
);
}
public function test_dataUtil_has()
{
$obj = DataUtil::fromRequest($this->request, includeQueryParams: true);
$this->assertTrue($obj->has('page'), 'has() returns true if the key exists.');
$this->assertTrue($obj->has('limit'), 'has() returns true if the key exists.');
$this->assertFalse($obj->has('not_set'), 'has() returns false if the key does not exist.');
$this->assertTrue($obj->has('sub'), 'has() returns true if the key is an array.');
$this->assertTrue($obj->has('sub.key'), 'has() returns true if the sub.key exists.');
}
public function test_dataUtil_map()
{
$obj = DataUtil::fromRequest($this->request, includeQueryParams: true);
$callback = fn($value) => is_string($value) ? strtoupper($value) : $value;
$data = array_replace_recursive($this->query, $this->post);
$this->assertSame(
array_map($callback, $data),
$obj->map($callback)->getAll(),
'map() returns the array with the callback applied to each value.'
);
}
public function test_dataUtil_filter()
{
$data = array_replace_recursive($this->query, $this->post);
$obj = DataUtil::fromRequest($this->request, includeQueryParams: true);
$callback = fn($value, $key) => is_string($value) && $key === 'foo';
$this->assertSame(
array_filter($data, $callback, ARRAY_FILTER_USE_BOTH),
$obj->filter($callback)->getAll(),
'filter() returns the array with the callback applied to each value.'
);
}
public function test_dataUtil_with()
{
$obj = DataUtil::fromArray($this->query);
$expected = $this->query;
$expected['new'] = 'value';
$this->assertSame(
$expected,
$obj->with('new', 'value')->getAll(),
'with() returns a new DataUtil object with the key and value set.'
);
}
public function test_dataUtil_without()
{
$obj = DataUtil::fromArray($this->query);
$this->assertSame(
['page' => $this->query['page']],
$obj->without('limit')->getAll(),
'without() returns a new DataUtil object without the key.'
);
$this->assertSame(
$this->query,
$obj->without('not_set')->getAll(),
'without() returns a new DataUtil object even if the key does not exist with same data.'
);
$this->assertNotSame(
spl_object_hash($obj),
spl_object_hash($obj->without('not_set')),
'without() Any mutation should return a new object. and the spl_object_hash() should not be the same.'
);
$this->assertNotSame(
spl_object_id($obj),
spl_object_id($obj->without('not_set')),
'without() Any mutation should return a new object. and the spl_object_id() should not be the same.'
);
}
public function test_dataUtil_jsonSerialize()
{
$obj = DataUtil::fromArray($this->query);
$this->assertSame(json_encode($this->query), json_encode($obj), 'jsonSerialize() returns the data array.');
}
public function test_dataUtil_toString()
{
$obj = DataUtil::fromArray($this->query);
$this->assertSame(json_encode($this->query), (string)$obj, 'jsonSerialize() returns the data array.');
}
}

View File

@@ -0,0 +1,155 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
declare(strict_types=1);
namespace Tests\Libs;
use App\Libs\Config;
use App\Libs\Enums\Http\Status;
use App\Libs\ServeStatic;
use App\Libs\TestCase;
use Nyholm\Psr7\ServerRequest;
class ServeStaticTest extends TestCase
{
private ServeStatic|null $server = null;
private string $dataPath = __DIR__ . '/../Fixtures/static_data';
protected function setUp(): void
{
parent::setUp();
$this->server = new ServeStatic(realpath($this->dataPath));
}
private function createRequest(string $method, string $uri, array $headers = []): ServerRequest
{
return new ServerRequest($method, $uri, $headers);
}
public function test_error_responses()
{
$this->checkException(
closure: fn() => $this->server->serve($this->createRequest('GET', '/nonexistent')),
reason: 'If file does not exist, A NotFoundException should be thrown.',
exception: \League\Route\Http\Exception\NotFoundException::class,
exceptionMessage: 'not found',
exceptionCode: Status::NOT_FOUND->value,
);
$this->checkException(
closure: function () {
Config::save('webui.path', '/nonexistent');
return (new ServeStatic())->serve($this->createRequest('GET', '/nonexistent'));
},
reason: 'If file does not exist, A NotFoundException should be thrown.',
exception: \League\Route\Http\Exception\BadRequestException::class,
exceptionMessage: 'The static path',
exceptionCode: Status::SERVICE_UNAVAILABLE->value,
);
$this->checkException(
closure: fn() => $this->server->serve($this->createRequest('PUT', '/nonexistent.md')),
reason: 'Non-idempotent methods should not be allowed on static files.',
exception: \League\Route\Http\Exception\BadRequestException::class,
exceptionMessage: 'is not allowed',
);
// -- Check for LFI vulnerability.
$this->checkException(
closure: fn() => $this->server->serve($this->createRequest('GET', '/../../../composer.json')),
reason: 'Should not allow serving files outside the static directory.',
exception: \League\Route\Http\Exception\BadRequestException::class,
exceptionMessage: 'is invalid.',
exceptionCode: Status::BAD_REQUEST->value,
);
// -- Check for invalid root static path.
$this->checkException(
closure: fn() => (new ServeStatic('/nonexistent'))->serve($this->createRequest('GET', '/test.html')),
reason: 'Should throw exception if the static path does not exist.',
exception: \League\Route\Http\Exception\BadRequestException::class,
exceptionMessage: 'The static path',
exceptionCode: Status::SERVICE_UNAVAILABLE->value,
);
$this->checkException(
closure: fn() => $this->server->serve($this->createRequest('GET', '/test2/foo/bar')),
reason: 'If file does not exist, A NotFoundException should be thrown.',
exception: \League\Route\Http\Exception\NotFoundException::class,
exceptionMessage: 'not found',
exceptionCode: Status::NOT_FOUND->value,
);
$this->checkException(
closure: fn() => $this->server->serve($this->createRequest('GET', '/')),
reason: 'If file does not exist, A NotFoundException should be thrown.',
exception: \League\Route\Http\Exception\NotFoundException::class,
exceptionMessage: 'not found',
exceptionCode: Status::NOT_FOUND->value,
);
}
public function test_responses()
{
$response = $this->server->serve($this->createRequest('GET', '/test.html'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
$this->assertEquals(file_get_contents($this->dataPath . '/test.html'), (string)$response->getBody());
$this->assertSame(filesize($this->dataPath . '/test.html'), $response->getBody()->getSize());
// -- test screenshots serving, as screenshots path is not in public directory and not subject
// -- to same path restrictions as other files.
$response = $this->server->serve($this->createRequest('GET', '/screenshots/add_backend.png'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$this->assertEquals('image/png', $response->getHeaderLine('Content-Type'));
$this->assertEquals(
file_get_contents(__DIR__ . '/../../screenshots/add_backend.png'),
(string)$response->getBody()
);
// -- There are similar rules for .md files test them.
$response = $this->server->serve($this->createRequest('GET', '/README.md'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$this->assertEquals('text/markdown; charset=utf-8', $response->getHeaderLine('Content-Type'));
$this->assertEquals(file_get_contents(__DIR__ . '/../../README.md'), (string)$response->getBody());
// -- Check directory serving.
$response = $this->server->serve($this->createRequest('GET', '/test'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
$this->assertEquals(
file_get_contents(__DIR__ . '/../Fixtures/static_data/test/index.html'),
(string)$response->getBody()
);
$response = $this->server->serve($this->createRequest('GET', '/test.html', [
'if-modified-since' => gmdate('D, d M Y H:i:s T', filemtime($this->dataPath . '/test.html')),
]));
$this->assertEquals(Status::NOT_MODIFIED->value, $response->getStatusCode());
// -- check for deep index lookup.
$response = $this->server->serve($this->createRequest('GET', '/test/view/action/1'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
$this->assertEquals(
file_get_contents(__DIR__ . '/../Fixtures/static_data/test/index.html'),
(string)$response->getBody()
);
$response = $this->server->serve($this->createRequest('GET', '/test/view/1'));
$this->assertEquals(Status::OK->value, $response->getStatusCode());
$response = $this->server->serve($this->createRequest('GET', '/test.html', [
'if-modified-since' => '$$ INVALID DATA',
]));
$this->assertEquals(
Status::OK->value,
$response->getStatusCode(),
'If the date is invalid, the file should be served as normal.'
);
}
}

View File

@@ -17,41 +17,28 @@ class StateEntityTest extends TestCase
private array $testMovie = []; private array $testMovie = [];
private array $testEpisode = []; private array $testEpisode = [];
private TestHandler|null $lHandler = null;
private Logger|null $logger = null;
protected function setUp(): void protected function setUp(): void
{ {
$this->testMovie = require __DIR__ . '/../Fixtures/MovieEntity.php'; $this->testMovie = require __DIR__ . '/../Fixtures/MovieEntity.php';
$this->testEpisode = require __DIR__ . '/../Fixtures/EpisodeEntity.php'; $this->testEpisode = require __DIR__ . '/../Fixtures/EpisodeEntity.php';
$this->lHandler = new TestHandler(); $logger = new Logger('logger', processors: [new LogMessageProcessor()]);
$this->logger = new Logger('logger', processors: [new LogMessageProcessor()]); $logger->pushHandler(new TestHandler());
$this->logger->pushHandler($this->lHandler);
} }
public function test_init_bad_type(): void public function test_init_bad_type(): void
{ {
$this->testMovie[iState::COLUMN_TYPE] = 'oi'; $this->testMovie[iState::COLUMN_TYPE] = 'oi';
try { $this->checkException(
new StateEntity($this->testMovie); closure: fn() => new StateEntity($this->testMovie),
} catch (RuntimeException $e) { reason: 'When new instance of StateEntity is called with invalid type, exception is thrown',
$this->assertInstanceOf( exception: RuntimeException::class,
RuntimeException::class, );
$e, $this->checkException(
'When new instance of StateEntity is called with invalid type, exception is thrown' closure: fn() => StateEntity::fromArray($this->testMovie),
); reason: 'When ::fromArray is called with invalid type, exception is thrown',
} exception: RuntimeException::class,
);
try {
StateEntity::fromArray($this->testMovie);
} catch (RuntimeException $e) {
$this->assertInstanceOf(
RuntimeException::class,
$e,
'When ::fromArray is called with invalid type, exception is thrown'
);
}
} }
public function test_init_bad_data(): void public function test_init_bad_data(): void
@@ -84,11 +71,16 @@ class StateEntityTest extends TestCase
public function test_diff_array_param(): void public function test_diff_array_param(): void
{ {
$entity = new StateEntity($this->testEpisode); $entity = new StateEntity($this->testEpisode);
$entity->setMetadata([iState::COLUMN_META_DATA_PLAYED_AT => 4]); $entity->setMetadata([
iState::COLUMN_META_DATA_PLAYED_AT => 4,
'test' => ['foo' => 'bar'],
]);
$arr = []; $arr = [];
$arr = ag_set($arr, 'metadata.home_plex.played_at.old', 2); $arr = ag_set($arr, 'metadata.home_plex.played_at.old', 2);
$arr = ag_set($arr, 'metadata.home_plex.played_at.new', 4); $arr = ag_set($arr, 'metadata.home_plex.played_at.new', 4);
$arr = ag_set($arr, 'metadata.home_plex.test.old', 'None');
$arr = ag_set($arr, 'metadata.home_plex.test.new', ['foo' => 'bar']);
$this->assertSame( $this->assertSame(
$arr, $arr,
@@ -538,6 +530,16 @@ class StateEntityTest extends TestCase
$entity->diff(), $entity->diff(),
'When apply() is called with fields that contain changed keys, only those fields are applied to current entity.' 'When apply() is called with fields that contain changed keys, only those fields are applied to current entity.'
); );
$data1 = $this->testMovie;
$data1[iState::COLUMN_ID] = 1;
$data2 = $this->testMovie;
$data2[iState::COLUMN_ID] = 2;
$id1 = new StateEntity($data1);
$id2 = new StateEntity($data2);
$this->assertSame(1, $id1->apply($id2)->id, 'When apply() should not alter the object ID.');
} }
public function test_updateOriginal(): void public function test_updateOriginal(): void
@@ -588,9 +590,14 @@ class StateEntityTest extends TestCase
'When setIsTainted() is called with true, isTainted() returns true' 'When setIsTainted() is called with true, isTainted() returns true'
); );
$this->expectException(\TypeError::class); $this->checkException(
/** @noinspection PhpStrictTypeCheckingInspection */ closure: function () use ($entity) {
$entity->setIsTainted('foo'); /** @noinspection PhpStrictTypeCheckingInspection */
return $entity->setIsTainted('foo');
},
reason: 'When setIsTainted() is called with invalid type, exception is thrown',
exception: \TypeError::class,
);
} }
public function test_isTainted(): void public function test_isTainted(): void
@@ -641,8 +648,12 @@ class StateEntityTest extends TestCase
unset($this->testMovie[iState::COLUMN_VIA]); unset($this->testMovie[iState::COLUMN_VIA]);
$entity = new StateEntity($this->testMovie); $entity = new StateEntity($this->testMovie);
$this->expectException(RuntimeException::class);
$entity->setMetadata([]); $this->checkException(
closure: fn() => $entity->setMetadata([]),
reason: 'When setMetadata() called with empty array, an exception is thrown',
exception: RuntimeException::class,
);
} }
public function test_getExtra(): void public function test_getExtra(): void
@@ -686,8 +697,11 @@ class StateEntityTest extends TestCase
unset($this->testMovie[iState::COLUMN_VIA]); unset($this->testMovie[iState::COLUMN_VIA]);
$entity = new StateEntity($this->testMovie); $entity = new StateEntity($this->testMovie);
$this->expectException(RuntimeException::class); $this->checkException(
$entity->setExtra([]); closure: fn() => $entity->setExtra([]),
reason: 'When setExtra() called with empty array, an exception is thrown',
exception: RuntimeException::class,
);
} }
public function test_shouldMarkAsUnplayed(): void public function test_shouldMarkAsUnplayed(): void
@@ -756,6 +770,16 @@ class StateEntityTest extends TestCase
'When metadata played date is missing, shouldMarkAsUnplayed() returns false' 'When metadata played date is missing, shouldMarkAsUnplayed() returns false'
); );
// -- Condition 3: no metadata for via.
$data1 = $this->testMovie;
$data = $this->testMovie;
$data[iState::COLUMN_VIA] = 'not_set';
$data[iState::COLUMN_WATCHED] = 0;
$this->assertFalse(
StateEntity::fromArray($data1)->shouldMarkAsUnplayed(StateEntity::fromArray($data)),
'When no metadata set for a backend, shouldMarkAsUnplayed() returns false'
);
// -- Condition 5: metadata played is false. // -- Condition 5: metadata played is false.
$data = $this->testMovie; $data = $this->testMovie;
$data[iState::COLUMN_META_DATA][$this->testMovie[iState::COLUMN_VIA]][iState::COLUMN_WATCHED] = 0; $data[iState::COLUMN_META_DATA][$this->testMovie[iState::COLUMN_VIA]][iState::COLUMN_WATCHED] = 0;
@@ -845,7 +869,7 @@ class StateEntityTest extends TestCase
public function test_getPlayProgress(): void public function test_getPlayProgress(): void
{ {
$testData = ag_set($this->testMovie, 'watched', 0); $testData = ag_set($this->testMovie, iState::COLUMN_WATCHED, 0);
$testData = ag_set($testData, 'metadata.home_plex.watched', 0); $testData = ag_set($testData, 'metadata.home_plex.watched', 0);
$entity = new StateEntity($testData); $entity = new StateEntity($testData);
$this->assertSame( $this->assertSame(
@@ -854,7 +878,7 @@ class StateEntityTest extends TestCase
'When hasPlayProgress() when valid play progress is set, returns true' 'When hasPlayProgress() when valid play progress is set, returns true'
); );
$testData = ag_set($this->testMovie, 'watched', 0); $testData = ag_set($this->testMovie, iState::COLUMN_WATCHED, 0);
$testData = ag_set($testData, 'metadata.home_plex.watched', 0); $testData = ag_set($testData, 'metadata.home_plex.watched', 0);
$testData = ag_set($testData, 'metadata.test_plex', ag($testData, 'metadata.home_plex', [])); $testData = ag_set($testData, 'metadata.test_plex', ag($testData, 'metadata.home_plex', []));
$testData = ag_set($testData, 'metadata.test.progress', 999); $testData = ag_set($testData, 'metadata.test.progress', 999);
@@ -865,6 +889,17 @@ class StateEntityTest extends TestCase
$entity->getPlayProgress(), $entity->getPlayProgress(),
'When hasPlayProgress() when valid play progress is set, returns true' 'When hasPlayProgress() when valid play progress is set, returns true'
); );
$testData[iState::COLUMN_WATCHED] = 1;
$entity = new StateEntity($testData);
$this->assertSame(0, $entity->getPlayProgress(), 'When entity is watched, getPlayProgress() returns 0');
$testData = ag_set($this->testMovie, iState::COLUMN_WATCHED, 0);
$testData = ag_set($testData, 'metadata.home_plex.watched', 1);
$testData = ag_set($testData, 'metadata.test_plex', ag($testData, 'metadata.home_plex', []));
$testData = ag_set($testData, 'metadata.test.progress', 999);
$entity = new StateEntity($testData);
$this->assertSame(0, $entity->getPlayProgress(), 'When entity is watched, getPlayProgress() returns 0');
} }
public function test_context(): void public function test_context(): void
@@ -960,4 +995,63 @@ class StateEntityTest extends TestCase
'Quorum will not be met if one of the values is null.' 'Quorum will not be met if one of the values is null.'
); );
} }
public function test_updated_added_at_columns()
{
$data = $this->testMovie;
$data[iState::COLUMN_CREATED_AT] = 0;
$data[iState::COLUMN_UPDATED_AT] = 0;
$entity = new StateEntity($data);
$this->assertSame(
$this->testMovie[iState::COLUMN_UPDATED],
$entity->updated_at,
'When entity is created with updated_at set to 0, updated_at is set to updated date from metadata'
);
$this->assertSame(
$this->testMovie[iState::COLUMN_UPDATED],
$entity->created_at,
'When entity is created with created_at set to 0, created_at is set to updated date from metadata'
);
}
public function test_decoding_array_fields()
{
$data = $this->testMovie;
$data[iState::COLUMN_PARENT] = 'garbage data';
$data[iState::COLUMN_GUIDS] = 'garbage data';
$data[iState::COLUMN_META_DATA] = 'garbage data';
$data[iState::COLUMN_EXTRA] = 'garbage data';
$entity = new StateEntity($data);
$this->assertSame([],
$entity->getMetadata(),
'When array keys are json decode fails, getMetadata() should returns empty array'
);
$this->assertSame([],
$entity->getGuids(),
'When array keys are json decode fails, getGuids() should returns empty array'
);
$this->assertSame([],
$entity->getParentGuids(),
'When array keys are json decode fails, getParentGuids() should returns empty array'
);
$this->assertSame([],
$entity->getExtra(),
'When array keys are json decode fails, getExtra() should returns empty array'
);
$this->assertSame([],
$entity->getPointers(),
'When array keys are json decode fails, getPointers() should returns empty array'
);
}
} }

223
tests/Libs/envFileTest.php Normal file
View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace Tests\Libs;
use App\Libs\EnvFile;
use App\Libs\Exceptions\RuntimeException;
use App\Libs\TestCase;
class envFileTest extends TestCase
{
private array $data = [];
protected function setUp(): void
{
parent::setUp();
$this->data = [
"WS_TZ" => "Asia/Kuwait",
"WS_CRON_IMPORT" => "1",
"WS_CRON_EXPORT" => "0",
"WS_CRON_IMPORT_AT" => "16 */1 * * *",
"WS_CRON_EXPORT_AT" => "30 */3 * * *",
"WS_CRON_PUSH_AT" => "*/10 * * * *",
];
}
public function test_constructor()
{
$this->checkException(
closure: fn() => new EnvFile('nonexistent.env', create: false),
reason: 'If file does not exist, and autoCreate is set to false, an exception should be thrown.',
exception: RuntimeException::class,
exceptionMessage: "does not exist.",
);
$tmpFile = sys_get_temp_dir() . '/watchstate_test.env';
try {
$envFile = new EnvFile($tmpFile, create: true);
$this->assertFileExists($tmpFile);
$this->assertEmpty($envFile->get(array_keys($this->data)[0]));
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
public function test_get()
{
$tmpFile = sys_get_temp_dir() . '/watchstate_test.env';
try {
$envFile = new EnvFile($tmpFile, create: true);
$this->assertEmpty($envFile->get(array_keys($this->data)[0]));
foreach ($this->data as $key => $value) {
$envFile->set($key, $value);
$this->assertEquals($value, $envFile->get($key), "The value of key '{$key}' should be '{$value}'.");
}
$this->assertNull(
$envFile->get('nonexistent_key'),
"The value of a nonexistent key should be NULL by default."
);
$this->assertSame(
'default',
$envFile->get('nonexistent', 'default'),
"The default value should be returned."
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
public function test_set()
{
$tmpFile = sys_get_temp_dir() . '/watchstate_test.env';
try {
$envFile = new EnvFile($tmpFile, create: true);
$this->assertEmpty($envFile->get(array_keys($this->data)[0]));
foreach ($this->data as $key => $value) {
$envFile->set($key, $value);
}
$envFile->persist();
$envFile = new EnvFile($tmpFile);
foreach ($this->data as $key => $value) {
$this->assertEquals($value, $envFile->get($key), "The value of key '{$key}' should be '{$value}'.");
}
$this->assertNull(
$envFile->get('nonexistent_key'),
"The value of a nonexistent key should be NULL by default."
);
$this->assertSame(
'default',
$envFile->get('nonexistent', 'default'),
"The default value should be returned."
);
$envFile->set('new_key', true);
$this->assertTrue(
$envFile->get('new_key'),
"Due to unfortunate design, the value of key bool 'new_key' should be true. until we persist it."
);
$envFile->persist();
$envFile = $envFile->newInstance();
$this->assertTrue($envFile->has('new_key'), "The key 'new_key' should exist.");
$this->assertSame(
'1',
$envFile->get('new_key'),
"The value of key 'new_key' should be '1' as we cast bool to string."
);
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
public function test_has()
{
$tmpFile = sys_get_temp_dir() . '/watchstate_test.env';
try {
$envFile = new EnvFile($tmpFile, create: true);
$this->assertEmpty($envFile->get(array_keys($this->data)[0]));
foreach ($this->data as $key => $value) {
$envFile->set($key, $value);
}
$envFile->persist();
$envFile = new EnvFile($tmpFile);
foreach ($this->data as $key => $value) {
$this->assertTrue($envFile->has($key), "The key '{$key}' should exist.");
}
$this->assertFalse($envFile->has('nonexistent_key'), "The key 'nonexistent_key' should not exist.");
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
public function test_persist()
{
$tmpFile = sys_get_temp_dir() . '/watchstate_test.env';
try {
$envFile = new EnvFile($tmpFile, create: true);
$this->assertEmpty($envFile->get(array_keys($this->data)[0]));
foreach ($this->data as $key => $value) {
$envFile->set($key, $value);
}
$envFile->persist();
$envFile = new EnvFile($tmpFile);
$this->assertSame($this->data, $envFile->getAll(), "The data should be persisted and retrieved correctly.");
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
public function test_getAll()
{
$tmpFile = sys_get_temp_dir() . '/watchstate_test.env';
try {
$envFile = new EnvFile($tmpFile, create: true);
$this->assertEmpty($envFile->get(array_keys($this->data)[0]));
foreach ($this->data as $key => $value) {
$envFile->set($key, $value);
}
$envFile->persist();
$envFile = new EnvFile($tmpFile);
$this->assertSame($this->data, $envFile->getAll(), "The data should be persisted and retrieved correctly.");
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
public function test_remove()
{
$tmpFile = sys_get_temp_dir() . '/watchstate_test.env';
$key = array_keys($this->data)[0];
try {
$envFile = new EnvFile($tmpFile, create: true);
$this->assertEmpty($envFile->get(array_keys($this->data)[0]));
foreach ($this->data as $key => $value) {
$envFile->set($key, $value);
}
$envFile->persist();
$envFile->remove($key);
$envFile->persist();
$envFile = new EnvFile($tmpFile);
$this->assertNotSame($this->data, $envFile->getAll(), "The key '{$key}' should be been removed.");
$this->assertArrayNotHasKey($key, $envFile->getAll(), "The key '{$key}' should be been removed.");
} finally {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
}