From 6bae45231822fa43e7623d28d73f8ee4aab3c145 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pudil?= <me@jiripudil.cz>
Date: Wed, 5 Feb 2025 10:52:46 +0100
Subject: [PATCH] Provide a wider selection of querying methods in Table.

- getOneBy()
- findOneBy()
- getFirstBy()
- findFirstBy()

Also deprecate getBy() in favor of its replacement getOneBy()

Closes #29
---
 src/Scaffolding/TableImplementation.php |  66 ++++-
 src/TableManager.php                    |  50 ++++
 tests/ConfigTableTest.phpt              |  57 +++++
 tests/Fixtures/.definition.php          |   8 +
 tests/Fixtures/ConfigModifications.php  |  56 +++++
 tests/Fixtures/ConfigPrimaryKey.php     |  52 ++++
 tests/Fixtures/ConfigRow.php            |  46 ++++
 tests/Fixtures/ConfigTable.php          | 316 ++++++++++++++++++++++++
 tests/Fixtures/GeneratedTable.php       |  64 ++++-
 tests/Fixtures/PackagesTable.php        |  64 ++++-
 tests/Fixtures/TestFixtures.php         |   1 +
 tests/Fixtures/TestsTable.php           |  64 ++++-
 tests/Scaffolding/ScaffoldingTest.phpt  |   2 +-
 tests/TableTest.php                     |   2 +-
 tests/initializeDatabase.php            |   8 +
 15 files changed, 837 insertions(+), 19 deletions(-)
 create mode 100644 tests/ConfigTableTest.phpt
 create mode 100644 tests/Fixtures/ConfigModifications.php
 create mode 100644 tests/Fixtures/ConfigPrimaryKey.php
 create mode 100644 tests/Fixtures/ConfigRow.php
 create mode 100644 tests/Fixtures/ConfigTable.php

diff --git a/src/Scaffolding/TableImplementation.php b/src/Scaffolding/TableImplementation.php
index 3127a13..4533bee 100644
--- a/src/Scaffolding/TableImplementation.php
+++ b/src/Scaffolding/TableImplementation.php
@@ -28,7 +28,6 @@ use Grifart\Tables\TypeResolver;
 use Nette\PhpGenerator as Code;
 use Nette\Utils\Paginator;
 use function Grifart\ClassScaffolder\Definition\Types\resolve;
-use function Phun\map;
 use function usort;
 
 final class TableImplementation implements Capability
@@ -159,6 +158,65 @@ final class TableImplementation implements Capability
 
 
 		$namespace->addUse(TooManyRowsFound::class);
+		$classType->addMethod('getOneBy')
+			->setParameters([
+				(new Code\Parameter('conditions'))->setType(Condition::class . '|array'),
+			])
+			->addComment('@param Condition|Condition[] $conditions')
+			->addComment('@return ' . $namespace->simplifyName($this->rowClass))
+			->addComment('@throws RowNotFound')
+			->setReturnType($this->rowClass)
+			->addBody('[$row, $count] = $this->tableManager->findOneBy($this, $conditions);')
+			->addBody('\assert($row instanceof ? || $row === null);', [new Code\Literal($namespace->simplifyName($this->rowClass))])
+			->addBody('if ($row === null) { throw new RowNotFound(); }')
+			->addBody('if ($count > 1) { throw new TooManyRowsFound(); }')
+			->addBody('return $row;');
+
+		$classType->addMethod('findOneBy')
+			->setParameters([
+				(new Code\Parameter('conditions'))->setType(Condition::class . '|array'),
+			])
+			->addComment('@param Condition|Condition[] $conditions')
+			->addComment('@return ' . $namespace->simplifyName($this->rowClass))
+			->addComment('@throws RowNotFound')
+			->setReturnType($this->rowClass)
+			->setReturnNullable()
+			->addBody('[$row, $count] = $this->tableManager->findOneBy($this, $conditions);')
+			->addBody('\assert($row instanceof ? || $row === null);', [new Code\Literal($namespace->simplifyName($this->rowClass))])
+			->addBody('if ($count > 1) { throw new TooManyRowsFound(); }')
+			->addBody('return $row;');
+
+		$classType->addMethod('getFirstBy')
+			->setParameters([
+				(new Code\Parameter('conditions'))->setType(Condition::class . '|array'),
+				(new Code\Parameter('orderBy'))->setType('array')->setDefaultValue([]),
+			])
+			->addComment('@param Condition|Condition[] $conditions')
+			->addComment('@param array<OrderBy|Expression<mixed>> $orderBy')
+			->addComment('@return ' . $namespace->simplifyName($this->rowClass))
+			->addComment('@throws RowNotFound')
+			->setReturnType($this->rowClass)
+			->addBody('[$row] = $this->tableManager->findOneBy($this, $conditions, $orderBy, checkCount: false);')
+			->addBody('\assert($row instanceof ? || $row === null);', [new Code\Literal($namespace->simplifyName($this->rowClass))])
+			->addBody('if ($row === null) { throw new RowNotFound(); }')
+			->addBody('return $row;');
+
+		$classType->addMethod('findFirstBy')
+			->setParameters([
+				(new Code\Parameter('conditions'))->setType(Condition::class . '|array'),
+				(new Code\Parameter('orderBy'))->setType('array')->setDefaultValue([]),
+			])
+			->addComment('@param Condition|Condition[] $conditions')
+			->addComment('@param array<OrderBy|Expression<mixed>> $orderBy')
+			->addComment('@return ' . $namespace->simplifyName($this->rowClass))
+			->addComment('@throws RowNotFound')
+			->setReturnType($this->rowClass)
+			->setReturnNullable()
+			->addBody('[$row] = $this->tableManager->findOneBy($this, $conditions, $orderBy, checkCount: false);')
+			->addBody('\assert($row instanceof ? || $row === null);', [new Code\Literal($namespace->simplifyName($this->rowClass))])
+			->addBody('return $row;');
+
+
 		$classType->addMethod('getBy')
 			->setParameters([
 				(new Code\Parameter('conditions'))->setType(Condition::class . '|array'),
@@ -166,11 +224,9 @@ final class TableImplementation implements Capability
 			->addComment('@param Condition|Condition[] $conditions')
 			->addComment('@return ' . $namespace->simplifyName($this->rowClass))
 			->addComment('@throws RowNotFound')
+			->addAttribute(\Deprecated::class, ['Use getOneBy() instead.'])
 			->setReturnType($this->rowClass)
-			->addBody('$result = $this->findBy($conditions);')
-			->addBody('if (\count($result) === 0) { throw new RowNotFound(); }')
-			->addBody('if (\count($result) > 1) { throw new TooManyRowsFound(); }')
-			->addBody('return $result[0];');
+			->addBody('return $this->getOneBy($conditions);');
 
 
 		$newMethod = $classType->addMethod('new')
diff --git a/src/TableManager.php b/src/TableManager.php
index acbf929..4b6880e 100644
--- a/src/TableManager.php
+++ b/src/TableManager.php
@@ -137,6 +137,56 @@ final class TableManager
 		return $modelRows;
 	}
 
+	/**
+	 * @template TableType of Table
+	 * @param TableType $table
+	 * @param Condition|Condition[] $conditions
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return array{Row|null, int}
+	 */
+	public function findOneBy(Table $table, Condition|array $conditions, array $orderBy = [], bool $checkCount = true): 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', $checkCount ? 2 : 1,
+		);
+
+		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) => $value !== null ? $table->getTypeOf($columnName)->fromDatabase($value) : null,
+				),
+			);
+		}
+
+		return [
+			$modelRows[0] ?? null,
+			\count($modelRows),
+		];
+	}
+
 	/**
 	 * @template TableType of Table
 	 * @param TableType $table
diff --git a/tests/ConfigTableTest.phpt b/tests/ConfigTableTest.phpt
new file mode 100644
index 0000000..a705a0d
--- /dev/null
+++ b/tests/ConfigTableTest.phpt
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Grifart\Tables\Tests;
+
+use Grifart\Tables\RowNotFound;
+use Grifart\Tables\Tests\Fixtures\ConfigTable;
+use Grifart\Tables\Tests\Fixtures\TestFixtures;
+use Grifart\Tables\TooManyRowsFound;
+use Tester\Assert;
+
+require __DIR__ . '/bootstrap.php';
+
+$connection = connect();
+
+$connection->nativeQuery("TRUNCATE TABLE public.config");
+$connection->nativeQuery("INSERT INTO public.config (id, key, value) VALUES ('4bd6f9a9-cccf-4e1d-bbdd-bcd89406f65a', 'key1', 'same value'), ('d9d29fb0-6c6e-48b9-a60f-8c8136fe0840', 'key2', 'same value'), ('c10c5e42-a2fe-4fbd-97f7-0d4d3e27541e', 'key3', 'different value');");
+
+$table = new ConfigTable(
+	TestFixtures::createTableManager($connection),
+	TestFixtures::createTypeResolver($connection),
+);
+
+$row = $table->getOneBy($table->key()->is('key1'));
+Assert::same('same value', $row->getValue());
+
+Assert::throws(fn() => $table->getOneBy($table->key()->is('key4')), RowNotFound::class);
+Assert::throws(fn() => $table->getOneBy($table->value()->is('same value')), TooManyRowsFound::class);
+
+$row = $table->findOneBy($table->key()->is('key1'));
+Assert::same('same value', $row->getValue());
+
+Assert::null($table->findOneBy($table->key()->is('key4')));
+Assert::throws(fn() => $table->findOneBy($table->value()->is('same value')), TooManyRowsFound::class);
+
+$row = $table->getFirstBy($table->key()->is('key1'));
+Assert::same('same value', $row->getValue());
+
+Assert::throws(fn() => $table->getFirstBy($table->key()->is('key4')), RowNotFound::class);
+
+$row = $table->getFirstBy($table->value()->is('same value'), [$table->key()->ascending()]);
+Assert::same('key1', $row->getKey());
+
+$row = $table->getFirstBy($table->value()->is('same value'), [$table->key()->descending()]);
+Assert::same('key2', $row->getKey());
+
+$row = $table->findFirstBy($table->key()->is('key1'));
+Assert::same('same value', $row->getValue());
+
+Assert::null($table->findFirstBy($table->key()->is('key4')));
+
+$row = $table->findFirstBy($table->value()->is('same value'), [$table->key()->ascending()]);
+Assert::same('key1', $row->getKey());
+
+$row = $table->findFirstBy($table->value()->is('same value'), [$table->key()->descending()]);
+Assert::same('key2', $row->getKey());
diff --git a/tests/Fixtures/.definition.php b/tests/Fixtures/.definition.php
index f160884..7f6dc2a 100644
--- a/tests/Fixtures/.definition.php
+++ b/tests/Fixtures/.definition.php
@@ -24,6 +24,14 @@ return [
 		TestsTable::class,
 		TestPrimaryKey::class,
 	),
+	...$tableDefinitions->for(
+		'public',
+		'config',
+		ConfigRow::class,
+		ConfigModifications::class,
+		ConfigTable::class,
+		ConfigPrimaryKey::class,
+	),
 	...$tableDefinitions->for(
 		'public',
 		'package',
diff --git a/tests/Fixtures/ConfigModifications.php b/tests/Fixtures/ConfigModifications.php
new file mode 100644
index 0000000..207bab3
--- /dev/null
+++ b/tests/Fixtures/ConfigModifications.php
@@ -0,0 +1,56 @@
+<?php
+
+/**
+ * Do not edit. This is generated file. Modify definition file instead.
+ */
+
+declare(strict_types=1);
+
+namespace Grifart\Tables\Tests\Fixtures;
+
+use Grifart\Tables\Modifications;
+use Grifart\Tables\ModificationsTrait;
+
+/**
+ * @implements Modifications<ConfigTable>
+ */
+final class ConfigModifications implements Modifications
+{
+	/** @use ModificationsTrait<ConfigTable> */
+	use ModificationsTrait;
+
+	public static function update(ConfigPrimaryKey $primaryKey): self
+	{
+		return self::_update($primaryKey);
+	}
+
+
+	public static function new(): self
+	{
+		return self::_new();
+	}
+
+
+	public static function forTable(): string
+	{
+		return ConfigTable::class;
+	}
+
+
+	public function modifyId(Uuid $id): void
+	{
+		$this->modifications['id'] = $id;
+	}
+
+
+	public function modifyKey(string $key): void
+	{
+		$this->modifications['key'] = $key;
+	}
+
+
+	public function modifyValue(string $value): void
+	{
+		$this->modifications['value'] = $value;
+	}
+}
diff --git a/tests/Fixtures/ConfigPrimaryKey.php b/tests/Fixtures/ConfigPrimaryKey.php
new file mode 100644
index 0000000..1200f31
--- /dev/null
+++ b/tests/Fixtures/ConfigPrimaryKey.php
@@ -0,0 +1,52 @@
+<?php
+
+/**
+ * Do not edit. This is generated file. Modify definition file instead.
+ */
+
+declare(strict_types=1);
+
+namespace Grifart\Tables\Tests\Fixtures;
+
+use Grifart\Tables\Conditions\Composite;
+use Grifart\Tables\Conditions\Condition;
+use Grifart\Tables\PrimaryKey;
+use Grifart\Tables\Table;
+use function Grifart\Tables\Conditions\equalTo;
+
+/**
+ * @implements PrimaryKey<ConfigTable>
+ */
+final class ConfigPrimaryKey implements PrimaryKey
+{
+	private function __construct(
+		private Uuid $id,
+	) {
+	}
+
+
+	public static function from(Uuid $id): self
+	{
+		return new self($id);
+	}
+
+
+	public static function fromRow(ConfigRow $row): self
+	{
+		return self::from($row->getId());
+	}
+
+
+	public function getCondition(Table $table): Condition
+	{
+		return Composite::and(
+			$table->id()->is(equalTo($this->id)),
+		);
+	}
+
+
+	public function getId(): Uuid
+	{
+		return $this->id;
+	}
+}
diff --git a/tests/Fixtures/ConfigRow.php b/tests/Fixtures/ConfigRow.php
new file mode 100644
index 0000000..691e6ef
--- /dev/null
+++ b/tests/Fixtures/ConfigRow.php
@@ -0,0 +1,46 @@
+<?php
+
+/**
+ * Do not edit. This is generated file. Modify definition file instead.
+ */
+
+declare(strict_types=1);
+
+namespace Grifart\Tables\Tests\Fixtures;
+
+use Grifart\Tables\Row;
+
+final class ConfigRow implements Row
+{
+	private function __construct(
+		private Uuid $id,
+		private string $key,
+		private string $value,
+	) {
+	}
+
+
+	public function getId(): Uuid
+	{
+		return $this->id;
+	}
+
+
+	public function getKey(): string
+	{
+		return $this->key;
+	}
+
+
+	public function getValue(): string
+	{
+		return $this->value;
+	}
+
+
+	public static function reconstitute(array $values): static
+	{
+		/** @var array{id: Uuid, key: string, value: string} $values */
+		return new static($values['id'], $values['key'], $values['value']);
+	}
+}
diff --git a/tests/Fixtures/ConfigTable.php b/tests/Fixtures/ConfigTable.php
new file mode 100644
index 0000000..605dd72
--- /dev/null
+++ b/tests/Fixtures/ConfigTable.php
@@ -0,0 +1,316 @@
+<?php
+
+/**
+ * Do not edit. This is generated file. Modify definition file instead.
+ */
+
+declare(strict_types=1);
+
+namespace Grifart\Tables\Tests\Fixtures;
+
+use Grifart\Tables\Column;
+use Grifart\Tables\ColumnMetadata;
+use Grifart\Tables\ColumnNotFound;
+use Grifart\Tables\Conditions\Condition;
+use Grifart\Tables\DefaultOrExistingValue;
+use Grifart\Tables\Expression;
+use Grifart\Tables\GivenSearchCriteriaHaveNotMatchedAnyRows;
+use Grifart\Tables\OrderBy\OrderBy;
+use Grifart\Tables\RowNotFound;
+use Grifart\Tables\RowWithGivenPrimaryKeyAlreadyExists;
+use Grifart\Tables\Table;
+use Grifart\Tables\TableManager;
+use Grifart\Tables\TooManyRowsFound;
+use Grifart\Tables\Type;
+use Grifart\Tables\TypeResolver;
+use Nette\Utils\Paginator;
+
+final class ConfigTable implements Table
+{
+	public const ID = 'id';
+	public const KEY = 'key';
+	public const VALUE = 'value';
+
+	/** @var array{id: Column<self, Uuid>, key: Column<self, string>, value: Column<self, string>} */
+	private array $columns;
+
+
+	public static function getSchema(): string
+	{
+		return 'public';
+	}
+
+
+	public static function getTableName(): string
+	{
+		return 'config';
+	}
+
+
+	public static function getPrimaryKeyClass(): string
+	{
+		return ConfigPrimaryKey::class;
+	}
+
+
+	public static function getRowClass(): string
+	{
+		return ConfigRow::class;
+	}
+
+
+	public static function getModificationClass(): string
+	{
+		return ConfigModifications::class;
+	}
+
+
+	/**
+	 * @return ColumnMetadata[]
+	 */
+	public static function getDatabaseColumns(): array
+	{
+		return [
+			'id' => new ColumnMetadata('id', 'uuid', false, false, false),
+			'key' => new ColumnMetadata('key', 'text', false, false, false),
+			'value' => new ColumnMetadata('value', 'text', false, false, false)
+		];
+	}
+
+
+	public function find(ConfigPrimaryKey $primaryKey): ?ConfigRow
+	{
+		$row = $this->tableManager->find($this, $primaryKey);
+		\assert($row instanceof ConfigRow || $row === NULL);
+		return $row;
+	}
+
+
+	/**
+	 * @throws RowNotFound
+	 */
+	public function get(ConfigPrimaryKey $primaryKey): ConfigRow
+	{
+		$row = $this->find($primaryKey);
+		if ($row === NULL) {
+			throw new RowNotFound();
+		}
+		return $row;
+	}
+
+
+	/**
+	 * @param OrderBy[] $orderBy
+	 * @return ConfigRow[]
+	 */
+	public function getAll(array $orderBy = [], ?Paginator $paginator = null): array
+	{
+		/** @var ConfigRow[] $result */
+		$result = $this->tableManager->getAll($this, $orderBy, $paginator);
+		return $result;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return ConfigRow[]
+	 */
+	public function findBy(Condition|array $conditions, array $orderBy = [], ?Paginator $paginator = null): array
+	{
+		/** @var ConfigRow[] $result */
+		$result = $this->tableManager->findBy($this, $conditions, $orderBy, $paginator);
+		return $result;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @return ConfigRow
+	 * @throws RowNotFound
+	 */
+	public function getOneBy(Condition|array $conditions): ConfigRow
+	{
+		[$row, $count] = $this->tableManager->findOneBy($this, $conditions);
+		\assert($row instanceof ConfigRow || $row === null);
+		if ($row === null) { throw new RowNotFound(); }
+		if ($count > 1) { throw new TooManyRowsFound(); }
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @return ConfigRow
+	 * @throws RowNotFound
+	 */
+	public function findOneBy(Condition|array $conditions): ?ConfigRow
+	{
+		[$row, $count] = $this->tableManager->findOneBy($this, $conditions);
+		\assert($row instanceof ConfigRow || $row === null);
+		if ($count > 1) { throw new TooManyRowsFound(); }
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return ConfigRow
+	 * @throws RowNotFound
+	 */
+	public function getFirstBy(Condition|array $conditions, array $orderBy = []): ConfigRow
+	{
+		[$row] = $this->tableManager->findOneBy($this, $conditions, $orderBy, checkCount: false);
+		\assert($row instanceof ConfigRow || $row === null);
+		if ($row === null) { throw new RowNotFound(); }
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return ConfigRow
+	 * @throws RowNotFound
+	 */
+	public function findFirstBy(Condition|array $conditions, array $orderBy = []): ?ConfigRow
+	{
+		[$row] = $this->tableManager->findOneBy($this, $conditions, $orderBy, checkCount: false);
+		\assert($row instanceof ConfigRow || $row === null);
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @return ConfigRow
+	 * @throws RowNotFound
+	 */
+	#[\Deprecated('Use getOneBy() instead.')]
+	public function getBy(Condition|array $conditions): ConfigRow
+	{
+		return $this->getOneBy($conditions);
+	}
+
+
+	public function new(Uuid $id, string $key, string $value): ConfigModifications
+	{
+		$modifications = ConfigModifications::new();
+		$modifications->modifyId($id);
+		$modifications->modifyKey($key);
+		$modifications->modifyValue($value);
+		return $modifications;
+	}
+
+
+	public function edit(
+		ConfigRow|ConfigPrimaryKey $rowOrKey,
+		Uuid|DefaultOrExistingValue $id = \Grifart\Tables\Unchanged,
+		string|DefaultOrExistingValue $key = \Grifart\Tables\Unchanged,
+		string|DefaultOrExistingValue $value = \Grifart\Tables\Unchanged,
+	): ConfigModifications
+	{
+		$primaryKey = $rowOrKey instanceof ConfigPrimaryKey ? $rowOrKey : ConfigPrimaryKey::fromRow($rowOrKey);
+		$modifications = ConfigModifications::update($primaryKey);
+		if (!$id instanceof DefaultOrExistingValue) {
+			$modifications->modifyId($id);
+		}
+		if (!$key instanceof DefaultOrExistingValue) {
+			$modifications->modifyKey($key);
+		}
+		if (!$value instanceof DefaultOrExistingValue) {
+			$modifications->modifyValue($value);
+		}
+		return $modifications;
+	}
+
+
+	/**
+	 * @throws RowWithGivenPrimaryKeyAlreadyExists
+	 * @throws GivenSearchCriteriaHaveNotMatchedAnyRows
+	 */
+	public function save(ConfigModifications $changes): void
+	{
+		$this->tableManager->save($this, $changes);
+	}
+
+
+	/**
+	 * @throws RowWithGivenPrimaryKeyAlreadyExists
+	 */
+	public function insert(ConfigModifications $changes): void
+	{
+		$this->tableManager->insert($this, $changes);
+	}
+
+
+	/**
+	 * @throws GivenSearchCriteriaHaveNotMatchedAnyRows
+	 */
+	public function update(ConfigModifications $changes): void
+	{
+		$this->tableManager->update($this, $changes);
+	}
+
+
+	public function delete(ConfigRow|ConfigPrimaryKey $rowOrKey): void
+	{
+		$primaryKey = $rowOrKey instanceof ConfigPrimaryKey ? $rowOrKey : ConfigPrimaryKey::fromRow($rowOrKey);
+		$this->tableManager->delete($this, $primaryKey);
+	}
+
+
+	public function __construct(
+		private TableManager $tableManager,
+		private TypeResolver $typeResolver,
+	) {
+		/** @var Column<self, Uuid> $id */
+		$id = Column::from($this, self::getDatabaseColumns()['id'], $this->typeResolver);
+		/** @var Column<self, string> $key */
+		$key = Column::from($this, self::getDatabaseColumns()['key'], $this->typeResolver);
+		/** @var Column<self, string> $value */
+		$value = Column::from($this, self::getDatabaseColumns()['value'], $this->typeResolver);
+		$this->columns = ['id' => $id, 'key' => $key, 'value' => $value];
+	}
+
+
+	/**
+	 * @return Column<self, Uuid>
+	 */
+	public function id(): Column
+	{
+		return $this->columns['id'];
+	}
+
+
+	/**
+	 * @return Column<self, string>
+	 */
+	public function key(): Column
+	{
+		return $this->columns['key'];
+	}
+
+
+	/**
+	 * @return Column<self, string>
+	 */
+	public function value(): Column
+	{
+		return $this->columns['value'];
+	}
+
+
+	/**
+	 * @internal
+	 * @return Type<mixed>
+	 */
+	public function getTypeOf(string $columnName): Type
+	{
+		$column = $this->columns[$columnName] ?? throw ColumnNotFound::of($columnName, \get_class($this));
+		/** @var Type<mixed> $type */
+		$type = $column->getType();
+		return $type;
+	}
+}
diff --git a/tests/Fixtures/GeneratedTable.php b/tests/Fixtures/GeneratedTable.php
index bd78f9d..5dbe6bf 100644
--- a/tests/Fixtures/GeneratedTable.php
+++ b/tests/Fixtures/GeneratedTable.php
@@ -129,12 +129,68 @@ final class GeneratedTable implements Table
 	 * @return GeneratedRow
 	 * @throws RowNotFound
 	 */
+	public function getOneBy(Condition|array $conditions): GeneratedRow
+	{
+		[$row, $count] = $this->tableManager->findOneBy($this, $conditions);
+		\assert($row instanceof GeneratedRow || $row === null);
+		if ($row === null) { throw new RowNotFound(); }
+		if ($count > 1) { throw new TooManyRowsFound(); }
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @return GeneratedRow
+	 * @throws RowNotFound
+	 */
+	public function findOneBy(Condition|array $conditions): ?GeneratedRow
+	{
+		[$row, $count] = $this->tableManager->findOneBy($this, $conditions);
+		\assert($row instanceof GeneratedRow || $row === null);
+		if ($count > 1) { throw new TooManyRowsFound(); }
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return GeneratedRow
+	 * @throws RowNotFound
+	 */
+	public function getFirstBy(Condition|array $conditions, array $orderBy = []): GeneratedRow
+	{
+		[$row] = $this->tableManager->findOneBy($this, $conditions, $orderBy, checkCount: false);
+		\assert($row instanceof GeneratedRow || $row === null);
+		if ($row === null) { throw new RowNotFound(); }
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return GeneratedRow
+	 * @throws RowNotFound
+	 */
+	public function findFirstBy(Condition|array $conditions, array $orderBy = []): ?GeneratedRow
+	{
+		[$row] = $this->tableManager->findOneBy($this, $conditions, $orderBy, checkCount: false);
+		\assert($row instanceof GeneratedRow || $row === null);
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @return GeneratedRow
+	 * @throws RowNotFound
+	 */
+	#[\Deprecated('Use getOneBy() instead.')]
 	public function getBy(Condition|array $conditions): GeneratedRow
 	{
-		$result = $this->findBy($conditions);
-		if (\count($result) === 0) { throw new RowNotFound(); }
-		if (\count($result) > 1) { throw new TooManyRowsFound(); }
-		return $result[0];
+		return $this->getOneBy($conditions);
 	}
 
 
diff --git a/tests/Fixtures/PackagesTable.php b/tests/Fixtures/PackagesTable.php
index bbf6364..4f6ca4d 100644
--- a/tests/Fixtures/PackagesTable.php
+++ b/tests/Fixtures/PackagesTable.php
@@ -129,12 +129,68 @@ final class PackagesTable implements Table
 	 * @return PackageRow
 	 * @throws RowNotFound
 	 */
+	public function getOneBy(Condition|array $conditions): PackageRow
+	{
+		[$row, $count] = $this->tableManager->findOneBy($this, $conditions);
+		\assert($row instanceof PackageRow || $row === null);
+		if ($row === null) { throw new RowNotFound(); }
+		if ($count > 1) { throw new TooManyRowsFound(); }
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @return PackageRow
+	 * @throws RowNotFound
+	 */
+	public function findOneBy(Condition|array $conditions): ?PackageRow
+	{
+		[$row, $count] = $this->tableManager->findOneBy($this, $conditions);
+		\assert($row instanceof PackageRow || $row === null);
+		if ($count > 1) { throw new TooManyRowsFound(); }
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return PackageRow
+	 * @throws RowNotFound
+	 */
+	public function getFirstBy(Condition|array $conditions, array $orderBy = []): PackageRow
+	{
+		[$row] = $this->tableManager->findOneBy($this, $conditions, $orderBy, checkCount: false);
+		\assert($row instanceof PackageRow || $row === null);
+		if ($row === null) { throw new RowNotFound(); }
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return PackageRow
+	 * @throws RowNotFound
+	 */
+	public function findFirstBy(Condition|array $conditions, array $orderBy = []): ?PackageRow
+	{
+		[$row] = $this->tableManager->findOneBy($this, $conditions, $orderBy, checkCount: false);
+		\assert($row instanceof PackageRow || $row === null);
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @return PackageRow
+	 * @throws RowNotFound
+	 */
+	#[\Deprecated('Use getOneBy() instead.')]
 	public function getBy(Condition|array $conditions): PackageRow
 	{
-		$result = $this->findBy($conditions);
-		if (\count($result) === 0) { throw new RowNotFound(); }
-		if (\count($result) > 1) { throw new TooManyRowsFound(); }
-		return $result[0];
+		return $this->getOneBy($conditions);
 	}
 
 
diff --git a/tests/Fixtures/TestFixtures.php b/tests/Fixtures/TestFixtures.php
index 0b03a86..212d43e 100644
--- a/tests/Fixtures/TestFixtures.php
+++ b/tests/Fixtures/TestFixtures.php
@@ -26,6 +26,7 @@ final class TestFixtures
 		$typeResolver = new TypeResolver($connection);
 		$typeResolver->addResolutionByLocation(new Identifier('public', 'test', 'id'), new UuidType());
 		$typeResolver->addResolutionByLocation(new Identifier('public', 'test', 'score'), IntType::integer());
+		$typeResolver->addResolutionByLocation(new Identifier('public', 'config', 'id'), new UuidType());
 		$typeResolver->addResolutionByLocation(new Identifier('public', 'package', 'version'), new TupleVersionType());
 		$typeResolver->addResolutionByLocation(new Identifier('public', 'package', 'previousVersions'), ArrayType::of(new VersionType()));
 		return $typeResolver;
diff --git a/tests/Fixtures/TestsTable.php b/tests/Fixtures/TestsTable.php
index 0a14d74..65f7f5f 100644
--- a/tests/Fixtures/TestsTable.php
+++ b/tests/Fixtures/TestsTable.php
@@ -129,12 +129,68 @@ final class TestsTable implements Table
 	 * @return TestRow
 	 * @throws RowNotFound
 	 */
+	public function getOneBy(Condition|array $conditions): TestRow
+	{
+		[$row, $count] = $this->tableManager->findOneBy($this, $conditions);
+		\assert($row instanceof TestRow || $row === null);
+		if ($row === null) { throw new RowNotFound(); }
+		if ($count > 1) { throw new TooManyRowsFound(); }
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @return TestRow
+	 * @throws RowNotFound
+	 */
+	public function findOneBy(Condition|array $conditions): ?TestRow
+	{
+		[$row, $count] = $this->tableManager->findOneBy($this, $conditions);
+		\assert($row instanceof TestRow || $row === null);
+		if ($count > 1) { throw new TooManyRowsFound(); }
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return TestRow
+	 * @throws RowNotFound
+	 */
+	public function getFirstBy(Condition|array $conditions, array $orderBy = []): TestRow
+	{
+		[$row] = $this->tableManager->findOneBy($this, $conditions, $orderBy, checkCount: false);
+		\assert($row instanceof TestRow || $row === null);
+		if ($row === null) { throw new RowNotFound(); }
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @param array<OrderBy|Expression<mixed>> $orderBy
+	 * @return TestRow
+	 * @throws RowNotFound
+	 */
+	public function findFirstBy(Condition|array $conditions, array $orderBy = []): ?TestRow
+	{
+		[$row] = $this->tableManager->findOneBy($this, $conditions, $orderBy, checkCount: false);
+		\assert($row instanceof TestRow || $row === null);
+		return $row;
+	}
+
+
+	/**
+	 * @param Condition|Condition[] $conditions
+	 * @return TestRow
+	 * @throws RowNotFound
+	 */
+	#[\Deprecated('Use getOneBy() instead.')]
 	public function getBy(Condition|array $conditions): TestRow
 	{
-		$result = $this->findBy($conditions);
-		if (\count($result) === 0) { throw new RowNotFound(); }
-		if (\count($result) > 1) { throw new TooManyRowsFound(); }
-		return $result[0];
+		return $this->getOneBy($conditions);
 	}
 
 
diff --git a/tests/Scaffolding/ScaffoldingTest.phpt b/tests/Scaffolding/ScaffoldingTest.phpt
index 4e76ed8..148d0c5 100644
--- a/tests/Scaffolding/ScaffoldingTest.phpt
+++ b/tests/Scaffolding/ScaffoldingTest.phpt
@@ -34,5 +34,5 @@ $results = $fileProcessor->processFile(
 	},
 );
 
-Assert::count(12, $results->getDefinitions());
+Assert::count(16, $results->getDefinitions());
 Assert::true($results->isSuccessful());
diff --git a/tests/TableTest.php b/tests/TableTest.php
index e4db509..aaad186 100644
--- a/tests/TableTest.php
+++ b/tests/TableTest.php
@@ -112,7 +112,7 @@ $nullDetails = $table->findBy($table->details()->is(null));
 Assert::count(1, $nullDetails);
 Assert::same(0, $nullDetails[0]->getScore());
 
-$unique = $table->getBy($table->score()->is(42));
+$unique = $table->getOneBy($table->score()->is(42));
 Assert::same(42, $unique->getScore());
 
 $table->update($table->edit(
diff --git a/tests/initializeDatabase.php b/tests/initializeDatabase.php
index b88144c..7f388be 100644
--- a/tests/initializeDatabase.php
+++ b/tests/initializeDatabase.php
@@ -25,6 +25,14 @@ CREATE TABLE IF NOT EXISTS public.test (
 );
 SQL);
 
+$connection->nativeQuery(<<<SQL
+CREATE TABLE IF NOT EXISTS public.config (
+    id uuid NOT NULL PRIMARY KEY,
+    key text NOT NULL UNIQUE,
+    value text NOT NULL
+);
+SQL);
+
 $connection->nativeQuery(<<<SQL
 CREATE TYPE public."packageVersion" AS (major int, minor int, patch int);
 CREATE TABLE IF NOT EXISTS public.package (
-- 
GitLab