From bdb25e93a42f2fa81bf3dd1f63fd0579b5e4b0c4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Date: Sat, 22 Aug 2020 16:51:00 +0200 Subject: [PATCH] [Doctrine] Use parameters instead of embedded In a very old version it was possible to have either embedded values or parameters. Then to simplify generation, embedded values were introduced. However, Doctrine ORM doesn't have a native escaping system for embedding values directly. Making the current system rather hacky, plus that the previous parameter system didn't allow value-splitting in conversions. Note. DBAL conversions are no longer applied in ORM, ORM now has it's own conversion API that operates directly with DQL (not SQL). Meaning special functions must be registered separately. _Minimum PHP version for the monorep was updated to PHP 7.2_ --- .github/phpunit/mysql.xml | 38 -- .github/phpunit/pgsql.xml | 38 -- .github/phpunit/sqlite.xml | 24 -- .github/workflows/ci.yaml | 6 +- UPGRADE-2.0.md | 54 ++- composer.json | 7 +- docs/integration/doctrine/conversions.rst | 202 +++------- docs/integration/doctrine/conversions_orm.rst | 6 + docs/integration/doctrine/dbal.rst | 21 +- docs/integration/doctrine/index.rst | 1 + docs/integration/doctrine/orm.rst | 69 +--- docs/integration/doctrine/troubleshooting.rst | 3 - .../Doctrine/Orm/CollectionDataProvider.php | 89 ----- .../Orm/Extension/SearchExtension.php | 4 - lib/ApiPlatform/Doctrine/Orm/QueryBuilder.php | 121 ------ .../Extension/SearchExtension.php | 2 +- .../Orm/Extension/SearchExtensionTest.php | 2 +- .../Extension/SearchExtensionTest.php | 6 +- .../Dbal/AbstractCachedConditionGenerator.php | 99 +++++ .../Dbal/CachedConditionGenerator.php | 54 ++- lib/Doctrine/Dbal/ColumnConversion.php | 6 +- lib/Doctrine/Dbal/ConditionGenerator.php | 15 + lib/Doctrine/Dbal/ConversionHints.php | 57 ++- .../Conversion/AgeDateConversion.php | 46 +-- .../Conversion/MoneyValueConversion.php | 36 +- lib/Doctrine/Dbal/Query/QueryField.php | 45 +-- lib/Doctrine/Dbal/Query/QueryGenerator.php | 154 ++++--- lib/Doctrine/Dbal/QueryPlatform.php | 44 -- .../QueryPlatform/AbstractQueryPlatform.php | 104 ++--- lib/Doctrine/Dbal/SqlConditionGenerator.php | 32 +- .../Dbal/StrategySupportedConversion.php | 41 -- .../Tests/CachedConditionGeneratorTest.php | 162 ++++---- .../Dbal/Tests/ColumnConversionStrategy.php | 21 - lib/Doctrine/Dbal/Tests/DbalTestCase.php | 13 + .../Functional/FunctionalDbalTestCase.php | 32 +- .../Dbal/Tests/SqlConditionGeneratorTest.php | 326 ++++++++++----- .../Dbal/Tests/ValueConversionStrategy.php | 24 -- lib/Doctrine/Dbal/ValueConversion.php | 10 +- lib/Doctrine/Dbal/composer.json | 4 +- .../Orm/AbstractCachedConditionGenerator.php | 117 ------ .../Orm/AbstractConditionGenerator.php | 118 ------ .../Orm/CachedDqlConditionGenerator.php | 155 ++++---- lib/Doctrine/Orm/ColumnConversion.php | 36 ++ lib/Doctrine/Orm/ConditionGenerator.php | 3 + lib/Doctrine/Orm/ConversionHintTrait.php | 51 --- lib/Doctrine/Orm/DoctrineOrmFactory.php | 13 +- lib/Doctrine/Orm/DqlConditionGenerator.php | 128 +++--- .../Conversion/AgeDateConversion.php | 40 ++ .../Conversion/ChildCountConversion.php | 25 ++ .../Conversion/MoneyValueConversion.php | 59 +++ .../Orm/Extension/DoctrineOrmExtension.php | 30 +- .../Orm/Extension/Functions/AgeFunction.php | 59 +++ .../Orm/Extension/Functions/CastFunction.php | 53 +++ .../Functions/CountChildrenFunction.php | 57 +++ .../Extension/Functions/MoneyCastFunction.php | 62 +++ .../Extension/Type/BirthdayTypeExtension.php | 40 ++ .../Orm/Extension/Type/ChildCountType.php | 39 ++ .../Orm/Extension/Type/FieldTypeExtension.php | 42 ++ .../Orm/Extension/Type/MoneyTypeExtension.php | 42 ++ lib/Doctrine/Orm/FieldConfigBuilder.php | 5 +- .../Orm/Functions/SqlFieldConversion.php | 73 ---- .../Orm/Functions/SqlValueConversion.php | 85 ---- lib/Doctrine/Orm/OrmQueryField.php | 41 ++ .../Orm/QueryPlatform/DqlQueryPlatform.php | 102 ++--- lib/Doctrine/Orm/QueryPlatformTrait.php | 36 -- lib/Doctrine/Orm/SqlConversionInfo.php | 54 --- .../Tests/CachedDqlConditionGeneratorTest.php | 50 ++- .../ConditionGeneratorResultsTestCase.php | 4 +- .../Orm/Tests/DqlConditionGeneratorTest.php | 376 +++++++----------- .../Orm/Tests/FieldConfigBuilderTest.php | 2 +- .../Fixtures/GetCustomerTypeFunction.php | 41 ++ lib/Doctrine/Orm/Tests/OrmTestCase.php | 40 +- .../Orm/Tests/QueryBuilderWithHints.php | 123 ------ .../Orm/Tests/ValueConversionStrategy.php | 24 -- lib/Doctrine/Orm/ValueConversion.php | 42 ++ lib/Doctrine/Orm/composer.json | 6 +- .../Compiler/DoctrineOrmPass.php | 12 +- .../Compiler/DoctrineOrmQueryBuilderPass.php | 42 -- .../Resources/config/doctrine_orm.xml | 16 + .../SearchBundle/RollerworksSearchBundle.php | 2 - phpstan.neon | 4 +- phpunit.xml.dist | 20 +- phpunit/mysql.xml | 38 +- phpunit/pgsql.xml | 36 +- 84 files changed, 2036 insertions(+), 2425 deletions(-) delete mode 100644 .github/phpunit/mysql.xml delete mode 100644 .github/phpunit/pgsql.xml delete mode 100644 .github/phpunit/sqlite.xml create mode 100644 docs/integration/doctrine/conversions_orm.rst delete mode 100644 lib/ApiPlatform/Doctrine/Orm/CollectionDataProvider.php delete mode 100644 lib/ApiPlatform/Doctrine/Orm/QueryBuilder.php create mode 100644 lib/Doctrine/Dbal/AbstractCachedConditionGenerator.php delete mode 100644 lib/Doctrine/Dbal/QueryPlatform.php delete mode 100644 lib/Doctrine/Dbal/StrategySupportedConversion.php delete mode 100644 lib/Doctrine/Dbal/Tests/ColumnConversionStrategy.php delete mode 100644 lib/Doctrine/Dbal/Tests/ValueConversionStrategy.php delete mode 100644 lib/Doctrine/Orm/AbstractCachedConditionGenerator.php delete mode 100644 lib/Doctrine/Orm/AbstractConditionGenerator.php create mode 100644 lib/Doctrine/Orm/ColumnConversion.php delete mode 100644 lib/Doctrine/Orm/ConversionHintTrait.php create mode 100644 lib/Doctrine/Orm/Extension/Conversion/AgeDateConversion.php create mode 100644 lib/Doctrine/Orm/Extension/Conversion/ChildCountConversion.php create mode 100644 lib/Doctrine/Orm/Extension/Conversion/MoneyValueConversion.php create mode 100644 lib/Doctrine/Orm/Extension/Functions/AgeFunction.php create mode 100644 lib/Doctrine/Orm/Extension/Functions/CastFunction.php create mode 100644 lib/Doctrine/Orm/Extension/Functions/CountChildrenFunction.php create mode 100644 lib/Doctrine/Orm/Extension/Functions/MoneyCastFunction.php create mode 100644 lib/Doctrine/Orm/Extension/Type/BirthdayTypeExtension.php create mode 100644 lib/Doctrine/Orm/Extension/Type/ChildCountType.php create mode 100644 lib/Doctrine/Orm/Extension/Type/FieldTypeExtension.php create mode 100644 lib/Doctrine/Orm/Extension/Type/MoneyTypeExtension.php delete mode 100644 lib/Doctrine/Orm/Functions/SqlFieldConversion.php delete mode 100644 lib/Doctrine/Orm/Functions/SqlValueConversion.php create mode 100644 lib/Doctrine/Orm/OrmQueryField.php delete mode 100644 lib/Doctrine/Orm/QueryPlatformTrait.php delete mode 100644 lib/Doctrine/Orm/SqlConversionInfo.php create mode 100644 lib/Doctrine/Orm/Tests/Fixtures/GetCustomerTypeFunction.php delete mode 100644 lib/Doctrine/Orm/Tests/QueryBuilderWithHints.php delete mode 100644 lib/Doctrine/Orm/Tests/ValueConversionStrategy.php create mode 100644 lib/Doctrine/Orm/ValueConversion.php delete mode 100644 lib/Symfony/SearchBundle/DependencyInjection/Compiler/DoctrineOrmQueryBuilderPass.php diff --git a/.github/phpunit/mysql.xml b/.github/phpunit/mysql.xml deleted file mode 100644 index cbba75b4..00000000 --- a/.github/phpunit/mysql.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ../lib/Doctrine/ - - - diff --git a/.github/phpunit/pgsql.xml b/.github/phpunit/pgsql.xml deleted file mode 100644 index faf9255c..00000000 --- a/.github/phpunit/pgsql.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ../lib/Doctrine/ - - - diff --git a/.github/phpunit/sqlite.xml b/.github/phpunit/sqlite.xml deleted file mode 100644 index 2138306e..00000000 --- a/.github/phpunit/sqlite.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - ../lib/Doctrine/ - - - diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 500b04ea..8f79aee0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,6 +10,7 @@ env: ES_HTTP_PORT: '59200' ELASTICSEARCH_HOST: 'localhost' ELASTICSEARCH_PORT: '59200' + DB_HOST: 127.0.0.1 jobs: test: @@ -89,9 +90,8 @@ jobs: SYMFONY_DEPRECATIONS_HELPER: weak run: | vendor/bin/phpunit --verbose - vendor/bin/phpunit --verbose --configuration .github/phpunit/sqlite.xml - vendor/bin/phpunit --verbose --configuration .github/phpunit/pgsql.xml - vendor/bin/phpunit --verbose --configuration .github/phpunit/mysql.xml + vendor/bin/phpunit --verbose --configuration phpunit/pgsql.xml + vendor/bin/phpunit --verbose --configuration phpunit/mysql.xml lint: name: PHP-QA diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index bba1dafe..a8292c04 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -3,11 +3,61 @@ UPGRADE FROM 2.0-ALPHA21 to 2.0-ALPHA22 * The `$forceNew` argument in `SearchConditionBuilder::field()` is deprecated and will be removed in v2.0.0-BETA1, use `overwriteField()` instead. + +### Doctrine DBAL + + * Support for SQLite was removed in Doctrine DBAL. + + * Values are no longer embedded but are now provided as parameters, + make sure to bind these before executing the query. + + Before: + + ```php + $whereClause = $conditionGenerator->getWhereClause(); + $statement = $connection->execute('SELECT * FROM tableName '.$whereClause); + + $rows = $statement->fetchAll(\PDO::FETCH_ASSOC); + ``` + + Now: + + ```php + $whereClause = $conditionGenerator->getWhereClause(); + $statement = $connection->prepare('SELECT * FROM tableName '.$whereClause); - * Support for Doctrine ORM NativeQuery was removed, use the Doctrine DBAL + $conditionGenerator->bindParameters($statement); + + $statement->execute(); + + $rows = $statement->fetchAll(\PDO::FETCH_ASSOC); + ``` + + * The `Rollerworks\Component\Search\Doctrine\Dbal\ValueConversion::convertValue()` method + now expects a `string` type is returned, and requires a return-type. + + * Conversion strategies was changed to return a different column/value + statement rather than keeping all strategies cached. + + Use the `ConversionHint` new parameters and helper method to determine + the value for the Column. + +### Doctrine ORM + + * Support for Doctrine ORM NativeQuery was removed, use the Doctrine DBAL condition-generator instead for this usage. + + * Values are no longer embedded but are now provided as parameters, + make sure to bind these before executing the query. + + Note: Using the `updateQuery()` method already performs the binding process. + + * Doctrine DBAL conversions are no longer applied, instead the Doctrine ORM + integration now has it's own conversion API with a much more powerful integration. + + **Note:** Any functions used in the conversion-generated DQL must be registered + with the EntityManager configuration, refer to the Doctrine ORM manual for details. - * Support for SQLite was removed in Doctrine DBAL and ORM. UPGRADE FROM 2.0-ALPHA19 to 2.0-ALPHA20 ======================================= diff --git a/composer.json b/composer.json index 4fc904c7..bf8195c0 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "php": "^7.1", + "php": "^7.2", "psr/container": "^1.0.0", "symfony/intl": "^4.4 || ^5.0", "symfony/options-resolver": "^4.4 || ^5.0", @@ -35,9 +35,9 @@ }, "require-dev": { "api-platform/core": "^2.4.5", - "doctrine/dbal": "^2.8.0", + "doctrine/dbal": "^2.10.3", "doctrine/doctrine-bundle": "^1.9.1 || ^2.0", - "doctrine/orm": "^2.6.2", + "doctrine/orm": "^2.7.3", "friendsofsymfony/elastica-bundle": "^5.0 || ^5.2@dev", "matthiasnoback/symfony-dependency-injection-test": "^3.0 || ^4.1.1", "moneyphp/money": "^3.2.0", @@ -62,6 +62,7 @@ }, "config": { "preferred-install": { + "api-platform/core": "source", "doctrine/dbal": "source", "doctrine/orm": "source", "*": "dist" diff --git a/docs/integration/doctrine/conversions.rst b/docs/integration/doctrine/conversions.rst index 40aaed32..e439cd40 100644 --- a/docs/integration/doctrine/conversions.rst +++ b/docs/integration/doctrine/conversions.rst @@ -1,5 +1,10 @@ -Value and Column Conversions -============================ +Value and Column Conversions (DBAL) +=================================== + +.. cuation:: + + Since RollerworksSearch v2.0-ALPHA22 conversions for Doctrine ORM + are handled separately. See the related chapter for reference. Conversions for Doctrine DBAL are similar to the DataTransformers used for transforming user-input to a model data-format. Except that @@ -71,16 +76,17 @@ Before you get started, it's important to know the following about conversions: generation process, so using a cached result does not execute them. #. Each method receives a :class:`Rollerworks\\Component\\Search\\Doctrine\\Dbal\\ConversionHints` object which provides access to the used database connection, SearchField - configuration, column and optionally the conversionStrategy. + configuration, column, and helper methods for using parameter-placeholders and + getting the actual value that is currently being processed (in the column context). #. The ``$options`` array provides the options of the SearchField. -.. tip:: +See existing conversions for a more detailed example. +https://github.com/rollerworks/search/tree/master/lib/Doctrine/Dbal/Extension/Conversion - If you use SQLite and need to register an user-defined-function (UDF) - you can register a ``postConnect`` event listener at the Doctrine EventsManager - to register the function. +.. tip:: - See `SqliteConnectionSubscriber.php`_ for an example. + To use a conversion for an existing FieldType use a + :ref:`FieldTypeExtension `. ColumnConversion ---------------- @@ -119,6 +125,31 @@ the first function converts the date to an Interval and then the second function extracts the years of the Interval and then casts the extracted years to a integer. Now you easily search for users with a certain age. +Value Specific Conversion +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Most column versions are singular, but in some cases you might need +to apply a different conversion depending on the value that is being +processed at the moment. + +For the ``Rollerworks\\Component\\Search\\Extension\\Doctrine\\Dbal\\Conversion\\DateIntervalConversion`` you need +to know whether the value needs to be subtracted or added, depending on the processing context. + +For the :class:`Rollerworks\\Component\\Search\\Extension\\Doctrine\\Dbal\\Conversion\\MoneyValueConversion` +you need to know the unit (*precision*) the Currency, but don't have access to the database value. + +* The ``$context`` property of the ``ConversionHints`` provides + the current processing-context, see the ``CONTEXT_`` constants of the + ``ConversionHints`` for possible options; + +* The ``$originalValue`` holds the actual value-holder + that is currently being processed, depending on the context + this either a ``Range``, ``Compare`` value-holder object or ``mixed`` + type value for ``CONTEXT_SIMPLE_VALUE``. + +When you only need the value (regardless of the context) use the +``getProcessingValue()`` method. + .. _value_conversion: ValueConversion @@ -129,34 +160,32 @@ or user-defined functional call. The :class:`Rollerworks\\Component\\Search\\Doctrine\\Dbal\\ValueConversion` requires the implementation of one method that must return the value -as SQL query-fragment (with proper escaping and quoting applied). +as SQL query-fragment. .. warning:: The ``convertValue`` method is required to return an SQL query-fragment that will be applied as-is! - Be extremely cautious to properly escape and quote values, failing to do - this can easily lead to a category of security holes called SQL injection, - where a third party can modify the executed SQL and even execute their own - queries through clever exploiting of the security hole! + Avoid embedding the values directly, use the ``createParamReferenceFor`` + on the ``$hints`` instead. + + Failing to do this can easily lead to a category of security holes called + SQL injection, where a third party can modify the executed SQL and even + execute their own queries through clever exploiting of the security hole! - The only only save way to escape and quote a value is with: + The only only save way to embed a value is with: .. code-block:: php - $hints->connection->quote($value); + $hints->createParamReferenceFor($value); // will return param-name `:search_x` where x an incremented number Don't try to replace the escaping with your own implementation as this may not provide a full protection against SQL injections. - One minor exception is using integer values with SQLite, because - quoting these values don't work as expected. Make sure the value is integer - and nothing else! - One of these values is Spatial data which requires a special type of input. -The input must be provided using an SQL function, and therefor this can not be done -with only PHP. +The input must be provided using an SQL function, and there for this can not +be done with only PHP. This example describes how to implement a MySQL specific column type called Point. @@ -191,6 +220,7 @@ And the GeoConversion class:: namespace Acme\Geo\Search\Dbal\Conversion; use Acme\Geo\Point; + use Doctrine\DBAL\Types\Type; use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; use Rollerworks\Component\Search\Doctrine\Dbal\SqlValueConversionInterface; @@ -199,7 +229,12 @@ And the GeoConversion class:: public function convertValue($input, array $options, ConversionHints $hints): string { if ($value instanceof Point) { - $value = sprintf('POINT(%F %F)', $input->getLongitude(), $input->getLatitude()); + // The second argument is a Doctrine DBAL type used for the binding-type and + // any SQL specific transformation (otherwise the value is marked as text and used as-is). + $long = $hints->createParamReferenceFor($input->getLongitude(), Type::getType('decimal')); + $lat = $hints->createParamReferenceFor($input->getLatitude(), Type::getType('decimal')); + + $value = sprintf('POINT(%s, %s)', $long, $lat); } return $value; @@ -212,128 +247,9 @@ And the GeoConversion class:: See `Custom Mapping Types`_ in the Doctrine DBAL manual for more information. But doing this may cause issues with certain database vendors as the generator - doesn't now the value is wrapped inside a function and therefor is unable + doesn't now the value is wrapped inside a function and there for is unable to adjust the generation process for better interoperability. -Using Strategies ----------------- - -You already know it's possible to convert columns and values -to a different format and that you can wrap them with SQL statements. -But there is more. - -Converting columns and/or values will work in most situations, but what if -you need to work with differing values like the birthday type, which accepts -both dates and integer (age) values? To make this possible you need to add -conversion-strategies. Conversion-strategies are based on the `Strategy pattern`_ -and work very simple and straightforward. - -A conversion-strategy is determined by the given value. - -.. note:: - - When conversion strategies are not supported, or no was determined - the conversion-strategy defaults to 0. - -Say you have the following values for the birthday type: 2010-01-05, 2010-05-05, 5. -The first two values are dates, but third is an age. With the conversion -strategy enabled the system will process the values as follow; - - Dates are assigned strategy-number 1, integers (ages) are assigned with - strategy-number 2. - - So ``2010-01-05`` and ``2010-05-05`` get strategy-number 1. - And the ``5`` value gets strategy-number 2. - - Now when the condition is generated the conversion methods receive the strategy - using the ``conversionStrategy`` property of the ``ConversionHints``, which - helps to determine how the conversion should be applied. - -Implementing conversion-strategies -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To enable strategies for conversions they need to -implement the :class:`Rollerworks\\Component\\Search\\Doctrine\\Dbal\\StrategySupportedConversion` -interface and the ``getConversionStrategy`` method. - -.. note:: - - If the conversion supports both the column and value conversions - then both conversion methods will receive the determined strategy. - -The following example uses a simplified version of the ``AgeConversion`` which is -provided by RollerworksSearch:: - - use Doctrine\DBAL\Types\Type as DBALType; - use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; - use Rollerworks\Component\Search\Doctrine\Dbal\StrategySupportedConversion; - use Rollerworks\Component\Search\Doctrine\Dbal\ColumnConversion; - use Rollerworks\Component\Search\Doctrine\Dbal\ValueConversion; - use Rollerworks\Component\Search\Exception\UnexpectedTypeException; - - /** - * AgeDateConversion. - * - * The chosen conversion strategy is done as follow. - * - * * 1: When the provided value is an integer, the DB-value is converted to an age. - * * 2: When the provided value is an DateTime the input-value is converted to an date string. - * * 3: When the provided value is an DateTime and the mapping-type is not a date - * the input-value is converted to an date string and the DB-value is converted to a date. - */ - class AgeDateConversion implements StrategySupportedConversion, ColumnConversionInterface, ValueConversion - { - public function getConversionStrategy($value, array $options, ConversionHints $hints): int - { - if (!$value instanceof \DateTimeInterface && !ctype_digit((string) $value)) { - throw new UnexpectedTypeException($value, '\DateTime object or integer'); - } - - if ($value instanceof \DateTimeInterface) { - return $hints->field->getDbType()->getName() !== 'date' ? 2 : 3; - } - - return 1; - } - - public function convertColumn(string $column, array $options, ConversionHints $hints): string - { - if (3 === $hints->conversionStrategy) { - return $column; - } - - if (2 === $hints->conversionStrategy) { - return "CAST($column AS DATE)"; - } - - $platform = $hints->connection->getDatabasePlatform()->getName(); - - switch ($platform) { - case 'postgresql': - return "to_char(age($column), 'YYYY'::text)::integer"; - - default: - throw new \RuntimeException( - sprintf('Unsupported platform "%s" for AgeDateConversion.', $platform) - ); - } - } - - public function convertValue($value, array $options, ConversionHints $hints) - { - if (2 === $hints->conversionStrategy || 3 === $hints->conversionStrategy) { - return DBALType::getType('date')->convertToDatabaseValue( - $value, - $hints->connection->getDatabasePlatform() - ); - } - - return (int) $value; - } - } - -That's it, your conversion is now ready for usage. - Testing Conversions ------------------- @@ -344,6 +260,4 @@ structure will remain the same for the future releases. The only way to ensure your conversions work is to run it against an actual database with existing records. -.. _`SqliteConnectionSubscriber.php`: https://github.com/rollerworks/rollerworks-search-doctrine-dbal/blob/master/src/EventSubscriber/SqliteConnectionSubscriber.php .. _`Custom Mapping Types`: http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/types.html#custom-mapping-types -.. _Strategy pattern: http://en.wikipedia.org/wiki/Strategy_pattern diff --git a/docs/integration/doctrine/conversions_orm.rst b/docs/integration/doctrine/conversions_orm.rst new file mode 100644 index 00000000..082d29d4 --- /dev/null +++ b/docs/integration/doctrine/conversions_orm.rst @@ -0,0 +1,6 @@ +Value and Column Conversions (ORM) +================================== + +TBD. + +Note that :doc:`Doctrine DBAL conversions ` are not applied here. diff --git a/docs/integration/doctrine/dbal.rst b/docs/integration/doctrine/dbal.rst index 52eec88c..a79e2f66 100644 --- a/docs/integration/doctrine/dbal.rst +++ b/docs/integration/doctrine/dbal.rst @@ -40,7 +40,7 @@ Querying the database As you already know RollerworksSearch uses a ``SearchFactory`` for bootstrapping the search system. This factory however doesn't know about integration extensions. -To Query a database with Doctrine DBAL extension, you use the +To Query a database with the Doctrine DBAL extension, you use the :class:`Rollerworks\\Component\\Search\\Doctrine\\Dbal\\DoctrineDbalFactory`. .. note:: @@ -80,7 +80,7 @@ Using the ConditionGenerator ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A ConditionGenerator generates an SQL Where-clause for a relational database -like PostgreSQL, MySQL, MSSQL, SQLite or Oracle OCI. +like PostgreSQL, MySQL, MSSQL, or Oracle OCI. .. caution:: @@ -88,7 +88,7 @@ like PostgreSQL, MySQL, MSSQL, SQLite or Oracle OCI. So reusing a ConditionGenerator is not possible. Secondly, the generated query is only valid for the give Database driver. - Meaning that when you generated a query with the SQLite database driver + Meaning that when you generated a query with the PostgreSQL database driver this query will not work on MySQL. First create a ``ConditionGenerator``:: @@ -199,7 +199,9 @@ Generating the Condition // Add the Where-clause $query .= $whereClause; - $statement = $connection->query($query); + $statement = $connection->prepare($query); + $conditionGenerator->bindParameters($statement); + $statement->execute(); // Get all the records // See http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/data-retrieval-and-manipulation.html#data-retrieval @@ -223,6 +225,7 @@ Generating the Condition $query .= $whereClause; $statement = $connection->prepare($query); + $conditionGenerator->bindParameters($statement); $statement->bindValue(1, $id); $statement->execute(); @@ -255,6 +258,7 @@ with only the first and/or last name. $query .= $whereClause; $statement = $connection->prepare($query); + $conditionGenerator->bindParameters($statement); $statement->execute(); Caching the Where-clause @@ -263,12 +267,12 @@ Caching the Where-clause Generating a Where-clause may require quite some time and system resources, which is why it's recommended to cache the generated query for future usage. -Fortunately the factory allows to create a CachedConditionGenerator +Fortunately the factory allows to create a ``CachedConditionGenerator`` which can handle caching of the ConditionGenerator for you. Plus, usage is no different then using the ``SqlConditionGenerator``, -the CachedConditionGenerator decorates the SqlConditionGenerator and can -be configured very similar:: +the ``CachedConditionGenerator`` decorates the ``SqlConditionGenerator`` +and can be configured very similar:: // ... @@ -288,7 +292,8 @@ be configured very similar:: // Add the Where-clause $query .= $whereClause; - $statement = $connection->query($query); + $statement = $connection->prepare($query); + $cacheConditionGenerator->bindParameters($statement); The cache-key is a hashed (sha256) combination of the SearchCondition (root ValuesGroup and FieldSet set-name) and configured field mappings. diff --git a/docs/integration/doctrine/index.rst b/docs/integration/doctrine/index.rst index 2b53597f..b9c8877d 100644 --- a/docs/integration/doctrine/index.rst +++ b/docs/integration/doctrine/index.rst @@ -38,4 +38,5 @@ If you need this, consider using :doc:`/integration/elasticsearch`. dbal orm conversions + conversions_orm troubleshooting diff --git a/docs/integration/doctrine/orm.rst b/docs/integration/doctrine/orm.rst index 274c9249..1fc71606 100644 --- a/docs/integration/doctrine/orm.rst +++ b/docs/integration/doctrine/orm.rst @@ -61,8 +61,6 @@ To Query a database with Doctrine ORM extension, you use the The ``DoctrineOrmFactory`` class provides an entry point for creating :class:`Rollerworks\\Component\\Search\\Doctrine\\Orm\\DqlConditionGenerator`, :class:`Rollerworks\\Component\\Search\\Doctrine\\Orm\\CachedDqlConditionGenerator`, -:class:`Rollerworks\\Component\\Search\\Doctrine\\Orm\\NativeQueryConditionGenerator` and -:class:`Rollerworks\\Component\\Search\\Doctrine\\Orm\\CachedNativeQueryConditionGenerator` object instances. Initiating the ``DoctrineDbalFactory`` is as simple as:: @@ -82,12 +80,8 @@ See also: :doc:`/reference/caching` Using the ConditionGenerator ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Depending on whether you use a ``Doctrine\ORM\Query`` or ``Doctrine\ORM\NativeQuery`` -the returned ConditionGenerator will be different. - -Both ConditionGenerators implement the same interface and API but the Where-clause -they will generate is completely different. Eg. you get an DQL or a platform -specific SQL condition. +The ConditionGenerator supports both ``Doctrine\ORM\Query`` and ``Doctrine\ORM\QueryBuilder``, +for NativeQuery use the Doctrine DBAL ConditionGenerator instead. .. caution:: @@ -96,7 +90,7 @@ specific SQL condition. Secondly, the generated query is only valid for the give query dialect or Database driver. Meaning that when you generated a query with the - SQLite database driver this query will not work on MySQL. + PostgreSQL database driver this query will not work on MySQL. First create a ``ConditionGenerator``:: @@ -211,16 +205,12 @@ the ConditionGenerator will fail with an exception. When using DQL, the column mapping of a field must point to the entity field that owns the value (not reference another Entity object). - So if you have an ``Invoice`` Entity with a ``customer`` (``Customer`` + Given you have an ``Invoice`` Entity with a ``customer`` (``Customer`` Entity) reference, the ``Customer`` Entity owns the the actual value and the field must point to the ``Customer.id`` field, **not** ``Invoice.customer``. If you point to a Join association the generator will throw an exception. - This limitation only applies for DQL and not NativeQuery. - - In NativeQuery you must provide the ``$type`` as this cannot be - automatically resolved. The ``$type`` (when given) must correspond to a Doctrine DBAL support type. So instead of using ``varchar`` you use ``string``. @@ -273,7 +263,7 @@ Generating the Condition Now to apply the generated condition on the query you have two options; You can use ``updateQuery`` which updates the query for you and sets -the Query-hints for DQL, but only when there is an actual condition generated:: +parameters, but only when there is an actual condition generated:: // ... @@ -285,27 +275,8 @@ the Query-hints for DQL, but only when there is an actual condition generated:: // use ` AND ` instead, this will be placed before the generated condition. $conditionGenerator->updateQuery(' AND '); -Or if you want to do more with the generated condition, you can update -the query yourself:: - - ... - - // The ' WHERE ' value is placed before the generated where-clause, - // but only when there is actual where-clause, else it returns an empty string. - $whereClause = $conditionGenerator->getWhereClause(' WHERE '); - - if (!empty($whereClause)) { - $query->setDql($query.$whereClause); - - // The QueryHints are only needed for DQL Queries - // the NativeWhereBuilder doesn't have these method. - $query->setHint($conditionGenerator->getQueryHintName(), $conditionGenerator->getQueryHintValue()); - } - -Effectively the two samples do the same, except that ``getQueryHintName`` -and ``getQueryHintValue`` don't exist for the ``NativeQueryConditionGenerator``. - -**Don't use ``updateQuery`` and the second example together, use only of the two.** +Note that when you passed a ``QueryBuilder`` instance the prepend argument is ignored, +as the builder handles this itself. .. tip:: @@ -387,12 +358,6 @@ Plus, usage is no different then using a regular ConditionGenerator, the CachedConditionGenerator decorates the ConditionGenerator and can be configured very similar. -.. note:: - - There are two different CachedConditionGenerators, one for the - ``DqlConditionGenerator`` and one for the - ``NativeQueryConditionGenerator``. - .. code-block:: php :linenos: @@ -431,23 +396,6 @@ be configured very similar. $users = $statement->getResult(); -Conversions ------------ - -Conversions for Doctrine ORM are similar to the DataTransformers -used for transforming user-input to a normalized data format. Except that -the transformation happens in a single direction. - -Field and Value Conversions are handled by the :doc:`Doctrine DBAL extension `. -You can read more about them in the :doc:`conversions` chapter. - -.. note:: - - Custom DQL-functions with the ``Column`` parameter receive the resolved - entity-alias and column-name that the Query parser has generated. Because - these functions only receive the column name of the current entity field - it's impossible to know the table and column aliases of other fields. - Next Steps ---------- @@ -455,6 +403,9 @@ Now that you have completed the basic installation and configuration, and know how to query the database for results. You are ready to learn about more advanced features and usages of this extension. +You may have noticed the word "conversions", now it's time learn more +about this! :doc:`conversions_orm`. + And if you get stuck with querying, there is a :doc:`Troubleshooter ` to help you. Good luck. diff --git a/docs/integration/doctrine/troubleshooting.rst b/docs/integration/doctrine/troubleshooting.rst index 186284ca..80060c95 100644 --- a/docs/integration/doctrine/troubleshooting.rst +++ b/docs/integration/doctrine/troubleshooting.rst @@ -13,9 +13,6 @@ If you are still not getting any results check the following: #. Using multiple tables (with JOIN) will only work when all the tables have a positive match, try using ``LEFT JOIN`` to ignore any missing matches. -#. Are you using any custom Conversions? Check if they are missing quotes - or are quoting an integer value. *SQLIte doesn't work well with quoted - integer values.* #. Try using a smaller SearchCondition and make sure the values you are searching are actually existent. diff --git a/lib/ApiPlatform/Doctrine/Orm/CollectionDataProvider.php b/lib/ApiPlatform/Doctrine/Orm/CollectionDataProvider.php deleted file mode 100644 index e3d56c7d..00000000 --- a/lib/ApiPlatform/Doctrine/Orm/CollectionDataProvider.php +++ /dev/null @@ -1,89 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\ApiPlatform\Doctrine\Orm; - -use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; -use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultCollectionExtensionInterface; -use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGenerator; -use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; -use ApiPlatform\Core\Exception\ResourceClassNotSupportedException; -use ApiPlatform\Core\Exception\RuntimeException; -use Doctrine\Common\Persistence\ManagerRegistry; -use Doctrine\ORM\EntityRepository; - -/** - * Collection data provider for the Doctrine ORM. - * - * This is a compatibility adapter for {@link \ApiPlatform\Core\Bridge\Doctrine\Orm\CollectionDataProvider} - * until https://github.com/doctrine/doctrine2/pull/6359 is accepted and - * the minimum Doctrine ORM version is bumped. - * - * @author Kévin Dunglas - * @author Samuel ROZE - */ -final class CollectionDataProvider implements CollectionDataProviderInterface -{ - private $managerRegistry; - private $collectionExtensions; - - /** - * @param QueryCollectionExtensionInterface[] $collectionExtensions - */ - public function __construct(ManagerRegistry $managerRegistry, iterable $collectionExtensions = []) - { - $this->managerRegistry = $managerRegistry; - $this->collectionExtensions = $collectionExtensions; - } - - /** - * {@inheritdoc} - * - * @throws RuntimeException - */ - public function getCollection(string $resourceClass, string $operationName = null) - { - $manager = $this->managerRegistry->getManagerForClass($resourceClass); - if (null === $manager) { - throw new ResourceClassNotSupportedException(); - } - - /** @var EntityRepository $repository */ - $repository = $manager->getRepository($resourceClass); - - if (!method_exists($repository, 'createQueryBuilder')) { - throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); - } - - $queryBuilder = $repository->createQueryBuilder('o'); - - // BC for https://github.com/doctrine/doctrine2/pull/6359 - if (!method_exists($queryBuilder, 'setHint')) { - $queryBuilder = new QueryBuilder($queryBuilder->getEntityManager()); - $queryBuilder - ->select('o') - ->from($repository->getClassName(), 'o'); - } - - $queryNameGenerator = new QueryNameGenerator(); - foreach ($this->collectionExtensions as $extension) { - $extension->applyToCollection($queryBuilder, $queryNameGenerator, $resourceClass, $operationName); - - if ($extension instanceof QueryResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operationName)) { - return $extension->getResult($queryBuilder); - } - } - - return $queryBuilder->getQuery()->getResult(); - } -} diff --git a/lib/ApiPlatform/Doctrine/Orm/Extension/SearchExtension.php b/lib/ApiPlatform/Doctrine/Orm/Extension/SearchExtension.php index a35ff774..c8084967 100644 --- a/lib/ApiPlatform/Doctrine/Orm/Extension/SearchExtension.php +++ b/lib/ApiPlatform/Doctrine/Orm/Extension/SearchExtension.php @@ -54,10 +54,6 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator return; } - if (!method_exists($queryBuilder, 'setHint')) { - return; - } - $context = $request->attributes->get('_api_search_context'); $configuration = $request->attributes->get('_api_search_config'); $configPath = "{$resourceClass}#attributes[rollerworks_search][contexts][{$context}][doctrine_orm]"; diff --git a/lib/ApiPlatform/Doctrine/Orm/QueryBuilder.php b/lib/ApiPlatform/Doctrine/Orm/QueryBuilder.php deleted file mode 100644 index 3ba27a2e..00000000 --- a/lib/ApiPlatform/Doctrine/Orm/QueryBuilder.php +++ /dev/null @@ -1,121 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\ApiPlatform\Doctrine\Orm; - -use Doctrine\ORM\Query; - -class QueryBuilder extends \Doctrine\ORM\QueryBuilder -{ - /** - * Sets a query hint. - * - * @param string $name the name of the hint - * @param mixed $value the value of the hint - * - * @return self - */ - public function setHint($name, $value) - { - $this->_hints[$name] = $value; - - return $this; - } - - /** - * Gets the value of a query hint. If the hint name is not recognized, FALSE is returned. - * - * @param string $name the name of the hint - * - * @return mixed the value of the hint or FALSE, if the hint name is not recognized - */ - public function getHint($name) - { - return $this->_hints[$name] ?? false; - } - - /** - * Check if the query has a hint. - * - * @param string $name The name of the hint - * - * @return bool False if the query does not have any hint - */ - public function hasHint($name) - { - return isset($this->_hints[$name]); - } - - /** - * Return the key value map of query hints that are currently set. - * - * @return array - */ - public function getHints() - { - return $this->_hints; - } - - /** - * The map of query hints. - * - * @var array - */ - private $_hints = []; - - /** - * Constructs a Query instance from the current specifications of the builder. - * - * - * $qb = $em->createQueryBuilder() - * ->select('u') - * ->from('User', 'u'); - * $q = $qb->getQuery(); - * $results = $q->execute(); - * - * - * @return Query - */ - public function getQuery() - { - $parameters = clone $this->getParameters(); - $query = $this->getEntityManager()->createQuery($this->getDQL()) - ->setParameters($parameters) - ->setFirstResult($this->getFirstResult()) - ->setMaxResults($this->getMaxResults()); - - if ($this->lifetime) { - $query->setLifetime($this->lifetime); - } - - if ($this->cacheMode) { - $query->setCacheMode($this->cacheMode); - } - - if ($this->cacheable) { - $query->setCacheable($this->cacheable); - } - - if ($this->cacheRegion) { - $query->setCacheRegion($this->cacheRegion); - } - - if ($this->_hints) { - foreach ($this->_hints as $name => $value) { - $query->setHint($name, $value); - } - } - - return $query; - } -} diff --git a/lib/ApiPlatform/Elasticsearch/Extension/SearchExtension.php b/lib/ApiPlatform/Elasticsearch/Extension/SearchExtension.php index 6dad9ed2..b83dff2b 100644 --- a/lib/ApiPlatform/Elasticsearch/Extension/SearchExtension.php +++ b/lib/ApiPlatform/Elasticsearch/Extension/SearchExtension.php @@ -15,8 +15,8 @@ use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; -use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ManagerRegistry; use Elastica\Client; use Elastica\Document; use Elastica\Query; diff --git a/lib/ApiPlatform/Tests/Doctrine/Orm/Extension/SearchExtensionTest.php b/lib/ApiPlatform/Tests/Doctrine/Orm/Extension/SearchExtensionTest.php index 3b4132c6..e6f5af2d 100644 --- a/lib/ApiPlatform/Tests/Doctrine/Orm/Extension/SearchExtensionTest.php +++ b/lib/ApiPlatform/Tests/Doctrine/Orm/Extension/SearchExtensionTest.php @@ -18,10 +18,10 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use Doctrine\ORM\QueryBuilder; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Rollerworks\Component\Search\ApiPlatform\Doctrine\Orm\Extension\SearchExtension; -use Rollerworks\Component\Search\ApiPlatform\Doctrine\Orm\QueryBuilder; use Rollerworks\Component\Search\Doctrine\Orm\CachedDqlConditionGenerator; use Rollerworks\Component\Search\Doctrine\Orm\DoctrineOrmFactory; use Rollerworks\Component\Search\Doctrine\Orm\DqlConditionGenerator; diff --git a/lib/ApiPlatform/Tests/Elasticsearch/Extension/SearchExtensionTest.php b/lib/ApiPlatform/Tests/Elasticsearch/Extension/SearchExtensionTest.php index 7e47d790..1f04ace9 100644 --- a/lib/ApiPlatform/Tests/Elasticsearch/Extension/SearchExtensionTest.php +++ b/lib/ApiPlatform/Tests/Elasticsearch/Extension/SearchExtensionTest.php @@ -14,11 +14,11 @@ namespace Rollerworks\Component\Search\ApiPlatform\Tests\Elasticsearch\Extension; use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGenerator; -use Doctrine\Common\Persistence\ManagerRegistry; -use Doctrine\Common\Persistence\Mapping\ClassMetadata; -use Doctrine\Common\Persistence\ObjectManager; use Doctrine\ORM\Query\Expr; use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\Mapping\ClassMetadata; +use Doctrine\Persistence\ObjectManager; use Elastica\Client; use Elastica\Query; use Elastica\Response; diff --git a/lib/Doctrine/Dbal/AbstractCachedConditionGenerator.php b/lib/Doctrine/Dbal/AbstractCachedConditionGenerator.php new file mode 100644 index 00000000..90f0a857 --- /dev/null +++ b/lib/Doctrine/Dbal/AbstractCachedConditionGenerator.php @@ -0,0 +1,99 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Doctrine\Dbal; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\DBAL\Types\Type; +use Psr\SimpleCache\CacheInterface as Cache; + +abstract class AbstractCachedConditionGenerator +{ + /** + * @var Cache + */ + protected $cacheDriver; + + /** + * @var int|\DateInterval|null + */ + protected $cacheLifeTime; + + /** + * @var string|null + */ + protected $cacheKey; + + /** + * @var ArrayCollection + */ + protected $parameters; + + /** + * @param Cache $cacheDriver PSR-16 SimpleCache instance. Use a custom pool to ease + * purging invalidated items + * @param int|\DateInterval|null $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default + * value for it or let the driver take care of that. + */ + protected function __construct(Cache $cacheDriver, $ttl = null) + { + $this->cacheDriver = $cacheDriver; + $this->cacheLifeTime = $ttl; + $this->parameters = new ArrayCollection(); + } + + protected function getFromCache(string $cacheKey): ?array + { + $cached = $this->cacheDriver->get($cacheKey); + + if (!\is_array($cached) || !isset($cached[0], $cached[1]) || !\is_string($cached[0]) || !\is_array($cached[1])) { + return null; + } + + try { + $cached[1] = $this->unpackParameters($cached[1]); + + return $cached; + } catch (\Throwable $e) { + return null; + } + } + + protected function unpackParameters(array $provided): ArrayCollection + { + $parameters = new ArrayCollection(); + + foreach ($provided as $name => [$value, $type]) { + $parameters->set($name, [$value, $type === null ? null : Type::getType($type)]); + } + + return $parameters; + } + + protected function packParameters(ArrayCollection $provided): array + { + $parameters = []; + + foreach ($provided as $name => [$value, $type]) { + $parameters[$name] = [$value, $type === null ? null : $type->getName()]; + } + + return $parameters; + } + + public function getParameters(): ArrayCollection + { + return $this->parameters; + } +} diff --git a/lib/Doctrine/Dbal/CachedConditionGenerator.php b/lib/Doctrine/Dbal/CachedConditionGenerator.php index 4b0007c3..5220182d 100644 --- a/lib/Doctrine/Dbal/CachedConditionGenerator.php +++ b/lib/Doctrine/Dbal/CachedConditionGenerator.php @@ -13,10 +13,11 @@ namespace Rollerworks\Component\Search\Doctrine\Dbal; +use Doctrine\DBAL\Statement; use Psr\SimpleCache\CacheInterface as Cache; use Rollerworks\Component\Search\SearchCondition; -/*** +/** * Handles caching of a Doctrine DBAL ConditionGenerator. * * Instead of using the ConditionGenerator directly you should use the @@ -30,45 +31,24 @@ * * @author Sebastiaan Stok */ -final class CachedConditionGenerator implements ConditionGenerator +final class CachedConditionGenerator extends AbstractCachedConditionGenerator implements ConditionGenerator { - /** - * @var Cache - */ - private $cacheDriver; - - /** - * @var int|\DateInterval|null - */ - private $cacheLifeTime; - /** * @var ConditionGenerator */ private $conditionGenerator; - /** - * @var string|null - */ - private $cacheKey; - /** * @var string */ private $whereClause; /** - * @param ConditionGenerator $conditionGenerator The actual ConditionGenerator to use when no cache exists - * @param Cache $cacheDriver PSR-16 SimpleCache instance. Use a custom pool to ease - * purging invalidated items - * @param int|\DateInterval|null $ttl Optional. The TTL value of this item. If no value is sent and - * the driver supports TTL then the library may set a default value - * for it or let the driver take care of that. + * @param ConditionGenerator $conditionGenerator The actual ConditionGenerator to use when no cache exists */ - public function __construct(ConditionGenerator $conditionGenerator, Cache $cacheDriver, $ttl = 0) + public function __construct(ConditionGenerator $conditionGenerator, Cache $cacheDriver, $ttl = null) { - $this->cacheDriver = $cacheDriver; - $this->cacheLifeTime = $ttl; + parent::__construct($cacheDriver, $ttl); $this->conditionGenerator = $conditionGenerator; } @@ -82,12 +62,20 @@ public function getWhereClause(string $prependQuery = ''): string { if (null === $this->whereClause) { $cacheKey = $this->getCacheKey(); + $cached = $this->getFromCache($cacheKey); - if ($this->cacheDriver->has($cacheKey)) { - $this->whereClause = $this->cacheDriver->get($cacheKey); + if ($cached !== null) { + $this->whereClause = $cached[0]; + $this->parameters = $cached[1]; } else { $this->whereClause = $this->conditionGenerator->getWhereClause(); - $this->cacheDriver->set($cacheKey, $this->whereClause, $this->cacheLifeTime); + $this->parameters = $this->conditionGenerator->getParameters(); + + $this->cacheDriver->set( + $cacheKey, + [$this->whereClause, $this->packParameters($this->parameters)], + $this->cacheLifeTime + ); } } @@ -98,6 +86,13 @@ public function getWhereClause(string $prependQuery = ''): string return ''; } + public function bindParameters(Statement $statement): void + { + foreach ($this->parameters as $name => [$value, $type]) { + $statement->bindValue($name, $value, $type); + } + } + public function getSearchCondition(): SearchCondition { return $this->conditionGenerator->getSearchCondition(); @@ -119,6 +114,7 @@ private function getCacheKey(): string { if (null === $this->cacheKey) { $searchCondition = $this->conditionGenerator->getSearchCondition(); + $this->cacheKey = hash( 'sha256', $searchCondition->getFieldSet()->getSetName(). diff --git a/lib/Doctrine/Dbal/ColumnConversion.php b/lib/Doctrine/Dbal/ColumnConversion.php index aa529f23..28acf6f8 100644 --- a/lib/Doctrine/Dbal/ColumnConversion.php +++ b/lib/Doctrine/Dbal/ColumnConversion.php @@ -17,8 +17,7 @@ * A ColumnConversion allows to wrap the query's column in a custom * SQL statement (as-is). * - * This interface can be combined with the ValueConversion interface - * and ConversionStrategy interface. + * This interface can be combined with the ValueConversion interface. * * @author Sebastiaan Stok */ @@ -30,9 +29,6 @@ interface ColumnConversion * The returned result must a be a platform specific SQL statement * that can be used as a column in query. * - * Caution: It's important to properly escape any values used in the returned - * statement, as they are used as-is! - * * @param string $column The column name and table alias, eg. i.id * @param array $options Options of the Field configuration * @param ConversionHints $hints Special information for the conversion process diff --git a/lib/Doctrine/Dbal/ConditionGenerator.php b/lib/Doctrine/Dbal/ConditionGenerator.php index bb69aa32..0fb55a5d 100644 --- a/lib/Doctrine/Dbal/ConditionGenerator.php +++ b/lib/Doctrine/Dbal/ConditionGenerator.php @@ -13,6 +13,8 @@ namespace Rollerworks\Component\Search\Doctrine\Dbal; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\DBAL\Statement; use Rollerworks\Component\Search\Exception\BadMethodCallException; use Rollerworks\Component\Search\Exception\UnknownFieldException; use Rollerworks\Component\Search\SearchCondition; @@ -40,6 +42,19 @@ interface ConditionGenerator */ public function getWhereClause(string $prependQuery = ''): string; + /** + * Binds the WHERE-statement parameters at the Doctrine DBAL Statement. + * + * Note: PDO directly is not supported due to a limitation in how + * types are handled. + */ + public function bindParameters(Statement $statement): void; + + /** + * Returns the value-parameters (to be used when executing). + */ + public function getParameters(): ArrayCollection; + /** * Set the search field to database table-column mapping configuration. * diff --git a/lib/Doctrine/Dbal/ConversionHints.php b/lib/Doctrine/Dbal/ConversionHints.php index e2f10505..b317ff08 100644 --- a/lib/Doctrine/Dbal/ConversionHints.php +++ b/lib/Doctrine/Dbal/ConversionHints.php @@ -14,10 +14,18 @@ namespace Rollerworks\Component\Search\Doctrine\Dbal; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Types\Type; use Rollerworks\Component\Search\Doctrine\Dbal\Query\QueryField; +use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform\AbstractQueryPlatform; +use Rollerworks\Component\Search\Value\ValueHolder; class ConversionHints { + public const CONTEXT_RANGE_LOWER_BOUND = 'range.lower_bound'; + public const CONTEXT_RANGE_UPPER_BOUND = 'range.upper_bound'; + public const CONTEXT_SIMPLE_VALUE = 'simple_value'; + public const CONTEXT_COMPARISON = 'comparison'; + /** * @var QueryField */ @@ -29,12 +37,55 @@ class ConversionHints public $connection; /** - * @var int + * @var string */ - public $conversionStrategy = 0; + public $column; /** * @var string */ - public $column; + public $context; + + /** + * @var mixed|ValueHolder + */ + public $originalValue; + + /** + * @var AbstractQueryPlatform + */ + private $queryPlatform; + + public function __construct(AbstractQueryPlatform $queryPlatform) + { + $this->queryPlatform = $queryPlatform; + } + + /** + * Returns a parameter-name to reference a value. + */ + public function createParamReferenceFor($value, Type $type = null): string + { + return $this->queryPlatform->createParamReferenceFor($value, $type); + } + + /** + * Returns the value that is currently being processed (in context). + * + * The $this->originalValue might return a value-holder or actual + * processing value depending on the context. + */ + public function getProcessingValue() + { + switch ($this->context) { + case self::CONTEXT_SIMPLE_VALUE: + return $this->originalValue; + case self::CONTEXT_COMPARISON: + return $this->originalValue->value; + case self::CONTEXT_RANGE_LOWER_BOUND: + return $this->originalValue->getLower(); + case self::CONTEXT_RANGE_UPPER_BOUND: + return $this->originalValue->getUpper(); + } + } } diff --git a/lib/Doctrine/Dbal/Extension/Conversion/AgeDateConversion.php b/lib/Doctrine/Dbal/Extension/Conversion/AgeDateConversion.php index 8e55c4c6..34246c8d 100644 --- a/lib/Doctrine/Dbal/Extension/Conversion/AgeDateConversion.php +++ b/lib/Doctrine/Dbal/Extension/Conversion/AgeDateConversion.php @@ -16,44 +16,13 @@ use Doctrine\DBAL\Types\Type as DBALType; use Rollerworks\Component\Search\Doctrine\Dbal\ColumnConversion; use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; -use Rollerworks\Component\Search\Doctrine\Dbal\StrategySupportedConversion; use Rollerworks\Component\Search\Doctrine\Dbal\ValueConversion; -use Rollerworks\Component\Search\Exception\UnexpectedTypeException; -/** - * AgeDateConversion. - * - * The chosen conversion strategy is done as follow. - * - * * 1: When the provided value is an integer, the DB-value is converted to an age. - * * 2: When the provided value is an DateTime the input-value is converted to an date string. - * * 3: When the provided value is an DateTime and the mapping-type is not a date - * the input-value is converted to an date string and the DB-value is converted to a date. - * - * @author Sebastiaan Stok - */ -class AgeDateConversion implements StrategySupportedConversion, ColumnConversion, ValueConversion +final class AgeDateConversion implements ColumnConversion, ValueConversion { - public function getConversionStrategy($value, array $options, ConversionHints $hints): int - { - if (!$value instanceof \DateTimeInterface && !\is_int($value)) { - throw new UnexpectedTypeException($value, '\DateTimeInterface object or integer'); - } - - if ($value instanceof \DateTimeInterface) { - return $hints->field->dbType->getName() !== 'date' ? 2 : 3; - } - - return 1; - } - public function convertColumn(string $column, array $options, ConversionHints $hints): string { - if (3 === $hints->conversionStrategy) { - return $column; - } - - if (2 === $hints->conversionStrategy) { + if ($hints->getProcessingValue() instanceof \DateTimeInterface) { return "CAST($column AS DATE)"; } @@ -76,15 +45,12 @@ public function convertColumn(string $column, array $options, ConversionHints $h ); } - /** - * @return string|int - */ - public function convertValue($value, array $options, ConversionHints $hints) + public function convertValue($value, array $options, ConversionHints $hints): string { - if (2 === $hints->conversionStrategy || 3 === $hints->conversionStrategy) { - return $hints->connection->quote($value, DBALType::getType('date')); + if ($value instanceof \DateTimeInterface) { + return $hints->createParamReferenceFor($value, DBALType::getType('date')); } - return (int) $value; + return $hints->createParamReferenceFor($value, DBALType::getType('integer')); } } diff --git a/lib/Doctrine/Dbal/Extension/Conversion/MoneyValueConversion.php b/lib/Doctrine/Dbal/Extension/Conversion/MoneyValueConversion.php index e35e23c6..eb8360de 100644 --- a/lib/Doctrine/Dbal/Extension/Conversion/MoneyValueConversion.php +++ b/lib/Doctrine/Dbal/Extension/Conversion/MoneyValueConversion.php @@ -13,19 +13,20 @@ namespace Rollerworks\Component\Search\Extension\Doctrine\Dbal\Conversion; -use Doctrine\DBAL\Types\Type as DbType; +use Doctrine\DBAL\Types\Types as DbType; use Money\Currencies\ISOCurrencies; use Money\Formatter\DecimalMoneyFormatter; use Rollerworks\Component\Search\Doctrine\Dbal\ColumnConversion; use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; -use Rollerworks\Component\Search\Doctrine\Dbal\StrategySupportedConversion; use Rollerworks\Component\Search\Doctrine\Dbal\ValueConversion; -use Rollerworks\Component\Search\Exception\UnexpectedTypeException; use Rollerworks\Component\Search\Extension\Core\Model\MoneyValue; -class MoneyValueConversion implements ValueConversion, ColumnConversion, StrategySupportedConversion +final class MoneyValueConversion implements ValueConversion, ColumnConversion { + /** @var DecimalMoneyFormatter */ private $formatter; + + /** @var ISOCurrencies */ private $currencies; public function __construct() @@ -34,43 +35,32 @@ public function __construct() $this->formatter = new DecimalMoneyFormatter($this->currencies); } + /** + * @param MoneyValue $value + */ public function convertValue($value, array $options, ConversionHints $hints): string { - if (!$value instanceof MoneyValue) { - throw new UnexpectedTypeException($value, MoneyValue::class); - } + $sqlValue = $hints->createParamReferenceFor($this->formatter->format($value->value)); + $castType = $this->getCastType($this->currencies->subunitFor($value->value->getCurrency()), $hints); - $sqlValue = $hints->connection->quote($this->formatter->format($value->value)); - $castType = $this->getCastType($hints->conversionStrategy, $hints); - - // https://github.com/rollerworks/rollerworks-search-doctrine-dbal/issues/9 return "CAST({$sqlValue} AS {$castType})"; } public function convertColumn(string $column, array $options, ConversionHints $hints): string { - if (DbType::DECIMAL === $hints->field->dbType->getName()) { + if ($hints->field->dbType->getName() === DbType::DECIMAL) { return $column; } $substr = $hints->connection->getDatabasePlatform()->getSubstringExpression($column, 5); - $castType = $this->getCastType($hints->conversionStrategy, $hints); + $castType = $this->getCastType($this->currencies->subunitFor($hints->getProcessingValue()->value->getCurrency()), $hints); return "CAST($substr AS $castType)"; } - public function getConversionStrategy($value, array $options, ConversionHints $hints): int - { - if (!$value instanceof MoneyValue) { - throw new UnexpectedTypeException($value, MoneyValue::class); - } - - return $this->currencies->subunitFor($value->value->getCurrency()); - } - private function getCastType(int $scale, ConversionHints $hints): string { - if (false !== strpos($hints->connection->getDatabasePlatform()->getName(), 'mysql')) { + if (strpos($hints->connection->getDatabasePlatform()->getName(), 'mysql') !== false) { return "DECIMAL(10, {$scale})"; } diff --git a/lib/Doctrine/Dbal/Query/QueryField.php b/lib/Doctrine/Dbal/Query/QueryField.php index a4a9627e..26fd0ee9 100644 --- a/lib/Doctrine/Dbal/Query/QueryField.php +++ b/lib/Doctrine/Dbal/Query/QueryField.php @@ -15,7 +15,6 @@ use Doctrine\DBAL\Types\Type as DbType; use Rollerworks\Component\Search\Doctrine\Dbal\ColumnConversion; -use Rollerworks\Component\Search\Doctrine\Dbal\StrategySupportedConversion; use Rollerworks\Component\Search\Doctrine\Dbal\ValueConversion; use Rollerworks\Component\Search\Field\FieldConfig; @@ -27,7 +26,7 @@ * * @author Sebastiaan Stok */ -final class QueryField implements \Serializable +class QueryField implements \Serializable { /** * @var string @@ -50,20 +49,15 @@ final class QueryField implements \Serializable public $column; /** - * @var ColumnConversion|StrategySupportedConversion|null + * @var ColumnConversion|null */ public $columnConversion; /** - * @var ValueConversion|StrategySupportedConversion|null + * @var ValueConversion|null */ public $valueConversion; - /** - * @var bool - */ - public $strategyEnabled; - /** * @var string */ @@ -84,21 +78,7 @@ public function __construct(string $mappingName, FieldConfig $fieldConfig, DbTyp $this->column = ($alias ? $alias.'.' : '').$column; $this->dbType = $dbType; - $converter = $fieldConfig->getOption('doctrine_dbal_conversion'); - - if ($converter instanceof \Closure) { - $converter = $converter(); - } - - if ($converter instanceof ColumnConversion) { - $this->columnConversion = $converter; - } - - if ($converter instanceof ValueConversion) { - $this->valueConversion = $converter; - } - - $this->strategyEnabled = $converter instanceof StrategySupportedConversion; + $this->initConversions($fieldConfig); } public function serialize() @@ -116,4 +96,21 @@ public function unserialize($serialized) { // noop } + + protected function initConversions(FieldConfig $fieldConfig): void + { + $converter = $fieldConfig->getOption('doctrine_dbal_conversion'); + + if ($converter instanceof \Closure) { + $converter = $converter(); + } + + if ($converter instanceof ColumnConversion) { + $this->columnConversion = $converter; + } + + if ($converter instanceof ValueConversion) { + $this->valueConversion = $converter; + } + } } diff --git a/lib/Doctrine/Dbal/Query/QueryGenerator.php b/lib/Doctrine/Dbal/Query/QueryGenerator.php index e1615559..a11caebb 100644 --- a/lib/Doctrine/Dbal/Query/QueryGenerator.php +++ b/lib/Doctrine/Dbal/Query/QueryGenerator.php @@ -13,11 +13,10 @@ namespace Rollerworks\Component\Search\Doctrine\Dbal\Query; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\DBAL\Connection; use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; -use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform; -use Rollerworks\Component\Search\Doctrine\Dbal\StrategySupportedConversion; -use Rollerworks\Component\Search\Doctrine\Dbal\ValueConversion; +use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform\AbstractQueryPlatform; use Rollerworks\Component\Search\SearchCondition; use Rollerworks\Component\Search\Value\Compare; use Rollerworks\Component\Search\Value\ExcludedRange; @@ -47,11 +46,11 @@ final class QueryGenerator private $connection; /** - * @var QueryPlatform + * @var AbstractQueryPlatform */ private $queryPlatform; - public function __construct(Connection $connection, QueryPlatform $queryPlatform, array $fields) + public function __construct(Connection $connection, AbstractQueryPlatform $queryPlatform, array $fields) { $this->connection = $connection; $this->queryPlatform = $queryPlatform; @@ -122,46 +121,23 @@ private function processGroups(array $groups, array &$query) $query[] = self::wrapIfNotEmpty(self::implodeWithValue(' OR ', $groupSql), '(', ')'); } - private function processSingleValuesInList(array $values, QueryField $mappingConfig, array &$query, bool $exclude = false): void + private function processSingleValues(array $values, QueryField $mappingConfig, array &$query, bool $exclude, ConversionHints $hints): void { - $valuesQuery = []; - $column = $this->queryPlatform->getFieldColumn($mappingConfig); - - foreach ($values as $value) { - $valuesQuery[] = $this->queryPlatform->getValueAsSql($value, $mappingConfig, $column); - } - - $patterns = ['%s IN(%s)', '%s NOT IN(%s)']; - - if (\count($valuesQuery) > 0) { - $query[] = sprintf( - $patterns[(int) $exclude], - $column, - implode(', ', $valuesQuery) - ); - } - } - - private function processSingleValues(array $values, QueryField $mappingConfig, array &$query, bool $exclude = false): void - { - if (!$mappingConfig->strategyEnabled && !$mappingConfig->valueConversion instanceof ValueConversion) { - // Don't use IN() with a custom SQL-statement for better compatibility - // Always using OR seems to decrease the performance on some DB engines - $this->processSingleValuesInList($values, $mappingConfig, $query, $exclude); - - return; - } + // NOTE. Using OR/AND seems to be less-performant on some vendors (*namely MySQL*) but this heavily depends + // on the engine's configuration and other aspects. $patterns = ['%s = %s', '%s <> %s']; + $hints->context = ConversionHints::CONTEXT_SIMPLE_VALUE; + foreach ($values as $value) { - $strategy = $this->getConversionStrategy($mappingConfig, $value); - $column = $this->queryPlatform->getFieldColumn($mappingConfig, $strategy); + $hints->originalValue = $value; + $column = $this->queryPlatform->getFieldColumn($mappingConfig, $mappingConfig->column, $hints); $query[] = sprintf( $patterns[(int) $exclude], $column, - $this->queryPlatform->getValueAsSql($value, $mappingConfig, $column, $strategy) + $this->queryPlatform->getValueAsSql($value, $mappingConfig, $hints) ); } } @@ -169,18 +145,24 @@ private function processSingleValues(array $values, QueryField $mappingConfig, a /** * @param Range[] $ranges */ - private function processRanges(array $ranges, QueryField $mappingConfig, array &$query, bool $exclude = false): void + private function processRanges(array $ranges, QueryField $mappingConfig, array &$query, bool $exclude, ConversionHints $hints): void { foreach ($ranges as $range) { - $strategy = $this->getConversionStrategy($mappingConfig, $range->getLower()); - $column = $this->queryPlatform->getFieldColumn($mappingConfig, $strategy); + $hints->originalValue = $range; + $hints->context = ConversionHints::CONTEXT_RANGE_LOWER_BOUND; + + $column = $this->queryPlatform->getFieldColumn($mappingConfig, $mappingConfig->column, $hints); + $lowerBound = $this->queryPlatform->getValueAsSql($range->getLower(), $mappingConfig, $hints); + + $hints->context = ConversionHints::CONTEXT_RANGE_UPPER_BOUND; + $upperBound = $this->queryPlatform->getValueAsSql($range->getUpper(), $mappingConfig, $hints); $query[] = sprintf( $this->getRangePattern($range, $exclude), $column, - $this->queryPlatform->getValueAsSql($range->getLower(), $mappingConfig, $column, $strategy), + $lowerBound, $column, - $this->queryPlatform->getValueAsSql($range->getUpper(), $mappingConfig, $column, $strategy) + $upperBound ); } } @@ -212,23 +194,28 @@ private function getRangePattern(Range $range, bool $exclude = false): string /** * @param Compare[] $compares */ - private function processCompares(array $compares, QueryField $mappingConfig, array &$query, bool $exclude = false): void + private function processCompares(array $compares, QueryField $mappingConfig, array &$query, bool $exclude, ConversionHints $hints): void { $valuesQuery = []; + $hints->context = ConversionHints::CONTEXT_COMPARISON; foreach ($compares as $comparison) { if ($exclude !== ('<>' === $comparison->getOperator())) { continue; } - $strategy = $this->getConversionStrategy($mappingConfig, $comparison->getValue()); - $column = $this->queryPlatform->getFieldColumn($mappingConfig, $strategy); + $hints->originalValue = $comparison; + $column = $this->queryPlatform->getFieldColumn($mappingConfig, $mappingConfig->column, $hints); $valuesQuery[] = sprintf( '%s %s %s', $column, $comparison->getOperator(), - $this->queryPlatform->getValueAsSql($comparison->getValue(), $mappingConfig, $column, $strategy) + $this->queryPlatform->getValueAsSql( + $comparison->getValue(), + $mappingConfig, + $hints + ) ); } @@ -243,8 +230,10 @@ private function processCompares(array $compares, QueryField $mappingConfig, arr * @param PatternMatch[] $patternMatchers * @param string[] $query */ - private function processPatternMatchers(array $patternMatchers, QueryField $mappingConfig, array &$query, bool $exclude = false): void + private function processPatternMatchers(array $patternMatchers, QueryField $mappingConfig, array &$query, bool $exclude, ConversionHints $hints): void { + $hints->context = ConversionHints::CONTEXT_SIMPLE_VALUE; + foreach ($patternMatchers as $patternMatch) { if ($exclude !== $patternMatch->isExclusive()) { continue; @@ -252,40 +241,9 @@ private function processPatternMatchers(array $patternMatchers, QueryField $mapp $query[] = $this->queryPlatform->getPatternMatcher( $patternMatch, - $this->queryPlatform->getFieldColumn($mappingConfig) - ); - } - } - - private function getConversionStrategy(QueryField $mappingConfig, $value): int - { - if ($mappingConfig->valueConversion instanceof StrategySupportedConversion) { - return $mappingConfig->valueConversion->getConversionStrategy( - $value, - $mappingConfig->fieldConfig->getOptions(), - $this->getConversionHints($mappingConfig) + $this->queryPlatform->getFieldColumn($mappingConfig, $mappingConfig->column, $hints) ); } - - if ($mappingConfig->columnConversion instanceof StrategySupportedConversion) { - return $mappingConfig->columnConversion->getConversionStrategy( - $value, - $mappingConfig->fieldConfig->getOptions(), - $this->getConversionHints($mappingConfig) - ); - } - - return 0; - } - - private function getConversionHints(QueryField $mappingConfig, string $column = null): ConversionHints - { - $hints = new ConversionHints(); - $hints->field = $mappingConfig; - $hints->column = $column; - $hints->connection = $this->connection; - - return $hints; } /** @@ -336,56 +294,80 @@ private static function wrapIfNotEmpty(string $value, string $prefix, string $su private function processFieldValues(ValuesBag $values, QueryField $mappingConfig, array &$inclusiveSqlGroup, array &$exclusiveSqlGroup) { + $hints = new ConversionHints($this->queryPlatform); + $hints->field = $mappingConfig; + $hints->column = $mappingConfig->column; + $hints->connection = $this->connection; + $this->processSingleValues( $values->getSimpleValues(), $mappingConfig, - $inclusiveSqlGroup + $inclusiveSqlGroup, + false, + $hints ); $this->processRanges( $values->get(Range::class), $mappingConfig, - $inclusiveSqlGroup + $inclusiveSqlGroup, + false, + $hints ); $this->processCompares( $values->get(Compare::class), $mappingConfig, - $inclusiveSqlGroup + $inclusiveSqlGroup, + false, + $hints ); $this->processPatternMatchers( $values->get(PatternMatch::class), $mappingConfig, - $inclusiveSqlGroup + $inclusiveSqlGroup, + false, + $hints ); $this->processSingleValues( $values->getExcludedSimpleValues(), $mappingConfig, $exclusiveSqlGroup, - true + true, + $hints ); $this->processRanges( $values->get(ExcludedRange::class), $mappingConfig, $exclusiveSqlGroup, - true + true, + $hints ); $this->processPatternMatchers( $values->get(PatternMatch::class), $mappingConfig, $exclusiveSqlGroup, - true + true, + $hints ); $this->processCompares( $values->get(Compare::class), $mappingConfig, $exclusiveSqlGroup, - true + true, + $hints ); + + $hints = null; + } + + public function getParameters(): ArrayCollection + { + return $this->queryPlatform->getParameters(); } } diff --git a/lib/Doctrine/Dbal/QueryPlatform.php b/lib/Doctrine/Dbal/QueryPlatform.php deleted file mode 100644 index 135ac0a4..00000000 --- a/lib/Doctrine/Dbal/QueryPlatform.php +++ /dev/null @@ -1,44 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Doctrine\Dbal; - -use Rollerworks\Component\Search\Doctrine\Dbal\Query\QueryField; -use Rollerworks\Component\Search\Value\PatternMatch; - -interface QueryPlatform -{ - /** - * Returns the correct column (with a SQLField conversion applied). - */ - public function getFieldColumn(QueryField $mappingConfig, int $strategy = 0, string $column = null): string; - - /** - * Returns either the converted value. - * - * @param mixed $value - */ - public function getValueAsSql($value, QueryField $mappingConfig, string $column, int $strategy = 0): string; - - /** - * Returns the formatted PatternMatch query. - * - * @return string Some like: u.name LIKE '%foo%' - */ - public function getPatternMatcher(PatternMatch $patternMatch, string $column): string; - - /** - * @param mixed $value - */ - public function convertSqlValue($value, QueryField $mappingConfig, string $column, int $strategy = 0): string; -} diff --git a/lib/Doctrine/Dbal/QueryPlatform/AbstractQueryPlatform.php b/lib/Doctrine/Dbal/QueryPlatform/AbstractQueryPlatform.php index 61426350..3277337a 100644 --- a/lib/Doctrine/Dbal/QueryPlatform/AbstractQueryPlatform.php +++ b/lib/Doctrine/Dbal/QueryPlatform/AbstractQueryPlatform.php @@ -13,12 +13,11 @@ namespace Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Types\Type; -use Rollerworks\Component\Search\Doctrine\Dbal\ColumnConversion; use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; use Rollerworks\Component\Search\Doctrine\Dbal\Query\QueryField; -use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform; use Rollerworks\Component\Search\Value\PatternMatch; /** @@ -28,64 +27,63 @@ * Note that is class is also used by the Doctrine ORM processor and therefore * methods and properties must be protected and easy to overwrite. */ -abstract class AbstractQueryPlatform implements QueryPlatform +abstract class AbstractQueryPlatform { - /** - * @var array[] - */ - protected $fieldsMappingCache = []; - /** * @var Connection */ protected $connection; + /** @var ArrayCollection */ + private $parameters; + + /** @var int */ + private $parameterIdx = -1; + public function __construct(Connection $connection) { $this->connection = $connection; + $this->parameters = new ArrayCollection(); } - public function getValueAsSql($value, QueryField $mappingConfig, string $column, int $strategy = 0): string + public function getValueAsSql($value, QueryField $mappingConfig, ConversionHints $hints): string { - if ($mappingConfig->valueConversion) { - return $this->convertSqlValue($value, $mappingConfig, $column, $strategy); + if ($mappingConfig->valueConversion !== null) { + return $mappingConfig->valueConversion->convertValue( + $value, + $mappingConfig->fieldConfig->getOptions(), + $hints + ); } - return $this->quoteValue( - $mappingConfig->dbType->convertToDatabaseValue($value, $this->connection->getDatabasePlatform()), - $mappingConfig->dbType - ); + return $this->createParamReferenceFor($value, $mappingConfig->dbType); } - public function getFieldColumn(QueryField $mappingConfig, int $strategy = 0, string $column = null): string + public function createParamReferenceFor($value, Type $type = null): string { - $mappingName = $mappingConfig->mappingName; - - if (isset($this->fieldsMappingCache[$mappingName][$strategy])) { - return $this->fieldsMappingCache[$mappingName][$strategy]; - } - - if (null === $column) { - $column = $mappingConfig->column; - } + $name = ':search_'.(++$this->parameterIdx); + $this->parameters->set($name, [$value, $type]); - $this->fieldsMappingCache[$mappingName][$strategy] = $column; + return $name; + } - if ($mappingConfig->columnConversion instanceof ColumnConversion) { - $this->fieldsMappingCache[$mappingName][$strategy] = $mappingConfig->columnConversion->convertColumn( + public function getFieldColumn(QueryField $mappingConfig, string $column, ConversionHints $hints): string + { + if ($mappingConfig->columnConversion !== null) { + return $mappingConfig->columnConversion->convertColumn( $column, $mappingConfig->fieldConfig->getOptions(), - $this->getConversionHints($mappingConfig, $column, $strategy) + $hints ); } - return $this->fieldsMappingCache[$mappingName][$strategy]; + return $column; } public function getPatternMatcher(PatternMatch $patternMatch, string $column): string { if (\in_array($patternMatch->getType(), [PatternMatch::PATTERN_EQUALS, PatternMatch::PATTERN_NOT_EQUALS], true)) { - $value = $this->connection->quote($patternMatch->getValue()); + $value = $this->createParamReferenceFor($patternMatch->getValue(), Type::getType('text')); if ($patternMatch->isCaseInsensitive()) { $column = "LOWER($column)"; @@ -96,41 +94,23 @@ public function getPatternMatcher(PatternMatch $patternMatch, string $column): s } $patternMap = [ - PatternMatch::PATTERN_STARTS_WITH => '%%%s', - PatternMatch::PATTERN_NOT_STARTS_WITH => '%%%s', - PatternMatch::PATTERN_CONTAINS => '%%%s%%', - PatternMatch::PATTERN_NOT_CONTAINS => '%%%s%%', - PatternMatch::PATTERN_ENDS_WITH => '%s%%', - PatternMatch::PATTERN_NOT_ENDS_WITH => '%s%%', + PatternMatch::PATTERN_STARTS_WITH => ["'%%'", '%s'], + PatternMatch::PATTERN_NOT_STARTS_WITH => ["'%%'", '%s'], + PatternMatch::PATTERN_CONTAINS => ["'%%'", '%s', "'%%'"], + PatternMatch::PATTERN_NOT_CONTAINS => ["'%%'", '%s', "'%%'"], + PatternMatch::PATTERN_ENDS_WITH => ['%s', "'%%'"], + PatternMatch::PATTERN_NOT_ENDS_WITH => ['%s', "'%%'"], ]; $value = addcslashes($patternMatch->getValue(), $this->getLikeEscapeChars()); - $value = $this->quoteValue(sprintf($patternMap[$patternMatch->getType()], $value), Type::getType('text')); - $escape = $this->quoteValue('\\', Type::getType('text')); + $value = sprintf($this->connection->getDatabasePlatform()->getConcatExpression(...$patternMap[$patternMatch->getType()]), $this->createParamReferenceFor($value, Type::getType('text'))); if ($patternMatch->isCaseInsensitive()) { $column = "LOWER($column)"; $value = "LOWER($value)"; } - return $column.($patternMatch->isExclusive() ? ' NOT' : '')." LIKE $value ESCAPE $escape"; - } - - public function convertSqlValue($value, QueryField $mappingConfig, string $column, int $strategy = 0): string - { - return (string) $mappingConfig->valueConversion->convertValue( - $value, - $mappingConfig->fieldConfig->getOptions(), - $this->getConversionHints($mappingConfig, $column, $strategy) - ); - } - - /** - * @param mixed $value - */ - protected function quoteValue($value, Type $type): string - { - return (string) $this->connection->quote($value, $type->getBindingType()); + return $column.($patternMatch->isExclusive() ? ' NOT' : '')." LIKE $value"; } /** @@ -141,14 +121,8 @@ protected function getLikeEscapeChars(): string return '%_'; } - protected function getConversionHints(QueryField $mappingConfig, string $column, int $strategy = 0): ConversionHints + public function getParameters(): ArrayCollection { - $hints = new ConversionHints(); - $hints->field = $mappingConfig; - $hints->column = $column; - $hints->connection = $this->connection; - $hints->conversionStrategy = $strategy; - - return $hints; + return $this->parameters; } } diff --git a/lib/Doctrine/Dbal/SqlConditionGenerator.php b/lib/Doctrine/Dbal/SqlConditionGenerator.php index 70b81655..f29ab146 100644 --- a/lib/Doctrine/Dbal/SqlConditionGenerator.php +++ b/lib/Doctrine/Dbal/SqlConditionGenerator.php @@ -13,10 +13,13 @@ namespace Rollerworks\Component\Search\Doctrine\Dbal; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Statement; use Doctrine\DBAL\Types\Type as MappingType; use Rollerworks\Component\Search\Doctrine\Dbal\Query\QueryField; use Rollerworks\Component\Search\Doctrine\Dbal\Query\QueryGenerator; +use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform\AbstractQueryPlatform; use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform\SqlQueryPlatform; use Rollerworks\Component\Search\Exception\BadMethodCallException; use Rollerworks\Component\Search\FieldSet; @@ -67,11 +70,17 @@ final class SqlConditionGenerator implements ConditionGenerator */ private $connection; + /** + * @var ArrayCollection + */ + private $parameters; + public function __construct(Connection $connection, SearchCondition $searchCondition) { $this->searchCondition = $searchCondition; $this->fieldSet = $searchCondition->getFieldSet(); $this->connection = $connection; + $this->parameters = new ArrayCollection(); } public function setField(string $fieldName, string $column, string $alias = null, string $type = 'string'): self @@ -85,7 +94,7 @@ public function setField(string $fieldName, string $column, string $alias = null $mappingIdx = null; if (false !== strpos($fieldName, '#')) { - list($fieldName, $mappingIdx) = explode('#', $fieldName, 2); + [$fieldName, $mappingIdx] = explode('#', $fieldName, 2); unset($this->fields[$fieldName][null]); } else { $this->fields[$fieldName] = []; @@ -105,9 +114,10 @@ public function setField(string $fieldName, string $column, string $alias = null public function getWhereClause(string $prependQuery = ''): string { if (null === $this->whereClause) { - $this->whereClause = (new QueryGenerator( - $this->connection, $this->getQueryPlatform(), $this->fields - ))->getWhereClause($this->searchCondition); + $queryGenerator = new QueryGenerator($this->connection, $this->getQueryPlatform(), $this->fields); + + $this->whereClause = $queryGenerator->getWhereClause($this->searchCondition); + $this->parameters = $queryGenerator->getParameters(); } if ('' !== $this->whereClause) { @@ -117,6 +127,18 @@ public function getWhereClause(string $prependQuery = ''): string return ''; } + public function bindParameters(Statement $statement): void + { + foreach ($this->parameters as $name => [$value, $type]) { + $statement->bindValue($name, $value, $type); + } + } + + public function getParameters(): ArrayCollection + { + return $this->parameters; + } + public function getFieldsMapping(): array { return $this->fields; @@ -127,7 +149,7 @@ public function getSearchCondition(): SearchCondition return $this->searchCondition; } - private function getQueryPlatform(): QueryPlatform + private function getQueryPlatform(): AbstractQueryPlatform { $dbPlatform = ucfirst($this->connection->getDatabasePlatform()->getName()); $platformClass = 'Rollerworks\\Component\\Search\\Doctrine\\Dbal\\QueryPlatform\\'.$dbPlatform.'QueryPlatform'; diff --git a/lib/Doctrine/Dbal/StrategySupportedConversion.php b/lib/Doctrine/Dbal/StrategySupportedConversion.php deleted file mode 100644 index dc7536e1..00000000 --- a/lib/Doctrine/Dbal/StrategySupportedConversion.php +++ /dev/null @@ -1,41 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Doctrine\Dbal; - -/** - * StrategySupportedConversion, allows for different conversion strategies. - * - * @author Sebastiaan Stok - */ -interface StrategySupportedConversion -{ - /** - * Returns the conversion strategy for the provided value. - * - * This must either return: 0 (default) or a positive integer. - * Each strategy will use a different 'slot' during the query building. - * - * For example searching by age/birthday. - * * If the value is a DateTime object, strategy 1 is used and the input-value is converted to a date string. - * * If the value is an integer, strategy 2 is used and the value is transformed using a custom SQL statement. - * - * Afterwards the conversion strategy is available as the `conversionStrategy` - * property of the {@link \Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints}. - * - * @param mixed $value The "model" value format - * @param array $options Options of the Field configuration - * @param ConversionHints $hints Special information for the conversion process - */ - public function getConversionStrategy($value, array $options, ConversionHints $hints): int; -} diff --git a/lib/Doctrine/Dbal/Tests/CachedConditionGeneratorTest.php b/lib/Doctrine/Dbal/Tests/CachedConditionGeneratorTest.php index db5ce17e..8a37959c 100644 --- a/lib/Doctrine/Dbal/Tests/CachedConditionGeneratorTest.php +++ b/lib/Doctrine/Dbal/Tests/CachedConditionGeneratorTest.php @@ -13,7 +13,9 @@ namespace Rollerworks\Component\Search\Tests\Doctrine\Dbal; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\DBAL\Types\Type; +use PHPUnit\Framework\MockObject\MockObject; use Psr\SimpleCache\CacheInterface as Cache; use Rollerworks\Component\Search\Doctrine\Dbal\CachedConditionGenerator; use Rollerworks\Component\Search\Doctrine\Dbal\ConditionGenerator; @@ -33,12 +35,12 @@ final class CachedConditionGeneratorTest extends DbalTestCase private $cachedConditionGenerator; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var MockObject */ private $cacheDriver; /** - * @var \PHPUnit_Framework_MockObject_MockObject|SqlConditionGenerator + * @var MockObject|SqlConditionGenerator */ private $conditionGenerator; @@ -47,8 +49,8 @@ public function testGetWhereClauseNoCache() $cacheKey = ''; $this->cacheDriver - ->expects($this->once()) - ->method('has') + ->expects(self::once()) + ->method('get') ->with( self::callback(function (string $key) use (&$cacheKey) { $cacheKey = $key; @@ -56,19 +58,20 @@ public function testGetWhereClauseNoCache() return true; }) ) - ->willReturn(false); - - $this->cacheDriver - ->expects($this->never()) - ->method('get'); + ->willReturn(null); $this->conditionGenerator - ->expects($this->once()) + ->expects(self::once()) ->method('getWhereClause') ->willReturn("me = 'foo'"); + $this->conditionGenerator + ->expects(self::once()) + ->method('getParameters') + ->willReturn($parameters = new ArrayCollection([':search' => [1, Type::getType('integer')]])); + $this->cacheDriver - ->expects($this->once()) + ->expects(self::once()) ->method('set') ->with( self::callback( @@ -76,20 +79,21 @@ function (string $key) use (&$cacheKey) { return $cacheKey === $key; } ), - "me = 'foo'", + ["me = 'foo'", [':search' => [1, 'integer']]], 60 ); self::assertEquals("me = 'foo'", $this->cachedConditionGenerator->getWhereClause()); + self::assertEquals($parameters, $this->cachedConditionGenerator->getParameters()); } - public function testGetWhereClauseWithCache() + public function testGetWhereClauseInvalidCache(): void { $cacheKey = ''; $this->cacheDriver ->expects(self::once()) - ->method('has') + ->method('get') ->with( self::callback(function (string $key) use (&$cacheKey) { $cacheKey = $key; @@ -97,59 +101,75 @@ public function testGetWhereClauseWithCache() return true; }) ) - ->willReturn(true); + ->willReturn([]); + + $this->conditionGenerator + ->expects(self::once()) + ->method('getWhereClause') + ->willReturn("me = 'foo'"); + + $this->conditionGenerator + ->expects(self::once()) + ->method('getParameters') + ->willReturn($parameters = new ArrayCollection([':search' => [1, Type::getType('integer')]])); $this->cacheDriver ->expects(self::once()) - ->method('get') + ->method('set') ->with( - self::callback(function (string $key) use (&$cacheKey) { - return $cacheKey === $key; - }) - ) - ->willReturn("me = 'foo'"); + self::callback( + function (string $key) use (&$cacheKey) { + return $cacheKey === $key; + } + ), + ["me = 'foo'", [':search' => [1, 'integer']]], + 60 + ); + + self::assertEquals("me = 'foo'", $this->cachedConditionGenerator->getWhereClause()); + self::assertEquals($parameters, $this->cachedConditionGenerator->getParameters()); + } + + public function testGetWhereClauseWithCache() + { + $this->cacheDriver + ->expects(self::once()) + ->method('get') + ->with('7bdf48ca3ce581f79fe43359148b2b4f91934d3f2a7b542b1da034c5fdd057af') + ->willReturn(["me = 'foo'", [':search' => [1, 'integer']]]); $this->conditionGenerator ->expects(self::never()) ->method('getWhereClause'); + $this->conditionGenerator + ->expects(self::never()) + ->method('getParameters'); + $this->cacheDriver ->expects(self::never()) ->method('set'); self::assertEquals("me = 'foo'", $this->cachedConditionGenerator->getWhereClause()); + self::assertEquals(new ArrayCollection([':search' => [1, Type::getType('integer')]]), $this->cachedConditionGenerator->getParameters()); } public function testGetWhereWithPrepend() { - $cacheKey = ''; - - $this->cacheDriver - ->expects(self::once()) - ->method('has') - ->with( - self::callback(function (string $key) use (&$cacheKey) { - $cacheKey = $key; - - return true; - }) - ) - ->willReturn(true); - $this->cacheDriver ->expects(self::once()) ->method('get') - ->with( - self::callback(function (string $key) use (&$cacheKey) { - return $cacheKey === $key; - }) - ) - ->willReturn("me = 'foo'"); + ->with('7bdf48ca3ce581f79fe43359148b2b4f91934d3f2a7b542b1da034c5fdd057af') + ->willReturn(["me = 'foo'", [':search' => [1, 'integer']]]); $this->conditionGenerator ->expects(self::never()) ->method('getWhereClause'); + $this->conditionGenerator + ->expects(self::never()) + ->method('getParameters'); + $this->cacheDriver ->expects(self::never()) ->method('set'); @@ -159,37 +179,26 @@ public function testGetWhereWithPrepend() public function testGetEmptyWhereWithPrepend() { - $this->cacheDriver - ->expects(self::once()) - ->method('has') - ->with( - self::callback(function (string $key) use (&$cacheKey) { - $cacheKey = $key; - - return true; - }) - ) - ->willReturn(true); - $this->cacheDriver ->expects(self::once()) ->method('get') - ->with( - self::callback(function (string $key) use (&$cacheKey) { - return $cacheKey === $key; - }) - ) - ->willReturn(''); + ->with('7bdf48ca3ce581f79fe43359148b2b4f91934d3f2a7b542b1da034c5fdd057af') + ->willReturn(['', []]); $this->conditionGenerator ->expects(self::never()) ->method('getWhereClause'); + $this->conditionGenerator + ->expects(self::never()) + ->method('getParameters'); + $this->cacheDriver ->expects(self::never()) ->method('set'); self::assertEquals('', $this->cachedConditionGenerator->getWhereClause('WHERE ')); + self::assertEquals(new ArrayCollection(), $this->cachedConditionGenerator->getParameters()); } public function testFieldMappingDelegation() @@ -197,8 +206,8 @@ public function testFieldMappingDelegation() $cacheKey = ''; $this->cacheDriver - ->expects($this->once()) - ->method('has') + ->expects(self::once()) + ->method('get') ->with( self::callback(function (string $key) use (&$cacheKey) { $cacheKey = $key; @@ -206,14 +215,10 @@ public function testFieldMappingDelegation() return true; }) ) - ->willReturn(false); + ->willReturn(null); $this->cacheDriver - ->expects($this->never()) - ->method('get'); - - $this->cacheDriver - ->expects($this->once()) + ->expects(self::once()) ->method('set') ->with( self::callback( @@ -221,7 +226,7 @@ function (string $key) use (&$cacheKey) { return $cacheKey === $key; } ), - '((I.id IN(18)))', + ['((I.id = :search_0))', [':search_0' => [18, 'integer']]], 60 ); @@ -236,7 +241,8 @@ function (string $key) use (&$cacheKey) { $this->cachedConditionGenerator = new CachedConditionGenerator($this->conditionGenerator, $this->cacheDriver, 60); $this->cachedConditionGenerator->setField('customer', 'id', 'I', 'integer'); - self::assertEquals('((I.id IN(18)))', $this->cachedConditionGenerator->getWhereClause()); + self::assertEquals('((I.id = :search_0))', $this->cachedConditionGenerator->getWhereClause()); + self::assertEquals(new ArrayCollection([':search_0' => [18, Type::getType('integer')]]), $this->cachedConditionGenerator->getParameters()); } public function testGetWhereClauseCachedAndPrimaryCond() @@ -244,16 +250,14 @@ public function testGetWhereClauseCachedAndPrimaryCond() $fieldSet = $this->getFieldSet(); $cacheDriver = $this->prophesize(Cache::class); - $cacheDriver->has('7503457faa505a978544359616a2b503638538170931ce460b69fcf35566f771')->willReturn(true); - $cacheDriver->get('7503457faa505a978544359616a2b503638538170931ce460b69fcf35566f771')->willReturn("me = 'foo'"); - - $cacheDriver->has('65dc24cc06603327105d067e431b024f9dc0f7573db68fe839b6e244a821c4bb')->willReturn(true); - $cacheDriver->get('65dc24cc06603327105d067e431b024f9dc0f7573db68fe839b6e244a821c4bb')->willReturn("you = 'me' AND me = 'foo'"); + $cacheDriver->get('7503457faa505a978544359616a2b503638538170931ce460b69fcf35566f771')->willReturn(["me = 'foo'", [':search' => [1, 'integer']]]); + $cacheDriver->get('65dc24cc06603327105d067e431b024f9dc0f7573db68fe839b6e244a821c4bb')->willReturn(["you = 'me' AND me = 'foo'", [':search' => [5, 'integer']]]); $cachedConditionGenerator = $this->createCachedConditionGenerator( $cacheDriver->reveal(), new SearchCondition($fieldSet, new ValuesGroup()), - "me = 'foo'" + "me = 'foo'", + $parameters = new ArrayCollection([':search' => [1, Type::getType('integer')]]) ); $searchCondition = new SearchCondition($fieldSet, new ValuesGroup()); @@ -262,11 +266,15 @@ public function testGetWhereClauseCachedAndPrimaryCond() $cachedConditionGenerator2 = $this->createCachedConditionGenerator( $cacheDriver->reveal(), $searchCondition, - "you = 'me' AND me = 'foo2'" + "you = 'me' AND me = 'foo2'", + $parameters2 = new ArrayCollection([':search' => [5, Type::getType('integer')]]) ); self::assertEquals("me = 'foo'", $cachedConditionGenerator->getWhereClause()); self::assertEquals("you = 'me' AND me = 'foo'", $cachedConditionGenerator2->getWhereClause()); + + self::assertEquals($parameters, $cachedConditionGenerator->getParameters()); + self::assertEquals($parameters2, $cachedConditionGenerator2->getParameters()); } protected function setUp(): void @@ -282,7 +290,7 @@ protected function setUp(): void $this->cachedConditionGenerator = new CachedConditionGenerator($this->conditionGenerator, $this->cacheDriver, 60); } - private function createCachedConditionGenerator(Cache $cacheDriver, SearchCondition $searchCondition, string $query): CachedConditionGenerator + private function createCachedConditionGenerator(Cache $cacheDriver, SearchCondition $searchCondition, string $query, ArrayCollection $parameters): CachedConditionGenerator { $conditionGenerator = $this->prophesize(ConditionGenerator::class); $conditionGenerator->getWhereClause()->willReturn($query); diff --git a/lib/Doctrine/Dbal/Tests/ColumnConversionStrategy.php b/lib/Doctrine/Dbal/Tests/ColumnConversionStrategy.php deleted file mode 100644 index a2163d03..00000000 --- a/lib/Doctrine/Dbal/Tests/ColumnConversionStrategy.php +++ /dev/null @@ -1,21 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Tests\Doctrine\Dbal; - -use Rollerworks\Component\Search\Doctrine\Dbal\ColumnConversion; -use Rollerworks\Component\Search\Doctrine\Dbal\StrategySupportedConversion; - -interface ColumnConversionStrategy extends StrategySupportedConversion, ColumnConversion -{ -} diff --git a/lib/Doctrine/Dbal/Tests/DbalTestCase.php b/lib/Doctrine/Dbal/Tests/DbalTestCase.php index dd841c72..45d417dd 100644 --- a/lib/Doctrine/Dbal/Tests/DbalTestCase.php +++ b/lib/Doctrine/Dbal/Tests/DbalTestCase.php @@ -42,6 +42,19 @@ protected function getFieldSet(bool $build = true) return $build ? $fieldSet->getFieldSet('invoice') : $fieldSet; } + protected function setUp(): void + { + parent::setUp(); + + if (isset($_SERVER['DB_HOST'])) { + $GLOBALS['db_host'] = $_SERVER['DB_HOST']; + } + + if (isset($_SERVER['DB_PORT'])) { + $GLOBALS['db_port'] = $_SERVER['DB_PORT']; + } + } + protected function getExtensions(): array { return [new DoctrineDbalExtension()]; diff --git a/lib/Doctrine/Dbal/Tests/Functional/FunctionalDbalTestCase.php b/lib/Doctrine/Dbal/Tests/Functional/FunctionalDbalTestCase.php index a324610e..694c02ba 100644 --- a/lib/Doctrine/Dbal/Tests/Functional/FunctionalDbalTestCase.php +++ b/lib/Doctrine/Dbal/Tests/Functional/FunctionalDbalTestCase.php @@ -60,6 +60,17 @@ protected function setUp(): void { parent::setUp(); + if (!isset( + $GLOBALS['db_driver'], + $GLOBALS['db_host'], + $GLOBALS['db_user'], + $GLOBALS['db_password'], + $GLOBALS['db_dbname'], + $GLOBALS['db_port'] + )) { + self::markTestSkipped('GLOBAL variables not enabled'); + } + if (!isset(self::$sharedConn)) { self::$sharedConn = TestUtil::getConnection(); @@ -139,7 +150,18 @@ protected function assertRecordsAreFound(SearchCondition $condition, array $ids) $this->configureConditionGenerator($conditionGenerator); $whereClause = $conditionGenerator->getWhereClause(); - $statement = $this->conn->query($this->getQuery().$whereClause); + $statement = $this->conn->prepare($this->getQuery().$whereClause); + + $paramsString = ''; + $platform = $this->conn->getDatabasePlatform(); + + foreach ($conditionGenerator->getParameters() as $name => [$value, $type]) { + $statement->bindValue($name, $value, $type); + + $paramsString .= sprintf("%s: %s\n", $name, $type === null ? get_debug_type($value) : $type->convertToDatabaseValue($value, $platform)); + } + + $statement->execute(); $rows = $statement->fetchAll(\PDO::FETCH_ASSOC); $idRows = array_map( @@ -155,7 +177,7 @@ function ($value) { $this->assertEquals( $ids, array_merge([], array_unique($idRows)), - sprintf("Found these records instead: \n%s\nWith WHERE-clause: %s", print_r($rows, true), $whereClause) + sprintf("Found these records instead: \n%s\nWith WHERE-clause: %s\nAnd params: %s", print_r($rows, true), $whereClause, $paramsString) ); } @@ -169,8 +191,12 @@ protected function assertQueryIsExecutable($conditionOrWhere) } $whereClause = $conditionGenerator->getWhereClause(); + $statement = $this->conn->prepare($this->getQuery().$whereClause); + + $conditionGenerator->bindParameters($statement); + $statement->execute(); - self::assertNotEmpty($this->conn->query($this->getQuery().$whereClause)); + self::assertNotNull($statement); } protected function onNotSuccessfulTest(\Throwable $e): void diff --git a/lib/Doctrine/Dbal/Tests/SqlConditionGeneratorTest.php b/lib/Doctrine/Dbal/Tests/SqlConditionGeneratorTest.php index f028e905..93e8d3d6 100644 --- a/lib/Doctrine/Dbal/Tests/SqlConditionGeneratorTest.php +++ b/lib/Doctrine/Dbal/Tests/SqlConditionGeneratorTest.php @@ -14,7 +14,9 @@ namespace Rollerworks\Component\Search\Tests\Doctrine\Dbal; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Types\Type; use Rollerworks\Component\Search\Doctrine\Dbal\ColumnConversion; +use Rollerworks\Component\Search\Doctrine\Dbal\ConditionGenerator; use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; use Rollerworks\Component\Search\Doctrine\Dbal\ValueConversion; use Rollerworks\Component\Search\Extension\Core\Type\DateType; @@ -58,7 +60,17 @@ public function testSimpleQuery() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('((I.customer IN(2, 5)))', $conditionGenerator->getWhereClause()); + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '(((I.customer = :search_0 OR I.customer = :search_1)))', + [':search_0' => [2, Type::getType('integer')], ':search_1' => [5, Type::getType('integer')]] + ); + } + + private function assertGeneratedQueryEquals(ConditionGenerator $conditionGenerator, string $query, array $params): void + { + self::assertEquals($query, $conditionGenerator->getWhereClause()); + self::assertEquals($params, $conditionGenerator->getParameters()->toArray()); } public function testQueryWithPrepend() @@ -72,7 +84,10 @@ public function testQueryWithPrepend() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('WHERE ((I.customer IN(2, 5)))', $conditionGenerator->getWhereClause('WHERE ')); + $this->assertEquals( + 'WHERE (((I.customer = :search_0 OR I.customer = :search_1)))', + $conditionGenerator->getWhereClause('WHERE ') + ); } public function testEmptyQueryWithPrepend() @@ -112,7 +127,10 @@ public function testQueryWithPrependAndPrimaryCond() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('WHERE ((I.status IN(1, 2))) AND ((I.customer IN(2, 5)))', $conditionGenerator->getWhereClause('WHERE ')); + $this->assertEquals( + 'WHERE (((I.status = :search_0 OR I.status = :search_1))) AND (((I.customer = :search_2 OR I.customer = :search_3)))', + $conditionGenerator->getWhereClause('WHERE ') + ); } public function testEmptyQueryWithPrependAndPrimaryCond() @@ -138,7 +156,7 @@ public function testEmptyQueryWithPrependAndPrimaryCond() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('WHERE ((I.status IN(1, 2)))', $conditionGenerator->getWhereClause('WHERE ')); + $this->assertEquals('WHERE (((I.status = :search_0 OR I.status = :search_1)))', $conditionGenerator->getWhereClause('WHERE ')); } public function testQueryWithMultipleFields() @@ -156,7 +174,16 @@ public function testQueryWithMultipleFields() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('((I.customer IN(2, 5)) AND (I.status IN(2, 5)))', $conditionGenerator->getWhereClause()); + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '(((I.customer = :search_0 OR I.customer = :search_1)) AND ((I.status = :search_2 OR I.status = :search_3)))', + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [5, Type::getType('integer')], + ':search_2' => [2, Type::getType('integer')], + ':search_3' => [5, Type::getType('integer')], + ] + ); } public function testQueryWithCombinedField() @@ -169,10 +196,19 @@ public function testQueryWithCombinedField() ->getSearchCondition(); $conditionGenerator = $this->getConditionGenerator($condition); - $conditionGenerator->setField('customer#1', 'id'); - $conditionGenerator->setField('customer#2', 'number2'); - - $this->assertEquals('(((id IN(2, 5) OR number2 IN(2, 5))))', $conditionGenerator->getWhereClause()); + $conditionGenerator->setField('customer#1', 'id', null, 'integer'); + $conditionGenerator->setField('customer#2', 'number2', null, 'integer'); + + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '(((id = :search_0 OR id = :search_1 OR number2 = :search_2 OR number2 = :search_3)))', + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [5, Type::getType('integer')], + ':search_2' => [2, Type::getType('integer')], + ':search_3' => [5, Type::getType('integer')], + ] + ); } public function testQueryWithCombinedFieldAndCustomAlias() @@ -185,10 +221,19 @@ public function testQueryWithCombinedFieldAndCustomAlias() ->getSearchCondition(); $conditionGenerator = $this->getConditionGenerator($condition); - $conditionGenerator->setField('customer#1', 'id'); + $conditionGenerator->setField('customer#1', 'id', null, 'integer'); $conditionGenerator->setField('customer#2', 'number2', 'C', 'string'); - $this->assertEquals('(((id IN(2, 5) OR C.number2 IN(2, 5))))', $conditionGenerator->getWhereClause()); + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '(((id = :search_0 OR id = :search_1 OR C.number2 = :search_2 OR C.number2 = :search_3)))', + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [5, Type::getType('integer')], + ':search_2' => [2, Type::getType('string')], + ':search_3' => [5, Type::getType('string')], + ] + ); } public function testEmptyResult() @@ -211,7 +256,14 @@ public function testExcludes() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('((I.customer NOT IN(2, 5)))', $conditionGenerator->getWhereClause()); + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '(((I.customer <> :search_0 AND I.customer <> :search_1)))', + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [5, Type::getType('integer')], + ] + ); } public function testIncludesAndExcludes() @@ -225,7 +277,14 @@ public function testIncludesAndExcludes() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('((I.customer IN(2) AND I.customer NOT IN(5)))', $conditionGenerator->getWhereClause()); + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '((I.customer = :search_0 AND I.customer <> :search_1))', + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [5, Type::getType('integer')], + ] + ); } public function testRanges() @@ -241,10 +300,20 @@ public function testRanges() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals( - '((((I.customer >= 2 AND I.customer <= 5) OR (I.customer >= 10 AND I.customer <= 20) OR '. - '(I.customer > 60 AND I.customer <= 70) OR (I.customer >= 100 AND I.customer < 150))))', - $conditionGenerator->getWhereClause() + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '((((I.customer >= :search_0 AND I.customer <= :search_1) OR (I.customer >= :search_2 AND I.customer <= :search_3) OR '. + '(I.customer > :search_4 AND I.customer <= :search_5) OR (I.customer >= :search_6 AND I.customer < :search_7))))', + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [5, Type::getType('integer')], + ':search_2' => [10, Type::getType('integer')], + ':search_3' => [20, Type::getType('integer')], + ':search_4' => [60, Type::getType('integer')], + ':search_5' => [70, Type::getType('integer')], + ':search_6' => [100, Type::getType('integer')], + ':search_7' => [150, Type::getType('integer')], + ] ); } @@ -261,10 +330,21 @@ public function testExcludedRanges() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals( - '((((I.customer <= 2 OR I.customer >= 5) AND (I.customer <= 10 OR I.customer >= 20) AND '. - '(I.customer < 60 OR I.customer >= 70) AND (I.customer <= 100 OR I.customer > 150))))', - $conditionGenerator->getWhereClause() + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '((((I.customer <= :search_0 OR I.customer >= :search_1) AND (I.customer <= :search_2 OR '. + 'I.customer >= :search_3) AND (I.customer < :search_4 OR I.customer >= :search_5) AND '. + '(I.customer <= :search_6 OR I.customer > :search_7))))', + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [5, Type::getType('integer')], + ':search_2' => [10, Type::getType('integer')], + ':search_3' => [20, Type::getType('integer')], + ':search_4' => [60, Type::getType('integer')], + ':search_5' => [70, Type::getType('integer')], + ':search_6' => [100, Type::getType('integer')], + ':search_7' => [150, Type::getType('integer')], + ] ); } @@ -278,7 +358,13 @@ public function testSingleComparison() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('((I.customer > 2))', $conditionGenerator->getWhereClause()); + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '((I.customer > :search_0))', + [ + ':search_0' => [2, Type::getType('integer')], + ] + ); } public function testMultipleComparisons() @@ -292,9 +378,13 @@ public function testMultipleComparisons() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals( - '(((I.customer > 2 AND I.customer < 10)))', - $conditionGenerator->getWhereClause() + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '(((I.customer > :search_0 AND I.customer < :search_1)))', + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [10, Type::getType('integer')], + ] ); } @@ -319,9 +409,15 @@ public function testMultipleComparisonsWithGroups() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals( - '((((I.customer IN(20) OR (I.customer > 2 AND I.customer < 10)))) OR ((I.customer > 30)))', - $conditionGenerator->getWhereClause() + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '((((I.customer = :search_0 OR (I.customer > :search_1 AND I.customer < :search_2)))) OR ((I.customer > :search_3)))', + [ + ':search_0' => [20, Type::getType('integer')], + ':search_1' => [2, Type::getType('integer')], + ':search_2' => [10, Type::getType('integer')], + ':search_3' => [30, Type::getType('integer')], + ] ); } @@ -336,9 +432,13 @@ public function testExcludingComparisons() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals( - '((I.customer <> 2 AND I.customer <> 5))', - $conditionGenerator->getWhereClause() + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '((I.customer <> :search_0 AND I.customer <> :search_1))', + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [5, Type::getType('integer')], + ] ); } @@ -355,9 +455,15 @@ public function testExcludingComparisonsWithNormal() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals( - '(((I.customer > 30 AND I.customer < 50) AND I.customer <> 35 AND I.customer <> 45))', - $conditionGenerator->getWhereClause() + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '(((I.customer > :search_0 AND I.customer < :search_1) AND I.customer <> :search_2 AND I.customer <> :search_3))', + [ + ':search_0' => [30, Type::getType('integer')], + ':search_1' => [50, Type::getType('integer')], + ':search_2' => [35, Type::getType('integer')], + ':search_3' => [45, Type::getType('integer')], + ] ); } @@ -377,11 +483,18 @@ public function testPatternMatchers() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals( - "(((C.name LIKE '%foo' ESCAPE '\\' OR C.name LIKE '%fo\\'o' ESCAPE '\\' OR C.name = 'My name' OR ". - "LOWER(C.name) = LOWER('Spider')) AND (LOWER(C.name) NOT LIKE LOWER('bar%') ESCAPE '\\' AND C.name <> 'Last' ". - "AND LOWER(C.name) <> LOWER('Piggy'))))", - $conditionGenerator->getWhereClause() + $this->assertGeneratedQueryEquals( + $conditionGenerator, + "(((C.name LIKE '%' || :search_0 OR C.name LIKE '%' || :search_1 OR C.name = :search_2 OR LOWER(C.name) = LOWER(:search_3)) AND (LOWER(C.name) NOT LIKE LOWER(:search_4 || '%') AND C.name <> :search_5 AND LOWER(C.name) <> LOWER(:search_6))))", + [ + ':search_0' => ['foo', Type::getType('text')], + ':search_1' => ['fo\\\'o', Type::getType('text')], + ':search_2' => ['My name', Type::getType('text')], + ':search_3' => ['Spider', Type::getType('text')], + ':search_4' => ['bar', Type::getType('text')], + ':search_5' => ['Last', Type::getType('text')], + ':search_6' => ['Piggy', Type::getType('text')], + ] ); } @@ -398,9 +511,13 @@ public function testSubGroups() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals( - '(((I.customer IN(2))) OR ((I.customer IN(3))))', - $conditionGenerator->getWhereClause() + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '(((I.customer = :search_0)) OR ((I.customer = :search_1)))', + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [3, Type::getType('integer')], + ] ); } @@ -419,9 +536,13 @@ public function testSubGroupWithRootCondition() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals( - "(((I.customer IN(2))) AND (((C.name LIKE '%foo' ESCAPE '\\'))))", - $conditionGenerator->getWhereClause() + $this->assertGeneratedQueryEquals( + $conditionGenerator, + "(((I.customer = :search_0)) AND (((C.name LIKE '%' || :search_1))))", + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => ['foo', Type::getType('text')], + ] ); } @@ -438,9 +559,13 @@ public function testOrGroupRoot() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals( - "((I.customer IN(2)) OR (C.name LIKE '%foo' ESCAPE '\\'))", - $conditionGenerator->getWhereClause() + $this->assertGeneratedQueryEquals( + $conditionGenerator, + "((I.customer = :search_0) OR (C.name LIKE '%' || :search_1))", + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => ['foo', Type::getType('text')], + ] ); } @@ -461,9 +586,13 @@ public function testSubOrGroup() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals( - "((((I.customer IN(2)) OR (C.name LIKE '%foo' ESCAPE '\\'))))", - $conditionGenerator->getWhereClause() + $this->assertGeneratedQueryEquals( + $conditionGenerator, + "((((I.customer = :search_0) OR (C.name LIKE '%' || :search_1))))", + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => ['foo', Type::getType('text')], + ] ); } @@ -495,7 +624,15 @@ public function testColumnConversion() ->getSearchCondition(); $conditionGenerator = $this->getConditionGenerator($condition); - self::assertEquals('((CAST(I.customer AS customer_type) IN(2, 5)))', $conditionGenerator->getWhereClause()); + + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '(((CAST(I.customer AS customer_type) = :search_0 OR CAST(I.customer AS customer_type) = :search_1)))', + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [5, Type::getType('integer')], + ] + ); } public function testValueConversion() @@ -504,10 +641,12 @@ public function testValueConversion() $converter ->expects($this->atLeastOnce()) ->method('convertValue') - ->willReturnCallback(function ($value, array $options) { + ->willReturnCallback(function ($value, array $options, ConversionHints $hints) { self::assertArrayHasKey('grouping', $options); self::assertTrue($options['grouping']); + $value = $hints->createParamReferenceFor($value); + return "get_customer_type($value)"; }) ; @@ -523,28 +662,20 @@ public function testValueConversion() ->getSearchCondition(); $conditionGenerator = $this->getConditionGenerator($condition); - self::assertEquals('(((I.customer = get_customer_type(2) OR I.customer = get_customer_type(5))))', $conditionGenerator->getWhereClause()); + + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '(((I.customer = get_customer_type(:search_0) OR I.customer = get_customer_type(:search_1))))', + [ + ':search_0' => [2, null], + ':search_1' => [5, null], + ] + ); } public function testConversionStrategyValue() { - $converter = $this->createMock(ValueConversionStrategy::class); - $converter - ->expects($this->atLeastOnce()) - ->method('getConversionStrategy') - ->willReturnCallback(function ($value) { - if (!$value instanceof \DateTime && !\is_int($value)) { - throw new \InvalidArgumentException('Only integer/string and DateTime are accepted.'); - } - - if ($value instanceof \DateTime) { - return 2; - } - - return 1; - }) - ; - + $converter = $this->createMock(ValueConversion::class); $converter ->expects($this->atLeastOnce()) ->method('convertValue') @@ -553,14 +684,10 @@ public function testConversionStrategyValue() self::assertEquals('dd-MM-yy', $passedOptions['pattern']); if ($value instanceof \DateTime) { - self::assertEquals(2, $hints->conversionStrategy); - - return 'CAST('.$hints->connection->quote($value->format('Y-m-d')).' AS AGE)'; + return 'CAST('.$hints->createParamReferenceFor($value->format('Y-m-d'), Type::getType('string')).' AS AGE)'; } - self::assertEquals(1, $hints->conversionStrategy); - - return $value; + return $hints->createParamReferenceFor($value, Type::getType('integer')); }) ; @@ -575,41 +702,28 @@ public function testConversionStrategyValue() ->getSearchCondition(); $conditionGenerator = $this->getConditionGenerator($condition); - self::assertEquals( - "(((C.birthday = 18 OR C.birthday = CAST('2001-01-15' AS AGE))))", - $conditionGenerator->getWhereClause() + + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '(((C.birthday = :search_0 OR C.birthday = CAST(:search_1 AS AGE))))', + [ + ':search_0' => [18, Type::getType('integer')], + ':search_1' => ['2001-01-15', Type::getType('string')], + ] ); } public function testConversionStrategyColumn() { - $converter = $this->createMock(ColumnConversionStrategy::class); - $converter - ->expects($this->atLeastOnce()) - ->method('getConversionStrategy') - ->willReturnCallback(function ($value) { - if (!\is_string($value) && !\is_int($value)) { - throw new \InvalidArgumentException('Only integer/string is accepted.'); - } - - if (\is_string($value)) { - return 2; - } - - return 1; - }) - ; - + $converter = $this->createMock(ColumnConversion::class); $converter ->expects($this->atLeastOnce()) ->method('convertColumn') ->willReturnCallback(function ($column, array $options, ConversionHints $hints) { - if (2 === $hints->conversionStrategy) { + if (\is_int($hints->originalValue)) { return "search_conversion_age($column)"; } - self::assertEquals(1, $hints->conversionStrategy); - return $column; }) ; @@ -627,9 +741,13 @@ public function testConversionStrategyColumn() $conditionGenerator = $this->getConditionGenerator($condition); $conditionGenerator->setField('customer_birthday', 'birthday', 'C', 'string'); - self::assertEquals( - "(((C.birthday = 18 OR search_conversion_age(C.birthday) = '2001-01-15')))", - $conditionGenerator->getWhereClause() + $this->assertGeneratedQueryEquals( + $conditionGenerator, + '(((search_conversion_age(C.birthday) = :search_0 OR C.birthday = :search_1)))', + [ + ':search_0' => [18, Type::getType('string')], + ':search_1' => ['2001-01-15', Type::getType('string')], + ] ); } @@ -665,6 +783,6 @@ public function testLazyConversionLoading() ->getSearchCondition(); $conditionGenerator = $this->getConditionGenerator($condition); - self::assertEquals('((CAST(I.customer AS customer_type) IN(2, 5)))', $conditionGenerator->getWhereClause()); + self::assertEquals('(((CAST(I.customer AS customer_type) = :search_0 OR CAST(I.customer AS customer_type) = :search_1)))', $conditionGenerator->getWhereClause()); } } diff --git a/lib/Doctrine/Dbal/Tests/ValueConversionStrategy.php b/lib/Doctrine/Dbal/Tests/ValueConversionStrategy.php deleted file mode 100644 index 3738ff41..00000000 --- a/lib/Doctrine/Dbal/Tests/ValueConversionStrategy.php +++ /dev/null @@ -1,24 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Tests\Doctrine\Dbal; - -use Rollerworks\Component\Search\Doctrine\Dbal\StrategySupportedConversion; -use Rollerworks\Component\Search\Doctrine\Dbal\ValueConversion; - -/** - * @internal - */ -interface ValueConversionStrategy extends StrategySupportedConversion, ValueConversion -{ -} diff --git a/lib/Doctrine/Dbal/ValueConversion.php b/lib/Doctrine/Dbal/ValueConversion.php index 6ff8b9d4..8f45ece5 100644 --- a/lib/Doctrine/Dbal/ValueConversion.php +++ b/lib/Doctrine/Dbal/ValueConversion.php @@ -27,16 +27,12 @@ interface ValueConversion * The returned result must a be a platform specific SQL statement * that can be used as a column's value. * - * Caution: It's important to properly escape any values used in the returned - * statement, as they are used as-is in the SQL query! - * - * The returned result is NOT for a prepared statement value binding. + * Used values must be registered as parameters using `$hints->createParamReferenceFor($value)` + * with an option DBAL Type as second argument (converted afterwards). * * @param mixed $value The "model" value format * @param array $options Options of the Field configuration * @param ConversionHints $hints Special information for the conversion process - * - * @return string|int|float String or any value that can be used in a string */ - public function convertValue($value, array $options, ConversionHints $hints); + public function convertValue($value, array $options, ConversionHints $hints): string; } diff --git a/lib/Doctrine/Dbal/composer.json b/lib/Doctrine/Dbal/composer.json index dd96e60e..308978ea 100644 --- a/lib/Doctrine/Dbal/composer.json +++ b/lib/Doctrine/Dbal/composer.json @@ -21,9 +21,9 @@ ], "require": { "php": "^7.1", - "doctrine/dbal": "^2.5.5", + "doctrine/dbal": "^2.8", "psr/simple-cache": "^1.0.0", - "rollerworks/search": "^2.0@dev,>=2.0.0-ALPHA13" + "rollerworks/search": "^2.0@dev,>=2.0.0-ALPHA22" }, "require-dev": { "moneyphp/money": "^3.0.7", diff --git a/lib/Doctrine/Orm/AbstractCachedConditionGenerator.php b/lib/Doctrine/Orm/AbstractCachedConditionGenerator.php deleted file mode 100644 index ce3d6521..00000000 --- a/lib/Doctrine/Orm/AbstractCachedConditionGenerator.php +++ /dev/null @@ -1,117 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Doctrine\Orm; - -use Psr\SimpleCache\CacheInterface as Cache; -use Rollerworks\Component\Search\Exception\BadMethodCallException; - -/** - * Handles caching of a Doctrine ORM ConditionGenerator. - * - * This checks if there is a cached result, if not it delegates - * the creating to the parent and caches the result. - * - * Instead of calling getWhereClause()/updateQuery() on the ConditionGenerator - * class you should call getWhereClause()/updateQuery() on this class instead. - * - * @internal this class should not be relied upon, use the ConditionGenerator - * interface instead for type hinting - * - * @author Sebastiaan Stok - */ -abstract class AbstractCachedConditionGenerator implements ConditionGenerator -{ - /** - * @var Cache - */ - protected $cacheDriver; - - /** - * @var int|\DateInterval|null - */ - protected $ttl; - - /** - * @var ConditionGenerator - */ - protected $conditionGenerator; - - /** - * @var string - */ - protected $cacheKey; - - /** - * @var string - */ - protected $whereClause; - - /** - * Constructor. - * - * @param AbstractConditionGenerator $conditionGenerator The actual ConditionGenerator - * @param Cache $cacheDriver PSR-16 SimpleCache instance. Use a custom pool to ease - * purging invalidated items - * @param int|\DateInterval|null $ttl Optional. The TTL value of this item. If no value is sent and - * the driver supports TTL then the library may set a default value - * for it or let the driver take care of that. - */ - public function __construct(AbstractConditionGenerator $conditionGenerator, Cache $cacheDriver, $ttl = null) - { - $this->conditionGenerator = $conditionGenerator; - $this->cacheDriver = $cacheDriver; - $this->ttl = $ttl; - } - - /** - * Set the default entity mapping configuration, only for fields - * configured *after* this method. - * - * Note: Calling this method after calling setField() will not affect - * fields that were already configured. Which means you can use this - * method to configure chunks of configuration. - * - * @param string $entity Entity name (FQCN) - * @param string $alias Table alias as used in the query "u" for `FROM Acme:Users AS u` - * - * @return static - */ - public function setDefaultEntity(string $entity, string $alias) - { - $this->guardNotGenerated(); - $this->conditionGenerator->setDefaultEntity($entity, $alias); - - return $this; - } - - public function setField(string $fieldName, string $property, string $alias = null, string $entity = null, string $dbType = null) - { - $this->guardNotGenerated(); - $this->conditionGenerator->setField($fieldName, $property, $alias, $entity, $dbType); - - return $this; - } - - /** - * @throws BadMethodCallException When the where-clause is already generated - */ - protected function guardNotGenerated() - { - if (null !== $this->whereClause) { - throw new BadMethodCallException( - 'ConditionGenerator configuration methods cannot be accessed anymore once the where-clause is generated.' - ); - } - } -} diff --git a/lib/Doctrine/Orm/AbstractConditionGenerator.php b/lib/Doctrine/Orm/AbstractConditionGenerator.php deleted file mode 100644 index 4a8a6662..00000000 --- a/lib/Doctrine/Orm/AbstractConditionGenerator.php +++ /dev/null @@ -1,118 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Doctrine\Orm; - -use Doctrine\ORM\EntityManagerInterface; -use Rollerworks\Component\Search\Exception\BadMethodCallException; -use Rollerworks\Component\Search\FieldSet; -use Rollerworks\Component\Search\SearchCondition; - -/** - * Handles abstracted logic of a Doctrine ORM ConditionGenerator. - * - * @author Sebastiaan Stok - * - * @internal this class should not be relied upon, use the ConditionGenerator - * interface instead for type hinting - */ -abstract class AbstractConditionGenerator implements ConditionGenerator -{ - use QueryPlatformTrait; - - /** - * @var SearchCondition - */ - protected $searchCondition; - - /** - * @var FieldSet - */ - protected $fieldset; - - /** - * @var EntityManagerInterface - */ - protected $entityManager; - - /** - * @var string - */ - protected $whereClause; - - /** - * @var FieldConfigBuilder - */ - protected $fieldsConfig; - - public function __construct(SearchCondition $searchCondition, EntityManagerInterface $entityManager) - { - $this->searchCondition = $searchCondition; - $this->fieldset = $searchCondition->getFieldSet(); - - $this->entityManager = $entityManager; - $this->fieldsConfig = new FieldConfigBuilder($entityManager, $this->fieldset); - } - - public function setDefaultEntity(string $entity, string $alias) - { - $this->guardNotGenerated(); - $this->fieldsConfig->setDefaultEntity($entity, $alias); - - return $this; - } - - public function setField(string $fieldName, string $property, string $alias = null, string $entity = null, string $dbType = null) - { - $this->guardNotGenerated(); - $this->fieldsConfig->setField($fieldName, $property, $alias, $entity, $dbType); - - return $this; - } - - /** - * @internal - */ - public function getSearchCondition(): SearchCondition - { - return $this->searchCondition; - } - - /** - * @internal - */ - public function getEntityManager(): EntityManagerInterface - { - return $this->entityManager; - } - - /** - * @internal - */ - public function getFieldsConfig(): FieldConfigBuilder - { - return $this->fieldsConfig; - } - - /** - * @throws BadMethodCallException When the where-clause is already generated - */ - protected function guardNotGenerated() - { - if (null !== $this->whereClause) { - throw new BadMethodCallException( - 'ConditionGenerator configuration methods cannot be accessed anymore once the where-clause is generated.' - ); - } - } -} diff --git a/lib/Doctrine/Orm/CachedDqlConditionGenerator.php b/lib/Doctrine/Orm/CachedDqlConditionGenerator.php index e7d20e5d..d73c632c 100644 --- a/lib/Doctrine/Orm/CachedDqlConditionGenerator.php +++ b/lib/Doctrine/Orm/CachedDqlConditionGenerator.php @@ -14,8 +14,9 @@ namespace Rollerworks\Component\Search\Doctrine\Orm; use Doctrine\ORM\Query; +use Doctrine\ORM\QueryBuilder; use Psr\SimpleCache\CacheInterface as Cache; -use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform; +use Rollerworks\Component\Search\Doctrine\Dbal\AbstractCachedConditionGenerator; use Rollerworks\Component\Search\Exception\BadMethodCallException; /** @@ -24,6 +25,9 @@ * Instead of using the ConditionGenerator directly you should use the * CachedConditionGenerator as all related calls are delegated. * + * Note: this class should not be relied upon as interface, + * use the ConditionGenerator interface instead for type hinting + * * The cache-key is a hashed (sha256) combination of the SearchCondition * (root ValuesGroup and FieldSet name) and configured field mappings. * @@ -32,61 +36,45 @@ * * @author Sebastiaan Stok * - * @property DqlConditionGenerator $conditionGenerator - * * @final */ -class CachedDqlConditionGenerator extends AbstractCachedConditionGenerator +class CachedDqlConditionGenerator extends AbstractCachedConditionGenerator implements ConditionGenerator { - use QueryPlatformTrait; - /** - * @var array + * @var Query */ - private $parameters; + private $query; /** - * @var Query + * @var ConditionGenerator */ - private $query; + private $conditionGenerator; /** - * @var QueryPlatform + * @var string */ - private $nativePlatform; + private $whereClause; /** - * @param DqlConditionGenerator $conditionGenerator The ConditionGenerator to use for generating - * the condition when no cache exists - * @param Cache $cacheDriver PSR-16 SimpleCache instance. Use a custom pool to ease - * purging invalidated items - * @param int|\DateInterval|null $ttl Optional. The TTL value of this item. If no value is sent and - * the driver supports TTL then the library may set a default value - * for it or let the driver take care of that. + * @param ConditionGenerator $conditionGenerator The actual ConditionGenerator to use when no cache exists */ - public function __construct(DqlConditionGenerator $conditionGenerator, Cache $cacheDriver, $ttl = null) + public function __construct(ConditionGenerator $conditionGenerator, Cache $cacheDriver, $ttl = null) { - parent::__construct($conditionGenerator, $cacheDriver, $ttl); + parent::__construct($cacheDriver, $ttl); - $this->ttl = $ttl; - $this->cacheDriver = $cacheDriver; $this->query = $conditionGenerator->getQuery(); + $this->conditionGenerator = $conditionGenerator; } - /** - * @param string $prependQuery Prepends this string to the where-clause - * ("WHERE" or "AND" for example) - */ public function getWhereClause(string $prependQuery = ''): string { if ($this->whereClause === null) { $cacheKey = $this->getCacheKey(); - $cacheItem = $this->cacheDriver->get($cacheKey); - - $this->nativePlatform = $this->getQueryPlatform($this->conditionGenerator->getEntityManager()->getConnection()); + $cached = $this->getFromCache($cacheKey); - if ($cacheItem !== null) { - [$this->whereClause, $this->parameters] = $cacheItem; + if ($cached !== null) { + $this->whereClause = $cached[0]; + $this->parameters = $cached[1]; } else { $this->whereClause = $this->conditionGenerator->getWhereClause(); $this->parameters = $this->conditionGenerator->getParameters(); @@ -94,11 +82,8 @@ public function getWhereClause(string $prependQuery = ''): string if ($this->whereClause !== '') { $this->cacheDriver->set( $cacheKey, - [ - $this->whereClause, - $this->parameters, - ], - $this->ttl + [$this->whereClause, $this->packParameters($this->parameters)], + $this->cacheLifeTime ); } } @@ -111,76 +96,76 @@ public function getWhereClause(string $prependQuery = ''): string return ''; } - /** - * Updates the configured query object with the where-clause. - * - * @param string $prependQuery Prepends this string to the where-clause - * ("WHERE" or "AND" for example) - */ + private function getCacheKey(): string + { + if (null === $this->cacheKey) { + $searchCondition = $this->conditionGenerator->getSearchCondition(); + + $this->cacheKey = hash( + 'sha256', + "dql\n". + $searchCondition->getFieldSet()->getSetName(). + "\n". + serialize($searchCondition->getValuesGroup()). + "\n". + serialize($searchCondition->getPrimaryCondition()). + "\n". + serialize($this->conditionGenerator->getFieldsConfig()->getFields()) + ); + } + + return $this->cacheKey; + } + public function updateQuery(string $prependQuery = ' WHERE '): self { $whereCase = $this->getWhereClause($prependQuery); if ($whereCase !== '') { - $this->query->setDQL($this->query->getDQL().$whereCase); - $this->query->setHint( - $this->conditionGenerator->getQueryHintName(), - $this->getQueryHintValue() - ); + if ($this->query instanceof QueryBuilder) { + $this->query->andWhere($this->getWhereClause()); + } else { + $this->query->setDQL($this->query->getDQL().$whereCase); + } + + $this->bindParameters(); } return $this; } - /** - * Returns the Query-hint name for the final query object. - * - * The Query-hint is used for conversions. - * - * @return string - */ - public function getQueryHintName() + public function bindParameters(): void + { + foreach ($this->parameters as $name => [$value, $type]) { + $this->query->setParameter($name, $value, $type); + } + } + + public function setDefaultEntity(string $entity, string $alias) { - return $this->conditionGenerator->getQueryHintName(); + $this->guardNotGenerated(); + $this->conditionGenerator->setDefaultEntity($entity, $alias); + + return $this; } /** - * Returns the Query-hint value for the final query object. - * - * The Query hint is used for value-conversions. + * @throws BadMethodCallException When the where-clause is already generated */ - public function getQueryHintValue(): SqlConversionInfo + private function guardNotGenerated() { - if (null === $this->whereClause) { + if (null !== $this->whereClause) { throw new BadMethodCallException( - 'Unable to get query-hint value for ConditionGenerator. Call getWhereClause() before calling this method.' + 'ConditionGenerator configuration methods cannot be accessed anymore once the where-clause is generated.' ); } - - return new SqlConversionInfo( - $this->nativePlatform, - $this->parameters, - $this->conditionGenerator->getFieldsConfig()->getFields() - ); } - private function getCacheKey(): string + public function setField(string $fieldName, string $property, string $alias = null, string $entity = null, string $dbType = null) { - if (null === $this->cacheKey) { - $searchCondition = $this->conditionGenerator->getSearchCondition(); - $this->cacheKey = hash( - 'sha256', - "dql\n". - $searchCondition->getFieldSet()->getSetName(). - "\n". - serialize($searchCondition->getValuesGroup()). - "\n". - serialize($searchCondition->getPrimaryCondition()). - "\n". - serialize($this->conditionGenerator->getFieldsConfig()->getFields()) - ); - } + $this->guardNotGenerated(); + $this->conditionGenerator->setField($fieldName, $property, $alias, $entity, $dbType); - return $this->cacheKey; + return $this; } } diff --git a/lib/Doctrine/Orm/ColumnConversion.php b/lib/Doctrine/Orm/ColumnConversion.php new file mode 100644 index 00000000..a89be2f8 --- /dev/null +++ b/lib/Doctrine/Orm/ColumnConversion.php @@ -0,0 +1,36 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Doctrine\Orm; + +use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; + +/** + * A ColumnConversion allows to wrap the query's column in a custom + * DQL statement (as-is). + * + * This interface can be combined with the ValueConversion interface. + * + * @author Sebastiaan Stok + */ +interface ColumnConversion +{ + /** + * Return the $column wrapped inside an DQL statement like: MY_FUNCTION(column). + * + * @param string $column The column name and table alias, eg. i.id + * @param array $options Options of the Field configuration + * @param ConversionHints $hints Special information for the conversion process + */ + public function convertColumn(string $column, array $options, ConversionHints $hints): string; +} diff --git a/lib/Doctrine/Orm/ConditionGenerator.php b/lib/Doctrine/Orm/ConditionGenerator.php index 8c93b4ba..d2a0bb86 100644 --- a/lib/Doctrine/Orm/ConditionGenerator.php +++ b/lib/Doctrine/Orm/ConditionGenerator.php @@ -13,6 +13,7 @@ namespace Rollerworks\Component\Search\Doctrine\Orm; +use Doctrine\Common\Collections\ArrayCollection; use Rollerworks\Component\Search\Exception\BadMethodCallException; use Rollerworks\Component\Search\Exception\UnknownFieldException; @@ -104,4 +105,6 @@ public function getWhereClause(string $prependQuery = ''): string; * clause is empty. Default is ' WHERE ' */ public function updateQuery(string $prependQuery = ' WHERE '); + + public function getParameters(): ArrayCollection; } diff --git a/lib/Doctrine/Orm/ConversionHintTrait.php b/lib/Doctrine/Orm/ConversionHintTrait.php deleted file mode 100644 index bb58c1ab..00000000 --- a/lib/Doctrine/Orm/ConversionHintTrait.php +++ /dev/null @@ -1,51 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Doctrine\Orm; - -use Doctrine\ORM\Query\SqlWalker; -use Rollerworks\Component\Search\Doctrine\Dbal\Query\QueryField; -use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform; - -/** - * @internal - */ -trait ConversionHintTrait -{ - /** - * @var QueryPlatform - */ - protected $nativePlatform; - - /** - * @var array - */ - protected $parameters = []; - - /** - * @var QueryField[] - */ - protected $fields = []; - - protected function loadConversionHints(SqlWalker $sqlWalker) - { - /* @var SqlConversionInfo $hintsValue */ - if (!($hintsValue = $sqlWalker->getQuery()->getHint('rws_conversion_hint'))) { - throw new \LogicException('Missing "rws_conversion_hint" hint for '.static::class); - } - - $this->nativePlatform = $hintsValue->nativePlatform; - $this->parameters = $hintsValue->parameters; - $this->fields = $hintsValue->fields; - } -} diff --git a/lib/Doctrine/Orm/DoctrineOrmFactory.php b/lib/Doctrine/Orm/DoctrineOrmFactory.php index ca71cac6..133caeff 100644 --- a/lib/Doctrine/Orm/DoctrineOrmFactory.php +++ b/lib/Doctrine/Orm/DoctrineOrmFactory.php @@ -30,9 +30,6 @@ class DoctrineOrmFactory */ private $cacheDriver; - /** - * @param Cache $cacheDriver - */ public function __construct(Cache $cacheDriver = null) { $this->cacheDriver = $cacheDriver; @@ -43,8 +40,7 @@ public function __construct(Cache $cacheDriver = null) * * Conversions are applied using the 'doctrine_dbal_conversion' option (when present). * - * @param Query|QueryBuilder $query Doctrine ORM (Native)Query object - * @param SearchCondition $searchCondition SearchCondition object + * @param Query|QueryBuilder $query */ public function createConditionGenerator($query, SearchCondition $searchCondition): DqlConditionGenerator { @@ -54,10 +50,9 @@ public function createConditionGenerator($query, SearchCondition $searchConditio /** * Creates a new CachedConditionGenerator instance for the ConditionGenerator. * - * @param DqlConditionGenerator $conditionGenerator - * @param int|\DateInterval|null $ttl Optional. The TTL value of this item. If no value is sent and - * the driver supports TTL then the library may set a default value - * for it or let the driver take care of that. + * @param int|\DateInterval|null $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. */ public function createCachedConditionGenerator(ConditionGenerator $conditionGenerator, $ttl = null): ConditionGenerator { diff --git a/lib/Doctrine/Orm/DqlConditionGenerator.php b/lib/Doctrine/Orm/DqlConditionGenerator.php index b1463e5d..36b5450a 100644 --- a/lib/Doctrine/Orm/DqlConditionGenerator.php +++ b/lib/Doctrine/Orm/DqlConditionGenerator.php @@ -13,13 +13,13 @@ namespace Rollerworks\Component\Search\Doctrine\Orm; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Query as DqlQuery; use Doctrine\ORM\QueryBuilder; use Rollerworks\Component\Search\Doctrine\Dbal\Query\QueryGenerator; -use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform; use Rollerworks\Component\Search\Doctrine\Orm\QueryPlatform\DqlQueryPlatform; use Rollerworks\Component\Search\Exception\BadMethodCallException; -use Rollerworks\Component\Search\Exception\InvalidArgumentException; use Rollerworks\Component\Search\Exception\UnexpectedTypeException; use Rollerworks\Component\Search\SearchCondition; @@ -34,68 +34,99 @@ * * Keep the following in mind when using conversions. * - * * Conversions are performed per search field and must be stateless, - * they receive the db-type and connection information for the conversion process. - * * Conversions apply at the SQL level, meaning they must be platform specific. - * * Conversion results must be properly escaped to prevent SQL injections. - * * Conversions require the correct query-hint to be set. + * * Conversions are performed per search field and must be stateless, they receive the db-type + * and connection information for the conversion process. + * * Unlike DBAL conversions the conversion must be DQL (not SQL) + * * Values must be registered as parameters (using the ConversionHints) + * * Conversion results must be properly escaped to prevent DQL injections. * * @author Sebastiaan Stok * * @final */ -class DqlConditionGenerator extends AbstractConditionGenerator +class DqlConditionGenerator implements ConditionGenerator { /** - * @var DqlQuery|QueryBuilder + * @var SearchCondition */ - private $query; + private $searchCondition; + + /** + * @var EntityManagerInterface + */ + private $entityManager; + + /** + * @var string + */ + private $whereClause; /** - * @var array + * @var FieldConfigBuilder */ - private $parameters = []; + private $fieldsConfig; /** - * @var QueryPlatform + * @var ArrayCollection */ - private $nativePlatform; + private $parameters; /** - * @param DqlQuery|QueryBuilder $query Doctrine ORM Query + * @var DqlQuery|QueryBuilder */ + private $query; + public function __construct($query, SearchCondition $searchCondition) { - if ($query instanceof QueryBuilder) { - if (!method_exists($query, 'setHint')) { - throw new InvalidArgumentException(sprintf('An "%s" instance was provided but method setHint is not implemented in "%s".', QueryBuilder::class, \get_class($query))); - } - } elseif (!$query instanceof DqlQuery) { - throw new UnexpectedTypeException($query, [DqlQuery::class, QueryBuilder::class.' (with QueryHint support)']); + if (!$query instanceof DqlQuery && !$query instanceof QueryBuilder) { + throw new UnexpectedTypeException($query, [DqlQuery::class, QueryBuilder::class]); } - parent::__construct($searchCondition, $query->getEntityManager()); - + $this->entityManager = $query->getEntityManager(); + $this->fieldsConfig = new FieldConfigBuilder($this->entityManager, $searchCondition->getFieldSet()); + $this->searchCondition = $searchCondition; + $this->parameters = new ArrayCollection(); $this->query = $query; } + public function setDefaultEntity(string $entity, string $alias) + { + $this->guardNotGenerated(); + $this->fieldsConfig->setDefaultEntity($entity, $alias); + + return $this; + } + /** - * {@inheritdoc} - * - * Note: For SQL conversions to work properly you need to set the required - * hints using getQueryHintName() and getQueryHintValue(). + * @throws BadMethodCallException When the where-clause is already generated */ + protected function guardNotGenerated() + { + if (null !== $this->whereClause) { + throw new BadMethodCallException( + 'ConditionGenerator configuration methods cannot be accessed anymore once the where-clause is generated.' + ); + } + } + + public function setField(string $fieldName, string $property, string $alias = null, string $entity = null, string $dbType = null) + { + $this->guardNotGenerated(); + $this->fieldsConfig->setField($fieldName, $property, $alias, $entity, $dbType); + + return $this; + } + public function getWhereClause(string $prependQuery = ''): string { if (null === $this->whereClause) { $fields = $this->fieldsConfig->getFields(); - $platform = new DqlQueryPlatform($this->entityManager); $connection = $this->entityManager->getConnection(); + $platform = new DqlQueryPlatform($connection); $queryGenerator = new QueryGenerator($connection, $platform, $fields); - $this->nativePlatform = $this->getQueryPlatform($connection); $this->whereClause = $queryGenerator->getWhereClause($this->searchCondition); - $this->parameters = $platform->getEmbeddedValues(); + $this->parameters = $platform->getParameters(); } if ('' !== $this->whereClause) { @@ -119,47 +150,46 @@ public function updateQuery(string $prependQuery = ' WHERE '): self $this->query->setDQL($this->query->getDQL().$whereCase); } - $this->query->setHint($this->getQueryHintName(), $this->getQueryHintValue()); + foreach ($this->parameters as $name => [$value, $type]) { + $this->query->setParameter($name, $value, $type); + } return $this; } + public function getParameters(): ArrayCollection + { + return $this->parameters; + } + /** - * Returns the Query-hint name for the query object. - * - * The Query-hint is used for conversions. + * @internal */ - public function getQueryHintName(): string + public function getSearchCondition(): SearchCondition { - return 'rws_conversion_hint'; + return $this->searchCondition; } /** - * Returns the Query-hint value for the query object. - * - * The Query hint is used for conversions. + * @internal */ - public function getQueryHintValue(): SqlConversionInfo + public function getEntityManager(): EntityManagerInterface { - if (null === $this->whereClause) { - throw new BadMethodCallException( - 'Unable to get query-hint value for ConditionGenerator. Call getWhereClause() before calling this method.' - ); - } - - return new SqlConversionInfo($this->nativePlatform, $this->parameters, $this->fieldsConfig->getFieldsForHint()); + return $this->entityManager; } /** * @internal */ - public function getParameters(): array + public function getFieldsConfig(): FieldConfigBuilder { - return $this->parameters; + return $this->fieldsConfig; } /** * @internal + * + * @return DqlQuery|QueryBuilder */ public function getQuery() { diff --git a/lib/Doctrine/Orm/Extension/Conversion/AgeDateConversion.php b/lib/Doctrine/Orm/Extension/Conversion/AgeDateConversion.php new file mode 100644 index 00000000..f41be091 --- /dev/null +++ b/lib/Doctrine/Orm/Extension/Conversion/AgeDateConversion.php @@ -0,0 +1,40 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Extension\Doctrine\Orm\Conversion; + +use Doctrine\DBAL\Types\Type as DBALType; +use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; +use Rollerworks\Component\Search\Doctrine\Orm\ColumnConversion; +use Rollerworks\Component\Search\Doctrine\Orm\ValueConversion; + +final class AgeDateConversion implements ColumnConversion, ValueConversion +{ + public function convertColumn(string $column, array $options, ConversionHints $hints): string + { + if ($hints->getProcessingValue() instanceof \DateTimeInterface) { + return "SEARCH_CONVERSION_CAST($column, 'DATE')"; + } + + return "SEARCH_CONVERSION_AGE($column)"; + } + + public function convertValue($value, array $options, ConversionHints $hints): string + { + if ($value instanceof \DateTimeInterface) { + return $hints->createParamReferenceFor($value, DBALType::getType('date')); + } + + return $hints->createParamReferenceFor($value, DBALType::getType('integer')); + } +} diff --git a/lib/Doctrine/Orm/Extension/Conversion/ChildCountConversion.php b/lib/Doctrine/Orm/Extension/Conversion/ChildCountConversion.php new file mode 100644 index 00000000..6ce8f6c9 --- /dev/null +++ b/lib/Doctrine/Orm/Extension/Conversion/ChildCountConversion.php @@ -0,0 +1,25 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Extension\Doctrine\Orm\Conversion; + +use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; +use Rollerworks\Component\Search\Doctrine\Orm\ColumnConversion; + +final class ChildCountConversion implements ColumnConversion +{ + public function convertColumn(string $column, array $options, ConversionHints $hints): string + { + return sprintf('SEARCH_COUNT_CHILDREN(%s, %s, %s)', $options['table_name'], $options['table_column'], $column); + } +} diff --git a/lib/Doctrine/Orm/Extension/Conversion/MoneyValueConversion.php b/lib/Doctrine/Orm/Extension/Conversion/MoneyValueConversion.php new file mode 100644 index 00000000..b404469c --- /dev/null +++ b/lib/Doctrine/Orm/Extension/Conversion/MoneyValueConversion.php @@ -0,0 +1,59 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Extension\Doctrine\Orm\Conversion; + +use Doctrine\DBAL\Types\Types as DbType; +use Money\Currencies\ISOCurrencies; +use Money\Formatter\DecimalMoneyFormatter; +use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; +use Rollerworks\Component\Search\Doctrine\Orm\ColumnConversion; +use Rollerworks\Component\Search\Doctrine\Orm\ValueConversion; +use Rollerworks\Component\Search\Extension\Core\Model\MoneyValue; + +final class MoneyValueConversion implements ValueConversion, ColumnConversion +{ + /** @var DecimalMoneyFormatter */ + private $formatter; + + /** @var ISOCurrencies */ + private $currencies; + + public function __construct() + { + $this->currencies = new ISOCurrencies(); + $this->formatter = new DecimalMoneyFormatter($this->currencies); + } + + /** + * @param MoneyValue $value + */ + public function convertValue($value, array $options, ConversionHints $hints): string + { + $sqlValue = $hints->createParamReferenceFor($this->formatter->format($value->value)); + $scale = $this->currencies->subunitFor($value->value->getCurrency()); + + return "SEARCH_MONEY_AS_NUMERIC({$sqlValue}, $scale)"; + } + + public function convertColumn(string $column, array $options, ConversionHints $hints): string + { + if ($hints->field->dbType->getName() === DbType::DECIMAL) { + return $column; + } + + $scale = $this->currencies->subunitFor($hints->getProcessingValue()->getCurrency()); + + return "SUBSTRING(SEARCH_MONEY_AS_NUMERIC($column, $scale), 5))"; + } +} diff --git a/lib/Doctrine/Orm/Extension/DoctrineOrmExtension.php b/lib/Doctrine/Orm/Extension/DoctrineOrmExtension.php index a3b199fb..84c6a82e 100644 --- a/lib/Doctrine/Orm/Extension/DoctrineOrmExtension.php +++ b/lib/Doctrine/Orm/Extension/DoctrineOrmExtension.php @@ -13,13 +13,19 @@ namespace Rollerworks\Component\Search\Extension\Doctrine\Orm; -use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ManagerRegistry; use Rollerworks\Component\Search\AbstractExtension; -use Rollerworks\Component\Search\Doctrine\Orm\Functions\SqlFieldConversion; -use Rollerworks\Component\Search\Doctrine\Orm\Functions\SqlValueConversion; +use Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions\AgeFunction; +use Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions\CastFunction; +use Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions\CountChildrenFunction; +use Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions\MoneyCastFunction; +use Rollerworks\Component\Search\Extension\Doctrine\Orm\Type\BirthdayTypeExtension; +use Rollerworks\Component\Search\Extension\Doctrine\Orm\Type\ChildCountType; +use Rollerworks\Component\Search\Extension\Doctrine\Orm\Type\FieldTypeExtension; +use Rollerworks\Component\Search\Extension\Doctrine\Orm\Type\MoneyTypeExtension; -class DoctrineOrmExtension extends AbstractExtension +final class DoctrineOrmExtension extends AbstractExtension { /** * @param string[] $managerNames A list manager names for which to enable this extension @@ -31,8 +37,20 @@ public function __construct(ManagerRegistry $registry, array $managerNames = ['d $manager = $registry->getManager($managerName); $emConfig = $manager->getConfiguration(); - $emConfig->addCustomStringFunction('RW_SEARCH_FIELD_CONVERSION', SqlFieldConversion::class); - $emConfig->addCustomStringFunction('RW_SEARCH_VALUE_CONVERSION', SqlValueConversion::class); + $emConfig->addCustomStringFunction('SEARCH_CONVERSION_CAST', CastFunction::class); + $emConfig->addCustomNumericFunction('SEARCH_CONVERSION_AGE', AgeFunction::class); + $emConfig->addCustomNumericFunction('SEARCH_COUNT_CHILDREN', CountChildrenFunction::class); + $emConfig->addCustomNumericFunction('SEARCH_MONEY_AS_NUMERIC', MoneyCastFunction::class); } } + + protected function loadTypesExtensions(): array + { + return [ + new BirthdayTypeExtension(), + new ChildCountType(), + new FieldTypeExtension(), + new MoneyTypeExtension(), + ]; + } } diff --git a/lib/Doctrine/Orm/Extension/Functions/AgeFunction.php b/lib/Doctrine/Orm/Extension/Functions/AgeFunction.php new file mode 100644 index 00000000..dc56448d --- /dev/null +++ b/lib/Doctrine/Orm/Extension/Functions/AgeFunction.php @@ -0,0 +1,59 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions; + +use Doctrine\ORM\Query\AST\Functions\FunctionNode; +use Doctrine\ORM\Query\Lexer; +use Doctrine\ORM\Query\Parser; +use Doctrine\ORM\Query\SqlWalker; + +/** + * "SEARCH_CONVERSION_AGE" "(" StringPrimary ")". + */ +final class AgeFunction extends FunctionNode +{ + public $stringPrimary; + + public function getSql(SqlWalker $sqlWalker): string + { + $platform = $sqlWalker->getConnection()->getDatabasePlatform()->getName(); + $expression = $sqlWalker->walkSimpleArithmeticExpression($this->stringPrimary); + + $convertMap = []; + $convertMap['postgresql'] = "to_char(age(%1\$s), 'YYYY'::text)::integer"; + $convertMap['mysql'] = "(DATE_FORMAT(NOW(), '%%Y') - DATE_FORMAT(%1\$s, '%%Y') - (DATE_FORMAT(NOW(), '00-%%m-%%d') < DATE_FORMAT(%1\$s, '00-%%m-%%d')))"; + $convertMap['drizzle'] = $convertMap['mysql']; + $convertMap['mssql'] = 'DATEDIFF(hour, %1$s, GETDATE())/8766'; + $convertMap['oracle'] = 'trunc((months_between(sysdate, (sysdate - %1$s)))/12)'; + $convertMap['mock'] = $convertMap['postgresql']; + + if (isset($convertMap[$platform])) { + return sprintf($convertMap[$platform], $expression); + } + + throw new \RuntimeException( + sprintf('Unsupported platform "%s" for SEARCH_CONVERSION_AGE.', $platform) + ); + } + + public function parse(Parser $parser): void + { + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + + $this->stringPrimary = $parser->StringPrimary(); + + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/lib/Doctrine/Orm/Extension/Functions/CastFunction.php b/lib/Doctrine/Orm/Extension/Functions/CastFunction.php new file mode 100644 index 00000000..c47cef8a --- /dev/null +++ b/lib/Doctrine/Orm/Extension/Functions/CastFunction.php @@ -0,0 +1,53 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions; + +use Doctrine\ORM\Query\AST\Functions\FunctionNode; +use Doctrine\ORM\Query\Lexer; +use Doctrine\ORM\Query\Parser; +use Doctrine\ORM\Query\SqlWalker; + +/** + * "SEARCH_CONVERSION_CAST" "(" StringPrimary ", " StringPrimary ")". + */ +final class CastFunction extends FunctionNode +{ + public $stringPrimary; + + /** + * @var string + */ + public $type; + + public function getSql(SqlWalker $sqlWalker): string + { + $expression = $sqlWalker->walkSimpleArithmeticExpression($this->stringPrimary); + + return sprintf('CAST(%s AS %s)', $expression, $this->type); + } + + public function parse(Parser $parser): void + { + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + + $this->stringPrimary = $parser->StringPrimary(); + + $parser->match(Lexer::T_COMMA); + + $this->type = (string) $parser->Literal()->value; + + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/lib/Doctrine/Orm/Extension/Functions/CountChildrenFunction.php b/lib/Doctrine/Orm/Extension/Functions/CountChildrenFunction.php new file mode 100644 index 00000000..d4b93d3f --- /dev/null +++ b/lib/Doctrine/Orm/Extension/Functions/CountChildrenFunction.php @@ -0,0 +1,57 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions; + +use Doctrine\ORM\Query\AST\Functions\FunctionNode; +use Doctrine\ORM\Query\Lexer; +use Doctrine\ORM\Query\Parser; +use Doctrine\ORM\Query\SqlWalker; + +/** + * "SEARCH_COUNT_CHILDREN" "(" StringPrimary "," StringPrimary "," StringPrimary ")". + */ +final class CountChildrenFunction extends FunctionNode +{ + public $stringPrimary; + public $field; + public $column; + + public function getSql(SqlWalker $sqlWalker): string + { + $platform = $sqlWalker->getConnection()->getDatabasePlatform()->getName(); + + $table = $sqlWalker->walkSimpleArithmeticExpression($this->stringPrimary); + $field = $sqlWalker->walkSimpleArithmeticExpression($this->field); + $column = $sqlWalker->walkSimpleArithmeticExpression($this->column); + + return '(SELECT COUNT(*) FROM '.$table.' WHERE '.$field." = $column)"; + } + + public function parse(Parser $parser): void + { + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + + $this->stringPrimary = $parser->StringPrimary(); + + $parser->match(Lexer::T_COMMA); + + $this->field = $parser->StringPrimary(); + $parser->match(Lexer::T_COMMA); + + $this->column = $parser->StringPrimary(); + + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/lib/Doctrine/Orm/Extension/Functions/MoneyCastFunction.php b/lib/Doctrine/Orm/Extension/Functions/MoneyCastFunction.php new file mode 100644 index 00000000..e365a1d7 --- /dev/null +++ b/lib/Doctrine/Orm/Extension/Functions/MoneyCastFunction.php @@ -0,0 +1,62 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions; + +use Doctrine\ORM\Query\AST\Functions\FunctionNode; +use Doctrine\ORM\Query\Lexer; +use Doctrine\ORM\Query\Parser; +use Doctrine\ORM\Query\SqlWalker; + +/** + * "SEARCH_MONEY_AS_NUMERIC" "(" StringPrimary ", " Literal ")". + */ +final class MoneyCastFunction extends FunctionNode +{ + public $stringPrimary; + + /** + * @var int + */ + public $scale; + + public function getSql(SqlWalker $sqlWalker): string + { + $expression = $sqlWalker->walkSimpleArithmeticExpression($this->stringPrimary); + $scale = $this->scale; + + if (strpos($sqlWalker->getConnection()->getDatabasePlatform()->getName(), 'mysql') !== false) { + $castType = "DECIMAL(10, {$scale})"; + } else { + $castType = $sqlWalker->getConnection()->getDatabasePlatform()->getDecimalTypeDeclarationSQL( + ['scale' => $scale] + ); + } + + return sprintf('CAST(%s AS %s)', $expression, $castType); + } + + public function parse(Parser $parser): void + { + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + + $this->stringPrimary = $parser->StringPrimary(); + + $parser->match(Lexer::T_COMMA); + + $this->scale = (int) $parser->Literal()->value; + + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/lib/Doctrine/Orm/Extension/Type/BirthdayTypeExtension.php b/lib/Doctrine/Orm/Extension/Type/BirthdayTypeExtension.php new file mode 100644 index 00000000..4bedccf2 --- /dev/null +++ b/lib/Doctrine/Orm/Extension/Type/BirthdayTypeExtension.php @@ -0,0 +1,40 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Extension\Doctrine\Orm\Type; + +use Rollerworks\Component\Search\Extension\Core\Type\BirthdayType; +use Rollerworks\Component\Search\Extension\Doctrine\Orm\Conversion\AgeDateConversion; +use Rollerworks\Component\Search\Field\AbstractFieldTypeExtension; +use Symfony\Component\OptionsResolver\OptionsResolver; + +final class BirthdayTypeExtension extends AbstractFieldTypeExtension +{ + /** @var AgeDateConversion */ + private $conversion; + + public function __construct() + { + $this->conversion = new AgeDateConversion(); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('doctrine_orm_conversion', $this->conversion); + } + + public function getExtendedType(): string + { + return BirthdayType::class; + } +} diff --git a/lib/Doctrine/Orm/Extension/Type/ChildCountType.php b/lib/Doctrine/Orm/Extension/Type/ChildCountType.php new file mode 100644 index 00000000..ec43df91 --- /dev/null +++ b/lib/Doctrine/Orm/Extension/Type/ChildCountType.php @@ -0,0 +1,39 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Extension\Doctrine\Orm\Type; + +use Rollerworks\Component\Search\Extension\Doctrine\Dbal\Type\ChildCountType as DbalChildCountType; +use Rollerworks\Component\Search\Extension\Doctrine\Orm\Conversion\ChildCountConversion; +use Rollerworks\Component\Search\Field\AbstractFieldTypeExtension; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ChildCountType extends AbstractFieldTypeExtension +{ + private $conversion; + + public function __construct() + { + $this->conversion = new ChildCountConversion(); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('doctrine_orm_conversion', $this->conversion); + } + + public function getExtendedType(): string + { + return DbalChildCountType::class; + } +} diff --git a/lib/Doctrine/Orm/Extension/Type/FieldTypeExtension.php b/lib/Doctrine/Orm/Extension/Type/FieldTypeExtension.php new file mode 100644 index 00000000..2ab9c5bd --- /dev/null +++ b/lib/Doctrine/Orm/Extension/Type/FieldTypeExtension.php @@ -0,0 +1,42 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Extension\Doctrine\Orm\Type; + +use Rollerworks\Component\Search\Doctrine\Orm\ColumnConversion; +use Rollerworks\Component\Search\Doctrine\Orm\ValueConversion; +use Rollerworks\Component\Search\Extension\Core\Type\SearchFieldType; +use Rollerworks\Component\Search\Field\AbstractFieldTypeExtension; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class FieldTypeExtension extends AbstractFieldTypeExtension +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['doctrine_orm_conversion' => null]); + $resolver->setAllowedTypes( + 'doctrine_orm_conversion', + [ + 'null', + \Closure::class, + ColumnConversion::class, + ValueConversion::class, + ] + ); + } + + public function getExtendedType(): string + { + return SearchFieldType::class; + } +} diff --git a/lib/Doctrine/Orm/Extension/Type/MoneyTypeExtension.php b/lib/Doctrine/Orm/Extension/Type/MoneyTypeExtension.php new file mode 100644 index 00000000..9e6bea68 --- /dev/null +++ b/lib/Doctrine/Orm/Extension/Type/MoneyTypeExtension.php @@ -0,0 +1,42 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Extension\Doctrine\Orm\Type; + +use Rollerworks\Component\Search\Extension\Core\Type\MoneyType; +use Rollerworks\Component\Search\Extension\Doctrine\Orm\Conversion\MoneyValueConversion; +use Rollerworks\Component\Search\Field\AbstractFieldTypeExtension; +use Symfony\Component\OptionsResolver\OptionsResolver; + +final class MoneyTypeExtension extends AbstractFieldTypeExtension +{ + /** + * @var MoneyValueConversion + */ + private $conversion; + + public function __construct() + { + $this->conversion = new MoneyValueConversion(); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('doctrine_orm_conversion', $this->conversion); + } + + public function getExtendedType(): string + { + return MoneyType::class; + } +} diff --git a/lib/Doctrine/Orm/FieldConfigBuilder.php b/lib/Doctrine/Orm/FieldConfigBuilder.php index c7e54f88..9c04d95d 100644 --- a/lib/Doctrine/Orm/FieldConfigBuilder.php +++ b/lib/Doctrine/Orm/FieldConfigBuilder.php @@ -15,7 +15,6 @@ use Doctrine\DBAL\Types\Type as MappingType; use Doctrine\ORM\EntityManagerInterface; -use Rollerworks\Component\Search\Doctrine\Dbal\Query\QueryField; use Rollerworks\Component\Search\FieldSet; /** @@ -37,7 +36,7 @@ final class FieldConfigBuilder /** @var string */ private $defaultAlias; - public function __construct(EntityManagerInterface $entityManager, FieldSet $fieldSet, bool $native = false) + public function __construct(EntityManagerInterface $entityManager, FieldSet $fieldSet) { $this->entityManager = $entityManager; $this->fieldSet = $fieldSet; @@ -67,7 +66,7 @@ public function setField(string $mappingName, string $property, string $alias = $property ); - $this->fields[$fieldName][$mappingIdx] = new QueryField( + $this->fields[$fieldName][$mappingIdx] = new OrmQueryField( $mappingName, $this->fieldSet->get($fieldName), $this->getMappingType($mappingName, $entity, $property, $type), diff --git a/lib/Doctrine/Orm/Functions/SqlFieldConversion.php b/lib/Doctrine/Orm/Functions/SqlFieldConversion.php deleted file mode 100644 index f230ba22..00000000 --- a/lib/Doctrine/Orm/Functions/SqlFieldConversion.php +++ /dev/null @@ -1,73 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Doctrine\Orm\Functions; - -use Doctrine\ORM\Query\AST\Functions\FunctionNode; -use Doctrine\ORM\Query\Lexer; -use Doctrine\ORM\Query\Parser; -use Doctrine\ORM\Query\SqlWalker; -use Rollerworks\Component\Search\Doctrine\Orm\ConversionHintTrait; - -/** - * "RW_SEARCH_FIELD_CONVERSION(FieldName, Column, Strategy)". - * - * SearchFieldConversion ::= - * "RW_SEARCH_FIELD_CONVERSION" "(" StringPrimary, StateFieldPathExpression "," Literal ")" - * - * @author Sebastiaan Stok - */ -class SqlFieldConversion extends FunctionNode -{ - use ConversionHintTrait; - - /** - * @var string - */ - private $fieldName; - - /** - * @var \Doctrine\ORM\Query\AST\PathExpression - */ - private $columnExpression; - - /** - * @var int - */ - private $strategy; - - public function getSql(SqlWalker $sqlWalker): string - { - $this->loadConversionHints($sqlWalker); - - return $this->nativePlatform->getFieldColumn( - $this->fields[$this->fieldName], - $this->strategy, - $this->columnExpression->dispatch($sqlWalker) - ); - } - - public function parse(Parser $parser): void - { - $parser->match(Lexer::T_IDENTIFIER); - $parser->match(Lexer::T_OPEN_PARENTHESIS); - - $this->fieldName = $parser->Literal()->value; - $parser->match(Lexer::T_COMMA); - $this->columnExpression = $parser->StateFieldPathExpression(); - $parser->match(Lexer::T_COMMA); - $this->strategy = (int) $parser->Literal()->value; - - $parser->match(Lexer::T_CLOSE_PARENTHESIS); - } -} diff --git a/lib/Doctrine/Orm/Functions/SqlValueConversion.php b/lib/Doctrine/Orm/Functions/SqlValueConversion.php deleted file mode 100644 index 9c5bc6b8..00000000 --- a/lib/Doctrine/Orm/Functions/SqlValueConversion.php +++ /dev/null @@ -1,85 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Doctrine\Orm\Functions; - -use Doctrine\ORM\Query\AST\Functions\FunctionNode; -use Doctrine\ORM\Query\AST\Node; -use Doctrine\ORM\Query\Lexer; -use Doctrine\ORM\Query\Parser; -use Doctrine\ORM\Query\SqlWalker; -use Rollerworks\Component\Search\Doctrine\Orm\ConversionHintTrait; - -/** - * "RW_SEARCH_VALUE_CONVERSION(FieldMame, Column, Value, Strategy)". - * - * SearchValueConversion ::= - * "RW_SEARCH_VALUE_CONVERSION" "(" Literal, ScalarExpression, - * Literal "," Literal ")" - * - * @author Sebastiaan Stok - */ -class SqlValueConversion extends FunctionNode -{ - use ConversionHintTrait; - - /** - * @var string - */ - private $fieldName; - - /** - * PathExpression or SqlFieldConversion. - * - * @var Node - */ - private $column; - - /** - * @var int - */ - private $valueIndex; - - /** - * @var int - */ - private $strategy = 0; - - public function getSql(SqlWalker $sqlWalker): string - { - $this->loadConversionHints($sqlWalker); - - return $this->nativePlatform->convertSqlValue( - $this->parameters[$this->valueIndex], - $this->fields[$this->fieldName], - $this->column->dispatch($sqlWalker), - $this->strategy - ); - } - - public function parse(Parser $parser): void - { - $parser->match(Lexer::T_IDENTIFIER); - $parser->match(Lexer::T_OPEN_PARENTHESIS); - - $this->fieldName = $parser->Literal()->value; - $parser->match(Lexer::T_COMMA); - $this->column = $parser->ScalarExpression(); - $parser->match(Lexer::T_COMMA); - $this->valueIndex = (int) $parser->Literal()->value; - $parser->match(Lexer::T_COMMA); - $this->strategy = (int) $parser->Literal()->value; - - $parser->match(Lexer::T_CLOSE_PARENTHESIS); - } -} diff --git a/lib/Doctrine/Orm/OrmQueryField.php b/lib/Doctrine/Orm/OrmQueryField.php new file mode 100644 index 00000000..6d414d1e --- /dev/null +++ b/lib/Doctrine/Orm/OrmQueryField.php @@ -0,0 +1,41 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Doctrine\Orm; + +use Rollerworks\Component\Search\Doctrine\Dbal\Query\QueryField; +use Rollerworks\Component\Search\Field\FieldConfig; + +/** + * @property ColumnConversion|null $columnConversion + * @property ValueConversion|null $valueConversion + */ +final class OrmQueryField extends QueryField +{ + protected function initConversions(FieldConfig $fieldConfig): void + { + $converter = $fieldConfig->getOption('doctrine_orm_conversion'); + + if ($converter instanceof \Closure) { + $converter = $converter(); + } + + if ($converter instanceof ColumnConversion) { + $this->columnConversion = $converter; + } + + if ($converter instanceof ValueConversion) { + $this->valueConversion = $converter; + } + } +} diff --git a/lib/Doctrine/Orm/QueryPlatform/DqlQueryPlatform.php b/lib/Doctrine/Orm/QueryPlatform/DqlQueryPlatform.php index 496a7cca..346c4670 100644 --- a/lib/Doctrine/Orm/QueryPlatform/DqlQueryPlatform.php +++ b/lib/Doctrine/Orm/QueryPlatform/DqlQueryPlatform.php @@ -14,93 +14,41 @@ namespace Rollerworks\Component\Search\Doctrine\Orm\QueryPlatform; use Doctrine\DBAL\Types\Type; -use Doctrine\ORM\EntityManagerInterface; -use Rollerworks\Component\Search\Doctrine\Dbal\ColumnConversion; -use Rollerworks\Component\Search\Doctrine\Dbal\Query\QueryField; use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform\AbstractQueryPlatform; -use Rollerworks\Component\Search\Doctrine\Dbal\ValueConversion; +use Rollerworks\Component\Search\Value\PatternMatch; final class DqlQueryPlatform extends AbstractQueryPlatform { - /** - * @var array - */ - private $embeddedValues = []; - - /** - * @var int - */ - private $currentEmbeddedValuesIndex = 0; - - public function __construct(EntityManagerInterface $entityManager) - { - parent::__construct($entityManager->getConnection()); - } - - public function getFieldColumn(QueryField $mappingConfig, int $strategy = 0, string $column = null): string + public function getPatternMatcher(PatternMatch $patternMatch, string $column): string { - $mappingName = $mappingConfig->mappingName; + if (\in_array($patternMatch->getType(), [PatternMatch::PATTERN_EQUALS, PatternMatch::PATTERN_NOT_EQUALS], true)) { + $value = $this->createParamReferenceFor($patternMatch->getValue(), Type::getType('text')); - if (isset($this->fieldsMappingCache[$mappingName][$strategy])) { - return $this->fieldsMappingCache[$mappingName][$strategy]; - } + if ($patternMatch->isCaseInsensitive()) { + $column = "LOWER($column)"; + $value = "LOWER($value)"; + } - if (null === $column) { - $column = $mappingConfig->column; + return $column.($patternMatch->isExclusive() ? ' <>' : ' =')." $value"; } - $this->fieldsMappingCache[$mappingName][$strategy] = $column; - - if ($mappingConfig->columnConversion instanceof ColumnConversion) { - $this->fieldsMappingCache[$mappingName][$strategy] = sprintf( - "RW_SEARCH_FIELD_CONVERSION('%s', %s, %d)", - $mappingName, - $column, - $strategy - ); - } - - return $this->fieldsMappingCache[$mappingName][$strategy]; - } - - /** - * @return mixed[] - * - * @internal - */ - public function getEmbeddedValues(): array - { - return $this->embeddedValues; - } - - public function getValueAsSql($value, QueryField $mappingConfig, string $column, int $strategy = 0): string - { - if ($mappingConfig->valueConversion instanceof ValueConversion) { - $this->embeddedValues[++$this->currentEmbeddedValuesIndex] = $value; - - return sprintf( - "RW_SEARCH_VALUE_CONVERSION('%s', %s, %s, %s)", - $mappingConfig->mappingName, - $column, - $this->currentEmbeddedValuesIndex, - $strategy - ); - } - - return $this->quoteValue( - $mappingConfig->dbType->convertToDatabaseValue($value, $this->connection->getDatabasePlatform()), - $mappingConfig->dbType - ); - } - - protected function quoteValue($value, Type $type): string - { - if (\is_bool($value)) { - return $value ? 'true' : 'false'; - } elseif (is_scalar($value) && ctype_digit((string) $value)) { - return (string) $value; + $patternMap = [ + PatternMatch::PATTERN_STARTS_WITH => "CONCAT('%%', %s)", + PatternMatch::PATTERN_NOT_STARTS_WITH => "CONCAT('%%', %s)", + PatternMatch::PATTERN_CONTAINS => "CONCAT('%%', %s, '%%')", + PatternMatch::PATTERN_NOT_CONTAINS => "CONCAT('%%', %s, '%%')", + PatternMatch::PATTERN_ENDS_WITH => "CONCAT(%s, '%%')", + PatternMatch::PATTERN_NOT_ENDS_WITH => "CONCAT(%s, '%%')", + ]; + + $value = addcslashes($patternMatch->getValue(), $this->getLikeEscapeChars()); + $value = sprintf($patternMap[$patternMatch->getType()], $this->createParamReferenceFor($value, Type::getType('text'))); + + if ($patternMatch->isCaseInsensitive()) { + $column = "LOWER($column)"; + $value = "LOWER($value)"; } - return "'".str_replace("'", "''", $value)."'"; + return $column.($patternMatch->isExclusive() ? ' NOT' : '')." LIKE $value"; } } diff --git a/lib/Doctrine/Orm/QueryPlatformTrait.php b/lib/Doctrine/Orm/QueryPlatformTrait.php deleted file mode 100644 index 89c01467..00000000 --- a/lib/Doctrine/Orm/QueryPlatformTrait.php +++ /dev/null @@ -1,36 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Doctrine\Orm; - -use Doctrine\DBAL\Connection; -use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform; -use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform\SqlQueryPlatform; - -/** - * @internal - */ -trait QueryPlatformTrait -{ - protected function getQueryPlatform(Connection $connection): QueryPlatform - { - $dbPlatform = ucfirst($connection->getDatabasePlatform()->getName()); - $platformClass = 'Rollerworks\\Component\\Search\\Doctrine\\Dbal\\QueryPlatform\\'.$dbPlatform.'QueryPlatform'; - - if (class_exists($platformClass)) { - return new $platformClass($connection); - } - - return new SqlQueryPlatform($connection); - } -} diff --git a/lib/Doctrine/Orm/SqlConversionInfo.php b/lib/Doctrine/Orm/SqlConversionInfo.php deleted file mode 100644 index 6b91adb9..00000000 --- a/lib/Doctrine/Orm/SqlConversionInfo.php +++ /dev/null @@ -1,54 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Doctrine\Orm; - -use Rollerworks\Component\Search\Doctrine\Dbal\QueryPlatform; - -final class SqlConversionInfo implements \Serializable -{ - /** - * @var QueryPlatform - */ - public $nativePlatform; - - /** - * @var array - */ - public $parameters; - - /** - * @var array - */ - public $fields; - - public function __construct(QueryPlatform $nativePlatform, array $parameters, array $fields) - { - $this->nativePlatform = $nativePlatform; - $this->parameters = $parameters; - $this->fields = $fields; - } - - public function serialize() - { - return serialize($this->parameters); - } - - /** - * This does not nothing. - */ - public function unserialize($serialized) - { - // no-op - } -} diff --git a/lib/Doctrine/Orm/Tests/CachedDqlConditionGeneratorTest.php b/lib/Doctrine/Orm/Tests/CachedDqlConditionGeneratorTest.php index 36206d56..7a0bd914 100644 --- a/lib/Doctrine/Orm/Tests/CachedDqlConditionGeneratorTest.php +++ b/lib/Doctrine/Orm/Tests/CachedDqlConditionGeneratorTest.php @@ -13,7 +13,11 @@ namespace Rollerworks\Component\Search\Tests\Doctrine\Orm; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\DBAL\Types\Type; use Doctrine\ORM\Query; +use Doctrine\ORM\Query\Parameter; +use PHPUnit\Framework\MockObject\MockObject; use Psr\SimpleCache\CacheInterface; use Rollerworks\Component\Search\Doctrine\Orm\CachedDqlConditionGenerator; use Rollerworks\Component\Search\Doctrine\Orm\DqlConditionGenerator; @@ -37,16 +41,16 @@ class CachedDqlConditionGeneratorTest extends OrmTestCase protected $cachedConditionGenerator; /** - * @var \PHPUnit_Framework_MockObject_MockObject|CacheInterface + * @var MockObject|CacheInterface */ protected $cacheDriver; /** - * @var \PHPUnit_Framework_MockObject_MockObject|DqlConditionGenerator + * @var DqlConditionGenerator */ protected $conditionGenerator; - public const CACHE_KEY = '8dbca2a85403b7afbece9461df29c567cf32dfda2c10a75b5aff4d6ac44e4c84'; + public const CACHE_KEY = 'fe836bd05eeafce1d549fcd8451f7190277f1b995420bead723e98ac721f2089'; public function testGetWhereClauseNoCache() { @@ -63,9 +67,20 @@ public function testGetWhereClauseNoCache() $this->cacheDriver ->expects(self::once()) ->method('set') - ->with(self::CACHE_KEY, ['((C.id IN(2, 5)))', []], 60); - - self::assertEquals('WHERE ((C.id IN(2, 5)))', $this->cachedConditionGenerator->getWhereClause('WHERE ')); + ->with( + self::CACHE_KEY, + [ + '(((C.id = :search_0 OR C.id = :search_1)))', + [ + ':search_0' => [2, 'integer'], + ':search_1' => [5, 'integer'], + ], + ], + 60 + ); + + self::assertEquals('(((C.id = :search_0 OR C.id = :search_1)))', $this->cachedConditionGenerator->getWhereClause()); + self::assertEquals(new ArrayCollection([':search_0' => [2, Type::getType('integer')], ':search_1' => [5, Type::getType('integer')]]), $this->cachedConditionGenerator->getParameters()); } public function testGetWhereClauseWithCache() @@ -78,14 +93,14 @@ public function testGetWhereClauseWithCache() ->expects(self::once()) ->method('get') ->with(self::CACHE_KEY) - ->willReturn(["me = 'foo'", ['1' => 'he']]); + ->willReturn(["me = 'foo'", [':search' => [1, 'integer']]]); $this->cacheDriver ->expects(self::never()) ->method('set'); self::assertEquals("me = 'foo'", $this->cachedConditionGenerator->getWhereClause()); - self::assertEquals(['1' => 'he'], $this->cachedConditionGenerator->getQueryHintValue()->parameters); + self::assertEquals(new ArrayCollection([':search' => [1, Type::getType('integer')]]), $this->cachedConditionGenerator->getParameters()); } public function testGetWhereWithPrepend() @@ -98,13 +113,14 @@ public function testGetWhereWithPrepend() ->expects(self::once()) ->method('get') ->with(self::CACHE_KEY) - ->willReturn(["me = 'foo'", []]); + ->willReturn(["me = 'foo'", [':search' => [1, 'integer']]]); $this->cacheDriver ->expects(self::never()) ->method('set'); - self::assertEquals("WHERE me = 'foo'", $this->cachedConditionGenerator->getWhereClause('WHERE ')); + self::assertEquals("me = 'foo'", $this->cachedConditionGenerator->getWhereClause()); + self::assertEquals(new ArrayCollection([':search' => [1, Type::getType('integer')]]), $this->cachedConditionGenerator->getParameters()); } public function testGetEmptyWhereWithPrepend() @@ -132,7 +148,7 @@ public function testGetEmptyWhereWithPrepend() $this->cacheDriver ->expects(self::once()) ->method('get') - ->with('a9c044cceecfd09b772d1190e8c2cc32b11c59d08b7d20b4b4459bab8f9b4bd6') + ->with('f8813fdfdea9d74adea380e30645c5e2705d3b4114dc5fbf252e63e583ea598d') ->willReturn(null); $this->cacheDriver @@ -140,6 +156,7 @@ public function testGetEmptyWhereWithPrepend() ->method('set'); self::assertEquals('', $this->cachedConditionGenerator->getWhereClause('WHERE ')); + self::assertEquals(new ArrayCollection(), $this->cachedConditionGenerator->getParameters()); } public function testUpdateQueryWithPrepend() @@ -147,11 +164,12 @@ public function testUpdateQueryWithPrepend() $whereCase = $this->cachedConditionGenerator->getWhereClause(); $this->cachedConditionGenerator->updateQuery(); - $this->assertEquals('((C.id IN(2, 5)))', $whereCase); + $this->assertEquals('(((C.id = :search_0 OR C.id = :search_1)))', $whereCase); $this->assertEquals( - 'SELECT I FROM Rollerworks\Component\Search\Tests\Fixtures\Entity\ECommerceInvoice I JOIN I.customer C WHERE ((C.id IN(2, 5)))', + 'SELECT I FROM Rollerworks\Component\Search\Tests\Fixtures\Entity\ECommerceInvoice I JOIN I.customer C WHERE (((C.id = :search_0 OR C.id = :search_1)))', $this->query->getDQL() ); + self::assertEquals(new ArrayCollection([new Parameter('search_0', 2, Type::getType('integer')), new Parameter('search_1', 5, Type::getType('integer'))]), $this->conditionGenerator->getQuery()->getParameters()); } public function testUpdateQueryWithNoResult() @@ -179,7 +197,7 @@ public function testUpdateQueryWithNoResult() $this->cacheDriver ->expects(self::once()) ->method('get') - ->with('a9c044cceecfd09b772d1190e8c2cc32b11c59d08b7d20b4b4459bab8f9b4bd6') + ->with('f8813fdfdea9d74adea380e30645c5e2705d3b4114dc5fbf252e63e583ea598d') ->willReturn(null); $this->cacheDriver @@ -201,8 +219,8 @@ public function testUpdateQueryWithNoResult() public function testGetWhereClauseWithCacheAndPrimaryCond() { $cacheDriverProphecy = $this->prophesize(CacheInterface::class); - $cacheDriverProphecy->get('41329a2e34ac65573fb097e858a5b12685b0327e3e55b5bb48902e4731b42afa')->willReturn(["me = 'foo'", ['1' => 'he']]); - $cacheDriverProphecy->get('a91ba1ea3289d6d2ad5caa7ef160c78fa52f0e20c305cc90c4d3cea8b7938cb4')->willReturn(["you = 'me' AND me = 'foo'", ['1' => 'he']]); + $cacheDriverProphecy->get('7fbf724b9ed73837313684319ec3d5772a53c6c0373dbf90a880e383900e5e07')->willReturn(["me = 'foo'", ['1' => 'he']]); + $cacheDriverProphecy->get('044d0466ebd4264c4e33c64a0e341df225657353316869049ab3c24cbba86ffa')->willReturn(["you = 'me' AND me = 'foo'", ['1' => 'he']]); $cacheDriver = $cacheDriverProphecy->reveal(); $searchCondition = SearchConditionBuilder::create($this->getFieldSet()) diff --git a/lib/Doctrine/Orm/Tests/ConditionGeneratorResultsTestCase.php b/lib/Doctrine/Orm/Tests/ConditionGeneratorResultsTestCase.php index 426a1e9d..8754f308 100644 --- a/lib/Doctrine/Orm/Tests/ConditionGeneratorResultsTestCase.php +++ b/lib/Doctrine/Orm/Tests/ConditionGeneratorResultsTestCase.php @@ -13,7 +13,7 @@ namespace Rollerworks\Component\Search\Tests\Doctrine\Orm; -use Rollerworks\Component\Search\Doctrine\Orm\AbstractConditionGenerator; +use Rollerworks\Component\Search\Doctrine\Orm\ConditionGenerator; use Rollerworks\Component\Search\Extension\Core\Type\BirthdayType; use Rollerworks\Component\Search\Extension\Core\Type\ChoiceType; use Rollerworks\Component\Search\Extension\Core\Type\DateType; @@ -139,7 +139,7 @@ protected function getDbRecords() ]; } - protected function configureConditionGenerator(AbstractConditionGenerator $conditionGenerator) + protected function configureConditionGenerator(ConditionGenerator $conditionGenerator) { $conditionGenerator->setDefaultEntity(self::INVOICE_CLASS, 'I'); $conditionGenerator->setField('id', 'id'); diff --git a/lib/Doctrine/Orm/Tests/DqlConditionGeneratorTest.php b/lib/Doctrine/Orm/Tests/DqlConditionGeneratorTest.php index 421a9f28..8d0ed5f2 100644 --- a/lib/Doctrine/Orm/Tests/DqlConditionGeneratorTest.php +++ b/lib/Doctrine/Orm/Tests/DqlConditionGeneratorTest.php @@ -13,21 +13,21 @@ namespace Rollerworks\Component\Search\Tests\Doctrine\Orm; +use Doctrine\DBAL\Types\Type; use Doctrine\ORM\Query\QueryException; use Doctrine\ORM\QueryBuilder; -use Prophecy\Argument; -use Rollerworks\Component\Search\Doctrine\Dbal\ColumnConversion; use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; -use Rollerworks\Component\Search\Doctrine\Dbal\ValueConversion; +use Rollerworks\Component\Search\Doctrine\Orm\ColumnConversion; use Rollerworks\Component\Search\Doctrine\Orm\DqlConditionGenerator; -use Rollerworks\Component\Search\Doctrine\Orm\SqlConversionInfo; +use Rollerworks\Component\Search\Doctrine\Orm\Tests\Fixtures\GetCustomerTypeFunction; +use Rollerworks\Component\Search\Doctrine\Orm\ValueConversion; use Rollerworks\Component\Search\Extension\Core\Type\ChoiceType; -use Rollerworks\Component\Search\Extension\Core\Type\DateType; use Rollerworks\Component\Search\Extension\Core\Type\IntegerType; use Rollerworks\Component\Search\Extension\Core\Type\TextType; use Rollerworks\Component\Search\SearchCondition; use Rollerworks\Component\Search\SearchConditionBuilder; use Rollerworks\Component\Search\SearchPrimaryCondition; +use Rollerworks\Component\Search\Tests\Doctrine\Orm\Fixtures\Entity\ECommerceInvoice; use Rollerworks\Component\Search\Value\Compare; use Rollerworks\Component\Search\Value\ExcludedRange; use Rollerworks\Component\Search\Value\PatternMatch; @@ -47,8 +47,8 @@ protected function getFieldSet(bool $build = true) private function getConditionGenerator(SearchCondition $condition, $query = null, $noMapping = false) { - if (null === $query) { - $query = $this->em->createQuery('SELECT I FROM Rollerworks\Component\Search\Tests\Doctrine\Orm\Fixtures\Entity\ECommerceInvoice I JOIN I.customer C'); + if ($query === null) { + $query = $this->em->createQuery('SELECT I FROM '.ECommerceInvoice::class.' I JOIN I.customer C'); } $conditionGenerator = $this->getOrmFactory()->createConditionGenerator($query, $condition); @@ -80,10 +80,27 @@ public function testSimpleQuery() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('((C.id IN(2, 5)))', $conditionGenerator->getWhereClause()); + $this->assertEquals('(((C.id = :search_0 OR C.id = :search_1)))', $conditionGenerator->getWhereClause()); $this->assertDqlCompiles( $conditionGenerator, - 'SELECT i0_.invoice_id AS invoice_id0, i0_.label AS label1, i0_.pubdate AS pubdate2, i0_.status AS status3, i0_.price_total AS price_total4, i0_.customer AS customer5, i0_.parent_id AS parent_id6 FROM invoices i0_ INNER JOIN customers c1_ ON i0_.customer = c1_.id WHERE ((c1_.id IN (2, 5)))' + <<<'SQL' +SELECT + i0_.invoice_id AS invoice_id_0, + i0_.label AS label_1, + i0_.pubdate AS pubdate_2, + i0_.status AS status_3, + i0_.price_total AS price_total_4, + i0_.customer AS customer_5, + i0_.parent_id AS parent_id_6 +FROM invoices i0_ + INNER JOIN customers c1_ ON i0_.customer = c1_.id +WHERE (((c1_.id = ? OR c1_.id = ?))) +SQL +, + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [5, Type::getType('integer')], + ] ); } @@ -102,10 +119,30 @@ public function testQueryWithMultipleFields() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('((C.id IN(2, 5)) AND (I.status IN(2, 5)))', $conditionGenerator->getWhereClause()); + $this->assertEquals('(((C.id = :search_0 OR C.id = :search_1)) AND ((I.status = :search_2 OR I.status = :search_3)))', $conditionGenerator->getWhereClause()); $this->assertDqlCompiles( $conditionGenerator, - 'SELECT i0_.invoice_id AS invoice_id0, i0_.label AS label1, i0_.pubdate AS pubdate2, i0_.status AS status3, i0_.price_total AS price_total4, i0_.customer AS customer5, i0_.parent_id AS parent_id6 FROM invoices i0_ INNER JOIN customers c1_ ON i0_.customer = c1_.id WHERE ((c1_.id IN (2, 5)) AND (i0_.status IN (2, 5)))' + <<<'SQL' +SELECT + i0_.invoice_id AS invoice_id_0, + i0_.label AS label_1, + i0_.pubdate AS pubdate_2, + i0_.status AS status_3, + i0_.price_total AS price_total_4, + i0_.customer AS customer_5, + i0_.parent_id AS parent_id_6 +FROM + invoices i0_ + INNER JOIN customers c1_ ON i0_.customer = c1_.id +WHERE (((c1_.id = ? OR c1_.id = ?)) AND ((i0_.status = ? OR i0_.status = ?))) +SQL +, + [ + ':search_0' => [2, Type::getType('integer')], + ':search_1' => [5, Type::getType('integer')], + ':search_2' => [2, Type::getType('integer')], + ':search_3' => [5, Type::getType('integer')], + ] ); } @@ -129,7 +166,7 @@ public function testExcludes() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('((C.id NOT IN(2, 5)))', $conditionGenerator->getWhereClause()); + $this->assertEquals('(((C.id <> :search_0 AND C.id <> :search_1)))', $conditionGenerator->getWhereClause()); $this->assertDqlCompiles($conditionGenerator); } @@ -144,7 +181,7 @@ public function testIncludesAndExcludes() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('((C.id IN(2) AND C.id NOT IN(5)))', $conditionGenerator->getWhereClause()); + $this->assertEquals('((C.id = :search_0 AND C.id <> :search_1))', $conditionGenerator->getWhereClause()); $this->assertDqlCompiles($conditionGenerator); } @@ -162,8 +199,7 @@ public function testRanges() $conditionGenerator = $this->getConditionGenerator($condition); $this->assertEquals( - '((((C.id >= 2 AND C.id <= 5) OR (C.id >= 10 AND C.id <= 20) OR '. - '(C.id > 60 AND C.id <= 70) OR (C.id >= 100 AND C.id < 150))))', + '((((C.id >= :search_0 AND C.id <= :search_1) OR (C.id >= :search_2 AND C.id <= :search_3) OR (C.id > :search_4 AND C.id <= :search_5) OR (C.id >= :search_6 AND C.id < :search_7))))', $conditionGenerator->getWhereClause() ); $this->assertDqlCompiles($conditionGenerator); @@ -183,8 +219,7 @@ public function testExcludedRanges() $conditionGenerator = $this->getConditionGenerator($condition); $this->assertEquals( - '((((C.id <= 2 OR C.id >= 5) AND (C.id <= 10 OR C.id >= 20) AND '. - '(C.id < 60 OR C.id >= 70) AND (C.id <= 100 OR C.id > 150))))', + '((((C.id <= :search_0 OR C.id >= :search_1) AND (C.id <= :search_2 OR C.id >= :search_3) AND (C.id < :search_4 OR C.id >= :search_5) AND (C.id <= :search_6 OR C.id > :search_7))))', $conditionGenerator->getWhereClause() ); $this->assertDqlCompiles($conditionGenerator); @@ -200,7 +235,7 @@ public function testSingleComparison() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals('((C.id > 2))', $conditionGenerator->getWhereClause()); + $this->assertEquals('((C.id > :search_0))', $conditionGenerator->getWhereClause()); $this->assertDqlCompiles($conditionGenerator); } @@ -216,7 +251,7 @@ public function testMultipleComparisons() $conditionGenerator = $this->getConditionGenerator($condition); $this->assertEquals( - '(((C.id > 2 AND C.id < 10)))', + '(((C.id > :search_0 AND C.id < :search_1)))', $conditionGenerator->getWhereClause() ); $this->assertDqlCompiles($conditionGenerator); @@ -244,7 +279,7 @@ public function testMultipleComparisonsWithGroups() $conditionGenerator = $this->getConditionGenerator($condition); $this->assertEquals( - '((((C.id IN(20) OR (C.id > 2 AND C.id < 10)))) OR ((C.id > 30)))', + '((((C.id = :search_0 OR (C.id > :search_1 AND C.id < :search_2)))) OR ((C.id > :search_3)))', $conditionGenerator->getWhereClause() ); $this->assertDqlCompiles($conditionGenerator); @@ -262,7 +297,7 @@ public function testExcludingComparisons() $conditionGenerator = $this->getConditionGenerator($condition); $this->assertEquals( - '((C.id <> 2 AND C.id <> 5))', + '((C.id <> :search_0 AND C.id <> :search_1))', $conditionGenerator->getWhereClause() ); $this->assertDqlCompiles($conditionGenerator); @@ -282,7 +317,7 @@ public function testExcludingComparisonsWithNormal() $conditionGenerator = $this->getConditionGenerator($condition); $this->assertEquals( - '(((C.id > 30 AND C.id < 50) AND C.id <> 35 AND C.id <> 45))', + '(((C.id > :search_0 AND C.id < :search_1) AND C.id <> :search_2 AND C.id <> :search_3))', $conditionGenerator->getWhereClause() ); $this->assertDqlCompiles($conditionGenerator); @@ -303,14 +338,34 @@ public function testPatternMatchers() $conditionGenerator = $this->getConditionGenerator($condition); $this->assertEquals( - "(((C.firstName LIKE '%foo' ESCAPE '\\' OR C.firstName LIKE '%fo\\''o' ESCAPE '\\' OR C.firstName LIKE '%fo''o' ESCAPE '\\' OR C.firstName LIKE '%fo''''o' ESCAPE '\\') AND LOWER(C.firstName) NOT LIKE LOWER('bar%') ESCAPE '\\'))", + "(((C.firstName LIKE CONCAT('%', :search_0) OR C.firstName LIKE CONCAT('%', :search_1) OR C.firstName LIKE CONCAT('%', :search_2) OR C.firstName LIKE CONCAT('%', :search_3)) AND LOWER(C.firstName) NOT LIKE LOWER(CONCAT(:search_4, '%'))))", $conditionGenerator->getWhereClause() ); - $this->assertDqlCompiles( - $conditionGenerator, - 'SELECT i0_.invoice_id AS invoice_id0, i0_.label AS label1, i0_.pubdate AS pubdate2, i0_.status AS status3, i0_.price_total AS price_total4, i0_.customer AS customer5, i0_.parent_id AS parent_id6 FROM invoices i0_ INNER JOIN customers c1_ ON i0_.customer = c1_.id WHERE (((c1_.first_name LIKE '.$this->conn->quote('%foo').' ESCAPE '.$this->conn->quote('\\').' OR c1_.first_name LIKE '.$this->conn->quote("%fo\\'o").' ESCAPE '.$this->conn->quote('\\').' OR c1_.first_name LIKE '.$this->conn->quote("%fo'o").' ESCAPE '.$this->conn->quote('\\').' OR c1_.first_name LIKE '.$this->conn->quote("%fo''o").' ESCAPE '.$this->conn->quote('\\').') AND LOWER(c1_.first_name) NOT LIKE LOWER('.$this->conn->quote('bar%').') ESCAPE '.$this->conn->quote('\\').'))' - ); + if ($this->conn->getDatabasePlatform()->getName() === 'postgresql') { + $this->assertDqlCompiles( + $conditionGenerator, + <<<'SQL' +SELECT + i0_.invoice_id AS invoice_id_0, + i0_.label AS label_1, + i0_.pubdate AS pubdate_2, + i0_.status AS status_3, + i0_.price_total AS price_total_4, + i0_.customer AS customer_5, + i0_.parent_id AS parent_id_6 +FROM + invoices i0_ + INNER JOIN customers c1_ ON i0_.customer = c1_.id +WHERE (((c1_.first_name LIKE '%' || ? OR c1_.first_name LIKE '%' || ? OR c1_.first_name LIKE '%' || ? OR + c1_.first_name LIKE '%' || ?) AND LOWER(c1_.first_name) NOT LIKE LOWER(? || '%'))) +SQL + ); + } else { + $this->assertDqlCompiles( + $conditionGenerator + ); + } } public function testSubGroups() @@ -331,7 +386,7 @@ public function testSubGroups() $conditionGenerator = $this->getConditionGenerator($condition); $this->assertEquals( - '(((C.id IN(2))) OR ((C.id IN(3))))', + '(((C.id = :search_0)) OR ((C.id = :search_1)))', $conditionGenerator->getWhereClause() ); $this->assertDqlCompiles($conditionGenerator); @@ -353,7 +408,7 @@ public function testSubGroupWithRootCondition() $conditionGenerator = $this->getConditionGenerator($condition); $this->assertEquals( - "(((C.id IN(2))) AND ((((C.firstName LIKE '%foo' ESCAPE '\\' OR C.lastName LIKE '%foo' ESCAPE '\\')))))", + "(((C.id = :search_0)) AND ((((C.firstName LIKE CONCAT('%', :search_1) OR C.lastName LIKE CONCAT('%', :search_2))))))", $conditionGenerator->getWhereClause() ); $this->assertDqlCompiles($conditionGenerator); @@ -373,7 +428,7 @@ public function testOrGroupRoot() $conditionGenerator = $this->getConditionGenerator($condition); $this->assertEquals( - "((C.id IN(2)) OR (C.firstName LIKE '%foo' ESCAPE '\\'))", + "((C.id = :search_0) OR (C.firstName LIKE CONCAT('%', :search_1)))", $conditionGenerator->getWhereClause() ); $this->assertDqlCompiles($conditionGenerator); @@ -397,7 +452,7 @@ public function testSubOrGroup() $conditionGenerator = $this->getConditionGenerator($condition); $this->assertEquals( - "((((C.id IN(2)) OR (C.firstName LIKE '%foo' ESCAPE '\\'))))", + "((((C.id = :search_0) OR (C.firstName LIKE CONCAT('%', :search_1)))))", $conditionGenerator->getWhereClause() ); $this->assertDqlCompiles($conditionGenerator); @@ -412,44 +467,14 @@ public function testColumnConversion() ->willReturnCallback(function ($column, array $options, ConversionHints $hints) { self::assertArrayHasKey('grouping', $options); self::assertTrue($options['grouping']); + self::assertEquals('C.id', $hints->column); - self::assertEquals('C', $hints->field->alias); // FIXME This is wrong, but the mapping system doesn't know of final aliases until processing - self::assertEquals('c1_.id', $hints->column); - - return "CAST($column AS customer_type)"; - }) - ; - - $fieldSetBuilder = $this->getFieldSet(false); - $fieldSetBuilder->add('customer', IntegerType::class, ['grouping' => true, 'doctrine_dbal_conversion' => $converter]); - - $condition = SearchConditionBuilder::create($fieldSetBuilder->getFieldSet()) - ->field('customer') - ->addSimpleValue(2) - ->end() - ->getSearchCondition(); - - $conditionGenerator = $this->getConditionGenerator($condition); - self::assertEquals("((RW_SEARCH_FIELD_CONVERSION('customer', C.id, 0) IN(2)))", $conditionGenerator->getWhereClause()); - $this->assertDqlCompiles($conditionGenerator, 'SELECT i0_.invoice_id AS invoice_id0, i0_.label AS label1, i0_.pubdate AS pubdate2, i0_.status AS status3, i0_.price_total AS price_total4, i0_.customer AS customer5, i0_.parent_id AS parent_id6 FROM invoices i0_ INNER JOIN customers c1_ ON i0_.customer = c1_.id WHERE ((CAST(c1_.id AS customer_type) IN (2)))'); - } - - public function testValueConversion() - { - $converter = $this->createMock(ValueConversion::class); - $converter - ->expects($this->atLeastOnce()) - ->method('convertValue') - ->willReturnCallback(function ($value, array $options) { - self::assertArrayHasKey('grouping', $options); - self::assertTrue($options['grouping']); - - return "get_customer_type($value)"; + return "SEARCH_CONVERSION_CAST($column, 'customer_type')"; }) ; $fieldSetBuilder = $this->getFieldSet(false); - $fieldSetBuilder->add('customer', IntegerType::class, ['grouping' => true, 'doctrine_dbal_conversion' => $converter]); + $fieldSetBuilder->add('customer', IntegerType::class, ['grouping' => true, 'doctrine_orm_conversion' => $converter]); $condition = SearchConditionBuilder::create($fieldSetBuilder->getFieldSet()) ->field('customer') @@ -459,132 +484,44 @@ public function testValueConversion() $conditionGenerator = $this->getConditionGenerator($condition); - $this->assertEquals("((C.id = RW_SEARCH_VALUE_CONVERSION('customer', C.id, 1, 0)))", $conditionGenerator->getWhereClause( - )); + self::assertEquals("((SEARCH_CONVERSION_CAST(C.id, 'customer_type') = :search_0))", $conditionGenerator->getWhereClause()); $this->assertDqlCompiles( $conditionGenerator, - 'SELECT i0_.invoice_id AS invoice_id0, i0_.label AS label1, i0_.pubdate AS pubdate2, i0_.status AS status3, i0_.price_total AS price_total4, i0_.customer AS customer5, i0_.parent_id AS parent_id6 FROM invoices i0_ INNER JOIN customers c1_ ON i0_.customer = c1_.id WHERE ((c1_.id = get_customer_type(2)))' + <<<'SQL' +SELECT + i0_.invoice_id AS invoice_id_0, i0_.label AS label_1, i0_.pubdate AS pubdate_2, i0_.status AS status_3, + i0_.price_total AS price_total_4, i0_.customer AS customer_5, i0_.parent_id AS parent_id_6 +FROM + invoices i0_ + INNER JOIN customers c1_ ON i0_.customer = c1_.id +WHERE ((CAST(c1_.id AS customer_type) = ?)) +SQL ); } - public function testConversionStrategyValue() + public function testValueConversion() { - $converter = $this->createMock(ValueConversionStrategy::class); - $converter - ->expects($this->atLeastOnce()) - ->method('getConversionStrategy') - ->willReturnCallback(function ($value) { - if (!$value instanceof \DateTime && !\is_int($value)) { - throw new \InvalidArgumentException('Only integer/string and DateTime are accepted.'); - } - - if ($value instanceof \DateTime) { - return 2; - } - - return 1; - }) - ; + $emConfig = $this->em->getConfiguration(); + $emConfig->addCustomStringFunction('GET_CUSTOMER_TYPE', GetCustomerTypeFunction::class); + $converter = $this->createMock(ValueConversion::class); $converter - ->expects($this->atLeastOnce()) + ->expects(self::atLeastOnce()) ->method('convertValue') - ->willReturnCallback(function ($value, array $passedOptions, ConversionHints $hints) { - self::assertArrayHasKey('pattern', $passedOptions); - self::assertEquals('dd-MM-yy', $passedOptions['pattern']); - - if ($value instanceof \DateTime) { - self::assertEquals(2, $hints->conversionStrategy); - - return 'CAST('.$hints->connection->quote($value->format('Y-m-d')).' AS AGE)'; - } - - self::assertEquals(1, $hints->conversionStrategy); - - return $value; - }) - ; - - $fieldSet = $this->getFieldSet(false); - $fieldSet->add('customer_birthday', DateType::class, ['doctrine_dbal_conversion' => $converter, 'pattern' => 'dd-MM-yy']); - - $condition = SearchConditionBuilder::create($fieldSet->getFieldSet()) - ->field('customer_birthday') - ->addSimpleValue(18) - ->addSimpleValue(new \DateTime('2001-01-15', new \DateTimeZone('UTC'))) - ->end() - ->getSearchCondition(); - - $conditionGenerator = $this->getConditionGenerator($condition); - self::assertEquals( - "(((C.birthday = RW_SEARCH_VALUE_CONVERSION('customer_birthday', C.birthday, 1, 1) OR C.birthday = RW_SEARCH_VALUE_CONVERSION('customer_birthday', C.birthday, 2, 2))))", - $conditionGenerator->getWhereClause() - ); - $this->assertDqlCompiles( - $conditionGenerator, - "SELECT i0_.invoice_id AS invoice_id0, i0_.label AS label1, i0_.pubdate AS pubdate2, i0_.status AS status3, i0_.price_total AS price_total4, i0_.customer AS customer5, i0_.parent_id AS parent_id6 FROM invoices i0_ INNER JOIN customers c1_ ON i0_.customer = c1_.id WHERE (((c1_.birthday = 18 OR c1_.birthday = CAST('2001-01-15' AS AGE))))" - ); - } - - public function testConversionStrategyColumn() - { - $converter = $this->createMock(ColumnConversionStrategy::class); - $converter - ->expects($this->atLeastOnce()) - ->method('getConversionStrategy') - ->willReturnCallback(function ($value) { - if (!\is_string($value) && !\is_int($value)) { - throw new \InvalidArgumentException('Only integer/string is accepted.'); - } - - if (\is_string($value)) { - return 2; - } - - return 1; - }) - ; - - $converter - ->expects($this->atLeastOnce()) - ->method('convertColumn') - ->willReturnCallback(function ($column, array $options, ConversionHints $hints) { - if (2 === $hints->conversionStrategy) { - return "search_conversion_age($column)"; - } + ->willReturnCallback(function ($value, array $options, ConversionHints $hints) { + self::assertArrayHasKey('grouping', $options); + self::assertTrue($options['grouping']); - self::assertEquals(1, $hints->conversionStrategy); + $value = $hints->createParamReferenceFor($value); - return $column; + return "get_customer_type($value)"; }) ; $fieldSetBuilder = $this->getFieldSet(false); - $fieldSetBuilder->add('customer_birthday', TextType::class, ['doctrine_dbal_conversion' => $converter]); + $fieldSetBuilder->add('customer', IntegerType::class, ['grouping' => true, 'doctrine_orm_conversion' => $converter]); $condition = SearchConditionBuilder::create($fieldSetBuilder->getFieldSet()) - ->field('customer_birthday') - ->addSimpleValue(18) - ->addSimpleValue('2001-01-15') - ->end() - ->getSearchCondition(); - - $conditionGenerator = $this->getConditionGenerator($condition); - $conditionGenerator->setField('customer_birthday', 'birthday', 'C', self::CUSTOMER_CLASS, 'string'); - - self::assertEquals( - "(((RW_SEARCH_FIELD_CONVERSION('customer_birthday', C.birthday, 1) = 18 OR RW_SEARCH_FIELD_CONVERSION('customer_birthday', C.birthday, 2) = '2001-01-15')))", - $conditionGenerator->getWhereClause() - ); - $this->assertDqlCompiles( - $conditionGenerator, - "SELECT i0_.invoice_id AS invoice_id0, i0_.label AS label1, i0_.pubdate AS pubdate2, i0_.status AS status3, i0_.price_total AS price_total4, i0_.customer AS customer5, i0_.parent_id AS parent_id6 FROM invoices i0_ INNER JOIN customers c1_ ON i0_.customer = c1_.id WHERE (((c1_.birthday = 18 OR search_conversion_age(c1_.birthday) = '2001-01-15')))" - ); - } - - public function testUpdateQuery() - { - $condition = SearchConditionBuilder::create($this->getFieldSet()) ->field('customer') ->addSimpleValue(2) ->end() @@ -592,18 +529,10 @@ public function testUpdateQuery() $conditionGenerator = $this->getConditionGenerator($condition); - $whereCase = $conditionGenerator->getWhereClause(); - $conditionGenerator->updateQuery(' WHERE '); - - $this->assertEquals('((C.id IN(2)))', $whereCase); - $this->assertEquals( - "SELECT I FROM Rollerworks\Component\Search\Tests\Doctrine\Orm\Fixtures\Entity\ECommerceInvoice I JOIN I.customer C WHERE ((C.id IN(2)))", - $conditionGenerator->getQuery()->getDQL() - ); + $this->assertEquals('((C.id = get_customer_type(:search_0)))', $conditionGenerator->getWhereClause()); $this->assertDqlCompiles( $conditionGenerator, - 'SELECT i0_.invoice_id AS invoice_id0, i0_.label AS label1, i0_.pubdate AS pubdate2, i0_.status AS status3, i0_.price_total AS price_total4, i0_.customer AS customer5, i0_.parent_id AS parent_id6 FROM invoices i0_ INNER JOIN customers c1_ ON i0_.customer = c1_.id WHERE ((c1_.id IN (2)))', - false + 'SELECT i0_.invoice_id AS invoice_id_0, i0_.label AS label_1, i0_.pubdate AS pubdate_2, i0_.status AS status_3, i0_.price_total AS price_total_4, i0_.customer AS customer_5, i0_.parent_id AS parent_id_6 FROM invoices i0_ INNER JOIN customers c1_ ON i0_.customer = c1_.id WHERE ((c1_.id = get_customer_type(?)))' ); } @@ -615,22 +544,16 @@ public function testUpdateQueryWithQueryBuilder() ->end() ->getSearchCondition(); - if (method_exists(QueryBuilder::class, 'setHint')) { - $qb = $this->prophesize(QueryBuilder::class); - } else { - $qb = $this->prophesize(QueryBuilderWithHints::class); - } + $qb = $this->em->createQueryBuilder(); + $qb->select('C')->from(self::CUSTOMER_CLASS, 'C'); - $qb->getEntityManager()->willReturn($this->em); - $qb->setHint('rws_conversion_hint', Argument::type(SqlConversionInfo::class))->shouldBeCalled(); - $qb->andWhere('((C.id IN(2)))')->shouldBeCalled(); - - $conditionGenerator = $this->getConditionGenerator($condition, $qb->reveal()); + $conditionGenerator = $this->getConditionGenerator($condition, $qb); $whereCase = $conditionGenerator->getWhereClause(); - $conditionGenerator->updateQuery(' WHERE '); - $this->assertEquals('((C.id IN(2)))', $whereCase); + $this->assertDqlCompiles($conditionGenerator, '', [':search_0' => [2, Type::getType('integer')]]); + $this->assertEquals('((C.id = :search_0))', $whereCase); + $this->assertEquals('SELECT C FROM Rollerworks\Component\Search\Tests\Doctrine\Orm\Fixtures\Entity\ECommerceCustomer C WHERE ((C.id = :search_0))', $qb->getDQL()); } public function testUpdateQueryWithNoResult() @@ -675,9 +598,9 @@ public function testQueryWithPrependAndPrimaryCond() $whereCase = $conditionGenerator->getWhereClause('WHERE '); $conditionGenerator->updateQuery(); - $this->assertEquals('WHERE ((I.status IN(1, 2))) AND ((C.id IN(2, 5)))', $whereCase); + $this->assertEquals('WHERE (((I.status = :search_0 OR I.status = :search_1))) AND (((C.id = :search_2 OR C.id = :search_3)))', $whereCase); $this->assertEquals( - 'SELECT I FROM Rollerworks\Component\Search\Tests\Doctrine\Orm\Fixtures\Entity\ECommerceInvoice I JOIN I.customer C WHERE ((I.status IN(1, 2))) AND ((C.id IN(2, 5)))', + 'SELECT I FROM Rollerworks\Component\Search\Tests\Doctrine\Orm\Fixtures\Entity\ECommerceInvoice I JOIN I.customer C WHERE (((I.status = :search_0 OR I.status = :search_1))) AND (((C.id = :search_2 OR C.id = :search_3)))', $conditionGenerator->getQuery()->getDQL() ); } @@ -706,60 +629,35 @@ public function testEmptyQueryWithPrependAndPrimaryCond() $conditionGenerator = $this->getConditionGenerator($condition); $conditionGenerator->updateQuery(); - $this->assertEquals('WHERE ((I.status IN(1, 2)))', $conditionGenerator->getWhereClause('WHERE ')); + $this->assertEquals('WHERE (((I.status = :search_0 OR I.status = :search_1)))', $conditionGenerator->getWhereClause('WHERE ')); $this->assertEquals( - 'SELECT I FROM Rollerworks\Component\Search\Tests\Doctrine\Orm\Fixtures\Entity\ECommerceInvoice I JOIN I.customer C WHERE ((I.status IN(1, 2)))', + 'SELECT I FROM Rollerworks\Component\Search\Tests\Doctrine\Orm\Fixtures\Entity\ECommerceInvoice I JOIN I.customer C WHERE (((I.status = :search_0 OR I.status = :search_1)))', $conditionGenerator->getQuery()->getDQL() ); } - public function testDoctrineAlias() + private function assertDqlCompiles(DqlConditionGenerator $conditionGenerator, string $expectedSql = '', ?array $parameters = null) { - $config = $this->em->getConfiguration(); - $config->addEntityNamespace('ECommerce', 'Rollerworks\Component\Search\Tests\Doctrine\Orm\Fixtures\Entity'); - - $query = $this->em->createQuery('SELECT I FROM ECommerce:ECommerceInvoice I JOIN I.customer C'); - - $condition = SearchConditionBuilder::create($this->getFieldSet()) - ->field('customer') - ->addSimpleValue(2) - ->end() - ->getSearchCondition(); - - $conditionGenerator = $this->getConditionGenerator($condition, $query, true); - - $conditionGenerator->setDefaultEntity('ECommerce:ECommerceInvoice', 'I'); - $conditionGenerator->setField('id', 'id', null, null, 'smallint'); - $conditionGenerator->setField('status', 'status'); - - $conditionGenerator->setDefaultEntity('ECommerce:ECommerceCustomer', 'C'); - $conditionGenerator->setField('customer', 'id'); - $conditionGenerator->setField('customer_name#first_name', 'firstName'); - $conditionGenerator->setField('customer_name#last_name', 'lastName'); - $conditionGenerator->setField('customer_birthday', 'birthday'); - - $whereCase = $conditionGenerator->getWhereClause(); - - $this->assertEquals('((C.id IN(2)))', $whereCase); - $this->assertDqlCompiles($conditionGenerator, 'SELECT i0_.invoice_id AS invoice_id0, i0_.label AS label1, i0_.pubdate AS pubdate2, i0_.status AS status3, i0_.price_total AS price_total4, i0_.customer AS customer5, i0_.parent_id AS parent_id6 FROM invoices i0_ INNER JOIN customers c1_ ON i0_.customer = c1_.id WHERE ((c1_.id IN (2)))'); - } + $conditionGenerator->updateQuery(); - private function assertDqlCompiles(DqlConditionGenerator $conditionGenerator, string $expectedSql = '', bool $updateQuery = true) - { - if ($updateQuery) { - $conditionGenerator->updateQuery(); + if ($parameters !== null) { + self::assertEquals($parameters, $conditionGenerator->getParameters()->toArray()); } try { - $sql = $conditionGenerator->getQuery()->getSQL(); + $query = $conditionGenerator->getQuery(); + + if ($query instanceof QueryBuilder) { + $query = $query->getQuery(); + } + + $sql = $query->getSQL(); - if ('' !== $expectedSql) { - // In Doctrine ORM 2.5 the column-alias naming has changed, - // as we need to be compatible with older versions we simple remove - // the underscore between the name and alias incrementer - $sql = preg_replace('/ AS ([\w\d]+)_(\d+)/i', ' AS $1$2', $sql); + if ($expectedSql !== '') { + $expectedSql = preg_replace('/\s+/', ' ', trim($expectedSql)); + $sql = preg_replace('/\s+/', ' ', trim($sql)); - $this->assertEquals($expectedSql, $sql); + self::assertEquals($expectedSql, $sql); } } catch (QueryException $e) { $this->fail('Compile error: '.$e->getMessage().' with Query: '.$conditionGenerator->getQuery()->getDQL()); diff --git a/lib/Doctrine/Orm/Tests/FieldConfigBuilderTest.php b/lib/Doctrine/Orm/Tests/FieldConfigBuilderTest.php index 73cc2a7e..a1c24b9d 100644 --- a/lib/Doctrine/Orm/Tests/FieldConfigBuilderTest.php +++ b/lib/Doctrine/Orm/Tests/FieldConfigBuilderTest.php @@ -16,8 +16,8 @@ use Doctrine\DBAL\Types\Type as DbType; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; -use Rollerworks\Component\Search\Doctrine\Dbal\Query\QueryField; use Rollerworks\Component\Search\Doctrine\Orm\FieldConfigBuilder; +use Rollerworks\Component\Search\Doctrine\Orm\OrmQueryField as QueryField; use Rollerworks\Component\Search\Extension\Core\Type\IntegerType; use Rollerworks\Component\Search\Extension\Core\Type\TextType; use Rollerworks\Component\Search\Searches; diff --git a/lib/Doctrine/Orm/Tests/Fixtures/GetCustomerTypeFunction.php b/lib/Doctrine/Orm/Tests/Fixtures/GetCustomerTypeFunction.php new file mode 100644 index 00000000..6eae0dd1 --- /dev/null +++ b/lib/Doctrine/Orm/Tests/Fixtures/GetCustomerTypeFunction.php @@ -0,0 +1,41 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Doctrine\Orm\Tests\Fixtures; + +use Doctrine\ORM\Query\AST\Functions\FunctionNode; +use Doctrine\ORM\Query\Lexer; +use Doctrine\ORM\Query\Parser; +use Doctrine\ORM\Query\SqlWalker; + +final class GetCustomerTypeFunction extends FunctionNode +{ + public $stringPrimary; + + public function getSql(SqlWalker $sqlWalker): string + { + $expression = $sqlWalker->walkSimpleArithmeticExpression($this->stringPrimary); + + return sprintf('get_customer_type(%s)', $expression); + } + + public function parse(Parser $parser): void + { + $parser->match(Lexer::T_IDENTIFIER); + $parser->match(Lexer::T_OPEN_PARENTHESIS); + + $this->stringPrimary = $parser->StringPrimary(); + + $parser->match(Lexer::T_CLOSE_PARENTHESIS); + } +} diff --git a/lib/Doctrine/Orm/Tests/OrmTestCase.php b/lib/Doctrine/Orm/Tests/OrmTestCase.php index 8be023a7..9d3b126a 100644 --- a/lib/Doctrine/Orm/Tests/OrmTestCase.php +++ b/lib/Doctrine/Orm/Tests/OrmTestCase.php @@ -22,10 +22,16 @@ use PHPUnit\Framework\Exception; use PHPUnit\Framework\Warning; use Psr\SimpleCache\CacheInterface; -use Rollerworks\Component\Search\Doctrine\Orm\AbstractConditionGenerator; +use Rollerworks\Component\Search\Doctrine\Orm\ConditionGenerator; use Rollerworks\Component\Search\Doctrine\Orm\DoctrineOrmFactory; -use Rollerworks\Component\Search\Doctrine\Orm\Functions\SqlFieldConversion; -use Rollerworks\Component\Search\Doctrine\Orm\Functions\SqlValueConversion; +use Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions\AgeFunction; +use Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions\CastFunction; +use Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions\CountChildrenFunction; +use Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions\MoneyCastFunction; +use Rollerworks\Component\Search\Extension\Doctrine\Orm\Type\BirthdayTypeExtension; +use Rollerworks\Component\Search\Extension\Doctrine\Orm\Type\ChildCountType; +use Rollerworks\Component\Search\Extension\Doctrine\Orm\Type\FieldTypeExtension; +use Rollerworks\Component\Search\Extension\Doctrine\Orm\Type\MoneyTypeExtension; use Rollerworks\Component\Search\SearchCondition; use Rollerworks\Component\Search\Tests\Doctrine\Dbal\DbalTestCase; use Rollerworks\Component\Search\Tests\Doctrine\Dbal\SchemaRecord; @@ -68,19 +74,17 @@ protected function setUp(): void if (!isset(self::$sharedConn)) { $config = Setup::createAnnotationMetadataConfiguration([__DIR__.'/Fixtures/Entity'], true, null, null, false); - $config->addCustomStringFunction( - 'RW_SEARCH_FIELD_CONVERSION', - SqlFieldConversion::class - ); - - $config->addCustomStringFunction( - 'RW_SEARCH_VALUE_CONVERSION', - SqlValueConversion::class - ); self::$sharedConn = TestUtil::getConnection(); self::$sharedEm = EntityManager::create(self::$sharedConn, $config); + $emConfig = self::$sharedEm->getConfiguration(); + + $emConfig->addCustomStringFunction('SEARCH_CONVERSION_CAST', CastFunction::class); + $emConfig->addCustomNumericFunction('SEARCH_CONVERSION_AGE', AgeFunction::class); + $emConfig->addCustomNumericFunction('SEARCH_COUNT_CHILDREN', CountChildrenFunction::class); + $emConfig->addCustomNumericFunction('SEARCH_MONEY_AS_NUMERIC', MoneyCastFunction::class); + $schemaTool = new SchemaTool(self::$sharedEm); $schemaTool->dropDatabase(); $schemaTool->updateSchema(self::$sharedEm->getMetadataFactory()->getAllMetadata(), false); @@ -122,6 +126,16 @@ protected function getOrmFactory() return new DoctrineOrmFactory($this->createMock(CacheInterface::class)); } + protected function getTypeExtensions(): array + { + return [ + new BirthdayTypeExtension(), + new ChildCountType(), + new FieldTypeExtension(), + new MoneyTypeExtension(), + ]; + } + /** * @return SchemaRecord[] */ @@ -142,7 +156,7 @@ protected function getQuery() /** * Configure fields of the ConditionGenerator. */ - protected function configureConditionGenerator(AbstractConditionGenerator $conditionGenerator) + protected function configureConditionGenerator(ConditionGenerator $conditionGenerator) { } diff --git a/lib/Doctrine/Orm/Tests/QueryBuilderWithHints.php b/lib/Doctrine/Orm/Tests/QueryBuilderWithHints.php deleted file mode 100644 index 36df5fed..00000000 --- a/lib/Doctrine/Orm/Tests/QueryBuilderWithHints.php +++ /dev/null @@ -1,123 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Tests\Doctrine\Orm; - -use Doctrine\ORM\Query; -use Doctrine\ORM\QueryBuilder; - -/** @internal */ -class QueryBuilderWithHints extends QueryBuilder -{ - /** - * Sets a query hint. - * - * @param string $name the name of the hint - * @param mixed $value the value of the hint - * - * @return self - */ - public function setHint($name, $value) - { - $this->_hints[$name] = $value; - - return $this; - } - - /** - * Gets the value of a query hint. If the hint name is not recognized, FALSE is returned. - * - * @param string $name the name of the hint - * - * @return mixed the value of the hint or FALSE, if the hint name is not recognized - */ - public function getHint($name) - { - return $this->_hints[$name] ?? false; - } - - /** - * Check if the query has a hint. - * - * @param string $name The name of the hint - * - * @return bool False if the query does not have any hint - */ - public function hasHint($name) - { - return isset($this->_hints[$name]); - } - - /** - * Return the key value map of query hints that are currently set. - * - * @return array - */ - public function getHints() - { - return $this->_hints; - } - - /** - * The map of query hints. - * - * @var array - */ - private $_hints = []; - - /** - * Constructs a Query instance from the current specifications of the builder. - * - * - * $qb = $em->createQueryBuilder() - * ->select('u') - * ->from('User', 'u'); - * $q = $qb->getQuery(); - * $results = $q->execute(); - * - * - * @return Query - */ - public function getQuery() - { - $parameters = clone $this->getParameters(); - $query = $this->getEntityManager()->createQuery($this->getDQL()) - ->setParameters($parameters) - ->setFirstResult($this->getFirstResult()) - ->setMaxResults($this->getMaxResults()); - - if ($this->lifetime) { - $query->setLifetime($this->lifetime); - } - - if ($this->cacheMode) { - $query->setCacheMode($this->cacheMode); - } - - if ($this->cacheable) { - $query->setCacheable($this->cacheable); - } - - if ($this->cacheRegion) { - $query->setCacheRegion($this->cacheRegion); - } - - if ($this->_hints) { - foreach ($this->_hints as $name => $value) { - $query->setHint($name, $value); - } - } - - return $query; - } -} diff --git a/lib/Doctrine/Orm/Tests/ValueConversionStrategy.php b/lib/Doctrine/Orm/Tests/ValueConversionStrategy.php deleted file mode 100644 index 5c993d5b..00000000 --- a/lib/Doctrine/Orm/Tests/ValueConversionStrategy.php +++ /dev/null @@ -1,24 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Component\Search\Tests\Doctrine\Orm; - -use Rollerworks\Component\Search\Doctrine\Dbal\StrategySupportedConversion; -use Rollerworks\Component\Search\Doctrine\Dbal\ValueConversion; - -/** - * @internal - */ -interface ValueConversionStrategy extends StrategySupportedConversion, ValueConversion -{ -} diff --git a/lib/Doctrine/Orm/ValueConversion.php b/lib/Doctrine/Orm/ValueConversion.php new file mode 100644 index 00000000..3a8acf33 --- /dev/null +++ b/lib/Doctrine/Orm/ValueConversion.php @@ -0,0 +1,42 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Rollerworks\Component\Search\Doctrine\Orm; + +use Rollerworks\Component\Search\Doctrine\Dbal\ConversionHints; + +/** + * A ValueConversion allows to convert the value "model" to a valid + * DQL statement to be used a column value. + * + * @author Sebastiaan Stok + */ +interface ValueConversion +{ + /** + * Returns the converted value as an DQL statement. + * + * The returned result must a be a valid DQL statement that can be used + * as a column's value. + * + * When using custom functions these need to be registered before usage. + * + * Used values must be registered as parameters using `$hints->createParamReferenceFor($value)` + * with an option DBAL Type as second argument (converted afterwards). + * + * @param mixed $value The "model" value format + * @param array $options Options of the Field configuration + * @param ConversionHints $hints Special information for the conversion process + */ + public function convertValue($value, array $options, ConversionHints $hints): string; +} diff --git a/lib/Doctrine/Orm/composer.json b/lib/Doctrine/Orm/composer.json index c286ce2f..6baa2f3c 100644 --- a/lib/Doctrine/Orm/composer.json +++ b/lib/Doctrine/Orm/composer.json @@ -21,9 +21,9 @@ ], "require": { "php": "^7.1", - "doctrine/orm": "^2.5", - "rollerworks/search": "^2.0@dev,>=2.0.0-ALPHA13", - "rollerworks/search-doctrine-dbal": "^2.0@dev,>=2.0.0-ALPHA13" + "doctrine/orm": "^2.6", + "rollerworks/search": "^2.0@dev,>=2.0.0-ALPHA22", + "rollerworks/search-doctrine-dbal": "^2.0@dev,>=2.0.0-ALPHA22" }, "require-dev": { "moneyphp/money": "^3.0.7", diff --git a/lib/Symfony/SearchBundle/DependencyInjection/Compiler/DoctrineOrmPass.php b/lib/Symfony/SearchBundle/DependencyInjection/Compiler/DoctrineOrmPass.php index 76b29eff..eaec5a19 100644 --- a/lib/Symfony/SearchBundle/DependencyInjection/Compiler/DoctrineOrmPass.php +++ b/lib/Symfony/SearchBundle/DependencyInjection/Compiler/DoctrineOrmPass.php @@ -13,8 +13,10 @@ namespace Rollerworks\Bundle\SearchBundle\DependencyInjection\Compiler; -use Rollerworks\Component\Search\Doctrine\Orm\Functions\SqlFieldConversion; -use Rollerworks\Component\Search\Doctrine\Orm\Functions\SqlValueConversion; +use Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions\AgeFunction; +use Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions\CastFunction; +use Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions\CountChildrenFunction; +use Rollerworks\Component\Search\Doctrine\Orm\Extension\Functions\MoneyCastFunction; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -44,8 +46,10 @@ public function process(ContainerBuilder $container) foreach ($entityManagers as $entityManager) { $ormConfigDef = $container->findDefinition('doctrine.orm.'.$entityManager.'_configuration'); - $ormConfigDef->addMethodCall('addCustomStringFunction', ['RW_SEARCH_FIELD_CONVERSION', SqlFieldConversion::class]); - $ormConfigDef->addMethodCall('addCustomStringFunction', ['RW_SEARCH_VALUE_CONVERSION', SqlValueConversion::class]); + $ormConfigDef->addMethodCall('addCustomStringFunction', ['SEARCH_CONVERSION_CAST', CastFunction::class]); + $ormConfigDef->addMethodCall('addCustomNumericFunction', ['SEARCH_CONVERSION_AGE', AgeFunction::class]); + $ormConfigDef->addMethodCall('addCustomNumericFunction', ['SEARCH_COUNT_CHILDREN', CountChildrenFunction::class]); + $ormConfigDef->addMethodCall('addCustomNumericFunction', ['SEARCH_MONEY_AS_NUMERIC', MoneyCastFunction::class]); } } } diff --git a/lib/Symfony/SearchBundle/DependencyInjection/Compiler/DoctrineOrmQueryBuilderPass.php b/lib/Symfony/SearchBundle/DependencyInjection/Compiler/DoctrineOrmQueryBuilderPass.php deleted file mode 100644 index 0f11cdba..00000000 --- a/lib/Symfony/SearchBundle/DependencyInjection/Compiler/DoctrineOrmQueryBuilderPass.php +++ /dev/null @@ -1,42 +0,0 @@ - - * - * This source file is subject to the MIT license that is bundled - * with this source code in the file LICENSE. - */ - -namespace Rollerworks\Bundle\SearchBundle\DependencyInjection\Compiler; - -use Doctrine\ORM\QueryBuilder; -use Rollerworks\Component\Search\ApiPlatform\Doctrine\Orm\CollectionDataProvider; -use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; - -/** - * Replace the default Doctrine CollectionDataProvider with a compatible adapter. - * - * This is a compatibility adapter for {@link \ApiPlatform\Core\Bridge\Doctrine\Orm\CollectionDataProvider} - * until https://github.com/doctrine/doctrine2/pull/6359 is accepted and - * the minimum Doctrine ORM version is bumped. - */ -final class DoctrineOrmQueryBuilderPass implements CompilerPassInterface -{ - public function process(ContainerBuilder $container) - { - if (!$container->hasDefinition('rollerworks_search.api_platform.doctrine.orm.query_extension.search')) { - return; - } - - if (!method_exists(QueryBuilder::class, 'setHint')) { - $container->findDefinition('api_platform.doctrine.orm.default.collection_data_provider')->setClass( - CollectionDataProvider::class - ); - } - } -} diff --git a/lib/Symfony/SearchBundle/Resources/config/doctrine_orm.xml b/lib/Symfony/SearchBundle/Resources/config/doctrine_orm.xml index f3b9b31e..1989faad 100644 --- a/lib/Symfony/SearchBundle/Resources/config/doctrine_orm.xml +++ b/lib/Symfony/SearchBundle/Resources/config/doctrine_orm.xml @@ -8,5 +8,21 @@ + + + + + + + + + + + + + + + + diff --git a/lib/Symfony/SearchBundle/RollerworksSearchBundle.php b/lib/Symfony/SearchBundle/RollerworksSearchBundle.php index 3ce82bd9..aacddef9 100644 --- a/lib/Symfony/SearchBundle/RollerworksSearchBundle.php +++ b/lib/Symfony/SearchBundle/RollerworksSearchBundle.php @@ -14,7 +14,6 @@ namespace Rollerworks\Bundle\SearchBundle; use Rollerworks\Bundle\SearchBundle\DependencyInjection\Compiler; -use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -28,6 +27,5 @@ public function build(ContainerBuilder $container) $container->addCompilerPass(new Compiler\FieldSetRegistryPass()); $container->addCompilerPass(new Compiler\DoctrineOrmPass()); $container->addCompilerPass(new Compiler\ElasticaBundlePass()); - $container->addCompilerPass(new Compiler\DoctrineOrmQueryBuilderPass(), PassConfig::TYPE_BEFORE_REMOVING); } } diff --git a/phpstan.neon b/phpstan.neon index 14bdb661..a6605f5c 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -28,7 +28,6 @@ parameters: # False positive - '#Call to an undefined method DateTimeInterface\:\:setTimezone\(\)#' - - '#Result of \|\| is always true#' - '#Negated boolean expression is always false#' ## Symony Config @@ -36,6 +35,5 @@ parameters: - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition\:\:#' ## Doctrine - - '#Call to an undefined method Doctrine\\DBAL\\Driver\\PDOConnection\:\:sqliteCreateFunction\(\)#' - - '#Parameter \#2 \$type of method Doctrine\\DBAL\\Connection\:\:quote\(\) expects ([^\s]+)#' - '#Call to an undefined method Rollerworks\\Component\\Search\\Doctrine\\Orm\\ConditionGenerator\:\:get[a-zA-Z]+#' + - '#Instanceof between Doctrine\\ORM\\Query and Doctrine\\ORM\\QueryBuilder will always evaluate to false#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c9a7aa17..fed06039 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - - - - - - - - - - - - - - - - ./lib/*/Tests/ ./lib/*/*/Tests/ + ./lib/Doctrine/Dbal/Tests/ + ./lib/Doctrine/Orm/Tests/ diff --git a/phpunit/mysql.xml b/phpunit/mysql.xml index 59d66055..53e4d88a 100644 --- a/phpunit/mysql.xml +++ b/phpunit/mysql.xml @@ -1,8 +1,8 @@ - - - - - - - - - - - - - + + + + + + @@ -35,4 +28,19 @@ ../lib/Doctrine/ + + + + ../lib + + ../vendor/ + ../lib/*/Tests/ + ../lib/*/*/Tests/ + + + + + + + diff --git a/phpunit/pgsql.xml b/phpunit/pgsql.xml index c64db4f0..bc8b1bce 100644 --- a/phpunit/pgsql.xml +++ b/phpunit/pgsql.xml @@ -1,8 +1,8 @@ - - - - - + + + + + - - - - - - - @@ -35,4 +28,19 @@ ../lib/Doctrine/ + + + + ../lib + + ../vendor/ + ../lib/*/Tests/ + ../lib/*/*/Tests/ + + + + + + +