diff --git a/docs/book/sql/where-having.md b/docs/book/sql/where-having.md index 802fce555..d52488fa4 100644 --- a/docs/book/sql/where-having.md +++ b/docs/book/sql/where-having.md @@ -394,6 +394,14 @@ class Expression implements } ``` +> **Note:** Unlike `Operator`, `Like`/`NotLike`, `Between`/`NotBetween`, +> `In`/`NotIn` and `IsNull`/`IsNotNull`, `Expression` does not accept a +> specification override. It always renders +> the string given to its constructor or `setExpression()`, so calling +> `setSpecification()` on an `Expression` (or `Predicate\Expression`) throws +> `PhpDb\Sql\Exception\RuntimeException`. Call `setExpression()` to change +> the SQL it renders. + Expression parameters can be supplied in multiple ways: ```php title="Using Expression with various parameter types" diff --git a/src/Sql/Exception/RuntimeException.php b/src/Sql/Exception/RuntimeException.php index 51ecffc7e..846c73472 100644 --- a/src/Sql/Exception/RuntimeException.php +++ b/src/Sql/Exception/RuntimeException.php @@ -34,6 +34,10 @@ class RuntimeException extends Exception\RuntimeException 'A number of parameters (%d) was found' . ' that is not supported by this specification'; + final public const string UNSUPPORTED_SPECIFICATION_OVERRIDE = + '%s renders its expression string and does not accept' + . ' a specification override; use setExpression() instead'; + public static function forReplacementMismatch(): self { return new self(self::REPLACEMENT_MISMATCH); @@ -68,4 +72,9 @@ public static function forUnsupportedParameterCountOf(int $count): self { return new self(sprintf(self::UNSUPPORTED_PARAMETER_COUNT_OF, $count)); } + + public static function forUnsupportedSpecificationOverride(string $class): self + { + return new self(sprintf(self::UNSUPPORTED_SPECIFICATION_OVERRIDE, $class)); + } } diff --git a/src/Sql/Expression.php b/src/Sql/Expression.php index 2386bfb9a..93c476422 100644 --- a/src/Sql/Expression.php +++ b/src/Sql/Expression.php @@ -153,6 +153,18 @@ public function setParameters( return $this; } + /** + * Expression renders the string given to setExpression(), so a + * specification override would be silently ignored. Use setExpression(). + * + * @throws Exception\RuntimeException + */ + #[Override] + public function setSpecification(string $specification): static + { + throw Exception\RuntimeException::forUnsupportedSpecificationOverride(static::class); + } + /** * Values are bound one by one, so each needs its own placeholder inside the parentheses around the `?` * diff --git a/test/unit/Sql/Exception/RuntimeExceptionTest.php b/test/unit/Sql/Exception/RuntimeExceptionTest.php index 57f965b28..88729babc 100644 --- a/test/unit/Sql/Exception/RuntimeExceptionTest.php +++ b/test/unit/Sql/Exception/RuntimeExceptionTest.php @@ -6,6 +6,7 @@ use PhpDb\Exception\ExceptionInterface; use PhpDb\Sql\Exception\RuntimeException; +use PhpDb\Sql\Expression; use PhpDb\Sql\SqlInterface; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\DataProvider; @@ -23,6 +24,7 @@ #[CoversMethod(RuntimeException::class, 'forSubjectNotSqlInterface')] #[CoversMethod(RuntimeException::class, 'forUnsupportedParameterCount')] #[CoversMethod(RuntimeException::class, 'forUnsupportedParameterCountOf')] +#[CoversMethod(RuntimeException::class, 'forUnsupportedSpecificationOverride')] final class RuntimeExceptionTest extends TestCase { /** @return array, string}> */ @@ -64,6 +66,11 @@ public static function namedConstructorProvider(): array [2], RuntimeException::UNSUPPORTED_PARAMETER_COUNT_OF, ], + 'unsupported specification override' => [ + 'forUnsupportedSpecificationOverride', + [Expression::class], + RuntimeException::UNSUPPORTED_SPECIFICATION_OVERRIDE, + ], ]; } diff --git a/test/unit/Sql/ExpressionTest.php b/test/unit/Sql/ExpressionTest.php index 5c019dba8..b30b837e5 100644 --- a/test/unit/Sql/ExpressionTest.php +++ b/test/unit/Sql/ExpressionTest.php @@ -33,6 +33,8 @@ #[CoversMethod(Expression::class, 'getParameters')] #[CoversMethod(Expression::class, 'getExpressionData')] #[CoversMethod(Expression::class, 'placeholderFor')] +#[CoversMethod(Expression::class, 'setSpecification')] +#[Group('unit')] final class ExpressionTest extends TestCase { /** @return array, string}> */ @@ -326,4 +328,17 @@ public function setParametersWrapsArrayInValuesArgument(): void static::assertCount(2, $data['values']); static::assertInstanceOf(Argument\Values::class, $data['values'][1]); } + + #[Test] + public function setSpecificationIsRejectedInFavourOfSetExpression(): void + { + $expression = new Expression('LOWER(?) = ?', ['x', 'y']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + sprintf(RuntimeException::UNSUPPORTED_SPECIFICATION_OVERRIDE, Expression::class), + ); + + $expression->setSpecification('IGNORED %s %s'); + } } diff --git a/test/unit/Sql/Predicate/ExpressionTest.php b/test/unit/Sql/Predicate/ExpressionTest.php index f6bc50ab2..590bffc21 100644 --- a/test/unit/Sql/Predicate/ExpressionTest.php +++ b/test/unit/Sql/Predicate/ExpressionTest.php @@ -9,12 +9,18 @@ use PhpDb\Sql\Argument\Value; use PhpDb\Sql\ArgumentInterface; use PhpDb\Sql\ArgumentType; +use PhpDb\Sql\Exception\RuntimeException; use PhpDb\Sql\Predicate\Expression; use PhpDb\Sql\Predicate\IsNull; +use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use function sprintf; + +#[CoversMethod(Expression::class, 'setSpecification')] +#[Group('unit')] final class ExpressionTest extends TestCase { #[Test] @@ -216,4 +222,17 @@ public function retrievingWherePartsReturnsSpecificationArrayOfLiteralAndParamet static::assertSame('foo.bar = %s AND id != %s', $expressionData['spec']); static::assertEquals([$parameter1, $parameter2], $expressionData['values']); } + + #[Test] + public function setSpecificationIsRejectedInFavourOfSetExpression(): void + { + $expression = new Expression('LOWER(?) = ?', ['x', 'y']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + sprintf(RuntimeException::UNSUPPORTED_SPECIFICATION_OVERRIDE, Expression::class), + ); + + $expression->setSpecification('IGNORED %s %s'); + } } diff --git a/test/unit/Sql/Predicate/SpecificationOverrideTest.php b/test/unit/Sql/Predicate/SpecificationOverrideTest.php new file mode 100644 index 000000000..1f9977ac6 --- /dev/null +++ b/test/unit/Sql/Predicate/SpecificationOverrideTest.php @@ -0,0 +1,102 @@ + */ + public static function predicateProvider(): array + { + return [ + 'operator' => [ + new Operator('a', Operator::OPERATOR_EQUAL_TO, 1), + '%1$s <=> %2$s', + 'SELECT "t".* FROM "t" WHERE "a" <=> \'1\'', + ], + 'like' => [ + new Like('a', 'b%'), + '%1$s SOUNDS LIKE %2$s', + 'SELECT "t".* FROM "t" WHERE "a" SOUNDS LIKE \'b%\'', + ], + 'not like' => [ + new NotLike('a', 'b%'), + 'NOT (%1$s SOUNDS LIKE %2$s)', + 'SELECT "t".* FROM "t" WHERE NOT ("a" SOUNDS LIKE \'b%\')', + ], + 'between' => [ + new Between('a', 1, 2), + '%1$s BETWEEN SYMMETRIC %2$s AND %3$s', + 'SELECT "t".* FROM "t" WHERE "a" BETWEEN SYMMETRIC \'1\' AND \'2\'', + ], + 'not between' => [ + new NotBetween('a', 1, 2), + '%1$s NOT BETWEEN SYMMETRIC %2$s AND %3$s', + 'SELECT "t".* FROM "t" WHERE "a" NOT BETWEEN SYMMETRIC \'1\' AND \'2\'', + ], + 'is null' => [new IsNull('a'), '%1$s IS UNKNOWN', 'SELECT "t".* FROM "t" WHERE "a" IS UNKNOWN'], + 'is not null' => [ + new IsNotNull('a'), + '%1$s IS NOT UNKNOWN', + 'SELECT "t".* FROM "t" WHERE "a" IS NOT UNKNOWN', + ], + 'in' => [ + new In('a', [1, 2]), + '%s = ANY (%s)', + 'SELECT "t".* FROM "t" WHERE "a" = ANY (\'1\', \'2\')', + ], + 'not in' => [ + new NotIn('a', [1, 2]), + '%s <> ALL (%s)', + 'SELECT "t".* FROM "t" WHERE "a" <> ALL (\'1\', \'2\')', + ], + ]; + } + + #[Test] + #[DataProvider('predicateProvider')] + public function rendersTheCustomSpecification( + AbstractExpression $predicate, + string $specification, + string $expectedSql, + ): void { + $predicate->setSpecification($specification); + + static::assertSame( + $expectedSql, + (new Select('t'))->where($predicate) + ->getSqlString(new TrustingSql92Platform()), + ); + } +}