From ae044bee6f4dceff5c4da6eacd9fa378d8d80fb0 Mon Sep 17 00:00:00 2001 From: Clemens Schwaighofer Date: Wed, 20 Nov 2024 18:58:59 +0900 Subject: [PATCH] DB IO Placeholder convert fixers and updates Add more checks in phpunit for this, Update the placeholder check and convert and move all regex into the placeholder convert support class Move $ placeholder count function to the SQL\PgSQL class Note: further moves of PgSQL only stuff have to be done for SQLite SQL class add --- 4dev/tests/DB/CoreLibsDBIOTest.php | 172 +++++++- .../class_test.db.convert-placeholder.php | 233 ++++++++++ www/admin/class_test.php | 1 + www/lib/CoreLibs/DB/IO.php | 78 ++-- www/lib/CoreLibs/DB/SQL/PgSQL.php | 34 +- .../DB/Support/ConvertPlaceholder.php | 404 +++++++++++------- 6 files changed, 728 insertions(+), 194 deletions(-) create mode 100644 www/admin/class_test.db.convert-placeholder.php diff --git a/4dev/tests/DB/CoreLibsDBIOTest.php b/4dev/tests/DB/CoreLibsDBIOTest.php index 93703db8..d6fa9408 100644 --- a/4dev/tests/DB/CoreLibsDBIOTest.php +++ b/4dev/tests/DB/CoreLibsDBIOTest.php @@ -37,8 +37,9 @@ namespace tests; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\MockObject\MockObject; -use CoreLibs\Logging\Logger\Level; +use CoreLibs\Logging; use CoreLibs\DB\Options\Convert; +use CoreLibs\DB\Support\ConvertPlaceholder; /** * Test class for DB\IO + DB\SQL\PgSQL @@ -117,7 +118,7 @@ final class CoreLibsDBIOTest extends TestCase ); } // define basic connection set valid and one invalid - self::$log = new \CoreLibs\Logging\Logging([ + self::$log = new Logging\Logging([ // 'log_folder' => __DIR__ . DIRECTORY_SEPARATOR . 'log', 'log_folder' => DIRECTORY_SEPARATOR . 'tmp', 'log_file_id' => 'CoreLibs-DB-IO-Test', @@ -570,11 +571,11 @@ final class CoreLibsDBIOTest extends TestCase ); $db->dbClose(); // second conenction with log set NOT debug - $log = new \CoreLibs\Logging\Logging([ + $log = new Logging\Logging([ // 'log_folder' => __DIR__ . DIRECTORY_SEPARATOR . 'log', 'log_folder' => DIRECTORY_SEPARATOR . 'tmp', 'log_file_id' => 'CoreLibs-DB-IO-Test', - 'log_level' => \CoreLibs\Logging\Logger\Level::Notice, + 'log_level' => Logging\Logger\Level::Notice, ]); $db = new \CoreLibs\DB\IO( self::$db_config[$connection], @@ -3293,6 +3294,7 @@ final class CoreLibsDBIOTest extends TestCase 'query' => 'INSERT INTO table_with_primary_key (row_int, uid) ' . 'VALUES ($1, $2) RETURNING table_with_primary_key_id', 'returning_id' => true, + 'placeholder_converted' => [], ], ], // update @@ -3327,6 +3329,7 @@ final class CoreLibsDBIOTest extends TestCase 'query' => 'UPDATE table_with_primary_key SET row_int = $1, ' . 'row_varchar = $2 WHERE uid = $3', 'returning_id' => false, + 'placeholder_converted' => [], ], ], // select @@ -3356,6 +3359,7 @@ final class CoreLibsDBIOTest extends TestCase 'count' => 1, 'query' => 'SELECT row_int, uid FROM table_with_primary_key WHERE uid = $1', 'returning_id' => false, + 'placeholder_converted' => [], ], ], // any query but with no parameters @@ -3388,6 +3392,7 @@ final class CoreLibsDBIOTest extends TestCase 'count' => 0, 'query' => 'SELECT row_int, uid FROM table_with_primary_key', 'returning_id' => false, + 'placeholder_converted' => [], ], ], // no statement name (25) @@ -3411,6 +3416,7 @@ final class CoreLibsDBIOTest extends TestCase 'count' => 0, 'query' => '', 'returning_id' => false, + 'placeholder_converted' => [], ], ], // no query (prepare 11) @@ -3435,6 +3441,7 @@ final class CoreLibsDBIOTest extends TestCase 'count' => 0, 'query' => '', 'returning_id' => false, + 'placeholder_converted' => [], ], ], // no db connection (prepare/execute 16) @@ -3464,6 +3471,7 @@ final class CoreLibsDBIOTest extends TestCase 'count' => 0, 'query' => 'SELECT row_int, uid FROM table_with_primary_key', 'returning_id' => false, + 'placeholder_converted' => [], ], ], // prepare with different statement name @@ -3489,6 +3497,7 @@ final class CoreLibsDBIOTest extends TestCase 'count' => 0, 'query' => 'SELECT row_int, uid FROM table_with_primary_key', 'returning_id' => false, + 'placeholder_converted' => [], ], ], // insert wrong data count compared to needed (execute 23) @@ -3514,10 +3523,12 @@ final class CoreLibsDBIOTest extends TestCase 'query' => 'INSERT INTO table_with_primary_key (row_int, uid) VALUES ' . '($1, $2) RETURNING table_with_primary_key_id', 'returning_id' => true, + 'placeholder_converted' => [], ], ], // execute does not return a result (22) // TODO execute does not return a result + // TODO prepared statement with placeholder params auto convert ]; } @@ -3662,7 +3673,7 @@ final class CoreLibsDBIOTest extends TestCase } // check dbGetPrepareCursorValue - foreach (['pk_name', 'count', 'query', 'returning_id'] as $key) { + foreach (['pk_name', 'count', 'query', 'returning_id', 'placeholder_converted'] as $key) { $this->assertEquals( $prepare_cursor[$key], $db->dbGetPrepareCursorValue($stm_name, $key), @@ -5031,8 +5042,151 @@ final class CoreLibsDBIOTest extends TestCase $db->dbClose(); } - // query placeholder convert + // MARK: QUERY PLACEHOLDERS + // test query placeholder detection for all possible sets + // ::dbPrepare + + /** + * placeholder sql + * + * @return array + */ + public function providerDbCountQueryParams(): array + { + return [ + 'one place holder' => [ + 'query' => 'SELECT row_varchar FROM table_with_primary_key WHERE row_varchar = $1', + 'count' => 1, + 'convert' => false, + ], + 'one place holder, json call' => [ + 'query' => "SELECT row_varchar FROM table_with_primary_key WHERE row_jsonb->>'data' = $1", + 'count' => 1, + 'convert' => false, + ], + 'one place holder, <> compare' => [ + 'query' => "SELECT row_varchar FROM table_with_primary_key WHERE row_varchar <> $1", + 'count' => 1, + 'convert' => false, + ], + 'one place holder, named' => [ + 'query' => "SELECT row_varchar FROM table_with_primary_key WHERE row_varchar <> :row_varchar", + 'count' => 1, + 'convert' => true, + ], + 'no replacement' => [ + 'query' => "SELECT row_varchar FROM table_with_primary_key WHERE row_varchar = '$1'", + 'count' => 0, + 'convert' => false, + ], + 'insert' => [ + 'query' => "INSERT INTO table_with_primary_key (row_varchar, row_jsonb, row_int) VALUES ($1, $2, $3)", + 'count' => 3, + 'convert' => false, + ], + 'update' => [ + 'query' => "UPDATE table_with_primary_key SET row_varchar = $1, row_jsonb = $2, row_int = $3 WHERE row_numeric = $4", + 'count' => 4, + 'convert' => false, + ], + 'multiple, multline' => [ + 'query' => << 3, + 'convert' => false, + ], + 'two digit numbers' => [ + 'query' => << 10, + 'convert' => false, + ], + 'things in brackets' => [ + 'query' => << 4, + 'convert' => false, + ], + 'number compare' => [ + 'query' => <<= $1 OR row_int <= $2 OR + row_int > $3 OR row_int < $4 + OR row_int = $5 OR row_int <> $6 + SQL, + 'count' => 6, + 'convert' => false, + ] + ]; + } + + /** + * Placeholder check and convert tests + * + * @covers ::dbPrepare + * @covers ::__dbCountQueryParams + * @onvers ::convertPlaceholderInQuery + * @dataProvider providerDbCountQueryParams + * @testdox Query replacement count test [$_dataName] + * + * @param string $query + * @param int $count + * @return void + */ + public function testDbCountQueryParams(string $query, int $count, bool $convert): void + { + $db = new \CoreLibs\DB\IO( + self::$db_config['valid'], + self::$log + ); + $id = sha1($query); + $db->dbSetConvertPlaceholder($convert); + $db->dbPrepare($id, $query); + // print "\n**\n"; + // print "PCount: " . $db->dbGetPrepareCursorValue($id, 'count') . "\n"; + // print "\n**\n"; + $this->assertEquals( + $count, + $db->dbGetPrepareCursorValue($id, 'count'), + 'DB count params' + ); + $placeholder = ConvertPlaceholder::convertPlaceholderInQuery($query, null, 'pg'); + // print "RES: " . print_r($placeholder, true) . "\n"; + $this->assertEquals( + $count, + $placeholder['needed'], + 'convert params' + ); + } + + /** + * query placeholder convert + * + * @return array + */ public function queryPlaceholderReplaceProvider(): array { // WHERE row_varchar = $1 @@ -5076,7 +5230,9 @@ final class CoreLibsDBIOTest extends TestCase WHERE row_varchar = $1 SQL, 'expected_params' => ['string a'], - ] + ], + // TODO: test with multiple entries + // TODO: test with same entry ($1, $1, :var, :var) ]; } @@ -5178,6 +5334,8 @@ final class CoreLibsDBIOTest extends TestCase // - data debug // dbDumpData + // MARK: ASYNC + // ASYNC at the end because it has 1s timeout // - asynchronous executions // dbExecAsync, dbCheckAsync diff --git a/www/admin/class_test.db.convert-placeholder.php b/www/admin/class_test.db.convert-placeholder.php new file mode 100644 index 00000000..8cca56a9 --- /dev/null +++ b/www/admin/class_test.db.convert-placeholder.php @@ -0,0 +1,233 @@ + BASE . LOG, + 'log_file_id' => $LOG_FILE_ID, + 'log_per_date' => true, +]); + + +$PAGE_NAME = 'TEST CLASS: DB CONVERT PLACEHOLDER'; +print ""; +print "" . $PAGE_NAME . ""; +print ""; +print ''; +print '

' . $PAGE_NAME . '

'; + +print "LOGFILE NAME: " . $log->getLogFile() . "
"; +print "LOGFILE ID: " . $log->getLogFileId() . "
"; + +print "Lookup Regex:
" . ConvertPlaceholder::REGEX_LOOKUP_PLACEHOLDERS . "
"; +print "Replace Named Regex:
" . ConvertPlaceholder::REGEX_REPLACE_NAMED . "
"; +print "Replace Named Regex:
" . ConvertPlaceholder::REGEX_REPLACE_QUESTION_MARK . "
"; +print "Replace Named Regex:
" . ConvertPlaceholder::REGEX_REPLACE_NUMBERED . "
"; + +$uniqid = \CoreLibs\Create\Uids::uniqIdShort(); +// $binary_data = $db->dbEscapeBytea(file_get_contents('class_test.db.php') ?: ''); +// $binary_data = file_get_contents('class_test.db.php') ?: ''; +$binary_data = ''; +$params = [ + $uniqid, + true, + 'STRING A', + 2, + 2.5, + 1, + date('H:m:s'), + date('Y-m-d H:i:s'), + json_encode(['a' => 'string', 'b' => 1, 'c' => 1.5, 'f' => true, 'g' => ['a', 1, 1.5]]), + null, + '{"a", "b"}', + '{1,2}', + '{"(array Text A, 5, 8.8)","(array Text B, 10, 15.2)"}', + '("Text", 4, 6.3)', + $binary_data +]; + +$query = <<"; +echo "
"; + +$query = "SELECT foo FROM bar WHERE baz = :baz AND buz = :baz AND biz = :biz AND boz = :bez"; +$params = [':baz' => 'SETBAZ', ':bez' => 'SETBEZ', ':biz' => 'SETBIZ']; +print "[NO PARAMS] Convert: " + . Support::printAr(ConvertPlaceholder::convertPlaceholderInQuery($query, $params)) + . "
"; +echo "
"; + +$query = "SELECT foo FROM bar WHERE baz = :baz AND buz = :baz AND biz = :biz AND boz = :bez"; +$params = null; +print "[NO PARAMS] Convert: " + . Support::printAr(ConvertPlaceholder::convertPlaceholderInQuery($query, $params)) + . "
"; +echo "
"; + +$query = "SELECT row_varchar FROM table_with_primary_key WHERE row_varchar <> :row_varchar"; +$params = null; +print "[NO PARAMS] Convert: " + . Support::printAr(ConvertPlaceholder::convertPlaceholderInQuery($query, $params)) + . "
"; +echo "
"; + +$query = "SELECT row_varchar, row_varchar_literal, row_int, row_date FROM table_with_primary_key"; +$params = null; +print "[NO PARAMS] TEST: " + . Support::printAr(ConvertPlaceholder::convertPlaceholderInQuery($query, $params)) + . "
"; +echo "
"; + +print "[P-CONV]: " + . Support::printAr( + ConvertPlaceholder::updateParamList([ + 'original' => [ + 'query' => 'SELECT foo FROM bar WHERE baz = :baz AND buz = :biz AND biz = :biz AND boz = :bez', + 'params' => [':baz' => 'SETBAZ', ':bez' => 'SETBEZ', ':biz' => 'SETBIZ'], + 'empty_params' => false, + ], + 'type' => 'named', + 'found' => 3, + // 'matches' => [ + // ':baz' + // ], + // 'params_lookup' => [ + // ':baz' => '$1' + // ], + // 'query' => "SELECT foo FROM bar WHERE baz = $1", + // 'parms' => [ + // 'SETBAZ' + // ], + ]) + ); + +echo "
"; + +// test connectors: = , <> () for query detection + +// convert placeholder tests +// ? -> $n +// :name -> $n + +// other way around (just visual) +$test_queries = [ + 'skip' => [ + 'query' => << [], + 'direction' => 'pg', + ], + 'numbers' => [ + 'query' => << [\CoreLibs\Create\Uids::uniqIdShort(), 'string A-1', 1234], + 'direction' => 'pdo', + ], + 'a?' => [ + 'query' => << [\CoreLibs\Create\Uids::uniqIdShort(), 'string A-1', 1234], + 'direction' => 'pg', + ], + 'b:' => [ + 'query' => << [ + ':test' => \CoreLibs\Create\Uids::uniqIdShort(), + ':string_a' => 'string B-1', + ':number_a' => 5678 + ], + 'direction' => 'pg', + ], + 'select, compare $' => [ + 'query' => <<= $1 OR row_int <= $2 OR + row_int > $3 OR row_int < $4 + OR row_int = $5 OR row_int <> $6 + SQL, + 'params' => null, + 'direction' => 'pg' + ] +]; + + +foreach ($test_queries as $info => $data) { + $query = $data['query']; + $params = $data['params']; + $direction = $data['direction']; + print "[$info] Convert: " + . Support::printAr(ConvertPlaceholder::convertPlaceholderInQuery($query, $params, $direction)) + . "
"; + echo "
"; +} + +print ""; +$log->debug('DEBUGEND', '==================================== [END]'); + +// __END__ diff --git a/www/admin/class_test.php b/www/admin/class_test.php index a5032e9a..a6355cec 100644 --- a/www/admin/class_test.php +++ b/www/admin/class_test.php @@ -73,6 +73,7 @@ $test_files = [ 'class_test.db.query-placeholder.php' => 'Class Test: DB query placeholder convert', 'class_test.db.dbReturn.php' => 'Class Test: DB dbReturn', 'class_test.db.single.php' => 'Class Test: DB single query tests', + 'class_test.db.convert-placeholder.php' => 'Class Test: DB convert placeholder', 'class_test.convert.colors.php' => 'Class Test: CONVERT COLORS', 'class_test.check.colors.php' => 'Class Test: CHECK COLORS', 'class_test.mime.php' => 'Class Test: MIME', diff --git a/www/lib/CoreLibs/DB/IO.php b/www/lib/CoreLibs/DB/IO.php index cae7cf50..4daad5b8 100644 --- a/www/lib/CoreLibs/DB/IO.php +++ b/www/lib/CoreLibs/DB/IO.php @@ -284,7 +284,8 @@ class IO public const ERROR_HASH_TYPE = 'adler32'; /** @var string regex to get returning with matches at position 1 */ public const REGEX_RETURNING = '/\s+returning\s+(.+\s*(?:.+\s*)+);?$/i'; - /** @var array allowed convert target for placeholder: pg or pdo (currently not available) */ + /** @var array allowed convert target for placeholder: + * pg or pdo (currently not available) */ public const DB_CONVERT_PLACEHOLDER_TARGET = ['pg']; // REGEX_SELECT // REGEX_UPDATE @@ -1311,33 +1312,14 @@ class IO } /** - * count $ leading parameters only + * count placeholder entries in the query * * @param string $query Query to check * @return int Number of parameters found */ private function __dbCountQueryParams(string $query): int { - $match = []; - // regex for params: only stand alone $number allowed - // exclude all '' enclosed strings, ignore all numbers [note must start with digit] - // can have space/tab/new line - // must have <> = , ( [not equal, equal, comma, opening round bracket] - // can have space/tab/new line - // $ number with 1-9 for first and 0-9 for further digits - // /s for matching new line in . list - // [disabled, we don't used ^ or $] /m for multi line match - // Matches in 1:, must be array_filtered to remove empty, count with array_unique - $query_split = '[(=,?-]|->|->>|#>|#>>|@>|<@|\?\|\?\&|\|\||#-'; - preg_match_all( - '/' - . '(?:\'.*?\')?\s*(?:\?\?|<>|' . $query_split . ')\s*' - . '(?:\d+|(?:\'.*?\')|(\$[1-9]{1}(?:[0-9]{1,})?))' - . '/s', - $query, - $match - ); - return count(array_unique(array_filter($match[1]))); + return $this->db_functions->__dbCountQueryParams($query); } /** @@ -3160,7 +3142,8 @@ class IO 'count' => 0, 'query' => '', 'result' => null, - 'returning_id' => false + 'returning_id' => false, + 'placeholder_converted' => [], ]; // if this is an insert query, check if we can add a return if ($this->dbCheckQueryForInsert($query, true)) { @@ -3200,6 +3183,39 @@ class IO $this->prepare_cursor[$stm_name]['pk_name'] = $pk_name; } } + // QUERY PARAMS: run query params check and rewrite + if ($this->dbGetConvertPlaceholder() === true) { + try { + $this->placeholder_converted = ConvertPlaceholder::convertPlaceholderInQuery( + $query, + null, + $this->dbGetConvertPlaceholderTarget() + ); + // write the new queries over the old + if (!empty($this->placeholder_converted['query'])) { + $query = $this->placeholder_converted['query']; + } + $this->prepare_cursor[$stm_name]['placeholder_converted'] = $this->placeholder_converted; + } catch (\OutOfRangeException $e) { + $this->__dbError($e->getCode(), context:[ + 'statement_name' => $stm_name, + 'query' => $query, + 'location' => 'dbPrepare', + 'error' => 'OutOfRangeException', + 'exception' => $e + ]); + return false; + } catch (\RuntimeException $e) { + $this->__dbError($e->getCode(), context:[ + 'statement_name' => $stm_name, + 'query' => $query, + 'location' => 'dbPrepare', + 'error' => 'RuntimeException', + 'exception' => $e + ]); + return false; + } + } // check prepared curser parameter count $this->prepare_cursor[$stm_name]['count'] = $this->__dbCountQueryParams($query); $this->prepare_cursor[$stm_name]['query'] = $query; @@ -3735,7 +3751,7 @@ class IO } /** - * convert db values (set) + * convert db values (set) to php matching types * * @param Convert $convert * @return void @@ -3746,7 +3762,7 @@ class IO } /** - * unsert convert db values flag + * unsert convert db values flag for converting db to php matching types * * @param Convert $convert * @return void @@ -3757,7 +3773,7 @@ class IO } /** - * Reset to origincal config file set + * Reset to original config file set for converting db to php matching type * * @return void */ @@ -3769,7 +3785,7 @@ class IO } /** - * check if a conert flag is set + * check if a convert flag is set for converting db to php matching type * * @param Convert $convert * @return bool @@ -3783,7 +3799,7 @@ class IO } /** - * Set if we want to auto convert PDO/\Pg placeholders + * Set if we want to auto convert to PDO/\Pg placeholders * * @param bool $flag * @return void @@ -4294,7 +4310,7 @@ class IO * @param string $stm_name The name of the stored statement * @param string $key Key field name in prepared cursor array * Allowed are: pk_name, count, query, returning_id - * @return null|string|int|bool Entry from each of the valid keys + * @return null|string|int|bool|array Entry from each of the valid keys * Will return false on error * Not ethat returnin_id also can return false * but will not set an error entry @@ -4302,7 +4318,7 @@ class IO public function dbGetPrepareCursorValue( string $stm_name, string $key - ): null|string|int|bool { + ): null|string|int|bool|array { // if no statement name if (empty($stm_name)) { $this->__dbError( @@ -4313,7 +4329,7 @@ class IO return false; } // if not a valid key - if (!in_array($key, ['pk_name', 'count', 'query', 'returning_id'])) { + if (!in_array($key, ['pk_name', 'count', 'query', 'returning_id', 'placeholder_converted'])) { $this->__dbError( 102, false, diff --git a/www/lib/CoreLibs/DB/SQL/PgSQL.php b/www/lib/CoreLibs/DB/SQL/PgSQL.php index fa4c7e89..53b97250 100644 --- a/www/lib/CoreLibs/DB/SQL/PgSQL.php +++ b/www/lib/CoreLibs/DB/SQL/PgSQL.php @@ -51,6 +51,8 @@ declare(strict_types=1); namespace CoreLibs\DB\SQL; +use CoreLibs\DB\Support\ConvertPlaceholder; + // below no ignore is needed if we want to use PgSql interface checks with PHP 8.0 // as main system. Currently all @var sets are written as object /** @#phan-file-suppress PhanUndeclaredTypeProperty,PhanUndeclaredTypeParameter,PhanUndeclaredTypeReturnType */ @@ -102,7 +104,7 @@ class PgSQL implements Interface\SqlFunctions * SELECT foo FROM bar WHERE foobar = $1 * * @param string $query Query string with placeholders $1, .. - * @param array $params Matching parameters for each placerhold + * @param array $params Matching parameters for each placeholder * @return \PgSql\Result|false Query result */ public function __dbQueryParams(string $query, array $params): \PgSql\Result|false @@ -140,7 +142,7 @@ class PgSQL implements Interface\SqlFunctions * sends an async query to the server with params * * @param string $query Query string with placeholders $1, .. - * @param array $params Matching parameters for each placerhold + * @param array $params Matching parameters for each placeholder * @return bool true/false Query sent successful status */ public function __dbSendQueryParams(string $query, array $params): bool @@ -966,6 +968,34 @@ class PgSQL implements Interface\SqlFunctions { return $this->__dbShow('client_encoding'); } + + /** + * Count placeholder queries. $ only + * + * @param string $query + * @return int + */ + public function __dbCountQueryParams(string $query): int + { + $matches = []; + // regex for params: only stand alone $number allowed + // exclude all '' enclosed strings, ignore all numbers [note must start with digit] + // can have space/tab/new line + // must have <> = , ( [not equal, equal, comma, opening round bracket] + // can have space/tab/new line + // $ number with 1-9 for first and 0-9 for further digits + // Collects also PDO ? and :named, but they are ignored + // /s for matching new line in . list + // [disabled, we don't used ^ or $] /m for multi line match + // Matches in 1:, must be array_filtered to remove empty, count with array_unique + // Regex located in the ConvertPlaceholder class + preg_match_all( + ConvertPlaceholder::REGEX_LOOKUP_PLACEHOLDERS, + $query, + $matches + ); + return count(array_unique(array_filter($matches[3]))); + } } // __END__ diff --git a/www/lib/CoreLibs/DB/Support/ConvertPlaceholder.php b/www/lib/CoreLibs/DB/Support/ConvertPlaceholder.php index 6e652acc..0b9542b2 100644 --- a/www/lib/CoreLibs/DB/Support/ConvertPlaceholder.php +++ b/www/lib/CoreLibs/DB/Support/ConvertPlaceholder.php @@ -14,6 +14,67 @@ namespace CoreLibs\DB\Support; class ConvertPlaceholder { + /** @var string split regex */ + private const PATTERN_QUERY_SPLIT = '[(<>=,?-]|->|->>|#>|#>>|@>|<@|\?\|\?\&|\|\||#-'; + /** @var string the main regex including the pattern query split */ + private const PATTERN_ELEMENT = '(?:\'.*?\')?\s*(?:\?\?|' . self::PATTERN_QUERY_SPLIT . ')\s*'; + /** @var string parts to ignore in the SQL */ + private const PATTERN_IGNORE = + // digit -> ignore + '\d+|' + // other string -> ignore + . '(?:\'.*?\')|'; + /** @var string named parameters */ + private const PATTERN_NAMED = '(:\w+)'; + /** @var string question mark parameters */ + private const PATTERN_QUESTION_MARK = '(?:(?:\?\?)?\s*(\?{1}))'; + /** @var string numbered parameters */ + private const PATTERN_NUMBERED = '(\$[1-9]{1}(?:[0-9]{1,})?)'; + // below here are full regex that will be used + /** @var string replace regex for named (:...) entries */ + public const REGEX_REPLACE_NAMED = '/' + . '(' . self::PATTERN_ELEMENT . ')' + . '(' + . self::PATTERN_IGNORE + . self::PATTERN_NAMED + . ')' + . '/s'; + /** @var string replace regex for question mark (?) entries */ + public const REGEX_REPLACE_QUESTION_MARK = '/' + . '(' . self::PATTERN_ELEMENT . ')' + . '(' + . self::PATTERN_IGNORE + . self::PATTERN_QUESTION_MARK + . ')' + . '/s'; + /** @var string replace regex for numbered ($n) entries */ + public const REGEX_REPLACE_NUMBERED = '/' + . '(' . self::PATTERN_ELEMENT . ')' + . '(' + . self::PATTERN_IGNORE + . self::PATTERN_NUMBERED + . ')' + . '/s'; + /** @var string the main lookup query for all placeholders */ + public const REGEX_LOOKUP_PLACEHOLDERS = '/' + // prefix string part, must match towards + // seperator for ( = , ? - [and json/jsonb in pg doc section 9.15] + . self::PATTERN_ELEMENT + // match for replace part + . '(?:' + // ignore parts + . self::PATTERN_IGNORE + // :name named part (PDO) [1] + . self::PATTERN_NAMED . '|' + // ? question mark part (PDO) [2] + . self::PATTERN_QUESTION_MARK . '|' + // $n numbered part (\PG php) [3] + . self::PATTERN_NUMBERED + // end match + . ')' + // single line -> add line break to matches in "." + . '/s'; + /** * Convert PDO type query with placeholders to \PG style and vica versa * For PDO to: ? and :named @@ -27,44 +88,24 @@ class ConvertPlaceholder * found has -1 if an error occoured in the preg_match_all call * * @param string $query Query with placeholders to convert - * @param array $params The parameters that are used for the query, and will be updated + * @param ?array $params The parameters that are used for the query, and will be updated * @param string $convert_to Either pdo or pg, will be converted to lower case for check - * @return array{original:array{query:string,params:array},type:''|'named'|'numbered'|'question_mark',found:int,matches:array,params_lookup:array,query:string,params:array} - * @throws \OutOfRangeException 200 + * @return array{original:array{query:string,params:array,empty_params:bool},type:''|'named'|'numbered'|'question_mark',found:int,matches:array,params_lookup:array,query:string,params:array} + * @throws \OutOfRangeException 200 If mixed placeholder types + * @throws \InvalidArgumentException 300 or 301 if wrong convert to with found placeholders */ public static function convertPlaceholderInQuery( string $query, - array $params, + ?array $params, string $convert_to = 'pg' ): array { $convert_to = strtolower($convert_to); $matches = []; - $query_split = '[(=,?-]|->|->>|#>|#>>|@>|<@|\?\|\?\&|\|\||#-'; - $pattern = '/' - // prefix string part, must match towards - // seperator for ( = , ? - [and json/jsonb in pg doc section 9.15] - . '(?:\'.*?\')?\s*(?:\?\?|' . $query_split . ')\s*' - // match for replace part - . '(?:' - // digit -> ignore - . '\d+|' - // other string -> ignore - . '(?:\'.*?\')|' - // :name named part (PDO) - . '(:\w+)|' - // ? question mark part (PDO) - . '(?:(?:\?\?)?\s*(\?{1}))|' - // $n numbered part (\PG php) - . '(\$[1-9]{1}(?:[0-9]{1,})?)' - // end match - . ')' - // single line -> add line break to matches in "." - . '/s'; // matches: // 1: :named // 2: ? question mark // 3: $n numbered - $found = preg_match_all($pattern, $query, $matches, PREG_UNMATCHED_AS_NULL); + $found = preg_match_all(self::REGEX_LOOKUP_PLACEHOLDERS, $query, $matches, PREG_UNMATCHED_AS_NULL); // if false or null set to -1 // || $found === null if ($found === false) { @@ -77,10 +118,10 @@ class ConvertPlaceholder /** @var array 3: $n matches */ $numbered_matches = array_filter($matches[3]); // count matches - $count_named = count($named_matches); + $count_named = count(array_unique($named_matches)); $count_qmark = count($qmark_matches); - $count_numbered = count($numbered_matches); - // throw if mixed + $count_numbered = count(array_unique($numbered_matches)); + // throw exception if mixed found if ( ($count_named && $count_qmark) || ($count_named && $count_numbered) || @@ -88,140 +129,195 @@ class ConvertPlaceholder ) { throw new \OutOfRangeException('Cannot have named, question mark and numbered in the same query', 200); } - // rebuild - $matches_return = []; - $type = ''; + // // throw if invalid conversion + // if (($count_named || $count_qmark) && $convert_to != 'pg') { + // throw new \InvalidArgumentException('Cannot convert from named or question mark placeholders to PDO', 300); + // } + // if ($count_numbered && $convert_to != 'pdo') { + // throw new \InvalidArgumentException('Cannot convert from numbered placeholders to Pg', 301); + // } + // return array + $return_placeholders = [ + // original + 'original' => [ + 'query' => $query, + 'params' => $params ?? [], + 'empty_params' => $params === null ? true : false, + ], + // type found, empty if nothing was done + 'type' => '', + // int: found, not found; -1: problem (set from false) + 'found' => (int)$found, + 'matches' => [], + // old to new lookup check + 'params_lookup' => [], + // this must match the count in params in new + 'needed' => 0, + // new + 'query' => '', + 'params' => [], + ]; + // replace basic regex and name settings + if ($count_named) { + $return_placeholders['type'] = 'named'; + $return_placeholders['matches'] = $named_matches; + $return_placeholders['needed'] = $count_named; + } elseif ($count_qmark) { + $return_placeholders['type'] = 'question_mark'; + $return_placeholders['matches'] = $qmark_matches; + $return_placeholders['needed'] = $count_qmark; + // for each ?:DTN: -> replace with $1 ... $n, any remaining :DTN: remove + } elseif ($count_numbered) { + $return_placeholders['type'] = 'numbered'; + $return_placeholders['matches'] = $numbered_matches; + $return_placeholders['needed'] = $count_numbered; + } + // run convert only if matching type and direction + if ( + (($count_named || $count_qmark) && $convert_to == 'pg') || + ($count_numbered && $convert_to == 'pdo') + ) { + $param_list = self::updateParamList($return_placeholders); + $return_placeholders['params_lookup'] = $param_list['params_lookup']; + $return_placeholders['query'] = $param_list['query']; + $return_placeholders['params'] = $param_list['params']; + } + // return data + return $return_placeholders; + } + + /** + * Updates the params list from one style to the other to match the query output + * if original.empty_params is set to true, no params replacement is done + * if param replacement has been done in a dbPrepare then this has to be run + * with the return palceholders array with params in original filled and empty_params turned off + * + * phpcs:disable Generic.Files.LineLength + * @param array{original:array{query:string,params:array,empty_params:bool},type:''|'named'|'numbered'|'question_mark',found:int,matches?:array,params_lookup?:array,query?:string,params?:array} $converted_placeholders + * phpcs:enable Generic.Files.LineLength + * @return array{params_lookup:array,query:string,params:array} + */ + public static function updateParamList(array $converted_placeholders): array + { + // skip if nothing set + if (!$converted_placeholders['found']) { + return [ + 'params_lookup' => [], + 'query' => '', + 'params' => [] + ]; + } $query_new = ''; $params_new = []; $params_lookup = []; - if ($count_named && $convert_to == 'pg') { - $type = 'named'; - $matches_return = $named_matches; - // only check for :named - $pattern_replace = '/' - . '((?:\'.*?\')?\s*(?:\?\?|' . $query_split . ')\s*)' - . '(\d+|(?:\'.*?\')|(:\w+))' - . '/s'; - // 0: full - // 1: pre part - // 2: keep part UNLESS '3' is set - // 3: replace part :named - $pos = 0; - $query_new = preg_replace_callback( - $pattern_replace, - function ($matches) use (&$pos, &$params_new, &$params_lookup, $params) { - // only count up if $match[3] is not yet in lookup table - if (!empty($matches[3]) && empty($params_lookup[$matches[3]])) { - $pos++; - $params_lookup[$matches[3]] = '$' . $pos; - $params_new[] = $params[$matches[3]] ?? - throw new \RuntimeException( - 'Cannot lookup ' . $matches[3] . ' in params list', - 210 - ); - } - // add the connectors back (1), and the data sets only if no replacement will be done - return $matches[1] . ( - empty($matches[3]) ? - $matches[2] : - $params_lookup[$matches[3]] ?? + // set to null if params is empty + $params = $converted_placeholders['original']['params']; + $empty_params = $converted_placeholders['original']['empty_params']; + switch ($converted_placeholders['type']) { + case 'named': + // 0: full + // 0: full + // 1: pre part + // 2: keep part UNLESS '3' is set + // 3: replace part :named + $pos = 0; + $query_new = preg_replace_callback( + self::REGEX_REPLACE_NAMED, + function ($matches) use (&$pos, &$params_new, &$params_lookup, $params, $empty_params) { + // only count up if $match[3] is not yet in lookup table + if (!empty($matches[3]) && empty($params_lookup[$matches[3]])) { + $pos++; + $params_lookup[$matches[3]] = '$' . $pos; + // skip params setup if param list is empty + if (!$empty_params) { + $params_new[] = $params[$matches[3]] ?? + throw new \RuntimeException( + 'Cannot lookup ' . $matches[3] . ' in params list', + 210 + ); + } + } + // add the connectors back (1), and the data sets only if no replacement will be done + return $matches[1] . ( + empty($matches[3]) ? + $matches[2] : + $params_lookup[$matches[3]] ?? + throw new \RuntimeException( + 'Cannot lookup ' . $matches[3] . ' in params lookup list', + 211 + ) + ); + }, + $converted_placeholders['original']['query'] + ); + break; + case 'question_mark': + if (!$empty_params) { + // order and data stays the same + $params_new = $params ?? []; + } + // 0: full + // 1: pre part + // 2: keep part UNLESS '3' is set + // 3: replace part ? + $pos = 0; + $query_new = preg_replace_callback( + self::REGEX_REPLACE_QUESTION_MARK, + function ($matches) use (&$pos, &$params_lookup) { + // only count pos up for actual replacements we will do + if (!empty($matches[3])) { + $pos++; + $params_lookup[] = '$' . $pos; + } + // add the connectors back (1), and the data sets only if no replacement will be done + return $matches[1] . ( + empty($matches[3]) ? + $matches[2] : + '$' . $pos + ); + }, + $converted_placeholders['original']['query'] + ); + break; + case 'numbered': + // 0: full + // 1: pre part + // 2: keep part UNLESS '3' is set + // 3: replace part $numbered + $pos = 0; + $query_new = preg_replace_callback( + self::REGEX_REPLACE_NUMBERED, + function ($matches) use (&$pos, &$params_new, &$params_lookup, $params, $empty_params) { + // only count up if $match[3] is not yet in lookup table + if (!empty($matches[3]) && empty($params_lookup[$matches[3]])) { + $pos++; + $params_lookup[$matches[3]] = ':' . $pos . '_named'; + // skip params setup if param list is empty + if (!$empty_params) { + $params_new[] = $params[($pos - 1)] ?? + throw new \RuntimeException( + 'Cannot lookup ' . ($pos - 1) . ' in params list', + 220 + ); + } + } + // add the connectors back (1), and the data sets only if no replacement will be done + return $matches[1] . ( + empty($matches[3]) ? + $matches[2] : + $params_lookup[$matches[3]] ?? throw new \RuntimeException( 'Cannot lookup ' . $matches[3] . ' in params lookup list', - 211 + 221 ) - ); - }, - $query - ); - } elseif ($count_qmark && $convert_to == 'pg') { - $type = 'question_mark'; - $matches_return = $qmark_matches; - // order and data stays the same - $params_new = $params; - // only check for ? - $pattern_replace = '/' - . '((?:\'.*?\')?\s*(?:\?\?|' . $query_split . ')\s*)' - . '(\d+|(?:\'.*?\')|(?:(?:\?\?)?\s*(\?{1})))' - . '/s'; - // 0: full - // 1: pre part - // 2: keep part UNLESS '3' is set - // 3: replace part ? - $pos = 0; - $query_new = preg_replace_callback( - $pattern_replace, - function ($matches) use (&$pos, &$params_lookup) { - // only count pos up for actual replacements we will do - if (!empty($matches[3])) { - $pos++; - $params_lookup[] = '$' . $pos; - } - // add the connectors back (1), and the data sets only if no replacement will be done - return $matches[1] . ( - empty($matches[3]) ? - $matches[2] : - '$' . $pos - ); - }, - $query - ); - // for each ?:DTN: -> replace with $1 ... $n, any remaining :DTN: remove - } elseif ($count_numbered && $convert_to == 'pdo') { - // convert numbered to named - $type = 'numbered'; - $matches_return = $numbered_matches; - // only check for $n - $pattern_replace = '/' - . '((?:\'.*?\')?\s*(?:\?\?|' . $query_split . ')\s*)' - . '(\d+|(?:\'.*?\')|(\$[1-9]{1}(?:[0-9]{1,})?))' - . '/s'; - // 0: full - // 1: pre part - // 2: keep part UNLESS '3' is set - // 3: replace part $numbered - $pos = 0; - $query_new = preg_replace_callback( - $pattern_replace, - function ($matches) use (&$pos, &$params_new, &$params_lookup, $params) { - // only count up if $match[3] is not yet in lookup table - if (!empty($matches[3]) && empty($params_lookup[$matches[3]])) { - $pos++; - $params_lookup[$matches[3]] = ':' . $pos . '_named'; - $params_new[] = $params[($pos - 1)] ?? - throw new \RuntimeException( - 'Cannot lookup ' . ($pos - 1) . ' in params list', - 220 - ); - } - // add the connectors back (1), and the data sets only if no replacement will be done - return $matches[1] . ( - empty($matches[3]) ? - $matches[2] : - $params_lookup[$matches[3]] ?? - throw new \RuntimeException( - 'Cannot lookup ' . $matches[3] . ' in params lookup list', - 221 - ) - ); - }, - $query - ); + ); + }, + $converted_placeholders['original']['query'] + ); + break; } - // return, old query is always set return [ - // original - 'original' => [ - 'query' => $query, - 'params' => $params, - ], - // type found, empty if nothing was done - 'type' => $type, - // int: found, not found; -1: problem (set from false) - 'found' => (int)$found, - 'matches' => $matches_return, - // old to new lookup check 'params_lookup' => $params_lookup, - // new 'query' => $query_new ?? '', 'params' => $params_new, ];