From d32942182b3f10d1315f41edd155126a9a4009f9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pudil?= <me@jiripudil.cz>
Date: Thu, 13 Feb 2025 13:19:52 +0100
Subject: [PATCH] extract TableManager interface

---
 src/DI/TablesExtension.php           |   4 +-
 src/SingleConnectionTableManager.php | 245 +++++++++++++++++++++++++++
 src/TableManager.php                 | 206 ++--------------------
 tests/DI/TablesExtensionTest.phpt    |   5 +-
 tests/Fixtures/TestFixtures.php      |   6 +-
 5 files changed, 264 insertions(+), 202 deletions(-)
 create mode 100644 src/SingleConnectionTableManager.php

diff --git a/src/DI/TablesExtension.php b/src/DI/TablesExtension.php
index 53894af..e07eee4 100644
--- a/src/DI/TablesExtension.php
+++ b/src/DI/TablesExtension.php
@@ -7,6 +7,7 @@ namespace Grifart\Tables\DI;
 use Grifart\Tables\Database\Identifier;
 use Grifart\Tables\Scaffolding\PostgresReflector;
 use Grifart\Tables\Scaffolding\TablesDefinitions;
+use Grifart\Tables\SingleConnectionTableManager;
 use Grifart\Tables\TableManager;
 use Grifart\Tables\TypeResolver;
 use Nette\DI\CompilerExtension;
@@ -41,7 +42,8 @@ final class TablesExtension extends CompilerExtension
 		$builder = $this->getContainerBuilder();
 
 		$builder->addDefinition($this->prefix('tableManager'))
-			->setFactory(TableManager::class);
+			->setType(TableManager::class)
+			->setFactory(SingleConnectionTableManager::class);
 
 		$builder->addDefinition($this->prefix('scaffolding'))
 			->setFactory(TablesDefinitions::class);
diff --git a/src/SingleConnectionTableManager.php b/src/SingleConnectionTableManager.php
new file mode 100644
index 0000000..9412760
--- /dev/null
+++ b/src/SingleConnectionTableManager.php
@@ -0,0 +1,245 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Grifart\Tables;
+
+use Dibi\IConnection;
+use Dibi\UniqueConstraintViolationException;
+use Grifart\Tables\Conditions\Composite;
+use Grifart\Tables\Conditions\Condition;
+use Grifart\Tables\OrderBy\OrderBy;
+use Grifart\Tables\OrderBy\OrderByDirection;
+use Nette\Utils\Paginator;
+use function Phun\map;
+use function Phun\mapWithKeys;
+
+// todo: error handling
+// todo: mapping of exceptions
+
+final class SingleConnectionTableManager implements TableManager
+{
+
+	public function __construct(
+		private IConnection $connection,
+	) {}
+
+	/**
+	 * @template TableType of Table
+	 * @param TableType $table
+	 * @param Modifications<TableType> $changes
+	 * @throws RowWithGivenPrimaryKeyAlreadyExists
+	 */
+	public function insert(Table $table, Modifications $changes): void
+	{
+		\assert($changes->getPrimaryKey() === NULL);
+
+		try {
+			$this->connection->query(
+				'INSERT',
+				'INTO %n.%n', $table::getSchema(), $table::getTableName(),
+				mapWithKeys(
+					$changes->getModifications(),
+					static fn(string $columnName, mixed $value) => $table->getTypeOf($columnName)->toDatabase($value),
+				),
+			);
+		} catch (UniqueConstraintViolationException $e) {
+			throw new RowWithGivenPrimaryKeyAlreadyExists(previous: $e);
+		}
+
+		\assert($this->connection->getAffectedRows() === 1);
+	}
+
+	/**
+	 * @template TableType of Table
+	 * @param TableType $table
+	 * @param PrimaryKey<TableType> $primaryKey
+	 * @return ($required is true ? Row : Row|null)
+	 * @throws RowNotFound
+	 */
+	public function find(Table $table, PrimaryKey $primaryKey, bool $required = true): ?Row
+	{
+		return $this->findOneBy($table, $primaryKey->getCondition($table), required: $required);
+	}
+
+	/**
+	 * @template TableType of Table
+	 * @param TableType $table
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return Row[]
+	 */
+	public function getAll(Table $table, array $orderBy = [], ?Paginator $paginator = null): array
+	{
+		return $this->findBy($table, [], $orderBy, $paginator);
+	}
+
+	/**
+	 * @template TableType of Table
+	 * @param TableType $table
+	 * @param Condition|Condition[] $conditions
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return Row[] (subclass of row)
+	 */
+	public function findBy(Table $table, Condition|array $conditions, array $orderBy = [], ?Paginator $paginator = null): array
+	{
+		$result = $this->connection->query(
+			'SELECT *',
+			'FROM %n.%n', $table::getSchema(), $table::getTableName(),
+			'WHERE %ex', (\is_array($conditions) ? Composite::and(...$conditions) : $conditions)->toSql()->getValues(),
+			'ORDER BY %by', \count($orderBy) > 0
+				? map($orderBy, function (OrderBy|Expression $orderBy) {
+					if ($orderBy instanceof Expression) {
+						$orderBy = new OrderByDirection($orderBy);
+					}
+
+					return $orderBy->toSql()->getValues();
+				})
+				: [['%sql', 'true::boolean']],
+			'%lmt', $paginator?->getItemsPerPage(),
+			'%ofs', $paginator?->getOffset(),
+		);
+
+		foreach ($table::getDatabaseColumns() as $column) {
+			$result->setType($column->getName(), NULL);
+		}
+
+		$dibiRows = $result->fetchAll();
+
+		/** @var class-string<Row> $rowClass */
+		$rowClass = $table::getRowClass();
+		$modelRows = [];
+		foreach ($dibiRows as $dibiRow) {
+			\assert($dibiRow instanceof \Dibi\Row);
+			$modelRows[] = $rowClass::reconstitute(
+				mapWithKeys(
+					$dibiRow->toArray(),
+					static fn(string $columnName, mixed $value) => $table->getTypeOf($columnName)->fromDatabase($value),
+				),
+			);
+		}
+
+		if ($paginator !== null) {
+			$totalCount = $this->connection->query(
+				'SELECT COUNT(*)',
+				'FROM %n.%n', $table::getSchema(), $table::getTableName(),
+				'WHERE %ex', (\is_array($conditions) ? Composite::and(...$conditions) : $conditions)->toSql()->getValues(),
+			)->fetchSingle();
+
+			\assert(\is_int($totalCount));
+			$paginator->setItemCount($totalCount);
+		}
+
+		return $modelRows;
+	}
+
+	/**
+	 * @template TableType of Table
+	 * @param TableType $table
+	 * @param Condition|Condition[] $conditions
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return ($required is true ? Row : Row|null)
+	 * @throws RowNotFound
+	 */
+	public function findOneBy(Table $table, Condition|array $conditions, array $orderBy = [], bool $required = true, bool $unique = true): ?Row
+	{
+		$result = $this->connection->query(
+			'SELECT *',
+			'FROM %n.%n', $table::getSchema(), $table::getTableName(),
+			'WHERE %ex', (\is_array($conditions) ? Composite::and(...$conditions) : $conditions)->toSql()->getValues(),
+			'ORDER BY %by', \count($orderBy) > 0
+				? map($orderBy, function (OrderBy|Expression $orderBy) {
+					if ($orderBy instanceof Expression) {
+						$orderBy = new OrderByDirection($orderBy);
+					}
+
+					return $orderBy->toSql()->getValues();
+				})
+				: [['%sql', 'true::boolean']],
+		);
+
+		foreach ($table::getDatabaseColumns() as $column) {
+			$result->setType($column->getName(), NULL);
+		}
+
+		$dibiRow = $result->fetch();
+		if ($dibiRow === null) {
+			return ! $required ? null : throw new RowNotFound();
+		}
+
+		if ($unique && $result->fetch() !== null) {
+			throw new TooManyRowsFound();
+		}
+
+		/** @var class-string<Row> $rowClass */
+		$rowClass = $table::getRowClass();
+		\assert($dibiRow instanceof \Dibi\Row);
+		return $rowClass::reconstitute(
+			mapWithKeys(
+				$dibiRow->toArray(),
+				static fn(string $columnName, mixed $value) => $table->getTypeOf($columnName)->fromDatabase($value),
+			),
+		);
+	}
+
+	/**
+	 * @template TableType of Table
+	 * @param TableType $table
+	 * @param Modifications<TableType> $changes
+	 * @throws GivenSearchCriteriaHaveNotMatchedAnyRows if no rows matches given criteria
+	 */
+	public function update(Table $table, Modifications $changes): void
+	{
+		$primaryKey = $changes->getPrimaryKey();
+		\assert($primaryKey !== NULL);
+		$this->connection->query(
+			'UPDATE %n.%n', $table::getSchema(), $table::getTableName(),
+			'SET %a',
+			mapWithKeys(
+				$changes->getModifications(),
+				static fn(string $columnName, mixed $value) => $table->getTypeOf($columnName)->toDatabase($value),
+			),
+			'WHERE %ex', $primaryKey->getCondition($table)->toSql()->getValues(),
+		);
+		$affectedRows = $this->connection->getAffectedRows();
+		if ($affectedRows !== 1) {
+			if ($affectedRows === 0) {
+				throw new GivenSearchCriteriaHaveNotMatchedAnyRows();
+			}
+
+			throw new ProbablyBrokenPrimaryIndexImplementation($table, $affectedRows);
+		}
+	}
+
+	/**
+	 * @template TableType of Table
+	 * @param TableType $table
+	 * @param PrimaryKey<TableType> $primaryKey
+	 */
+	public function delete(Table $table, PrimaryKey $primaryKey): void
+	{
+		$this->connection->query(
+			'DELETE',
+			'FROM %n.%n', $table::getSchema(), $table::getTableName(),
+			'WHERE %ex', $primaryKey->getCondition($table)->toSql()->getValues(),
+		);
+		\assert($this->connection->getAffectedRows() === 1);
+	}
+
+	/**
+	 * @template TableType of Table
+	 * @param TableType $table
+	 * @param Modifications<TableType> $changes
+	 * @throws RowWithGivenPrimaryKeyAlreadyExists
+	 * @throws GivenSearchCriteriaHaveNotMatchedAnyRows
+	 */
+	public function save(Table $table, Modifications $changes): void {
+		if ($changes->getPrimaryKey() === NULL) {
+			// INSERT
+			$this->insert($table, $changes);
+			return;
+		}
+
+		// UPDATE:
+		$this->update($table, $changes);
+	}
+}
diff --git a/src/TableManager.php b/src/TableManager.php
index 98ab150..7575c1c 100644
--- a/src/TableManager.php
+++ b/src/TableManager.php
@@ -4,51 +4,20 @@ declare(strict_types=1);
 
 namespace Grifart\Tables;
 
-use Dibi\IConnection;
-use Dibi\UniqueConstraintViolationException;
-use Grifart\Tables\Conditions\Composite;
 use Grifart\Tables\Conditions\Condition;
 use Grifart\Tables\OrderBy\OrderBy;
-use Grifart\Tables\OrderBy\OrderByDirection;
+use Grifart\Tables\Table as TableType;
 use Nette\Utils\Paginator;
-use function Phun\map;
-use function Phun\mapWithKeys;
 
-// todo: error handling
-// todo: mapping of exceptions
-
-final class TableManager
+interface TableManager
 {
-
-	public function __construct(
-		private IConnection $connection,
-	) {}
-
 	/**
 	 * @template TableType of Table
 	 * @param TableType $table
 	 * @param Modifications<TableType> $changes
 	 * @throws RowWithGivenPrimaryKeyAlreadyExists
 	 */
-	public function insert(Table $table, Modifications $changes): void
-	{
-		\assert($changes->getPrimaryKey() === NULL);
-
-		try {
-			$this->connection->query(
-				'INSERT',
-				'INTO %n.%n', $table::getSchema(), $table::getTableName(),
-				mapWithKeys(
-					$changes->getModifications(),
-					static fn(string $columnName, mixed $value) => $table->getTypeOf($columnName)->toDatabase($value),
-				),
-			);
-		} catch (UniqueConstraintViolationException $e) {
-			throw new RowWithGivenPrimaryKeyAlreadyExists(previous: $e);
-		}
-
-		\assert($this->connection->getAffectedRows() === 1);
-	}
+	public function insert(Table $table, Modifications $changes): void;
 
 	/**
 	 * @template TableType of Table
@@ -57,21 +26,15 @@ final class TableManager
 	 * @return ($required is true ? Row : Row|null)
 	 * @throws RowNotFound
 	 */
-	public function find(Table $table, PrimaryKey $primaryKey, bool $required = true): ?Row
-	{
-		return $this->findOneBy($table, $primaryKey->getCondition($table), required: $required);
-	}
+	public function find(Table $table, PrimaryKey $primaryKey, bool $required = true): ?Row;
 
 	/**
 	 * @template TableType of Table
 	 * @param TableType $table
-	 * @param OrderBy[] $orderBy
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
 	 * @return Row[]
 	 */
-	public function getAll(Table $table, array $orderBy = [], ?Paginator $paginator = null): array
-	{
-		return $this->findBy($table, [], $orderBy, $paginator);
-	}
+	public function getAll(Table $table, array $orderBy = [], ?Paginator $paginator = null): array;
 
 	/**
 	 * @template TableType of Table
@@ -80,57 +43,7 @@ final class TableManager
 	 * @param array<OrderBy|Expression<mixed>> $orderBy
 	 * @return Row[] (subclass of row)
 	 */
-	public function findBy(Table $table, Condition|array $conditions, array $orderBy = [], ?Paginator $paginator = null): array
-	{
-		$result = $this->connection->query(
-			'SELECT *',
-			'FROM %n.%n', $table::getSchema(), $table::getTableName(),
-			'WHERE %ex', (\is_array($conditions) ? Composite::and(...$conditions) : $conditions)->toSql()->getValues(),
-			'ORDER BY %by', \count($orderBy) > 0
-				? map($orderBy, function (OrderBy|Expression $orderBy) {
-					if ($orderBy instanceof Expression) {
-						$orderBy = new OrderByDirection($orderBy);
-					}
-
-					return $orderBy->toSql()->getValues();
-				})
-				: [['%sql', 'true::boolean']],
-			'%lmt', $paginator?->getItemsPerPage(),
-			'%ofs', $paginator?->getOffset(),
-		);
-
-		foreach ($table::getDatabaseColumns() as $column) {
-			$result->setType($column->getName(), NULL);
-		}
-
-		$dibiRows = $result->fetchAll();
-
-		/** @var class-string<Row> $rowClass */
-		$rowClass = $table::getRowClass();
-		$modelRows = [];
-		foreach ($dibiRows as $dibiRow) {
-			\assert($dibiRow instanceof \Dibi\Row);
-			$modelRows[] = $rowClass::reconstitute(
-				mapWithKeys(
-					$dibiRow->toArray(),
-					static fn(string $columnName, mixed $value) => $table->getTypeOf($columnName)->fromDatabase($value),
-				),
-			);
-		}
-
-		if ($paginator !== null) {
-			$totalCount = $this->connection->query(
-				'SELECT COUNT(*)',
-				'FROM %n.%n', $table::getSchema(), $table::getTableName(),
-				'WHERE %ex', (\is_array($conditions) ? Composite::and(...$conditions) : $conditions)->toSql()->getValues(),
-			)->fetchSingle();
-
-			\assert(\is_int($totalCount));
-			$paginator->setItemCount($totalCount);
-		}
-
-		return $modelRows;
-	}
+	public function findBy(Table $table, Condition|array $conditions, array $orderBy = [], ?Paginator $paginator = null): array;
 
 	/**
 	 * @template TableType of Table
@@ -140,46 +53,7 @@ final class TableManager
 	 * @return ($required is true ? Row : Row|null)
 	 * @throws RowNotFound
 	 */
-	public function findOneBy(Table $table, Condition|array $conditions, array $orderBy = [], bool $required = true, bool $unique = true): ?Row
-	{
-		$result = $this->connection->query(
-			'SELECT *',
-			'FROM %n.%n', $table::getSchema(), $table::getTableName(),
-			'WHERE %ex', (\is_array($conditions) ? Composite::and(...$conditions) : $conditions)->toSql()->getValues(),
-			'ORDER BY %by', \count($orderBy) > 0
-				? map($orderBy, function (OrderBy|Expression $orderBy) {
-					if ($orderBy instanceof Expression) {
-						$orderBy = new OrderByDirection($orderBy);
-					}
-
-					return $orderBy->toSql()->getValues();
-				})
-				: [['%sql', 'true::boolean']],
-		);
-
-		foreach ($table::getDatabaseColumns() as $column) {
-			$result->setType($column->getName(), NULL);
-		}
-
-		$dibiRow = $result->fetch();
-		if ($dibiRow === null) {
-			return ! $required ? null : throw new RowNotFound();
-		}
-
-		if ($unique && $result->fetch() !== null) {
-			throw new TooManyRowsFound();
-		}
-
-		/** @var class-string<Row> $rowClass */
-		$rowClass = $table::getRowClass();
-		\assert($dibiRow instanceof \Dibi\Row);
-		return $rowClass::reconstitute(
-			mapWithKeys(
-				$dibiRow->toArray(),
-				static fn(string $columnName, mixed $value) => $table->getTypeOf($columnName)->fromDatabase($value),
-			),
-		);
-	}
+	public function findOneBy(Table $table, Condition|array $conditions, array $orderBy = [], bool $required = true, bool $unique = true): ?Row;
 
 	/**
 	 * @template TableType of Table
@@ -187,43 +61,14 @@ final class TableManager
 	 * @param Modifications<TableType> $changes
 	 * @throws GivenSearchCriteriaHaveNotMatchedAnyRows if no rows matches given criteria
 	 */
-	public function update(Table $table, Modifications $changes): void
-	{
-		$primaryKey = $changes->getPrimaryKey();
-		\assert($primaryKey !== NULL);
-		$this->connection->query(
-			'UPDATE %n.%n', $table::getSchema(), $table::getTableName(),
-			'SET %a',
-			mapWithKeys(
-				$changes->getModifications(),
-				static fn(string $columnName, mixed $value) => $table->getTypeOf($columnName)->toDatabase($value),
-			),
-			'WHERE %ex', $primaryKey->getCondition($table)->toSql()->getValues(),
-		);
-		$affectedRows = $this->connection->getAffectedRows();
-		if ($affectedRows !== 1) {
-			if ($affectedRows === 0) {
-				throw new GivenSearchCriteriaHaveNotMatchedAnyRows();
-			}
-
-			throw new ProbablyBrokenPrimaryIndexImplementation($table, $affectedRows);
-		}
-	}
+	public function update(Table $table, Modifications $changes): void;
 
 	/**
 	 * @template TableType of Table
 	 * @param TableType $table
 	 * @param PrimaryKey<TableType> $primaryKey
 	 */
-	public function delete(Table $table, PrimaryKey $primaryKey): void
-	{
-		$this->connection->query(
-			'DELETE',
-			'FROM %n.%n', $table::getSchema(), $table::getTableName(),
-			'WHERE %ex', $primaryKey->getCondition($table)->toSql()->getValues(),
-		);
-		\assert($this->connection->getAffectedRows() === 1);
-	}
+	public function delete(Table $table, PrimaryKey $primaryKey): void;
 
 	/**
 	 * @template TableType of Table
@@ -232,34 +77,5 @@ final class TableManager
 	 * @throws RowWithGivenPrimaryKeyAlreadyExists
 	 * @throws GivenSearchCriteriaHaveNotMatchedAnyRows
 	 */
-	public function save(Table $table, Modifications $changes): void {
-		if ($changes->getPrimaryKey() === NULL) {
-			// INSERT
-			$this->insert($table, $changes);
-			return;
-		}
-
-		// UPDATE:
-		$this->update($table, $changes);
-	}
-
-	/**
-	 * @template T
-	 * @param \Closure(): T $block
-	 * @return T
-	 */
-	public function withConnection(
-		IConnection $connection,
-		\Closure $block,
-	): mixed
-	{
-		$previousConnection = $this->connection;
-		$this->connection = $connection;
-
-		try {
-			return $block();
-		} finally {
-			$this->connection = $previousConnection;
-		}
-	}
+	public function save(Table $table, Modifications $changes): void;
 }
diff --git a/tests/DI/TablesExtensionTest.phpt b/tests/DI/TablesExtensionTest.phpt
index cb362b3..e602f90 100644
--- a/tests/DI/TablesExtensionTest.phpt
+++ b/tests/DI/TablesExtensionTest.phpt
@@ -4,11 +4,10 @@ declare(strict_types=1);
 
 namespace Grifart\Tables\Tests\DI;
 
-use Dibi\Connection;
 use Grifart\Tables\Database\Identifier;
 use Grifart\Tables\Scaffolding\TablesDefinitions;
+use Grifart\Tables\SingleConnectionTableManager;
 use Grifart\Tables\TableManager;
-use Grifart\Tables\Tests\Fixtures\UuidType;
 use Grifart\Tables\Tests\Fixtures\VersionType;
 use Grifart\Tables\TypeResolver;
 use Grifart\Tables\Types\DecimalType;
@@ -40,7 +39,7 @@ $createContainer = function (string $configFile): Container
 
 (function () use ($createContainer) {
 	$container = $createContainer('default');
-	Assert::type(TableManager::class, $container->getByType(TableManager::class));
+	Assert::type(SingleConnectionTableManager::class, $container->getByType(TableManager::class));
 	Assert::type(TablesDefinitions::class, $container->getByType(TablesDefinitions::class));
 })();
 
diff --git a/tests/Fixtures/TestFixtures.php b/tests/Fixtures/TestFixtures.php
index 212d43e..a0f18aa 100644
--- a/tests/Fixtures/TestFixtures.php
+++ b/tests/Fixtures/TestFixtures.php
@@ -6,7 +6,7 @@ namespace Grifart\Tables\Tests\Fixtures;
 
 use Dibi\Connection;
 use Grifart\Tables\Database\Identifier;
-use Grifart\Tables\TableManager;
+use Grifart\Tables\SingleConnectionTableManager;
 use Grifart\Tables\TypeResolver;
 use Grifart\Tables\Types\ArrayType;
 use Grifart\Tables\Types\IntType;
@@ -16,9 +16,9 @@ final class TestFixtures
 {
 	use StaticClass;
 
-	public static function createTableManager(Connection $connection): TableManager
+	public static function createTableManager(Connection $connection): SingleConnectionTableManager
 	{
-		return new TableManager($connection);
+		return new SingleConnectionTableManager($connection);
 	}
 
 	public static function createTypeResolver(Connection $connection): TypeResolver
-- 
GitLab