@@ -187,12 +187,17 @@ return (function (): array {
|
||||
|
||||
PDO::class => [
|
||||
'class' => function (): PDO {
|
||||
$dbFile = Config::get('database.file');
|
||||
$changePerm = !file_exists($dbFile);
|
||||
$inTestMode = true === (defined('IN_TEST_MODE') && true === IN_TEST_MODE);
|
||||
$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);
|
||||
}
|
||||
|
||||
|
||||
@@ -563,8 +563,6 @@ class ExportCommand extends Command
|
||||
],
|
||||
]);
|
||||
|
||||
$this->db->singleTransaction();
|
||||
|
||||
$requests = [];
|
||||
|
||||
foreach ($backends as $backend) {
|
||||
@@ -601,6 +599,7 @@ class ExportCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$start = makeDate();
|
||||
$this->logger->notice("SYSTEM: Sending '{total}' play state comparison requests.", [
|
||||
'total' => count($requests),
|
||||
]);
|
||||
@@ -614,9 +613,25 @@ class ExportCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->notice("SYSTEM: Sent '{total}' play state comparison requests.", [
|
||||
'total' => count($requests),
|
||||
]);
|
||||
$end = makeDate();
|
||||
$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}'.", [
|
||||
'backends' => implode(', ', array_keys($backends)),
|
||||
|
||||
@@ -381,8 +381,6 @@ class ImportCommand extends Command
|
||||
],
|
||||
]);
|
||||
|
||||
$this->db->singleTransaction();
|
||||
|
||||
foreach ($list as $name => &$backend) {
|
||||
$metadata = false;
|
||||
$opts = ag($backend, 'options', []);
|
||||
@@ -472,21 +470,24 @@ class ImportCommand extends Command
|
||||
|
||||
$end = makeDate();
|
||||
|
||||
$this->logger->notice("SYSTEM: Completed waiting on '{total}' requests in '{time.duration}s'.", [
|
||||
'total' => number_format(count($queue)),
|
||||
'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(
|
||||
"SYSTEM: Completed waiting on '{total}' requests in '{time.duration}'s. Parsed '{responses.size}' of data.",
|
||||
[
|
||||
'total' => number_format(count($queue)),
|
||||
'time' => [
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
'duration' => $end->getTimestamp() - $start->getTimestamp(),
|
||||
],
|
||||
'memory' => [
|
||||
'now' => getMemoryUsage(),
|
||||
'peak' => getPeakMemoryUsage(),
|
||||
],
|
||||
'responses' => [
|
||||
'size' => fsize((int)Message::get('response.size', 0)),
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$queue = $requestData = null;
|
||||
|
||||
|
||||
@@ -54,6 +54,11 @@ final readonly class DataUtil implements JsonSerializable, Stringable
|
||||
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
|
||||
{
|
||||
return new self(ag_set($this->data, $key, $value));
|
||||
@@ -71,6 +76,6 @@ final readonly class DataUtil implements JsonSerializable, Stringable
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return json_encode($this->data);
|
||||
return json_encode($this->jsonSerialize());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ declare(strict_types=1);
|
||||
namespace App\Libs\Database;
|
||||
|
||||
use App\Libs\Exceptions\DBLayerException;
|
||||
use App\Libs\Exceptions\RuntimeException;
|
||||
use Closure;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use PDOStatement;
|
||||
use Psr\Log\LoggerAwareInterface;
|
||||
use Psr\Log\LoggerAwareTrait;
|
||||
use RuntimeException;
|
||||
|
||||
final class DBLayer implements LoggerAwareInterface
|
||||
{
|
||||
@@ -20,6 +20,8 @@ final class DBLayer implements LoggerAwareInterface
|
||||
|
||||
private const int LOCK_RETRY = 4;
|
||||
|
||||
private int $retry;
|
||||
|
||||
private int $count = 0;
|
||||
|
||||
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_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);
|
||||
|
||||
if (is_string($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
|
||||
{
|
||||
try {
|
||||
return $this->wrap(function (DBLayer $db) use ($sql, $options) {
|
||||
$queryString = $sql;
|
||||
$opts = [];
|
||||
|
||||
$this->last = [
|
||||
'sql' => $queryString,
|
||||
'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);
|
||||
if (true === ag_exists($options, 'on_failure')) {
|
||||
$opts['on_failure'] = $options['on_failure'];
|
||||
}
|
||||
|
||||
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 {
|
||||
return $this->wrap(function (DBLayer $db) use ($sql, $bind, $options) {
|
||||
$isStatement = $sql instanceof PDOStatement;
|
||||
$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);
|
||||
$opts = [];
|
||||
if (true === ag_exists($options, 'on_failure')) {
|
||||
$opts['on_failure'] = $options['on_failure'];
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
if ($this->pdo->inTransaction()) {
|
||||
if (true === $this->pdo->inTransaction()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->pdo->beginTransaction();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a transaction.
|
||||
*
|
||||
* @return bool Returns true on success, false on failure.
|
||||
*/
|
||||
public function commit(): bool
|
||||
{
|
||||
return $this->pdo->commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback a transaction.
|
||||
*
|
||||
* @return bool Returns true on success, false on failure.
|
||||
*/
|
||||
public function rollBack(): bool
|
||||
{
|
||||
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
|
||||
{
|
||||
return $this->pdo->inTransaction();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
* @deprecated Use {@link self::start()} instead.
|
||||
* This method wraps db operations in a single transaction.
|
||||
*
|
||||
* @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
|
||||
{
|
||||
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
|
||||
{
|
||||
if (empty($conditions)) {
|
||||
@@ -188,9 +269,13 @@ final class DBLayer implements LoggerAwareInterface
|
||||
$query[] = 'DELETE FROM ' . $this->escapeIdentifier($table, true) . ' WHERE';
|
||||
$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']);
|
||||
|
||||
$query[] = $_['query'];
|
||||
$bind = array_replace_recursive($bind, $_['bind']);
|
||||
}
|
||||
@@ -277,7 +362,16 @@ final class DBLayer implements LoggerAwareInterface
|
||||
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 = [];
|
||||
|
||||
@@ -308,8 +402,20 @@ final class DBLayer implements LoggerAwareInterface
|
||||
}
|
||||
|
||||
$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
|
||||
{
|
||||
if (empty($changes)) {
|
||||
@@ -341,7 +447,12 @@ final class DBLayer implements LoggerAwareInterface
|
||||
|
||||
$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']);
|
||||
|
||||
$query[] = $_['query'];
|
||||
@@ -357,6 +468,15 @@ final class DBLayer implements LoggerAwareInterface
|
||||
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
|
||||
{
|
||||
if (empty($conditions)) {
|
||||
@@ -387,31 +507,40 @@ final class DBLayer implements LoggerAwareInterface
|
||||
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
|
||||
{
|
||||
return (string)$this->pdo->quote($text, $type);
|
||||
}
|
||||
|
||||
public function escape(string $text): string
|
||||
{
|
||||
return mb_substr($this->quote($text), 1, -1, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID generated in the last query.
|
||||
*
|
||||
* @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
|
||||
{
|
||||
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
|
||||
{
|
||||
return $this->count;
|
||||
}
|
||||
|
||||
public function close(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure only valid characters make it in column/table names
|
||||
*
|
||||
@@ -456,11 +585,21 @@ final class DBLayer implements LoggerAwareInterface
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PDO driver name.
|
||||
*
|
||||
* @return string The driver name.
|
||||
*/
|
||||
public function getDriver(): string
|
||||
{
|
||||
return $this->driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reference to the PDO object.
|
||||
*
|
||||
* @return PDO The PDO object.
|
||||
*/
|
||||
public function getBackend(): PDO
|
||||
{
|
||||
return $this->pdo;
|
||||
@@ -617,10 +756,22 @@ final class DBLayer implements LoggerAwareInterface
|
||||
}
|
||||
|
||||
$eBindName = '__db_ftS_' . random_int(1, 1000);
|
||||
$keys[] = sprintf(
|
||||
"MATCH(%s) AGAINST(%s)",
|
||||
implode(', ', array_map(fn($columns) => $this->escapeIdentifier($columns, true), $opt[1])),
|
||||
':' . $eBindName
|
||||
|
||||
$keys[] = str_replace(
|
||||
['(column)', '(bind)', '(expr)'],
|
||||
[
|
||||
$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];
|
||||
@@ -643,7 +794,7 @@ final class DBLayer implements LoggerAwareInterface
|
||||
break;
|
||||
case self::IS_JSON_EXTRACT:
|
||||
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);
|
||||
@@ -651,7 +802,7 @@ final class DBLayer implements LoggerAwareInterface
|
||||
$keys[] = sprintf(
|
||||
"JSON_EXTRACT(%s, %s) %s %s",
|
||||
$this->escapeIdentifier($column, true),
|
||||
$opt[1],
|
||||
$this->escapeIdentifier($opt[1], true),
|
||||
$opt[2],
|
||||
':' . $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
|
||||
{
|
||||
$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
|
||||
{
|
||||
$groupBy = array_map(
|
||||
@@ -699,6 +865,13 @@ final class DBLayer implements LoggerAwareInterface
|
||||
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
|
||||
{
|
||||
$sortBy = [];
|
||||
@@ -712,6 +885,14 @@ final class DBLayer implements LoggerAwareInterface
|
||||
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
|
||||
{
|
||||
$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
|
||||
{
|
||||
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();
|
||||
|
||||
for ($i = 1; $i <= self::LOCK_RETRY; $i++) {
|
||||
try {
|
||||
if (true === $autoStartTransaction) {
|
||||
$this->start();
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
static $lastFailure = [];
|
||||
$on_lock = ag($options, 'on_lock', null);
|
||||
$errorHandler = ag($options, 'on_failure', null);
|
||||
$exception = null;
|
||||
if (false === ag_exists($options, 'attempt')) {
|
||||
$options['attempt'] = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* We return in try or throw exception.
|
||||
* As such this return should never be reached.
|
||||
*/
|
||||
return null;
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
return $callback($this, $options);
|
||||
} catch (PDOException $e) {
|
||||
$attempts = (int)ag($options, 'attempts', 0);
|
||||
if (true === str_contains(strtolower($e->getMessage()), 'database is locked')) {
|
||||
if ($attempts >= $this->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 = (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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,13 +179,6 @@ interface DatabaseInterface
|
||||
*/
|
||||
public function getDBLayer(): DBLayer;
|
||||
|
||||
/**
|
||||
* Enable single transaction mode.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function singleTransaction(): bool;
|
||||
|
||||
/**
|
||||
* Wrap queries into single transaction.
|
||||
*
|
||||
|
||||
@@ -9,16 +9,14 @@ use App\Libs\Database\DatabaseInterface as iDB;
|
||||
use App\Libs\Database\DBLayer;
|
||||
use App\Libs\Entity\StateInterface as iState;
|
||||
use App\Libs\Exceptions\DBAdapterException as DBException;
|
||||
use App\Libs\Exceptions\DBLayerException;
|
||||
use App\Libs\Options;
|
||||
use Closure;
|
||||
use DateTimeInterface;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use PDOStatement;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Random\RandomException;
|
||||
use RuntimeException;
|
||||
use Psr\Log\LoggerInterface as iLogger;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Class PDOAdapter
|
||||
@@ -27,21 +25,11 @@ use RuntimeException;
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
private bool $viaTransaction = false;
|
||||
|
||||
/**
|
||||
* @var bool Whether the current operation is using a single transaction.
|
||||
*/
|
||||
private bool $singleTransaction = false;
|
||||
|
||||
/**
|
||||
* @var array Adapter options.
|
||||
*/
|
||||
@@ -55,20 +43,14 @@ final class PDOAdapter implements iDB
|
||||
'update' => null,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var string The database driver to be used.
|
||||
*/
|
||||
private string $driver = 'sqlite';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
* @throws RandomException if an error occurs while generating a random number.
|
||||
*/
|
||||
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();
|
||||
} catch (PDOException $e) {
|
||||
$this->stmt['insert'] = null;
|
||||
if (false === $this->viaTransaction && false === $this->singleTransaction) {
|
||||
if (false === $this->viaTransaction) {
|
||||
$this->logger->error(
|
||||
message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.",
|
||||
context: [
|
||||
@@ -193,7 +182,6 @@ final class PDOAdapter implements iDB
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @throws RandomException if an error occurs while generating a random number.
|
||||
*/
|
||||
public function get(iState $entity): iState|null
|
||||
{
|
||||
@@ -204,19 +192,7 @@ final class PDOAdapter implements iDB
|
||||
}
|
||||
|
||||
if (null !== $entity->id) {
|
||||
$stmt = $this->query(
|
||||
r(
|
||||
'SELECT * FROM state WHERE ${column} = ${id}',
|
||||
context: [
|
||||
'column' => iState::COLUMN_ID,
|
||||
'id' => (int)$entity->id
|
||||
],
|
||||
opts: [
|
||||
'tag_left' => '${',
|
||||
'tag_right' => '}'
|
||||
],
|
||||
)
|
||||
);
|
||||
$stmt = $this->db->query('SELECT * FROM state WHERE id = :id', ['id' => (int)$entity->id]);
|
||||
|
||||
if (false !== ($item = $stmt->fetch(PDO::FETCH_ASSOC))) {
|
||||
$item = $entity::fromArray($item);
|
||||
@@ -247,7 +223,6 @@ final class PDOAdapter implements iDB
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @throws RandomException if an error occurs while generating a random number.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
$class = $opts['class'];
|
||||
$class = $fromClass;
|
||||
}
|
||||
|
||||
foreach ($this->query($sql) as $row) {
|
||||
foreach ($this->db->query($sql) as $row) {
|
||||
$arr[] = $class::fromArray($row);
|
||||
}
|
||||
|
||||
@@ -286,7 +262,6 @@ final class PDOAdapter implements iDB
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @throws RandomException if an error occurs while generating a random number.
|
||||
*/
|
||||
public function find(iState ...$items): array
|
||||
{
|
||||
@@ -305,13 +280,11 @@ final class PDOAdapter implements iDB
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @throws RandomException
|
||||
*/
|
||||
public function findByBackendId(string $backend, int|string $id, string|null $type = null): iState|null
|
||||
{
|
||||
$key = $backend . '.' . iState::COLUMN_ID;
|
||||
$cond = [
|
||||
'id' => $id
|
||||
];
|
||||
|
||||
$type_sql = '';
|
||||
@@ -320,39 +293,33 @@ final class PDOAdapter implements iDB
|
||||
$cond['type'] = $type;
|
||||
}
|
||||
|
||||
$sql = "SELECT * FROM state WHERE {$type_sql} JSON_EXTRACT(" . iState::COLUMN_META_DATA . ",'$.{$key}') = :id LIMIT 1";
|
||||
$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
|
||||
);
|
||||
}
|
||||
$sql = "SELECT * FROM state WHERE {$type_sql} JSON_EXTRACT(" . iState::COLUMN_META_DATA . ",'$.{$key}') = {id} LIMIT 1";
|
||||
$stmt = $this->db->query(r($sql, ['id' => is_int($id) ? $id : $this->db->quote($id)]), $cond);
|
||||
|
||||
if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) {
|
||||
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
|
||||
* @throws RandomException if an error occurs while generating a random number.
|
||||
*/
|
||||
public function update(iState $entity): iState
|
||||
{
|
||||
try {
|
||||
if (null === ($entity->id ?? null)) {
|
||||
throw new DBException(
|
||||
r("PDOAdapter: Unable to update '{title}' without primary key defined.", [
|
||||
'title' => $entity->getName() ?? 'Unknown'
|
||||
]), 51
|
||||
);
|
||||
throw new DBException(r("PDOAdapter: Unable to update '{title}' without primary key defined.", [
|
||||
'title' => $entity->getName() ?? 'Unknown'
|
||||
]), 51);
|
||||
}
|
||||
|
||||
if (true === $entity->isEpisode() && $entity->episode < 1) {
|
||||
@@ -391,15 +358,21 @@ final class PDOAdapter implements iDB
|
||||
}
|
||||
|
||||
if (null === ($this->stmt['update'] ?? null)) {
|
||||
$this->stmt['update'] = $this->db->prepare(
|
||||
$this->pdoUpdate('state', iState::ENTITY_KEYS)
|
||||
);
|
||||
$this->stmt['update'] = $this->db->prepare($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) {
|
||||
$this->stmt['update'] = null;
|
||||
if (false === $this->viaTransaction && false === $this->singleTransaction) {
|
||||
if (false === $this->viaTransaction) {
|
||||
$this->logger->error(
|
||||
message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.",
|
||||
context: [
|
||||
@@ -429,7 +402,6 @@ final class PDOAdapter implements iDB
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @throws RandomException if an error occurs while generating a random number.
|
||||
*/
|
||||
public function remove(iState $entity): bool
|
||||
{
|
||||
@@ -447,19 +419,7 @@ final class PDOAdapter implements iDB
|
||||
$id = $entity->id;
|
||||
}
|
||||
|
||||
$this->query(
|
||||
r(
|
||||
'DELETE FROM state WHERE ${column} = ${id}',
|
||||
[
|
||||
'column' => iState::COLUMN_ID,
|
||||
'id' => (int)$id
|
||||
],
|
||||
opts: [
|
||||
'tag_left' => '${',
|
||||
'tag_right' => '}'
|
||||
]
|
||||
)
|
||||
);
|
||||
$this->db->query('DELETE FROM state WHERE id = :id', ['id' => (int)$id]);
|
||||
} catch (PDOException $e) {
|
||||
$this->logger->error(
|
||||
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
|
||||
* @throws RandomException if an error occurs while generating a random number.
|
||||
*/
|
||||
public function commit(array $entities, array $opts = []): array
|
||||
{
|
||||
@@ -563,7 +522,7 @@ final class PDOAdapter implements iDB
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
@@ -618,7 +577,7 @@ final class PDOAdapter implements iDB
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function setLogger(LoggerInterface $logger): iDB
|
||||
public function setLogger(iLogger $logger): iDB
|
||||
{
|
||||
$this->logger = $logger;
|
||||
|
||||
@@ -630,20 +589,6 @@ final class PDOAdapter implements iDB
|
||||
return $this->db;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function singleTransaction(): bool
|
||||
{
|
||||
$this->singleTransaction = true;
|
||||
|
||||
if (false === $this->db->inTransaction()) {
|
||||
$this->db->start();
|
||||
}
|
||||
|
||||
return $this->db->inTransaction();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
@@ -684,7 +629,7 @@ final class PDOAdapter implements iDB
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
if (true === $this->singleTransaction && true === $this->db->inTransaction()) {
|
||||
if (true === $this->db->inTransaction()) {
|
||||
$this->db->commit();
|
||||
}
|
||||
|
||||
@@ -755,7 +700,6 @@ final class PDOAdapter implements iDB
|
||||
* @param iState $entity Entity get external ids from.
|
||||
*
|
||||
* @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
|
||||
{
|
||||
@@ -809,17 +753,7 @@ final class PDOAdapter implements iDB
|
||||
$sqlGuids = ' AND ( ' . implode(' OR ', $guids) . ' ) ';
|
||||
|
||||
$sql = "SELECT * FROM state WHERE " . iState::COLUMN_TYPE . " = :type {$sqlEpisode} {$sqlGuids} LIMIT 1";
|
||||
|
||||
$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
|
||||
);
|
||||
}
|
||||
$stmt = $this->db->query($sql, $cond);
|
||||
|
||||
if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) {
|
||||
return null;
|
||||
@@ -827,150 +761,4 @@ final class PDOAdapter implements iDB
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,19 @@ final class EnvFile
|
||||
$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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,15 @@ use League\Route\Http\Exception\BadRequestException;
|
||||
use League\Route\Http\Exception\NotFoundException;
|
||||
use Psr\Http\Message\ResponseInterface as iResponse;
|
||||
use Psr\Http\Message\ServerRequestInterface as iRequest;
|
||||
use Psr\Log\LoggerAwareInterface;
|
||||
use Psr\Log\LoggerAwareTrait;
|
||||
use SplFileInfo;
|
||||
use Throwable;
|
||||
|
||||
final class ServeStatic
|
||||
final class ServeStatic implements LoggerAwareInterface
|
||||
{
|
||||
use LoggerAwareTrait;
|
||||
|
||||
private finfo|null $mimeType = null;
|
||||
|
||||
private const array CONTENT_TYPE = [
|
||||
@@ -39,7 +43,6 @@ final class ServeStatic
|
||||
private const array MD_IMAGES = [
|
||||
'/screenshots' => __DIR__ . '/../../',
|
||||
];
|
||||
private array $looked = [];
|
||||
|
||||
public function __construct(private string|null $staticPath = null)
|
||||
{
|
||||
@@ -59,8 +62,6 @@ final class ServeStatic
|
||||
*/
|
||||
public function serve(iRequest $request): iResponse
|
||||
{
|
||||
$requestPath = $request->getUri()->getPath();
|
||||
|
||||
if (false === in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) {
|
||||
throw new BadRequestException(
|
||||
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)) {
|
||||
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
|
||||
foreach (self::MD_IMAGES as $key => $value) {
|
||||
if (str_starts_with($requestPath, $key)) {
|
||||
$this->staticPath = realpath($value);
|
||||
$staticPath = realpath($value);
|
||||
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)) {
|
||||
$filePath = $filePath . '/index.html';
|
||||
}
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
$checkIndex = $this->deepIndexLookup($this->staticPath, $requestPath);
|
||||
if (!file_exists($checkIndex)) {
|
||||
throw new NotFoundException(
|
||||
message: r(
|
||||
"File '{file}' is not found. {checkIndex} {looked}",
|
||||
[
|
||||
'file' => $requestPath,
|
||||
'checkIndex' => $checkIndex,
|
||||
'looked' => $this->looked,
|
||||
]
|
||||
),
|
||||
code: Status::NOT_FOUND->value
|
||||
);
|
||||
$this->logger?->debug("File '{file}' is not found.", ['file' => $filePath]);
|
||||
$checkIndex = fixPath($staticPath . $this->deepIndexLookup($staticPath, $requestPath));
|
||||
if (false === file_exists($checkIndex) || false === is_file($checkIndex)) {
|
||||
throw new NotFoundException(r("Path '{file}' is not found.", [
|
||||
'file' => $requestPath,
|
||||
]), code: Status::NOT_FOUND->value);
|
||||
}
|
||||
$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);
|
||||
|
||||
if (false === $filePath || false === str_starts_with($filePath, $realBasePath)) {
|
||||
throw new BadRequestException(
|
||||
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
|
||||
// return the first index.html found
|
||||
$path = fixPath($path);
|
||||
|
||||
if ('/' === $path) {
|
||||
if ('/' === $path || empty($path)) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$paths = explode('/', $path);
|
||||
$count = count($paths);
|
||||
if ($count < 2) {
|
||||
$index = $count - 1;
|
||||
|
||||
if ($index < 2) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$index = $count - 1;
|
||||
|
||||
for ($i = $index; $i > 0; $i--) {
|
||||
$check = $base . implode('/', array_slice($paths, 0, $i)) . '/index.html';
|
||||
$this->looked[] = $check;
|
||||
if (file_exists($check)) {
|
||||
$check = implode('/', array_slice($paths, 0, $i)) . '/index.html';
|
||||
if (file_exists($base . $check)) {
|
||||
return $check;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,13 @@ use Symfony\Component\Process\Process;
|
||||
*/
|
||||
final class Server
|
||||
{
|
||||
public const CONFIG_HOST = 'host';
|
||||
public const CONFIG_PORT = 'port';
|
||||
public const CONFIG_ROOT = 'root';
|
||||
public const CONFIG_PHP = 'php';
|
||||
public const CONFIG_ENV = 'env';
|
||||
public const CONFIG_ROUTER = 'router';
|
||||
public const CONFIG_THREADS = 'threads';
|
||||
public const string CONFIG_HOST = 'host';
|
||||
public const string CONFIG_PORT = 'port';
|
||||
public const string CONFIG_ROOT = 'root';
|
||||
public const string CONFIG_PHP = 'php';
|
||||
public const string CONFIG_ENV = 'env';
|
||||
public const string CONFIG_ROUTER = 'router';
|
||||
public const string CONFIG_THREADS = 'threads';
|
||||
|
||||
/**
|
||||
* @var array $config The configuration settings for the server
|
||||
|
||||
@@ -36,6 +36,7 @@ class TestCase extends \PHPUnit\Framework\TestCase
|
||||
* @param Throwable|string $exception Expected exception class
|
||||
* @param string $exceptionMessage (optional) Exception message
|
||||
* @param int|null $exceptionCode (optional) Exception code
|
||||
* @param callable{ TestCase, Throwable}|null $callback (optional) Custom callback to handle the exception
|
||||
* @return void
|
||||
*/
|
||||
protected function checkException(
|
||||
@@ -44,6 +45,7 @@ class TestCase extends \PHPUnit\Framework\TestCase
|
||||
Throwable|string $exception,
|
||||
string $exceptionMessage = '',
|
||||
int|null $exceptionCode = null,
|
||||
callable $callback = null,
|
||||
): void {
|
||||
$caught = null;
|
||||
try {
|
||||
@@ -51,13 +53,17 @@ class TestCase extends \PHPUnit\Framework\TestCase
|
||||
} catch (Throwable $e) {
|
||||
$caught = $e;
|
||||
} finally {
|
||||
if (null !== $callback) {
|
||||
$callback($this, $caught);
|
||||
return;
|
||||
}
|
||||
if (null === $caught) {
|
||||
$this->fail($reason);
|
||||
$this->fail('No exception was thrown. ' . $reason);
|
||||
} else {
|
||||
$this->assertInstanceOf(
|
||||
is_object($exception) ? $exception::class : $exception,
|
||||
$caught,
|
||||
$reason
|
||||
$reason . ' ' . $caught->getMessage(),
|
||||
);
|
||||
if (!empty($exceptionMessage)) {
|
||||
$this->assertStringContainsString($exceptionMessage, $caught->getMessage(), $reason);
|
||||
|
||||
@@ -7,6 +7,8 @@ use App\Backends\Common\Cache as BackendCache;
|
||||
use App\Backends\Common\ClientInterface as iClient;
|
||||
use App\Backends\Common\Context;
|
||||
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\Item as ScannerItem;
|
||||
use App\Libs\Config;
|
||||
@@ -27,7 +29,6 @@ use App\Libs\Guid;
|
||||
use App\Libs\Initializer;
|
||||
use App\Libs\Options;
|
||||
use App\Libs\Response;
|
||||
use App\Libs\Router;
|
||||
use App\Libs\Stream;
|
||||
use App\Libs\Uri;
|
||||
use App\Listeners\ProcessPushEvent;
|
||||
@@ -1021,29 +1022,53 @@ if (false === function_exists('generateRoutes')) {
|
||||
*/
|
||||
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);
|
||||
|
||||
$routes_cli = $routes_http = [];
|
||||
|
||||
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'));
|
||||
} catch (\Psr\SimpleCache\InvalidArgumentException) {
|
||||
}
|
||||
|
||||
$routes_http = (new Router([__DIR__ . '/../API']))->generate();
|
||||
|
||||
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'));
|
||||
} catch (\Psr\SimpleCache\InvalidArgumentException) {
|
||||
}
|
||||
|
||||
@@ -15,13 +15,9 @@ trait UsesBasicRepository
|
||||
{
|
||||
use UsesPaging;
|
||||
|
||||
protected DBLayer $db;
|
||||
|
||||
public function __construct(DBLayer $db)
|
||||
public function __construct(private readonly DBLayer $db)
|
||||
{
|
||||
$this->init($db);
|
||||
|
||||
$this->db = $db;
|
||||
$this->init($this->db);
|
||||
|
||||
if (empty($this->table)) {
|
||||
throw new RuntimeException('You must set table name in $this->table');
|
||||
|
||||
646
tests/Database/DBLayerTest.php
Normal file
646
tests/Database/DBLayerTest.php
Normal 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',
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,24 +1,25 @@
|
||||
<?php
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Database;
|
||||
|
||||
use App\Libs\Config;
|
||||
use App\Libs\Container;
|
||||
use App\Libs\Database\DatabaseInterface as iDB;
|
||||
use App\Libs\Database\DBLayer;
|
||||
use App\Libs\Database\PDO\PDOAdapter;
|
||||
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\Guid;
|
||||
use App\Libs\Options;
|
||||
use App\Libs\TestCase;
|
||||
use DateTimeImmutable;
|
||||
use Error;
|
||||
use Monolog\Handler\TestHandler;
|
||||
use Monolog\Logger;
|
||||
use PDO;
|
||||
use Random\RandomException;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
@@ -48,32 +49,81 @@ class PDOAdapterTest extends TestCase
|
||||
Guid::setLogger($logger);
|
||||
|
||||
$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');
|
||||
}
|
||||
|
||||
public function test_insert_throw_exception_if_has_id(): void
|
||||
{
|
||||
$this->expectException(DBException::class);
|
||||
$this->expectExceptionCode(21);
|
||||
$item = new StateEntity($this->testEpisode);
|
||||
$this->db->insert($item);
|
||||
$this->db->insert($item);
|
||||
$this->checkException(
|
||||
closure: function () {
|
||||
$item = new StateEntity($this->testEpisode);
|
||||
$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
|
||||
{
|
||||
$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.');
|
||||
|
||||
$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
|
||||
{
|
||||
$test = $this->testEpisode;
|
||||
|
||||
foreach (StateInterface::ENTITY_ARRAY_KEYS as $key) {
|
||||
foreach (iState::ENTITY_ARRAY_KEYS as $key) {
|
||||
if (null === ($test[$key] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
@@ -104,9 +154,13 @@ class PDOAdapterTest extends TestCase
|
||||
|
||||
public function test_getAll_call_without_initialized_container(): void
|
||||
{
|
||||
$this->expectException(Error::class);
|
||||
$this->expectExceptionMessage('Call to a member function');
|
||||
$this->db->getAll();
|
||||
$this->db->setOptions(['class' => null]);
|
||||
$this->checkException(
|
||||
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
|
||||
@@ -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->expectExceptionCode(51);
|
||||
$item = new StateEntity($this->testEpisode);
|
||||
$this->checkException(
|
||||
closure: fn() => $this->db->update(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
|
||||
{
|
||||
$test = $this->testEpisode;
|
||||
|
||||
foreach (StateInterface::ENTITY_ARRAY_KEYS as $key) {
|
||||
foreach (iState::ENTITY_ARRAY_KEYS as $key) {
|
||||
if (null === ($test[$key] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
@@ -161,12 +229,23 @@ class PDOAdapterTest extends TestCase
|
||||
$this->assertSame($item, $updatedItem, 'When updating item, same object is returned.');
|
||||
|
||||
$r = $this->db->get($item)->getAll();
|
||||
$updatedItem->updated_at = $r[StateInterface::COLUMN_UPDATED_AT];
|
||||
$updatedItem->updated_at = $r[iState::COLUMN_UPDATED_AT];
|
||||
$this->assertSame(
|
||||
$updatedItem->getAll(),
|
||||
$r,
|
||||
'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
|
||||
@@ -185,7 +264,7 @@ class PDOAdapterTest extends TestCase
|
||||
'When db is not empty, remove returns true if record removed.'
|
||||
);
|
||||
$this->assertInstanceOf(
|
||||
StateInterface::class,
|
||||
iState::class,
|
||||
$this->db->get($item2),
|
||||
'When Record exists an instance of StateInterface is returned.'
|
||||
);
|
||||
@@ -205,6 +284,13 @@ class PDOAdapterTest extends TestCase
|
||||
$this->db->remove($item3),
|
||||
'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
|
||||
@@ -218,8 +304,8 @@ class PDOAdapterTest extends TestCase
|
||||
'Array<added, updated, failed> with count of each operation status.'
|
||||
);
|
||||
|
||||
$item1->guids['guid_anidb'] = StateInterface::TYPE_EPISODE . '/1';
|
||||
$item2->guids['guid_anidb'] = StateInterface::TYPE_MOVIE . '/1';
|
||||
$item1->guids['guid_anidb'] = iState::TYPE_EPISODE . '/1';
|
||||
$item2->guids['guid_anidb'] = iState::TYPE_MOVIE . '/1';
|
||||
|
||||
$this->assertSame(
|
||||
['added' => 0, 'updated' => 2, 'failed' => 0],
|
||||
@@ -230,8 +316,156 @@ class PDOAdapterTest extends TestCase
|
||||
|
||||
public function test_migrations_call_with_wrong_direction_exception(): void
|
||||
{
|
||||
$this->expectException(DBException::class);
|
||||
$this->expectExceptionCode(91);
|
||||
$this->db->migrations('not_dd');
|
||||
$this->checkException(
|
||||
closure: fn() => $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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
6
tests/Fixtures/static_data/test.css
Normal file
6
tests/Fixtures/static_data/test.css
Normal file
@@ -0,0 +1,6 @@
|
||||
html {
|
||||
background-color: #f0f0f0;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
12
tests/Fixtures/static_data/test.html
Normal file
12
tests/Fixtures/static_data/test.html
Normal 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>
|
||||
1
tests/Fixtures/static_data/test.js
Normal file
1
tests/Fixtures/static_data/test.js
Normal file
@@ -0,0 +1 @@
|
||||
const testFunc = () => 'test'
|
||||
3
tests/Fixtures/static_data/test.json
Normal file
3
tests/Fixtures/static_data/test.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"test": "test"
|
||||
}
|
||||
1
tests/Fixtures/static_data/test.md
Normal file
1
tests/Fixtures/static_data/test.md
Normal file
@@ -0,0 +1 @@
|
||||
# Test markdown
|
||||
1
tests/Fixtures/static_data/test.woff2
Normal file
1
tests/Fixtures/static_data/test.woff2
Normal file
@@ -0,0 +1 @@
|
||||
test
|
||||
1
tests/Fixtures/static_data/test/index.html
Normal file
1
tests/Fixtures/static_data/test/index.html
Normal file
@@ -0,0 +1 @@
|
||||
test_index.html
|
||||
1
tests/Fixtures/static_data/test2/test.html
Normal file
1
tests/Fixtures/static_data/test2/test.html
Normal file
@@ -0,0 +1 @@
|
||||
test
|
||||
205
tests/Libs/DataUtilTest.php
Normal file
205
tests/Libs/DataUtilTest.php
Normal 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.');
|
||||
}
|
||||
|
||||
}
|
||||
155
tests/Libs/ServeStaticTest.php
Normal file
155
tests/Libs/ServeStaticTest.php
Normal 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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,41 +17,28 @@ class StateEntityTest extends TestCase
|
||||
private array $testMovie = [];
|
||||
private array $testEpisode = [];
|
||||
|
||||
private TestHandler|null $lHandler = null;
|
||||
private Logger|null $logger = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->testMovie = require __DIR__ . '/../Fixtures/MovieEntity.php';
|
||||
$this->testEpisode = require __DIR__ . '/../Fixtures/EpisodeEntity.php';
|
||||
$this->lHandler = new TestHandler();
|
||||
$this->logger = new Logger('logger', processors: [new LogMessageProcessor()]);
|
||||
$this->logger->pushHandler($this->lHandler);
|
||||
$logger = new Logger('logger', processors: [new LogMessageProcessor()]);
|
||||
$logger->pushHandler(new TestHandler());
|
||||
}
|
||||
|
||||
public function test_init_bad_type(): void
|
||||
{
|
||||
$this->testMovie[iState::COLUMN_TYPE] = 'oi';
|
||||
|
||||
try {
|
||||
new StateEntity($this->testMovie);
|
||||
} catch (RuntimeException $e) {
|
||||
$this->assertInstanceOf(
|
||||
RuntimeException::class,
|
||||
$e,
|
||||
'When new instance of StateEntity is called with invalid type, exception is thrown'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
StateEntity::fromArray($this->testMovie);
|
||||
} catch (RuntimeException $e) {
|
||||
$this->assertInstanceOf(
|
||||
RuntimeException::class,
|
||||
$e,
|
||||
'When ::fromArray is called with invalid type, exception is thrown'
|
||||
);
|
||||
}
|
||||
$this->checkException(
|
||||
closure: fn() => new StateEntity($this->testMovie),
|
||||
reason: 'When new instance of StateEntity is called with invalid type, exception is thrown',
|
||||
exception: RuntimeException::class,
|
||||
);
|
||||
$this->checkException(
|
||||
closure: fn() => StateEntity::fromArray($this->testMovie),
|
||||
reason: 'When ::fromArray is called with invalid type, exception is thrown',
|
||||
exception: RuntimeException::class,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_init_bad_data(): void
|
||||
@@ -84,11 +71,16 @@ class StateEntityTest extends TestCase
|
||||
public function test_diff_array_param(): void
|
||||
{
|
||||
$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 = 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.test.old', 'None');
|
||||
$arr = ag_set($arr, 'metadata.home_plex.test.new', ['foo' => 'bar']);
|
||||
|
||||
$this->assertSame(
|
||||
$arr,
|
||||
@@ -538,6 +530,16 @@ class StateEntityTest extends TestCase
|
||||
$entity->diff(),
|
||||
'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
|
||||
@@ -588,9 +590,14 @@ class StateEntityTest extends TestCase
|
||||
'When setIsTainted() is called with true, isTainted() returns true'
|
||||
);
|
||||
|
||||
$this->expectException(\TypeError::class);
|
||||
/** @noinspection PhpStrictTypeCheckingInspection */
|
||||
$entity->setIsTainted('foo');
|
||||
$this->checkException(
|
||||
closure: function () use ($entity) {
|
||||
/** @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
|
||||
@@ -641,8 +648,12 @@ class StateEntityTest extends TestCase
|
||||
|
||||
unset($this->testMovie[iState::COLUMN_VIA]);
|
||||
$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
|
||||
@@ -686,8 +697,11 @@ class StateEntityTest extends TestCase
|
||||
|
||||
unset($this->testMovie[iState::COLUMN_VIA]);
|
||||
$entity = new StateEntity($this->testMovie);
|
||||
$this->expectException(RuntimeException::class);
|
||||
$entity->setExtra([]);
|
||||
$this->checkException(
|
||||
closure: fn() => $entity->setExtra([]),
|
||||
reason: 'When setExtra() called with empty array, an exception is thrown',
|
||||
exception: RuntimeException::class,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_shouldMarkAsUnplayed(): void
|
||||
@@ -756,6 +770,16 @@ class StateEntityTest extends TestCase
|
||||
'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.
|
||||
$data = $this->testMovie;
|
||||
$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
|
||||
{
|
||||
$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);
|
||||
$entity = new StateEntity($testData);
|
||||
$this->assertSame(
|
||||
@@ -854,7 +878,7 @@ class StateEntityTest extends TestCase
|
||||
'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.test_plex', ag($testData, 'metadata.home_plex', []));
|
||||
$testData = ag_set($testData, 'metadata.test.progress', 999);
|
||||
@@ -865,6 +889,17 @@ class StateEntityTest extends TestCase
|
||||
$entity->getPlayProgress(),
|
||||
'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
|
||||
@@ -960,4 +995,63 @@ class StateEntityTest extends TestCase
|
||||
'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
223
tests/Libs/envFileTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user