Files
watchstate/src/Libs/Database/PDO/PDOAdapter.php
2024-09-10 19:49:27 +03:00

765 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Libs\Database\PDO;
use App\Libs\Container;
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\Options;
use Closure;
use DateTimeInterface;
use PDO;
use PDOException;
use PDOStatement;
use Psr\Log\LoggerInterface as iLogger;
use Throwable;
/**
* Class PDOAdapter
*
* This class implements the iDB interface and provides functionality for interacting with a database using PDO.
*/
final class PDOAdapter implements iDB
{
/**
* @var bool Whether the current operation is in a transaction.
*/
private bool $viaTransaction = false;
/**
* @var array Adapter options.
*/
private array $options = [];
/**
* @var array<array-key, PDOStatement> Prepared statements.
*/
private array $stmt = [
'insert' => null,
'update' => null,
];
/**
* Creates a new instance of the class.
*
* @param iLogger $logger The logger object used for logging.
* @param DBLayer $db The PDO object used for database connections.
*/
public function __construct(private iLogger $logger, private readonly DBLayer $db)
{
}
/**
* @inheritdoc
*/
public function setOptions(array $options): self
{
$this->options = $options;
return $this;
}
/**
* @inheritdoc
*/
public function insert(iState $entity): iState
{
try {
if (null !== ($entity->id ?? null)) {
throw new DBException(
r("PDOAdapter: Unable to insert item that has primary key already defined. '#{id}'.", [
'id' => $entity->id
]), 21
);
}
if (true === $entity->isEpisode() && $entity->episode < 1) {
throw new DBException(
r(
"PDOAdapter: Unexpected episode number '{number}' was given for '{via}: {title}'.",
[
'via' => $entity->via,
'title' => $entity->getName(),
'number' => $entity->episode,
]
)
);
}
if (false === in_array($entity->type, [iState::TYPE_MOVIE, iState::TYPE_EPISODE])) {
throw new DBException(
r(
"PDOAdapter: Unexpected content type '{type}' was given for '{via}: {title}'. Expecting '{types}'.",
[
'type' => $entity->type,
'types' => implode(', ', [iState::TYPE_MOVIE, iState::TYPE_EPISODE]),
'id' => $entity->via,
'title' => $entity->getName(),
]
), 22
);
}
$data = $entity->getAll();
if (0 === (int)ag($data, iState::COLUMN_CREATED_AT, 0)) {
$data[iState::COLUMN_CREATED_AT] = time();
}
if (0 === (int)ag($data, iState::COLUMN_UPDATED_AT, 0)) {
$data[iState::COLUMN_UPDATED_AT] = $data[iState::COLUMN_CREATED_AT];
}
unset($data[iState::COLUMN_ID]);
// -- @TODO i dont like this section, And this should not happen here.
if (false === $entity->isWatched()) {
foreach ($data[iState::COLUMN_META_DATA] ?? [] as $via => $metadata) {
$data[iState::COLUMN_META_DATA][$via][iState::COLUMN_WATCHED] = '0';
if (null === ($metadata[iState::COLUMN_META_DATA_PLAYED_AT] ?? null)) {
continue;
}
unset($data[iState::COLUMN_META_DATA][$via][iState::COLUMN_META_DATA_PLAYED_AT]);
}
}
foreach (iState::ENTITY_ARRAY_KEYS as $key) {
if (null !== ($data[$key] ?? null) && true === is_array($data[$key])) {
ksort($data[$key]);
$data[$key] = json_encode($data[$key], flags: JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
}
if (null === ($this->stmt['insert'] ?? null)) {
$this->stmt['insert'] = $this->db->prepare(
$this->pdoInsert('state', iState::ENTITY_KEYS)
);
}
$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) {
$this->logger->error(
message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.",
context: [
'entity' => $entity->getAll(),
'error' => [
'kind' => $e::class,
'line' => $e->getLine(),
'message' => $e->getMessage(),
'file' => after($e->getFile(), ROOT_PATH),
],
'exception' => [
'kind' => $e::class,
'line' => $e->getLine(),
'trace' => $e->getTrace(),
'message' => $e->getMessage(),
'file' => after($e->getFile(), ROOT_PATH),
],
]
);
return $entity;
}
throw $e;
}
return $entity->updateOriginal();
}
/**
* @inheritdoc
*/
public function get(iState $entity): iState|null
{
$inTraceMode = true === (bool)($this->options[Options::DEBUG_TRACE] ?? false);
if ($inTraceMode) {
$this->logger->debug("PDOAdapter: Looking for '{name}'.", ['name' => $entity->getName()]);
}
if (null !== $entity->id) {
$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);
if ($inTraceMode) {
$this->logger->debug("PDOAdapter: Found '{name}' using direct id match.", [
'name' => $item->getName(),
iState::COLUMN_ID => $entity->id
]);
}
return $item;
}
}
if (null !== ($item = $this->findByExternalId($entity))) {
if ($inTraceMode) {
$this->logger->debug("PDOAdapter: Found '{name}' using external id match.", [
'name' => $item->getName(),
iState::COLUMN_GUIDS => $entity->getGuids(),
]);
}
return $item;
}
return null;
}
/**
* @inheritdoc
*/
public function getAll(DateTimeInterface|null $date = null, array $opts = []): array
{
$arr = [];
if (true === array_key_exists('fields', $opts)) {
$fields = implode(', ', $opts['fields']);
} else {
$fields = '*';
}
if (true === (bool)($this->options[Options::DEBUG_TRACE] ?? false)) {
$this->logger->debug("PDOAdapter: Selecting fields '{fields}'.", [
'fields' => arrayToString($opts['fields'] ?? ['all'])
]);
}
$sql = "SELECT {$fields} FROM state";
if (null !== $date) {
$sql .= ' WHERE ' . iState::COLUMN_UPDATED . ' > ' . $date->getTimestamp();
}
$fromClass = $opts['class'] ?? $this->options['class'] ?? null;
if (null === ($fromClass ?? null) || false === ($fromClass instanceof iState)) {
$class = Container::get(iState::class);
} else {
$class = $fromClass;
}
foreach ($this->db->query($sql) as $row) {
$arr[] = $class::fromArray($row);
}
return $arr;
}
/**
* @inheritdoc
*/
public function find(iState ...$items): array
{
$list = [];
foreach ($items as $item) {
if (null === ($entity = $this->get($item))) {
continue;
}
$list[$entity->id] = $entity;
}
return $list;
}
/**
* @inheritdoc
*/
public function findByBackendId(string $backend, int|string $id, string|null $type = null): iState|null
{
$key = $backend . '.' . iState::COLUMN_ID;
$cond = [
];
$type_sql = '';
if (null !== $type) {
$type_sql = iState::COLUMN_TYPE . ' = :type AND ';
$cond['type'] = $type;
}
$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;
}
$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
*/
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);
}
if (true === $entity->isEpisode() && $entity->episode < 1) {
throw new DBException(
r(
"PDOAdapter: Unexpected episode number '{number}' was given for '#{id}' '{via}: {title}'.",
[
'id' => $entity->id,
'via' => $entity->via,
'title' => $entity->getName(),
'number' => $entity->episode,
]
)
);
}
$data = $entity->getAll();
$data[iState::COLUMN_UPDATED_AT] = time();
// -- @TODO i dont like this block, And this should not happen here.
if (false === $entity->isWatched()) {
foreach ($data[iState::COLUMN_META_DATA] ?? [] as $via => $metadata) {
$data[iState::COLUMN_META_DATA][$via][iState::COLUMN_WATCHED] = '0';
if (null === ($metadata[iState::COLUMN_META_DATA_PLAYED_AT] ?? null)) {
continue;
}
unset($data[iState::COLUMN_META_DATA][$via][iState::COLUMN_META_DATA_PLAYED_AT]);
}
}
foreach (iState::ENTITY_ARRAY_KEYS as $key) {
if (null !== ($data[$key] ?? null) && true === is_array($data[$key])) {
ksort($data[$key]);
$data[$key] = json_encode($data[$key], flags: JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
}
if (null === ($this->stmt['update'] ?? null)) {
$this->stmt['update'] = $this->db->prepare($this->pdoUpdate('state', iState::ENTITY_KEYS));
}
$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) {
$this->logger->error(
message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.",
context: [
'entity' => $entity->getAll(),
'error' => [
'kind' => $e::class,
'line' => $e->getLine(),
'message' => $e->getMessage(),
'file' => after($e->getFile(), ROOT_PATH),
],
'exception' => [
'kind' => $e::class,
'line' => $e->getLine(),
'trace' => $e->getTrace(),
'message' => $e->getMessage(),
'file' => after($e->getFile(), ROOT_PATH),
]
]
);
return $entity;
}
throw $e;
}
return $entity->updateOriginal();
}
/**
* @inheritdoc
*/
public function remove(iState $entity): bool
{
if (null === $entity->id && !$entity->hasGuids() && $entity->hasRelativeGuid()) {
return false;
}
try {
if (null === $entity->id) {
if (null === ($dbEntity = $this->get($entity))) {
return false;
}
$id = $dbEntity->id;
} else {
$id = $entity->id;
}
$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}'.",
context: [
'entity' => $entity->getAll(),
'error' => [
'kind' => $e::class,
'line' => $e->getLine(),
'message' => $e->getMessage(),
'file' => after($e->getFile(), ROOT_PATH),
],
'exception' => [
'kind' => $e::class,
'line' => $e->getLine(),
'trace' => $e->getTrace(),
'message' => $e->getMessage(),
'file' => after($e->getFile(), ROOT_PATH),
],
]
);
return false;
}
return true;
}
/**
* @inheritdoc
*/
public function commit(array $entities, array $opts = []): array
{
return $this->transactional(function () use ($entities) {
$actions = [
'added' => 0,
'updated' => 0,
'failed' => 0,
];
foreach ($entities as $entity) {
try {
if (null === $entity->id) {
$this->insert($entity);
$actions['added']++;
} else {
$this->update($entity);
$actions['updated']++;
}
} catch (PDOException $e) {
$actions['failed']++;
$this->logger->error(
message: "PDOAdapter: Exception '{error.kind}' was thrown unhandled. '{error.message}' at '{error.file}:{error.line}'.",
context: [
'entity' => $entity->getAll(),
'error' => [
'kind' => $e::class,
'line' => $e->getLine(),
'message' => $e->getMessage(),
'file' => after($e->getFile(), ROOT_PATH),
],
'exception' => [
'kind' => $e::class,
'line' => $e->getLine(),
'trace' => $e->getTrace(),
'message' => $e->getMessage(),
'file' => after($e->getFile(), ROOT_PATH),
],
]
);
}
}
return $actions;
});
}
/**
* @inheritdoc
*/
public function migrations(string $dir, array $opts = []): mixed
{
$class = new PDOMigrations($this->db, $this->logger);
return match (strtolower($dir)) {
iDB::MIGRATE_UP => $class->up(),
iDB::MIGRATE_DOWN => $class->down(),
default => throw new DBException(r("PDOAdapter: Unknown migration direction '{dir}' was given.", [
'name' => $dir
]), 91),
};
}
/**
* @inheritdoc
*/
public function ensureIndex(array $opts = []): mixed
{
return (new PDOIndexer($this->db, $this->logger))->ensureIndex($opts);
}
/**
* @inheritdoc
*/
public function migrateData(string $version, iLogger|null $logger = null): mixed
{
return (new PDODataMigration($this->db, $logger ?? $this->logger))->automatic();
}
/**
* @inheritdoc
*/
public function isMigrated(): bool
{
return (new PDOMigrations($this->db, $this->logger))->isMigrated();
}
/**
* @inheritdoc
*/
public function makeMigration(string $name, array $opts = []): mixed
{
return (new PDOMigrations($this->db, $this->logger))->make($name);
}
/**
* @inheritdoc
*/
public function maintenance(array $opts = []): mixed
{
return (new PDOMigrations($this->db, $this->logger))->runMaintenance();
}
/**
* @inheritdoc
* @noinspection SqlWithoutWhere
*/
public function reset(): bool
{
$this->db->transactional(function (DBLayer $db) {
/** @noinspection SqlResolve */
$tables = $db->query(
'SELECT name FROM sqlite_master WHERE "type" = "table" AND "name" NOT LIKE "sqlite_%"'
);
foreach ($tables->fetchAll(PDO::FETCH_COLUMN) as $table) {
$db->exec('DELETE FROM "' . $table . '"');
$db->exec('DELETE FROM sqlite_sequence WHERE "name" = "' . $table . '"');
}
});
$this->db->exec('VACUUM');
return true;
}
/**
* @inheritdoc
*/
public function setLogger(iLogger $logger): iDB
{
$this->logger = $logger;
return $this;
}
public function getDBLayer(): DBLayer
{
return $this->db;
}
/**
* @inheritdoc
*/
public function transactional(Closure $callback): mixed
{
if (true === $this->db->inTransaction()) {
$this->viaTransaction = true;
$result = $callback($this);
$this->viaTransaction = false;
return $result;
}
try {
$this->db->start();
$this->viaTransaction = true;
$result = $callback($this);
$this->viaTransaction = false;
$this->db->commit();
return $result;
} catch (PDOException $e) {
$this->db->rollBack();
throw $e;
} finally {
$this->viaTransaction = false;
}
}
/**
* Class Destructor
*
* This method is called when the object is destroyed. It checks if a transaction is in progress and commits it
* if necessary. It also clears the statement list array.
*
* @return void
*/
public function __destruct()
{
if (true === $this->db->inTransaction()) {
$this->db->commit();
}
$this->stmt = [];
}
/**
* Inserts data into the specified table using PDO.
*
* @param string $table The name of the table to insert the data into.
* @param array $columns An associative array containing the column names and their values.
*
* @return string The generated SQL query for the insert operation.
*/
private function pdoInsert(string $table, array $columns): string
{
$queryString = "INSERT INTO {$table} ({columns}) VALUES({values})";
$sql_columns = $sql_placeholder = [];
foreach ($columns as $column) {
if (iState::COLUMN_ID === $column) {
continue;
}
$sql_columns[] = $column;
$sql_placeholder[] = ':' . $column;
}
$queryString = str_replace(
['{columns}', '{values}'],
[implode(', ', $sql_columns), implode(', ', $sql_placeholder)],
$queryString
);
return trim($queryString);
}
/**
* Generate SQL update statement.
*
* @param string $table Table name.
* @param array $columns Columns to update.
*
* @return string SQL update statement.
*/
private function pdoUpdate(string $table, array $columns): string
{
/** @noinspection SqlWithoutWhere */
$queryString = "UPDATE {$table} SET {place} = {holder} WHERE " . iState::COLUMN_ID . " = :id";
$placeholders = [];
foreach ($columns as $column) {
if (iState::COLUMN_ID === $column) {
continue;
}
$placeholders[] = r('{column} = :{column}', ['column' => $column]);
}
return trim(str_replace('{place} = {holder}', implode(', ', $placeholders), $queryString));
}
/**
* Find db entity using external id.
* External id format is: (db_name)://(id)
*
* @param iState $entity Entity get external ids from.
*
* @return iState|null Entity if found, null otherwise.
*/
private function findByExternalId(iState $entity): iState|null
{
$guids = [];
$cond = [
'type' => $entity->type,
];
$sqlEpisode = '';
if (true === $entity->isEpisode()) {
if (null !== $entity->season) {
$sqlEpisode .= ' AND ' . iState::COLUMN_SEASON . ' = :season ';
$cond['season'] = $entity->season;
}
if (null !== $entity->episode) {
$sqlEpisode .= ' AND ' . iState::COLUMN_EPISODE . ' = :episode ';
$cond['episode'] = $entity->episode;
}
foreach ($entity->getParentGuids() as $key => $val) {
if (empty($val)) {
continue;
}
$guids[] = "JSON_EXTRACT(" . iState::COLUMN_PARENT . ",'$.{$key}') = :p_{$key}";
$cond['p_' . $key] = $val;
}
}
foreach ($entity->getGuids() as $key => $val) {
if (empty($val)) {
continue;
}
$guids[] = "JSON_EXTRACT(" . iState::COLUMN_GUIDS . ",'$.{$key}') = :g_{$key}";
$cond['g_' . $key] = $val;
}
if (null !== ($backendId = $entity->getMetadata($entity->via)[iState::COLUMN_ID] ?? null)) {
$key = $entity->via . '.' . iState::COLUMN_ID;
$guids[] = "JSON_EXTRACT(" . iState::COLUMN_META_DATA . ",'$.{$key}') = :m_bid";
$cond['m_bid'] = $backendId;
}
if (empty($guids)) {
return null;
}
$sqlGuids = ' AND ( ' . implode(' OR ', $guids) . ' ) ';
$sql = "SELECT * FROM state WHERE " . iState::COLUMN_TYPE . " = :type {$sqlEpisode} {$sqlGuids} LIMIT 1";
$stmt = $this->db->query($sql, $cond);
if (false === ($row = $stmt->fetch(PDO::FETCH_ASSOC))) {
return null;
}
return $entity::fromArray($row);
}
}