From 70703f2c349c88f755abb4f7a074c83a579609bc Mon Sep 17 00:00:00 2001 From: Alexey Borzov Date: Fri, 18 Aug 2023 11:04:07 +0300 Subject: [PATCH] A few more Conditions and methods in GenericTableGateway to create them --- src/conditions/column/AnyCondition.php | 45 +++++ src/conditions/column/BoolCondition.php | 44 +++++ src/conditions/column/IsNullCondition.php | 36 ++++ src/gateways/GenericTableGateway.php | 147 +++++++++++++++- tests/assets/update-create.sql | 5 +- tests/gateways/BuildersTest.php | 205 ++++++++++++++++++++++ 6 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 src/conditions/column/AnyCondition.php create mode 100644 src/conditions/column/BoolCondition.php create mode 100644 src/conditions/column/IsNullCondition.php create mode 100644 tests/gateways/BuildersTest.php diff --git a/src/conditions/column/AnyCondition.php b/src/conditions/column/AnyCondition.php new file mode 100644 index 0000000..b1cc468 --- /dev/null +++ b/src/conditions/column/AnyCondition.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace sad_spirit\pg_gateway\conditions\column; + +use sad_spirit\pg_gateway\TableGateway; +use sad_spirit\pg_builder\nodes\{ + ColumnReference, + ScalarExpression, + expressions\ArrayComparisonExpression, + expressions\NamedParameter, + expressions\OperatorExpression, + expressions\TypecastExpression +}; + +/** + * Generates a "foo = any(:foo::foo_type[])" condition for the "foo" table column + */ +final class AnyCondition extends TypedCondition +{ + protected function generateExpressionImpl(): ScalarExpression + { + $typeName = $this->converterFactory->createTypeNameNodeForOID($this->column->getTypeOID()); + $typeName->bounds = [-1]; + + return new OperatorExpression( + '=', + new ColumnReference(TableGateway::ALIAS_SELF, $this->column->getName()), + new ArrayComparisonExpression( + ArrayComparisonExpression::ANY, + new TypecastExpression(new NamedParameter($this->column->getName()), $typeName) + ) + ); + } +} diff --git a/src/conditions/column/BoolCondition.php b/src/conditions/column/BoolCondition.php new file mode 100644 index 0000000..0654b6a --- /dev/null +++ b/src/conditions/column/BoolCondition.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace sad_spirit\pg_gateway\conditions\column; + +use sad_spirit\pg_gateway\{ + TableGateway, + conditions\ColumnCondition, + exceptions\LogicException, + metadata\Column +}; +use sad_spirit\pg_builder\nodes\ColumnReference; +use sad_spirit\pg_builder\nodes\ScalarExpression; + +/** + * Uses the value of the bool-typed column as a Condition + */ +final class BoolCondition extends ColumnCondition +{ + public const BOOL_OID = 16; + + public function __construct(Column $column) + { + if (self::BOOL_OID !== (int)$column->getTypeOID()) { + throw new LogicException("Column '{$column->getName()}' is not of type 'bool'"); + } + parent::__construct($column); + } + + protected function generateExpressionImpl(): ScalarExpression + { + return new ColumnReference(TableGateway::ALIAS_SELF, $this->column->getName()); + } +} diff --git a/src/conditions/column/IsNullCondition.php b/src/conditions/column/IsNullCondition.php new file mode 100644 index 0000000..5a9582b --- /dev/null +++ b/src/conditions/column/IsNullCondition.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace sad_spirit\pg_gateway\conditions\column; + +use sad_spirit\pg_gateway\TableGateway; +use sad_spirit\pg_gateway\conditions\ColumnCondition; +use sad_spirit\pg_builder\nodes\{ + ColumnReference, + ScalarExpression, + expressions\IsExpression +}; + +/** + * Generates a "foo IS [NOT] NULL" Condition for the "foo" table column + */ +final class IsNullCondition extends ColumnCondition +{ + protected function generateExpressionImpl(): ScalarExpression + { + return new IsExpression( + new ColumnReference(TableGateway::ALIAS_SELF, $this->column->getName()), + IsExpression::NULL + ); + } +} diff --git a/src/gateways/GenericTableGateway.php b/src/gateways/GenericTableGateway.php index 683878a..90b2032 100644 --- a/src/gateways/GenericTableGateway.php +++ b/src/gateways/GenericTableGateway.php @@ -19,14 +19,24 @@ TableGateway, TableLocator, TableSelect, + exceptions\InvalidArgumentException, fragments\ClosureFragment, fragments\InsertSelectFragment, fragments\SetClauseFragment, - exceptions\InvalidArgumentException, metadata\Columns, metadata\PrimaryKey, metadata\References }; +use sad_spirit\pg_gateway\conditions\{ + NotCondition, + ParametrizedCondition, + SqlStringCondition, + column\AnyCondition, + column\BoolCondition, + column\IsNullCondition, + column\NotAllCondition, + column\OperatorCondition +}; use sad_spirit\pg_builder\{ Delete, Insert, @@ -273,4 +283,139 @@ protected function generateStatementKey(string $statementType, FragmentList $fra $fragmentKey ); } + + /** + * Creates a "column = any(values::column_type[])" Condition + * + * This is roughly equivalent to "column IN (...values)" but requires only one placeholder + * + * @param string $column + * @param array $values + * @return ParametrizedCondition + */ + public function any(string $column, array $values): ParametrizedCondition + { + return new ParametrizedCondition( + new AnyCondition( + $this->getColumns()->get($column), + $this->tableLocator->getTypeConverterFactory() + ), + [$column => $values] + ); + } + + /** + * Creates a "column" Condition for a column of "bool" type + * + * @param string $column + * @return BoolCondition + */ + public function column(string $column): BoolCondition + { + return new BoolCondition($this->getColumns()->get($column)); + } + + /** + * Creates a "NOT column" Condition for a column of "bool" type + * + * @param string $column + * @return NotCondition + */ + public function notColumn(string $column): NotCondition + { + return new NotCondition($this->column($column)); + } + + /** + * Creates a "column IS NULL" Condition + * + * @param string $column + * @return IsNullCondition + */ + public function isNull(string $column): IsNullCondition + { + return new IsNullCondition($this->getColumns()->get($column)); + } + + /** + * Creates a "column IS NOT NULL" Condition + * + * @param string $column + * @return NotCondition + */ + public function isNotNull(string $column): NotCondition + { + return new NotCondition($this->isNull($column)); + } + + /** + * Creates a "column <> all(values::column_type[])" Condition + * + * This is roughly equivalent to "column NOT IN (...values)" but requires only one placeholder + * + * @param string $column + * @param array $values + * @return ParametrizedCondition + */ + public function notAll(string $column, array $values): ParametrizedCondition + { + return new ParametrizedCondition( + new NotAllCondition( + $this->getColumns()->get($column), + $this->tableLocator->getTypeConverterFactory() + ), + [$column => $values] + ); + } + + /** + * Creates a "column OPERATOR value" condition + * + * The value will be actually passed separately as a query parameter + * + * @param string $column + * @param string $operator + * @param mixed $value + * @return ParametrizedCondition + */ + public function operatorCondition(string $column, string $operator, $value): ParametrizedCondition + { + return new ParametrizedCondition( + new OperatorCondition( + $this->getColumns()->get($column), + $this->tableLocator->getTypeConverterFactory(), + $operator + ), + [$column => $value] + ); + } + + /** + * Creates a "column = value" condition + * + * The value will be actually passed separately as a query parameter + * + * @param string $column + * @param mixed $value + * @return ParametrizedCondition + */ + public function equal(string $column, $value): ParametrizedCondition + { + return $this->operatorCondition($column, '=', $value); + } + + /** + * Creates a Condition based on the given SQL expression + * + * @param string $sql + * @param array $parameters + * @return ParametrizedCondition + */ + public function sqlCondition(string $sql, array $parameters = []): ParametrizedCondition + { + return new ParametrizedCondition( + new SqlStringCondition($this->tableLocator->getParser(), $sql), + $parameters + ); + } } diff --git a/tests/assets/update-create.sql b/tests/assets/update-create.sql index 5f82f5c..7965c1a 100644 --- a/tests/assets/update-create.sql +++ b/tests/assets/update-create.sql @@ -3,13 +3,14 @@ create table update_test ( id integer not null, title text default 'A string', - added timestamp with time zone default now() + added timestamp with time zone default now(), + flag bool default false ); insert into update_test (id, title) values (1, 'One'); insert into update_test (id, title) values (2, 'Two'); insert into update_test (id, title, added) values (3, 'Many', '2020-01-01'); -insert into update_test (id, title) values (4, 'Too many'); +insert into update_test (id, title, flag) values (4, 'Too many', true); create table unconditional ( id integer not null, diff --git a/tests/gateways/BuildersTest.php b/tests/gateways/BuildersTest.php new file mode 100644 index 0000000..ba4df33 --- /dev/null +++ b/tests/gateways/BuildersTest.php @@ -0,0 +1,205 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace sad_spirit\pg_gateway\tests\gateways; + +use sad_spirit\pg_gateway\{ + TableLocator, + conditions\ParametrizedCondition, + exceptions\LogicException, + exceptions\OutOfBoundsException, + gateways\GenericTableGateway, + tests\DatabaseBackedTest, + tests\NormalizeWhitespace +}; +use sad_spirit\pg_builder\nodes\QualifiedName; + +/** + * Tests for methods of GenericTableGateway creating Fragments / FragmentBuilders + */ +class BuildersTest extends DatabaseBackedTest +{ + use NormalizeWhitespace; + + protected static ?TableLocator $tableLocator; + protected static ?GenericTableGateway $gateway; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::executeSqlFromFile(self::$connection, 'update-drop.sql', 'update-create.sql'); + self::$tableLocator = new TableLocator(self::$connection); + self::$gateway = new GenericTableGateway(new QualifiedName('update_test'), self::$tableLocator); + } + + public static function tearDownAfterClass(): void + { + self::executeSqlFromFile(self::$connection, 'update-drop.sql'); + self::$gateway = null; + self::$tableLocator = null; + self::$connection = null; + } + + public function testAny(): void + { + $condition = self::$gateway->any('id', [1, 2]); + + $this::assertStringEqualsStringNormalizingWhitespace( + 'self.id = any(:id::int4[])', + $condition->generateExpression()->dispatch( + self::$tableLocator->getStatementFactory()->getBuilder() + ) + ); + $this::assertEquals( + ['id' => [1, 2]], + $condition->getParameterHolder()->getParameters() + ); + + $this::expectException(OutOfBoundsException::class); + $this::expectExceptionMessage('does not exist'); + self::$gateway->any('missing', ['foo', 'bar']); + } + + public function testBoolColumn(): void + { + $condition = self::$gateway->column('flag'); + + $this::assertStringEqualsStringNormalizingWhitespace( + 'self.flag', + $condition->generateExpression()->dispatch( + self::$tableLocator->getStatementFactory()->getBuilder() + ) + ); + $this::assertNotInstanceOf(ParametrizedCondition::class, $condition); + + $this::expectException(OutOfBoundsException::class); + $this::expectExceptionMessage('does not exist'); + self::$gateway->column('missing'); + } + + public function testNotBoolColumn(): void + { + $condition = self::$gateway->notColumn('flag'); + + $this::assertStringEqualsStringNormalizingWhitespace( + 'not self.flag', + $condition->generateExpression()->dispatch( + self::$tableLocator->getStatementFactory()->getBuilder() + ) + ); + $this::assertNotInstanceOf(ParametrizedCondition::class, $condition); + } + + public function testColumnConditionRequiresBoolColumn(): void + { + $this::expectException(LogicException::class); + $this::expectExceptionMessage("is not of type 'bool'"); + self::$gateway->column('id'); + } + + public function testIsNull(): void + { + $condition = self::$gateway->isNull('title'); + + $this::assertStringEqualsStringNormalizingWhitespace( + 'self.title is null', + $condition->generateExpression()->dispatch( + self::$tableLocator->getStatementFactory()->getBuilder() + ) + ); + $this::assertNotInstanceOf(ParametrizedCondition::class, $condition); + + $this::expectException(OutOfBoundsException::class); + $this::expectExceptionMessage('does not exist'); + self::$gateway->column('missing'); + } + + public function testIsNotNull(): void + { + $condition = self::$gateway->isNotNull('title'); + + $this::assertStringEqualsStringNormalizingWhitespace( + 'self.title is not null', + $condition->generateExpression()->dispatch( + self::$tableLocator->getStatementFactory()->getBuilder() + ) + ); + $this::assertNotInstanceOf(ParametrizedCondition::class, $condition); + } + + public function testNotAll(): void + { + $condition = self::$gateway->notAll('id', [3, 4]); + + $this::assertStringEqualsStringNormalizingWhitespace( + 'self.id <> all(:id::int4[])', + $condition->generateExpression()->dispatch( + self::$tableLocator->getStatementFactory()->getBuilder() + ) + ); + $this::assertEquals( + ['id' => [3, 4]], + $condition->getParameterHolder()->getParameters() + ); + + $this::expectException(OutOfBoundsException::class); + $this::expectExceptionMessage('does not exist'); + self::$gateway->notAll('missing', ['baz', 'quux']); + } + + public function testOperator(): void + { + $condition = self::$gateway->operatorCondition('title', '~*', 'gateway'); + + $this::assertStringEqualsStringNormalizingWhitespace( + 'self.title ~* :title::"text"', + $condition->generateExpression()->dispatch( + self::$tableLocator->getStatementFactory()->getBuilder() + ) + ); + $this::assertEquals(['title' => 'gateway'], $condition->getParameterHolder()->getParameters()); + + $this::expectException(OutOfBoundsException::class); + $this::expectExceptionMessage('does not exist'); + self::$gateway->operatorCondition('missing', '!~*', 'gateway'); + } + + public function testEqual(): void + { + $condition = self::$gateway->equal('id', 5); + + $this::assertStringEqualsStringNormalizingWhitespace( + 'self.id = :id::int4', + $condition->generateExpression()->dispatch( + self::$tableLocator->getStatementFactory()->getBuilder() + ) + ); + $this::assertEquals(['id' => 5], $condition->getParameterHolder()->getParameters()); + } + + public function testSqlCondition(): void + { + $condition = self::$gateway->sqlCondition( + "added between :cutoff and current_date", + ['cutoff' => '2023-08-07'] + ); + + $this::assertStringEqualsStringNormalizingWhitespace( + "added between :cutoff and current_date", + $condition->generateExpression()->dispatch( + self::$tableLocator->getStatementFactory()->getBuilder() + ) + ); + $this::assertEquals(['cutoff' => '2023-08-07'], $condition->getParameterHolder()->getParameters()); + } +}