From 89e3888bf8bc2fc087575167c874064e4e4ea995 Mon Sep 17 00:00:00 2001 From: Clemens Schwaighofer Date: Mon, 16 Oct 2023 14:43:55 +0900 Subject: [PATCH] Update DB\IO with auto query placeholder rewrite and better error logging All errors have context that is used to add query, params, etc info for logging into the DB. Avoid double logging for PostGreSQL direct errors as those will be logged now in context to the actual error log Remove error: 16 missing/empty dbh has this is handled with error 14 in the connect method. Auto convert ?, :named to $numbered, default off. Activate with 'db_convert_placeholder' flag or method dbSetConvertPlaceholder. Converted result data for single queries in dbGetPlaceholderConverted or in the cursor_ext array in placeholer_converted key Do not auto translate debug queries with placeholder values in query but keep them in the array in the context array. If needed 'db_debug_replace_placeholder' can be set to show prepared query with placeholder replaced in the context New methods: public function dbSetConvertPlaceholder(bool $flag): void public function dbGetConvertPlaceholder(): bool public function dbSetConvertPlaceholderTarget(string $target): bool public function dbGetConvertPlaceholderTarget(): string public function dbSetDebugReplacePlaceholder(bool $flag): void public function dbGetDebugReplacePlaceholder(): bool public function dbGetPlaceholderConverted(): array Chagned to public: public function dbCheckQueryForSelect(string $query): bool public function dbCheckQueryForInsert(string $query, bool $pure = false): bool public function dbCheckQueryForUpdate(string $query): bool --- 4dev/tests/DB/CoreLibsDBIOTest.php | 334 ++++++--- www/admin/class_test.db.php | 32 +- www/admin/class_test.db.query-placeholder.php | 208 ++++++ www/admin/class_test.db.single.php | 61 +- www/admin/class_test.php | 5 +- www/lib/CoreLibs/DB/IO.php | 676 ++++++++++++------ .../DB/Support/ConvertPlaceholder.php | 29 +- 7 files changed, 947 insertions(+), 398 deletions(-) create mode 100644 www/admin/class_test.db.query-placeholder.php diff --git a/4dev/tests/DB/CoreLibsDBIOTest.php b/4dev/tests/DB/CoreLibsDBIOTest.php index d1d598f3..93703db8 100644 --- a/4dev/tests/DB/CoreLibsDBIOTest.php +++ b/4dev/tests/DB/CoreLibsDBIOTest.php @@ -232,7 +232,7 @@ final class CoreLibsDBIOTest extends TestCase $this->assertEquals( $error, $last_error, - 'Assert query warning' + 'Assert query error' ); return [$last_warning, $last_error]; } @@ -251,8 +251,6 @@ final class CoreLibsDBIOTest extends TestCase */ public function testDbVersion(): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -276,8 +274,6 @@ final class CoreLibsDBIOTest extends TestCase */ public function testDbVersionNumeric(): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -306,8 +302,6 @@ final class CoreLibsDBIOTest extends TestCase */ public function testDbVersionInfoParameters(): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -365,8 +359,6 @@ final class CoreLibsDBIOTest extends TestCase */ public function testDbVersionInfo(string $parameter, string $expected): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -1592,8 +1584,6 @@ final class CoreLibsDBIOTest extends TestCase string $error, bool $run_many_times = false ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -1832,8 +1822,6 @@ final class CoreLibsDBIOTest extends TestCase string $error, string $insert_data ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -2002,8 +1990,6 @@ final class CoreLibsDBIOTest extends TestCase string $error, string $insert_data ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -3069,8 +3055,6 @@ final class CoreLibsDBIOTest extends TestCase string $error, string $insert_data ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -3465,7 +3449,7 @@ final class CoreLibsDBIOTest extends TestCase $read_query, null, null, - // + // warning: 20 true, '20', '', // 'result', '', '', @@ -3482,6 +3466,31 @@ final class CoreLibsDBIOTest extends TestCase 'returning_id' => false, ], ], + // prepare with different statement name + 'prepare query with same statement name, different query' => [ + 'double_error', + $read_query, + // primary key + null, + // arguments (none) + null, + // expected return false, warning: no, error: 26 + false, '', '26', + // return expected, warning, error + '', '', '', + // dummy query for second prepare with wrong query + $read_query . ' WHERE uid = $3', + [], + // + $insert_query, + // + [ + 'pk_name' => '', + 'count' => 0, + 'query' => 'SELECT row_int, uid FROM table_with_primary_key', + 'returning_id' => false, + ], + ], // insert wrong data count compared to needed (execute 23) 'wrong parmeter count' => [ 'wrong_param_count', @@ -3554,8 +3563,6 @@ final class CoreLibsDBIOTest extends TestCase string $insert_data, array $prepare_cursor, ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -3575,6 +3582,9 @@ final class CoreLibsDBIOTest extends TestCase $db->dbPrepare($stm_name, $query) : $db->dbPrepare($stm_name, $query, $pk_name); } + if ($error_prepare == '26') { + $prepare_result = $db->dbPrepare($stm_name, $expected_data_query); + } // if result type, or if forced bool if (is_string($expected_prepare) && $expected_prepare == 'result') { // if PHP or newer, must be Object PgSql\Result @@ -3597,66 +3607,68 @@ final class CoreLibsDBIOTest extends TestCase // for non fail prepare test exec // check test result - $execute_result = $query_data === null ? - $db->dbExecute($stm_name) : - $db->dbExecute($stm_name, $query_data); - if ($expected_execute == 'result') { - // if PHP or newer, must be Object PgSql\Result - $this->assertIsObject( - $execute_result - ); - // also check that this is correct instance type - $this->assertInstanceOf( - 'PgSql\Result', - $execute_result - ); - // if this is an select use dbFetchArray to get data and test - } else { - $this->assertEquals( - $expected_execute, - $execute_result - ); - } - // error/warning check - $this->subAssertErrorTest($db, $warning_execute, $error_execute); - // now check test result if expected return is result - if ( - $expected_execute == 'result' && - !empty($expected_data_query) - ) { - // $expected_data_query - // $expected_data - $rows = $db->dbReturnArray($expected_data_query); - $this->assertEquals( - $expected_data, - $rows - ); - } - if ( - $expected_execute == 'result' && - $execute_result !== false && - empty($expected_data_query) && - count($expected_data) - ) { - // compare previously read data to compare data - $compare_data = []; - // read in the query data - while (is_array($row = $db->dbFetchArray($execute_result, true))) { - $compare_data[] = $row; + if (!$error_prepare) { + $execute_result = $query_data === null ? + $db->dbExecute($stm_name) : + $db->dbExecute($stm_name, $query_data); + if ($expected_execute == 'result') { + // if PHP or newer, must be Object PgSql\Result + $this->assertIsObject( + $execute_result + ); + // also check that this is correct instance type + $this->assertInstanceOf( + 'PgSql\Result', + $execute_result + ); + // if this is an select use dbFetchArray to get data and test + } else { + $this->assertEquals( + $expected_execute, + $execute_result + ); + } + // error/warning check + $this->subAssertErrorTest($db, $warning_execute, $error_execute); + // now check test result if expected return is result + if ( + $expected_execute == 'result' && + !empty($expected_data_query) + ) { + // $expected_data_query + // $expected_data + $rows = $db->dbReturnArray($expected_data_query); + $this->assertEquals( + $expected_data, + $rows + ); + } + if ( + $expected_execute == 'result' && + $execute_result !== false && + empty($expected_data_query) && + count($expected_data) + ) { + // compare previously read data to compare data + $compare_data = []; + // read in the query data + while (is_array($row = $db->dbFetchArray($execute_result, true))) { + $compare_data[] = $row; + } + $this->assertEquals( + $expected_data, + $compare_data + ); } - $this->assertEquals( - $expected_data, - $compare_data - ); - } - // check dbGetPrepareCursorValue - foreach (['pk_name', 'count', 'query', 'returning_id'] as $key) { - $this->assertEquals( - $prepare_cursor[$key], - $db->dbGetPrepareCursorValue($stm_name, $key), - 'Prepared cursor: ' . $key . ': failed assertion' - ); + // check dbGetPrepareCursorValue + foreach (['pk_name', 'count', 'query', 'returning_id'] as $key) { + $this->assertEquals( + $prepare_cursor[$key], + $db->dbGetPrepareCursorValue($stm_name, $key), + 'Prepared cursor: ' . $key . ': failed assertion' + ); + } } // reset all data @@ -3844,8 +3856,6 @@ final class CoreLibsDBIOTest extends TestCase string $expected_get_var, string $expected_get_db ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config[$connection], self::$log @@ -3910,7 +3920,10 @@ final class CoreLibsDBIOTest extends TestCase // 'main::run::run::run::run::run::run::run::runBare::runTest::testDbErrorHandling::dbSetMaxQueryCall 'source' => "/^(include::)?main::(run::)+runBare::runTest::testDbErrorHandling::dbSetMaxQueryCall$/", 'pg_error' => '', - 'msg' => '', + 'message' => '', + 'context' => [ + 'max_calls' => 0 + ] ] ], 'trigger warning' => [ @@ -3943,8 +3956,6 @@ final class CoreLibsDBIOTest extends TestCase string $error_id, array $expected_history ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -3970,7 +3981,7 @@ final class CoreLibsDBIOTest extends TestCase foreach ($expected_history as $key => $value) { // check if starts with / because this is regex (timestamp) // if (substr($expected_2, 0, 1) == '/) { - if (strpos($value, '/') === 0) { + if (!is_array($value) && strpos($value, '/') === 0) { // this is regex $this->assertMatchesRegularExpression( $value, @@ -4058,8 +4069,6 @@ final class CoreLibsDBIOTest extends TestCase bool $expected_set_flag, string $expected_get_encoding ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config[$connection], self::$log @@ -4141,8 +4150,6 @@ final class CoreLibsDBIOTest extends TestCase ?string $encoding_php, string $text ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config[$connection], self::$log @@ -4272,8 +4279,6 @@ final class CoreLibsDBIOTest extends TestCase string $table, string $primary_key ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -4330,7 +4335,7 @@ final class CoreLibsDBIOTest extends TestCase // NOTE if there are different INSERTS before the primary keys // will not match anymore. Must be updated by hand // IMPORTANT: if this is stand alone the primary key will not match and fail - $table_with_primary_key_id = 68; + $table_with_primary_key_id = 70; // 0: query + returning // 1: params // 1: pk name for db exec @@ -4530,8 +4535,6 @@ final class CoreLibsDBIOTest extends TestCase array|string|int|null $expected_ret_ext, array $expected_ret_arr ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -4875,8 +4878,6 @@ final class CoreLibsDBIOTest extends TestCase array $expected_col_names, array $expected_col_types ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log @@ -5030,6 +5031,147 @@ final class CoreLibsDBIOTest extends TestCase $db->dbClose(); } + // query placeholder convert + + public function queryPlaceholderReplaceProvider(): array + { + // WHERE row_varchar = $1 + return [ + 'select, no change' => [ + 'query' => << [], + 'found' => 0, + 'expected_query' => '', + 'expected_params' => [], + ], + 'select, params ?' => [ + 'query' => << ['string a'], + 'found' => 1, + 'expected_query' => << ['string a'], + ], + 'select, params :' => [ + 'query' => << [':row_varchar' => 'string a'], + 'found' => 1, + 'expected_query' => << ['string a'], + ] + ]; + } + + /** + * test query string with placeholders convert + * + * @dataProvider queryPlaceholderReplaceProvider + * @testdox Query replacement test [$_dataName] + * + * @param string $query + * @param array $params + * @param string $expected_query + * @param array $expected_params + * @return void + */ + public function testQueryPlaceholderReplace( + string $query, + array $params, + int $expected_found, + string $expected_query, + array $expected_params + ): void { + $db = new \CoreLibs\DB\IO( + self::$db_config['valid'], + self::$log + ); + $db->dbSetConvertPlaceholder(true); + // + if ($db->dbCheckQueryForSelect($query)) { + $res = $db->dbReturnRowParams($query, $params); + $converted = $db->dbGetPlaceholderConverted(); + } else { + $db->dbExecParams($query, $params); + $converted = $db->dbGetPlaceholderConverted(); + } + $this->assertEquals( + $expected_found, + $converted['found'], + 'Found not equal' + ); + $this->assertEquals( + $expected_query, + $converted['query'], + 'Query not equal' + ); + $this->assertEquals( + $expected_params, + $converted['params'], + 'Params not equal' + ); + } + + /** + * test exception for placeholder convert + * -> internally converted to error + * + * @testdox Query Replace error tests + * + * @return void + */ + public function testQueryPlaceholderReplaceException(): void + { + $db = new \CoreLibs\DB\IO( + self::$db_config['valid'], + self::$log + ); + $db->dbSetConvertPlaceholder(true); + $db->dbExecParams( + <<assertEquals( + 200, + $db->dbGetLastError() + ); + + // catch unset, for :names + $db->dbExecParams( + << 'a', ':bname' => 'b'] + ); + $this->assertEquals( + 210, + $db->dbGetLastError() + ); + + // TODO: other way around for to pdo + } + // TODO implement below checks // - complex write sets // dbWriteData, dbWriteDataExt @@ -5158,8 +5300,6 @@ final class CoreLibsDBIOTest extends TestCase string $warning_final, string $error_final ): void { - // self::$log->setLogLevelAll('debug', true); - // self::$log->setLogLevelAll('print', true); $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log diff --git a/www/admin/class_test.db.php b/www/admin/class_test.db.php index c791d8a9..848d0f13 100644 --- a/www/admin/class_test.db.php +++ b/www/admin/class_test.db.php @@ -38,9 +38,10 @@ print ""; print "" . $PAGE_NAME . ""; print ""; print ''; -print ''; +print ''; +print ''; print ''; -print ''; +print ''; print '

' . $PAGE_NAME . '

'; print "LOGFILE NAME: " . $db->log->getLogFile() . "
"; @@ -549,11 +550,13 @@ print ""; print "PREPARE QUERIES
"; // READ PREPARE -$q_prep = "SELECT test_foo_id, test, some_bool, string_a, number_a, " - . "number_a_numeric, some_time " - . "FROM test_foo " - . "WHERE test = $1 " - . "ORDER BY test_foo_id DESC LIMIT 5"; +$q_prep = <<dbPrepare('sel_test_foo', $q_prep) === false) { print "Error in sel_test_foo prepare
"; } else { @@ -681,16 +684,27 @@ echo "
"; $db_pgb = new CoreLibs\DB\IO($DB_CONFIG['test_pgbouncer'] ?? [], $log); print "[PGB] DBINFO: " . $db_pgb->dbInfo() . "
"; if ($db->dbPrepare('pgb_sel_test_foo', $q_prep) === false) { - print "[PGB] [1] Error in pgb_sel_test_foo prepare
"; + print "[PGB] [1] Warning in pgb_sel_test_foo prepare
"; } else { print "[PGB] [1] pgb_sel_test_foo prepare OK
"; } // second prepare if ($db->dbPrepare('pgb_sel_test_foo', $q_prep) === false) { - print "[PGB] [2] Error in pgb_sel_test_foo prepare
"; + print "[PGB] [2] Warning in pgb_sel_test_foo prepare
"; } else { print "[PGB] [2] pgb_sel_test_foo prepare OK
"; } +// same statment name, different query +if ( + $db->dbPrepare('pgb_sel_test_foo', <<"; +} else { + print "[PGB] [3] pgb_sel_test_foo prepare OK
"; +} $db_pgb->dbClose(); # db write class test diff --git a/www/admin/class_test.db.query-placeholder.php b/www/admin/class_test.db.query-placeholder.php new file mode 100644 index 00000000..6da93482 --- /dev/null +++ b/www/admin/class_test.db.query-placeholder.php @@ -0,0 +1,208 @@ + BASE . LOG, + 'log_file_id' => $LOG_FILE_ID, + 'log_per_date' => true, +]); +// db connection and attach logger +$db = new CoreLibs\DB\IO(DB_CONFIG, $log); +$db->log->debug('START', '=============================>'); + +$PAGE_NAME = 'TEST CLASS: DB QUERY PLACEHOLDER'; +print ""; +print "" . $PAGE_NAME . ""; +print ""; +print ''; +print '

' . $PAGE_NAME . '

'; + +print "LOGFILE NAME: " . $db->log->getLogFile() . "
"; +print "LOGFILE ID: " . $db->log->getLogFileId() . "
"; +print "DBINFO: " . $db->dbInfo() . "
"; +// DB client encoding +print "DB client encoding: " . $db->dbGetEncoding() . "
"; +print "DB search path: " . $db->dbGetSchema() . "
"; + +$to_db_version = '15.2'; +print "VERSION DB: " . $db->dbVersion() . "
"; +print "SERVER ENCODING: " . $db->dbVersionInfo('server_encoding') . "
"; +if (($dbh = $db->dbGetDbh()) instanceof \PgSql\Connection) { + print "ALL OUTPUT [TEST]:
" . print_r(pg_version($dbh), true) . "

"; +} else { + print "NO DB HANDLER
"; +} +// turn on debug replace for placeholders +$db->dbSetDebugReplacePlaceholder(true); + +print "TRUNCATE test_foo
"; +$db->dbExec("TRUNCATE test_foo"); + +$uniqid = \CoreLibs\Create\Uids::uniqIdShort(); +$binary_data = $db->dbEscapeBytea(file_get_contents('class_test.db.php') ?: ''); +$query_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_insert = <<dbExecParams($query_insert, $query_params); +echo "*
"; +echo "INSERT ALL COLUMN TYPES: " + . Support::printToString($query_params) . " |
" + . "QUERY: " . $db->dbGetQuery() . " |
" + . "PRIMARY KEY: " . Support::printToString($db->dbGetInsertPK()) . " |
" + . "RETURNING EXT:
" . print_r($db->dbGetReturningExt(), true) . "
|
" + . "RETURNING RETURN:
" . print_r($db->dbGetReturningArray(), true) . "
 |
" + . "ERROR: " . $db->dbGetLastError(true) . "
"; +echo "
"; + +// convert placeholder tests +// ? -> $n +// :name -> $n + +// other way around (just visual) +$test_queries = [ + 'skip' => [ + 'query' => << [], + 'direction' => 'pg', + ], + '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', + ], +]; + +$db->dbSetConvertPlaceholder(true); +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)) + // . "
"; + if ($db->dbCheckQueryForSelect($query)) { + $row = $db->dbReturnRowParams($query, $params); + print "[$info] SELECT: " . Support::prAr($row) . "
"; + } else { + $db->dbExecParams($query, $params); + } + print "[$info] " . Support::printAr($db->dbGetPlaceholderConverted()) . "
"; + echo "
"; +} + +echo "dbReturn read:
"; +while ( + is_array($res = $db->dbReturnParams( + <<"; +} + +print "CursorExt: " . Support::prAr($db->dbGetCursorExt(<<dbReturnRowParams(<<dbGetPlaceholderConverted()) . "
"; + +print ""; +$db->log->debug('DEBUGEND', '==================================== [END]'); + +// __END__ diff --git a/www/admin/class_test.db.single.php b/www/admin/class_test.db.single.php index 256dc902..ea196ff0 100644 --- a/www/admin/class_test.db.single.php +++ b/www/admin/class_test.db.single.php @@ -16,7 +16,7 @@ define('USE_DATABASE', true); // sample config require 'config.php'; // define log file id -$LOG_FILE_ID = 'classTest-db-single'; +$LOG_FILE_ID = 'classTest-db-query-placeholders'; ob_end_flush(); use CoreLibs\Debug\Support; @@ -30,7 +30,7 @@ $log = new CoreLibs\Logging\Logging([ $db = new CoreLibs\DB\IO(DB_CONFIG, $log); $db->log->debug('START', '=============================>'); -$PAGE_NAME = 'TEST CLASS: DB SINGLE'; +$PAGE_NAME = 'TEST CLASS: DB QUERY PLACEHOLDERS'; print ""; print "" . $PAGE_NAME . ""; print ""; @@ -65,63 +65,6 @@ function testDBS(\CoreLibs\DB\IO $dbc): void $dbc->dbReturnRow("SELECT test FROM test_foo LIMIT 1"); } -$uniqid = \CoreLibs\Create\Uids::uniqIdShort(); -$binary_data = $db->dbEscapeBytea(file_get_contents('class_test.db.php') ?: ''); -$query_params = [ - $uniqid, - true, - 'STRING A', - 2, - 2.5, - 1, - date('H:m:s'), - date('Y-m-d H:m: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_insert = <<dbExecParams($query_insert, $query_params); -$query_select = <<dbReturnRowParams($query_select, [$uniqid]); -if (is_array($res)) { - var_dump($res); -} - testDBS($db); print ""; diff --git a/www/admin/class_test.php b/www/admin/class_test.php index c8f528f3..75c9e516 100644 --- a/www/admin/class_test.php +++ b/www/admin/class_test.php @@ -69,9 +69,10 @@ print ""; // key: file name, value; name $test_files = [ 'class_test.db.php' => 'Class Test: DB', - 'class_test.db.types.php' => 'Class Test: DB COLUMN TYPES', - 'class_test.db.single.php' => 'Class Test: DB SINGLE', + 'class_test.db.types.php' => 'Class Test: DB column type convert', + '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.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 a4f83395..23014b8b 100644 --- a/www/lib/CoreLibs/DB/IO.php +++ b/www/lib/CoreLibs/DB/IO.php @@ -284,8 +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 */ - public const DB_CONVERT_PLACEHOLDER_TARGET = ['pg', 'pdo']; + /** @var array allowed convert target for placeholder: pg or pdo (currently not available) */ + public const DB_CONVERT_PLACEHOLDER_TARGET = ['pg']; // REGEX_SELECT // REGEX_UPDATE // REGEX INSERT @@ -302,6 +302,9 @@ class IO private string $query = ''; /** @var array current params for query */ private array $params = []; + // if we do have a convert call, store the convert data in here, else it will be empty + /** @var array{}|array{original:array{query:string,params:array},type:''|'named'|'numbered'|'question_mark',found:int,matches:array,params_lookup:array,query:string,params:array} */ + private array $placeholder_converted = []; // only inside // basic vars // the dbh handler, if disconnected by command is null, bool:false on error, @@ -333,6 +336,8 @@ class IO private bool $db_convert_placeholder = false; /** @var string convert placeholders target, default is 'pg', other allowed is 'pdo' */ private string $db_convert_placeholder_target = 'pg'; + /** @var bool Replace the placeholders in a query for debug output, defaults to false */ + private bool $db_debug_replace_placeholder = false; // convert type settings // 0: OFF (CONVERT_OFF) // >0: ON @@ -374,6 +379,7 @@ class IO private string $warning_id; /** @var string */ private string $error_history_id; + // timestamp:string,level:string,id:string,error:string,source:string,pg_error:string,message:string,context:array /** @var array Stores warning and errors combinded with detail info */ private array $error_history_long = []; /** @var bool error thrown on class init if we cannot connect to db */ @@ -413,7 +419,7 @@ class IO * and failure set on failed connection * * phpcs:ignore - * @param array{db_name:string,db_user:string,db_pass:string,db_host:string,db_port:int,db_schema:string,db_encoding:string,db_type:string,db_ssl:string,db_convert_type?:string[],db_convert_placeholder?:bool,db_convert_placeholder_target?:string} $db_config DB configuration array + * @param array{db_name:string,db_user:string,db_pass:string,db_host:string,db_port:int,db_schema:string,db_encoding:string,db_type:string,db_ssl:string,db_convert_type?:string[],db_convert_placeholder?:bool,db_convert_placeholder_target?:string,db_debug_replace_placeholder?:bool} $db_config DB configuration array * @param \CoreLibs\Logging\Logging $log Logging class * @throws \RuntimeException If no DB connection can be established on launch */ @@ -442,15 +448,15 @@ class IO '11' => 'No Querystring given', '12' => 'No Cursor given, no correct query perhaps?', '13' => 'Query could not be executed without errors', - '14' => 'Can\'t connect to DB server', - '15' => 'Can\'t select DB or no db name given', - '16' => 'No DB Handler found / connect or reconnect failed', + '14' => 'No DB Handler found / connect or reconnect failed', + '15' => 'Cannot select DB or no db name given', + // '16' => 'No DB Handler found / connect or reconnect failed', // 16 merged into 14 '17' => 'All dbReturn* methods work only with SELECT statements, ' . 'please use dbExec for everything else', '18' => 'Query not found in cache. Nothing has been reset', '19' => 'Wrong PK name given or no PK name given at all, can\'t get Insert ID', - '20' => 'Found given Prepare Statement Name in array, ' - . 'Query not prepared, will use existing one', + '20' => 'Query has already been prepared', + '26' => 'Same prepare statement name has been used for a different query', '21' => 'Query Prepare failed', '22' => 'Query Execute failed', '23' => 'Query Execute failed, data array does not match placeholders', @@ -481,6 +487,10 @@ class IO '104' => 'No Key with this name in the prepared cursor array', // abort on Placeholder convert '200' => 'Cannot have named, question mark or numbered placeholders in the same query', + '210' => 'Cannot lookup param named in param list', + '211' => 'Cannot lookup param named in param lookup list', + '220' => 'Cannot lookup param number in param list', + '221' => 'Cannot lookup param number in param lookup list', ]; // load the core DB functions wrapper class @@ -493,7 +503,6 @@ class IO // connect to DB if (!$this->__connectToDB()) { - $this->__dbError(16); $this->db_connection_closed = true; throw new \RuntimeException('INIT: No DB Handler found / connect or reconnect failed', 16); } @@ -515,7 +524,7 @@ class IO * Setup DB config and options * * phpcs:ignore - * @param array{db_name:string,db_user:string,db_pass:string,db_host:string,db_port:int,db_schema:string,db_encoding:string,db_type:string,db_ssl:string,db_convert_type?:string[],db_convert_placeholder?:bool,db_convert_placeholder_target?:string} $db_config + * @param array{db_name:string,db_user:string,db_pass:string,db_host:string,db_port:int,db_schema:string,db_encoding:string,db_type:string,db_ssl:string,db_convert_type?:string[],db_convert_placeholder?:bool,db_convert_placeholder_target?:string,db_debug_replace_placeholder?:bool} $db_config * @return bool */ private function __setConfigOptions(array $db_config): bool @@ -558,7 +567,10 @@ class IO $this->__setConvertType($db_convert_type); } // set placeholder convert flag and target - if (isset($db_config['db_convert_placeholder']) && is_bool($db_config['db_convert_placeholder'])) { + if ( + isset($db_config['db_convert_placeholder']) && + is_bool($db_config['db_convert_placeholder']) + ) { $this->db_convert_placeholder = $db_config['db_convert_placeholder']; } if ( @@ -568,6 +580,13 @@ class IO $this->db_convert_placeholder_target = $db_config['db_convert_placeholder_target']; } + if ( + isset($db_config['db_debug_replace_placeholder']) && + is_bool($db_config['db_debug_replace_placeholder']) + ) { + $this->db_debug_replace_placeholder = $db_config['db_debug_replace_placeholder']; + } + // return status true: ok, false: options error return true; } @@ -619,7 +638,7 @@ class IO // if non set or none matching abort default: // abort error - $this->__dbError(10); + $this->__dbError(10, context: ['db_type' => $this->db_type]); $this->db_connection_closed = true; break; } @@ -637,7 +656,14 @@ class IO { // no DB name set, abort if (empty($this->db_name)) { - $this->__dbError(15); + $this->__dbError(15, context: [ + 'host' => $this->db_host, + 'user' => $this->db_user, + 'password' => 'sha256:' . hash('sha256', $this->db_pwd), + 'database' => $this->db_name, + 'port' => $this->db_port, + 'ssl' => $this->db_ssl + ]); return false; } // generate connect string @@ -651,7 +677,14 @@ class IO ); // if no dbh here, we couldn't connect to the DB itself if (!$this->dbh) { - $this->__dbError(14); + $this->__dbError(14, context: [ + 'host' => $this->db_host, + 'user' => $this->db_user, + 'password' => 'sha256:' . hash('sha256', $this->db_pwd), + 'database' => $this->db_name, + 'port' => $this->db_port, + 'ssl' => $this->db_ssl + ]); return false; } // 15 error (cant select to DB is not valid in postgres, as connect is different) @@ -686,59 +719,6 @@ class IO } } - /** - * checks if query is a SELECT, SHOW or WITH, if not error, 0 return - * NOTE: - * Query needs to start with SELECT, SHOW or WITH - * - * @param string $query query to check - * @return bool true if matching, false if not - */ - private function __checkQueryForSelect(string $query): bool - { - // change to string starts with? - if (preg_match("/^\s*(?:SELECT|SHOW|WITH)\s/i", $query)) { - return true; - } - return false; - } - - /** - * check for DELETE, INSERT, UPDATE - * if pure is set to true, only when INSERT is set will return true - * NOTE: - * Queries need to start with INSERT, UPDATE, DELETE. Anything else is ignored - * - * @param string $query query to check - * @param bool $pure pure check (only insert), default false - * @return bool true if matching, false if not - */ - private function __checkQueryForInsert(string $query, bool $pure = false): bool - { - if ($pure && preg_match("/^\s*INSERT\s+?INTO\s/i", $query)) { - return true; - } - if (!$pure && preg_match("/^\s*(?:INSERT\s+?INTO|DELETE\s+?FROM|UPDATE)\s/i", $query)) { - return true; - } - return false; - } - - /** - * returns true if the query starts with UPDATE - * query NEEDS to start with UPDATE - * - * @param string $query query to check - * @return bool returns true if the query starts with UPDATE - */ - private function __checkQueryForUpdate(string $query): bool - { - if (preg_match("/^\s*UPDATE\s?(.+)/i", $query)) { - return true; - } - return false; - } - /** * internal funktion that creates the array * NOTE: @@ -899,12 +879,15 @@ class IO * @param \PgSql\Result|false $cursor current cursor for pg_result_error, * pg_last_error too, but pg_result_error * is more accurate (PgSql\Result) + * @param bool $force_log [=false] if we want to log this error to log, on default logging is handled in the + * dbError/dbWarning calls * @return array Pos 0: if we could get the method where it was called * if not found [Uknown Method] * Pos 1: if we have the pg_error_string from last error * if nothing then empty string + * Pos 2: context array for detailed logging */ - private function __dbErrorPreprocessor(\PgSql\Result|false $cursor = false): array + private function __dbErrorPreprocessor(\PgSql\Result|false $cursor = false, bool $force_log = false): array { $db_prefix = ''; $db_error_string = ''; @@ -941,12 +924,20 @@ class IO } elseif (!empty($db_error_string)) { $db_error_string = $db_prefix . ' ' . $db_error_string; } - if ($db_error_string) { + if ($db_error_string && $force_log) { $this->__dbDebugMessage('db', $db_error_string, 'DB_ERROR', $where_called); } + $context = []; + if ($db_error_string) { + $context = [ + 'pg_error_string' => $db_error_string, + 'where_called' => $where_called, + ]; + } return [ $where_called, - $db_error_string + $db_error_string, + $context ]; } @@ -957,11 +948,12 @@ class IO * additional pg error message if exists and optional msg given on error call * all error messages are grouped by error_history_id set when errors are reset * - * @param string $level - * @param string $error_id - * @param string $where_called - * @param string $pg_error_string - * @param string $msg + * @param string $level warning or error + * @param string $error_id error id + * @param string $where_called context wher eerror was colled + * @param string $pg_error_string if set, postgresql error string + * @param string $message additional message + * @param array $context array with more context information (eg query, params, etc) * @return void */ private function __dbErrorHistory( @@ -969,7 +961,8 @@ class IO string $error_id, string $where_called, string $pg_error_string, - string $msg + string $message, + array $context ): void { if (empty($this->error_history_id)) { $this->error_history_id = Uids::uniqId(self::ERROR_HASH_TYPE); @@ -981,7 +974,8 @@ class IO 'error' => $this->error_string[$error_id] ?? '[UNKNOWN ERROR]', 'source' => $where_called, 'pg_error' => $pg_error_string, - 'msg' => $msg, + 'message' => $message, + 'context' => $context, ]; } @@ -990,54 +984,60 @@ class IO * * @param integer $error_id Any Error ID, used in debug message string * @param \PgSql\Result|false $cursor Optional cursor, passed on to preprocessor - * @param string $msg optional message added to debug + * @param string $message Optional message added to debug + * @param array $context Optional Context array, passed on as error_data to the main error * @return void */ protected function __dbError( int $error_id, \PgSql\Result|false $cursor = false, - string $msg = '' + string $message = '', + array $context = [] ): void { $error_id = (string)$error_id; - [$where_called, $pg_error_string] = $this->__dbErrorPreprocessor($cursor); + [$where_called, $pg_error_string, $_context] = $this->__dbErrorPreprocessor($cursor); // write error msg ... $this->__dbDebugMessage( 'db', $error_id . ': ' . ($this->error_string[$error_id] ?? '[UNKNOWN ERROR]') - . ($msg ? ', ' . $msg : ''), + . ($message ? ', ' . $message : ''), 'DB_ERROR', - $where_called + $where_called, + array_merge($context, $_context) ); $this->error_id = $error_id; - // keep warning history - $this->__dbErrorHistory('error', $error_id, $where_called, $pg_error_string, $msg); + // keep error history + $this->__dbErrorHistory('error', $error_id, $where_called, $pg_error_string, $message, $context); } /** * write a warning * - * @param integer $warning_id Integer warning id added to debug + * @param integer $warning_id Integer warning id added to debug * @param \PgSql\Result|false $cursor Optional cursor, passed on to preprocessor - * @param string $msg optional message added to debug + * @param string $message Optional message added to debug + * @param array $context Optional Context array, passed on as error_data to the main error * @return void */ protected function __dbWarning( int $warning_id, \PgSql\Result|false $cursor = false, - string $msg = '' + string $message = '', + array $context = [] ): void { $warning_id = (string)$warning_id; - [$where_called, $pg_error_string] = $this->__dbErrorPreprocessor($cursor); + [$where_called, $pg_error_string, $_context] = $this->__dbErrorPreprocessor($cursor); $this->__dbDebugMessage( 'db', $warning_id . ': ' . ($this->error_string[$warning_id] ?? '[UNKNOWN WARNING') - . ($msg ? ', ' . $msg : ''), + . ($message ? ', ' . $message : ''), 'DB_WARNING', - $where_called + $where_called, + array_merge($context, $_context) ); $this->warning_id = $warning_id; // keep warning history - $this->__dbErrorHistory('warning', $warning_id, $where_called, $pg_error_string, $msg); + $this->__dbErrorHistory('warning', $warning_id, $where_called, $pg_error_string, $message, $context); } /** @@ -1138,39 +1138,69 @@ class IO /** * for debug purpose replaces $1, $2, etc with actual data + * TODO: :name and ? params + * Also works with :name parameters + * ? parameters, will be ignored * * @param string $query Query to replace values in - * @param array $data The data array + * @param array $params The data param array * @return string string of query with data inside */ - private function __dbDebugPrepare(string $query, array $data = []): string + private function __dbDebugPrepare(string $query, array $params = []): string { // skip anything if there is no data - if ($data === []) { + if ($params === []) { return $query; } // get the keys from data array - $keys = array_keys($data); + $keys = array_keys($params); + // check if there is ? or :name i the keys list // because the placeholders start with $ and at 1, // we need to increase each key and prefix it with a $ char for ($i = 0, $iMax = count($keys); $i < $iMax; $i++) { // note: if I use $ here, the str_replace will // replace it again. eg $11 '$1'1would be replaced with $1 again // prefix data set with parameter pos - $data[$i] = '#' . ($keys[$i] + 1) . ':' . ($data[$i] === null ? - '"NULL"' : (string)$data[$i] + $params[$i] = '#' . ($keys[$i] + 1) . ':' . ($params[$i] === null ? + '"NULL"' : (string)$params[$i] ); // search part $keys[$i] = '$' . ($keys[$i] + 1); } // simply replace the $1, $2, ... with the actual data and return it + // note that we do this in return to go from highest number to lowest return str_replace( array_reverse($keys), - array_reverse($data), + array_reverse($params), $query ); } + /** + * Created the context/error_data error for debug messages + * + * @param string $query Query called + * @param array $params Params + * @return array{}|array Empty array if no params, or params + * with optional prepared statement + */ + private function __dbDebugPrepareContext(string $query, array $params = []): array + { + if ($this->params === []) { + return []; + } + $error_data = [ + 'params' => $params + ]; + if ($this->dbGetDebugReplacePlaceholder()) { + $error_data['prepared'] = $this->__dbDebugPrepare( + $query, + $params + ); + } + return $error_data; + } + /** * extracts schema and table from the query, * if no schema returns just empty string @@ -1182,7 +1212,7 @@ class IO { $matches = []; $schema_table = []; - if ($this->__checkQueryForSelect($query)) { + if ($this->dbCheckQueryForSelect($query)) { // only selects the first one, this is more a fallback // MATCHES 1 (call), 3 (schema), 4 (table) preg_match("/\s+?(FROM)\s+?([\"'])?(?:([\w_]+)\.)?([\w_]+)(?:\2)?\s?/i", $query, $matches); @@ -1306,20 +1336,25 @@ class IO * Checks if the placeholder count in the query matches the params given * on call * - * @param string $query Query to check - * @param int $params_count The parms count expected + * @param string $query Query to check + * @param array $params The parms to count count expected * @return bool True for params count ok, else false */ - private function __dbCheckQueryParams(string $query, int $params_count): bool + private function __dbCheckQueryParams(string $query, array $params): bool { $placeholder_count = $this->__dbCountQueryParams($query); + $params_count = count($params); if ($params_count != $placeholder_count) { $this->__dbError( 23, false, - 'Array data count does not match prepared fields. Need: ' - . $placeholder_count . ', has: ' - . $params_count + 'Need: ' . $placeholder_count . ', has: ' . $params_count, + [ + 'query' => $query, + 'params' => $params, + 'placeholder_needed' => $placeholder_count, + 'placeholder_provided' => $params_count, + ] ); return false; } @@ -1366,7 +1401,6 @@ class IO if (!$this->dbh) { // if reconnect fails drop out if (!$this->__connectToDB()) { - $this->__dbError(16); return false; } } @@ -1376,7 +1410,7 @@ class IO } // if we do have an insert, check if there is no RETURNING pk_id, // add it if I can get the PK id - if ($this->__checkQueryForInsert($this->query, true)) { + if ($this->dbCheckQueryForInsert($this->query, true)) { $this->pk_name = $pk_name; if ($this->pk_name != 'NULL') { if (!$this->pk_name) { @@ -1414,7 +1448,7 @@ class IO } // if we have an UPDATE and RETURNING, flag for true, but do not add anything if ( - $this->__checkQueryForUpdate($this->query) && + $this->dbCheckQueryForUpdate($this->query) && preg_match(self::REGEX_RETURNING, $this->query, $matches) ) { $this->returning_id = true; @@ -1423,23 +1457,46 @@ class IO $query_hash = $this->dbGetQueryHash($this->query, $this->params); // QUERY PARAMS: run query params check and rewrite if ($this->dbGetConvertPlaceholder() === true) { - $convert = ConvertPlaceholder::convertPlaceholderInQuery( - $query, - $params, - $this->dbGetConvertPlaceholderTarget() - ); + try { + $this->placeholder_converted = ConvertPlaceholder::convertPlaceholderInQuery( + $this->query, + $this->params, + $this->dbGetConvertPlaceholderTarget() + ); + // write the new queries over the old + if (!empty($this->placeholder_converted['query'])) { + $this->query = $this->placeholder_converted['query']; + $this->params = $this->placeholder_converted['params']; + } + } catch (\OutOfRangeException $e) { + $this->__dbError($e->getCode(), context:[ + 'query' => $this->query, + 'params' => $this->params, + 'location' => '__dbPrepareExec', + 'error' => 'OutOfRangeException', + 'exception' => $e + ]); + return false; + } catch (\RuntimeException $e) { + $this->__dbError($e->getCode(), context:[ + 'query' => $this->query, + 'params' => $this->params, + 'location' => '__dbPrepareExec', + 'error' => 'RuntimeException', + 'exception' => $e + ]); + return false; + } } // $this->debug('DB IO', 'Q: ' . $this->query . ', RETURN: ' . $this->returning_id); // for DEBUG, only on first time ;) $this->__dbDebug( 'db', - $this->__dbDebugPrepare( - $this->query, - $this->params - ), + $this->query, '__dbPrepareExec', - ($this->params === [] ? 'Q' : 'Qp') + ($this->params === [] ? 'Q' : 'Qp'), + error_data: $this->__dbDebugPrepareContext($this->query, $this->params) ); // if the array index does not exists set it 0 if (!array_key_exists($query_hash, $this->query_called)) { @@ -1455,16 +1512,11 @@ class IO $this->MAX_QUERY_CALL != -1 && $this->query_called[$query_hash] > $this->MAX_QUERY_CALL ) { - $this->__dbError(30, false, $this->query); - $this->__dbDebugMessage( - 'db', - $this->__dbDebugPrepare( - $this->query, - $this->params - ), - 'dbExec', - ($this->params === [] ? 'Q[nc]' : 'Qp[nc]') - ); + $this->__dbError(30, false, context: [ + 'query' => $this->query, + 'params' => $this->params, + 'location' => '__dbPrepareExec' + ]); return false; } $this->query_called[$query_hash] ++; @@ -1484,14 +1536,17 @@ class IO // if FALSE returned, set error stuff // if either the cursor is false if ($this->cursor === false || $this->db_functions->__dbLastErrorQuery()) { - // printout Query if debug is turned on - $this->__dbDebug('db', $this->query, 'dbExec', 'Q[nc]'); // internal error handling - $this->__dbError(13, $this->cursor); + $this->__dbError(13, $this->cursor, context: [ + 'query' => $this->query, + 'params' => $this->params, + 'location' => 'dbExec', + 'query_id' => 'Q[nc]', + ]); return false; } else { // if SELECT do here ... - if ($this->__checkQueryForSelect($this->query)) { + if ($this->dbCheckQueryForSelect($this->query)) { // count the rows returned (if select) $this->num_rows = $this->db_functions->__dbNumRows($this->cursor); // count the fields @@ -1512,15 +1567,15 @@ class IO $this->field_names, $this->field_types ); - } elseif ($this->__checkQueryForInsert($this->query)) { + } elseif ($this->dbCheckQueryForInsert($this->query)) { // if not select do here // count affected rows $this->num_rows = $this->db_functions->__dbAffectedRows($this->cursor); if ( // ONLY insert with set pk name - ($this->__checkQueryForInsert($this->query, true) && $this->pk_name != 'NULL') || + ($this->dbCheckQueryForInsert($this->query, true) && $this->pk_name != 'NULL') || // insert or update with returning add - ($this->__checkQueryForInsert($this->query) && $this->returning_id) + ($this->dbCheckQueryForInsert($this->query) && $this->returning_id) ) { $this->__dbSetInsertId( $this->returning_id, @@ -1890,6 +1945,63 @@ class IO return $string; } + // *************************** + // CHECK QUERY TYPE + // *************************** + + /** + * checks if query is a SELECT, SHOW or WITH, if not error, 0 return + * NOTE: + * Query needs to start with SELECT, SHOW or WITH + * + * @param string $query query to check + * @return bool true if matching, false if not + */ + public function dbCheckQueryForSelect(string $query): bool + { + // change to string starts with? + if (preg_match("/^\s*(?:SELECT|SHOW|WITH)\s/i", $query)) { + return true; + } + return false; + } + + /** + * check for DELETE, INSERT, UPDATE + * if pure is set to true, only when INSERT is set will return true + * NOTE: + * Queries need to start with INSERT, UPDATE, DELETE. Anything else is ignored + * + * @param string $query query to check + * @param bool $pure pure check (only insert), default false + * @return bool true if matching, false if not + */ + public function dbCheckQueryForInsert(string $query, bool $pure = false): bool + { + if ($pure && preg_match("/^\s*INSERT\s+?INTO\s/i", $query)) { + return true; + } + if (!$pure && preg_match("/^\s*(?:INSERT\s+?INTO|DELETE\s+?FROM|UPDATE)\s/i", $query)) { + return true; + } + return false; + } + + /** + * returns true if the query starts with UPDATE + * query NEEDS to start with UPDATE + * + * @param string $query query to check + * @return bool returns true if the query starts with UPDATE + */ + public function dbCheckQueryForUpdate(string $query): bool + { + if (preg_match("/^\s*UPDATE\s?(.+)/i", $query)) { + return true; + } + return false; + } + // *************************** // DATA WRITE CONVERSION // *************************** @@ -2136,7 +2248,10 @@ class IO $table = (!empty($schema) ? $schema . '.' : '') . $table; $array = $this->db_functions->__dbMetaData($table); if (!is_array($array)) { - $this->__dbError(60); + $this->__dbError(60, context: [ + 'table' => $table, + 'schema' => $schema + ]); $array = false; } return $array; @@ -2245,6 +2360,8 @@ class IO 'query' => '', // parameter 'params' => [], + // if we convert placeholders, conversion data is stored here + 'placeholder_converted' => [], // cache flag from method call 'cache_flag' => $cache, // flag if we only have assoc data @@ -2262,44 +2379,71 @@ class IO 'log_pos' => 1, // how many times called overall 'log' => [], // current run log ]; + + // set the query + $this->cursor_ext[$query_hash]['query'] = $query; + // before doing ANYTHING check if query is "SELECT ..." everything else does not work + if (!$this->dbCheckQueryForSelect($this->cursor_ext[$query_hash]['query'])) { + $this->__dbError(17, false, context: [ + 'query' => $this->cursor_ext[$query_hash]['query'], + 'params' => $this->cursor_ext[$query_hash]['params'], + 'location' => 'dbReturn', + ]); + return false; + } + // set the query parameters + $this->cursor_ext[$query_hash]['params'] = $params; + // QUERY PARAMS: run query params check and rewrite + if ($this->dbGetConvertPlaceholder() === true) { + try { + $this->cursor_ext[$query_hash]['placeholder_converted'] = + ConvertPlaceholder::convertPlaceholderInQuery( + $this->cursor_ext[$query_hash]['query'], + $this->cursor_ext[$query_hash]['params'], + $this->dbGetConvertPlaceholderTarget() + ); + if (!empty($this->cursor_ext[$query_hash]['placeholder_converted']['query'])) { + $this->cursor_ext[$query_hash]['query'] = + $this->cursor_ext[$query_hash]['placeholder_converted']['query']; + $this->cursor_ext[$query_hash]['params'] = + $this->cursor_ext[$query_hash]['placeholder_converted']['params']; + } + } catch (\OutOfRangeException $e) { + $this->__dbError($e->getCode(), context:[ + 'query' => $this->cursor_ext[$query_hash]['query'], + 'params' => $this->cursor_ext[$query_hash]['params'], + 'location' => 'dbReturn', + 'error' => 'OutOfRangeException', + 'exception' => $e + ]); + return false; + } catch (\RuntimeException $e) { + $this->__dbError($e->getCode()); + $this->__dbError($e->getCode(), context:[ + 'query' => $this->cursor_ext[$query_hash]['query'], + 'params' => $this->cursor_ext[$query_hash]['params'], + 'location' => 'dbReturn', + 'error' => 'RuntimeException', + 'exception' => $e + ]); + return false; + } + } + // check if params count matches + // checks if the params count given matches the expected count + if ( + $this->__dbCheckQueryParams( + $this->cursor_ext[$query_hash]['query'], + $this->cursor_ext[$query_hash]['params'] + ) === false + ) { + return false; + } } else { $this->cursor_ext[$query_hash]['log_pos'] ++; } // reset log for each read $this->cursor_ext[$query_hash]['log'] = []; - // set the query - $this->cursor_ext[$query_hash]['query'] = $query; - // before doing ANYTHING check if query is "SELECT ..." everything else does not work - if (!$this->__checkQueryForSelect($this->cursor_ext[$query_hash]['query'])) { - $this->__dbError(17, false, $this->cursor_ext[$query_hash]['query']); - return false; - } - // QUERY PARAMS: run query params check and rewrite - if ($this->dbGetConvertPlaceholder() === true) { - $convert = ConvertPlaceholder::convertPlaceholderInQuery( - $query, - $params, - $this->dbGetConvertPlaceholderTarget() - ); - } - - // set the query parameters - $this->cursor_ext[$query_hash]['params'] = $params; - // check if params count matches - // checks if the params count given matches the expected count - if ($this->__dbCheckQueryParams($query, count($params)) === false) { - // in case we got an error print out query - $this->__dbDebug( - 'db', - $this->__dbDebugPrepare( - $this->query, - $this->params - ), - 'dbReturn', - ($this->params === [] ? 'Q[e]' : 'Qp[e]') - ); - return false; - } // set first call to false $first_call = false; // init return als false @@ -2322,18 +2466,18 @@ class IO // for DEBUG, print out each query executed $this->__dbDebug( 'db', - $this->__dbDebugPrepare( - $this->cursor_ext[$query_hash]['query'], - $this->cursor_ext[$query_hash]['params'] - ), + $this->cursor_ext[$query_hash]['query'], 'dbReturn', ($this->cursor_ext[$query_hash]['params'] === [] ? 'Q' : 'Qp'), + error_data: $this->__dbDebugPrepareContext( + $this->cursor_ext[$query_hash]['query'], + $this->cursor_ext[$query_hash]['params'] + ) ); // if no DB Handler try to reconnect if (!$this->dbh) { // if reconnect fails drop out if (!$this->__connectToDB()) { - $this->__dbError(16); return false; } } @@ -2355,17 +2499,12 @@ class IO } // if still no cursor ... if (!$this->cursor_ext[$query_hash]['cursor']) { - $this->__dbDebug( - 'db', - $this->__dbDebugPrepare( - $this->cursor_ext[$query_hash]['query'], - $this->cursor_ext[$query_hash]['params'] - ), - 'dbReturn', - ($this->cursor_ext[$query_hash]['params'] === [] ? 'Q[e]' : 'Qp[e]'), - ); // internal error handling - $this->__dbError(13, $this->cursor_ext[$query_hash]['cursor']); + $this->__dbError(13, $this->cursor_ext[$query_hash]['cursor'], context: [ + 'query' => $this->cursor_ext[$query_hash]['query'], + 'params' => $this->cursor_ext[$query_hash]['params'], + 'location' => 'dbReturn' + ]); return false; } else { $first_call = true; @@ -2580,19 +2719,23 @@ class IO return false; } // checks if the params count given matches the expected count - if ($this->__dbCheckQueryParams($query, count($params)) === false) { + if ($this->__dbCheckQueryParams($this->query, $this->params) === false) { return false; } // ** actual db exec call - if ($params === []) { + if ($this->params === []) { $cursor = $this->db_functions->__dbQuery($this->query); } else { - $cursor = $this->db_functions->__dbQueryParams($this->query, $params); + $cursor = $this->db_functions->__dbQueryParams($this->query, $this->params); } // if we faield, just set the master cursors to false too $this->cursor = $cursor; if ($cursor === false) { - $this->__dbError(13); + $this->__dbError(13, context: [ + 'query' => $this->query, + 'params' => $this->params, + 'location' => 'dbExecParams', + ]); return false; } // if FALSE returned, set error stuff @@ -2672,8 +2815,13 @@ class IO } // before doing ANYTHING check if query is // "SELECT ..." everything else does not work - if (!$this->__checkQueryForSelect($query)) { - $this->__dbError(17, false, $query); + if (!$this->dbCheckQueryForSelect($query)) { + $this->__dbError(17, false, context: [ + 'query' => $query, + 'params' => $params, + 'assoc_only' => $assoc_only, + 'location' => 'dbReturnRowParams' + ]); return false; } $cursor = $this->dbExecParams($query, $params); @@ -2717,8 +2865,13 @@ class IO return false; } // before doing ANYTHING check if query is "SELECT ..." everything else does not work - if (!$this->__checkQueryForSelect($query)) { - $this->__dbError(17, false, $query); + if (!$this->dbCheckQueryForSelect($query)) { + $this->__dbError(17, false, context: [ + 'query' => $query, + 'params' => $params, + 'assoc_only' => $assoc_only, + 'location' => 'dbReturnArrayParams' + ]); return false; } $cursor = $this->dbExecParams($query, $params); @@ -2763,7 +2916,11 @@ class IO $query_hash = $this->dbGetQueryHash($query, $params); // clears cache for this query if (empty($this->cursor_ext[$query_hash]['query'])) { - $this->__dbError(18); + $this->__dbError(18, context: [ + 'query' => $query, + 'params' => $params, + 'hash' => $query_hash, + ]); return false; } unset($this->cursor_ext[$query_hash]); @@ -2932,7 +3089,6 @@ class IO if (!$this->dbh) { // if reconnect fails drop out if (!$this->__connectToDB()) { - $this->__dbError(16); return false; } } @@ -2959,7 +3115,7 @@ class IO 'returning_id' => false ]; // if this is an insert query, check if we can add a return - if ($this->__checkQueryForInsert($query, true)) { + if ($this->dbCheckQueryForInsert($query, true)) { if ($pk_name != 'NULL') { // set primary key name // current: only via parameter @@ -3007,14 +3163,34 @@ class IO $this->__dbError( 21, false, - $stm_name . ': Prepare field with: ' . $stm_name . ' | ' . $query + context: [ + 'statement_name' => $stm_name, + 'query' => $query, + 'pk_name' => $pk_name, + ] ); return $result; } } else { - // thrown warning - $this->__dbWarning(20, false, $stm_name); - return true; + // if we try to use the same statement name for a differnt query, error abort + if ($this->prepare_cursor[$stm_name]['query'] != $query) { + // thrown error + $this->__dbError(26, false, context: [ + 'statement_name' => $stm_name, + 'prepared_query' => $this->prepare_cursor[$stm_name]['query'], + 'query' => $query, + 'pk_name' => $pk_name, + ]); + return false; + } else { + // thrown warning + $this->__dbWarning(20, false, context: [ + 'statement_name' => $stm_name, + 'query' => $query, + 'pk_name' => $pk_name, + ]); + return true; + } } } @@ -3032,7 +3208,6 @@ class IO if (!$this->dbh) { // if reconnect fails drop out if (!$this->__connectToDB()) { - $this->__dbError(16); return false; } } @@ -3054,28 +3229,37 @@ class IO $this->__dbError( 24, false, - $stm_name . ': We do not have a prepared query entry for this statement name.' + $stm_name . ': We do not have a prepared query entry for this statement name.', + context: ['statement_name' => $stm_name] ); return false; } $this->__dbDebug( 'db', - $this->__dbDebugPrepare( + $this->prepare_cursor[$stm_name]['query'], + 'dbExecute', + 'Qpe', + error_data: array_merge([ + 'statement_name' => $stm_name, + ], $this->__dbDebugPrepareContext( $this->prepare_cursor[$stm_name]['query'], $data - ), - 'dbExecPrep', - 'Qpe' + )) ); // if the count does not match if ($this->prepare_cursor[$stm_name]['count'] != count($data)) { $this->__dbError( 23, false, - $stm_name - . ': Array data count does not match prepared fields. Need: ' - . $this->prepare_cursor[$stm_name]['count'] . ', has: ' - . count($data) + '(' . $stm_name . ') ' + . 'Need: ' . $this->prepare_cursor[$stm_name]['count'] . ', has: ' . count($data), + context: [ + 'statement_name' => $stm_name, + 'query' => $this->prepare_cursor[$stm_name]['query'], + 'params' => $data, + 'placeholder_needed' => $this->prepare_cursor[$stm_name]['count'], + 'placeholder_provided' => count($data) + ] ); return false; } @@ -3087,16 +3271,20 @@ class IO $this->__dbError( 22, $this->prepare_cursor[$stm_name]['result'], - $stm_name . ': Execution failed' + context: [ + 'statement_name' => $stm_name, + 'query' => $this->prepare_cursor[$stm_name]['query'], + 'params' => $data + ] ); return false; } if ( // pure insert wth pk name - ($this->__checkQueryForInsert($this->prepare_cursor[$stm_name]['query'], true) && + ($this->dbCheckQueryForInsert($this->prepare_cursor[$stm_name]['query'], true) && $this->prepare_cursor[$stm_name]['pk_name'] != 'NULL') || // insert or update with returning set - ($this->__checkQueryForInsert($this->prepare_cursor[$stm_name]['query']) && + ($this->dbCheckQueryForInsert($this->prepare_cursor[$stm_name]['query']) && $this->prepare_cursor[$stm_name]['returning_id'] === true ) ) { @@ -3157,20 +3345,24 @@ class IO return false; } // checks if the params count given matches the expected count - if ($this->__dbCheckQueryParams($query, count($params)) === false) { + if ($this->__dbCheckQueryParams($this->query, $this->params) === false) { return false; } // ** actual db exec call if ($params === []) { $status = $this->db_functions->__dbSendQuery($this->query); } else { - $status = $this->db_functions->__dbSendQueryParams($this->query, $params); + $status = $this->db_functions->__dbSendQueryParams($this->query, $this->params); } // run the async query, this just returns true or false // the actually result is in dbCheckAsync if (!$status) { // if failed, process here - $this->__dbError(40); + $this->__dbError(40, context: [ + 'query' => $this->query, + 'params' => $this->params, + 'pk_name' => $pk_name, + ]); return false; } else { $this->async_running = (string)$query_hash; @@ -3251,8 +3443,7 @@ class IO // if no async running print error $this->__dbError( 42, - false, - 'No async query has been started yet.' + false ); return false; } @@ -3589,6 +3780,27 @@ class IO return $this->db_convert_placeholder_target; } + /** + * Set flag if we print the query with replaced placeholders or not + * + * @param bool $flag + * @return void + */ + public function dbSetDebugReplacePlaceholder(bool $flag): void + { + $this->db_debug_replace_placeholder = $flag; + } + + /** + * get the current setting for the debug replace placeholder + * + * @return bool True for replace query, False for not + */ + public function dbGetDebugReplacePlaceholder(): bool + { + return $this->db_debug_replace_placeholder; + } + /** * set max query calls, set to -1 to disable loop * protection. this will generate a warning @@ -3606,11 +3818,11 @@ class IO // if -1 then disable loop check // DANGEROUS, WARN USER if ($max_calls == -1) { - $this->__dbWarning(50); + $this->__dbWarning(50, context: ['max_calls' => $max_calls]); } // negative or 0 if ($max_calls < -1 || $max_calls == 0) { - $this->__dbError(51); + $this->__dbError(51, context: ['max_calls' => $max_calls]); // early abort return false; } @@ -3654,7 +3866,7 @@ class IO case 2: // setting schema failed (3) case 3: - $this->__dbError(71); + $this->__dbError(71, context: ['schema' => $db_schema]); $status = false; break; } @@ -3704,7 +3916,7 @@ class IO case 2: // 3 is set failed case 3: - $this->__dbError(81); + $this->__dbError(81, context: ['encoding' => $db_encoding]); $status = false; break; } @@ -3829,6 +4041,16 @@ class IO $this->params = []; } + /** + * Returns the placeholder convert set or empty + * + * @return array{}|array{original:array{query:string,params:array},type:''|'named'|'numbered'|'question_mark',found:int,matches:array,params_lookup:array,query:string,params:array} + */ + public function dbGetPlaceholderConverted(): array + { + return $this->placeholder_converted; + } + // *************************** // INTERNAL VARIABLES READ POST QUERY RUN // *************************** @@ -4047,7 +4269,8 @@ class IO $this->__dbError( 102, false, - 'Invalid key name' + 'Invalid key name', + context: ['key' => $key] ); return false; } @@ -4056,7 +4279,8 @@ class IO $this->__dbError( 103, false, - 'Statement name does not exist in prepare cursor array' + 'Statement name does not exist in prepare cursor array', + context: ['statement_name' => $stm_name] ); return false; } @@ -4065,7 +4289,11 @@ class IO $this->__dbError( 104, false, - 'Key does not exist in prepare cursor array' + 'Key does not exist in prepare cursor array', + context: [ + 'statement_name' => $stm_name, + 'key' => $key + ] ); return false; } diff --git a/www/lib/CoreLibs/DB/Support/ConvertPlaceholder.php b/www/lib/CoreLibs/DB/Support/ConvertPlaceholder.php index 0c194dde..bcfd5613 100644 --- a/www/lib/CoreLibs/DB/Support/ConvertPlaceholder.php +++ b/www/lib/CoreLibs/DB/Support/ConvertPlaceholder.php @@ -24,10 +24,12 @@ class ConvertPlaceholder * * If the convert_to is either pg or pdo, nothing will be changed * + * 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 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|false,matches:array,params_lookup:array,query:string,params:array} + * @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 */ public static function convertPlaceholderInQuery( @@ -61,14 +63,27 @@ class ConvertPlaceholder // 2: ? question mark // 3: $n numbered $found = preg_match_all($pattern, $query, $matches, PREG_UNMATCHED_AS_NULL); + // if false or null set to -1 + // || $found === null + if ($found === false) { + $found = -1; + } /** @var array 1: named */ $named_matches = array_filter($matches[1]); /** @var array 2: open ? */ $qmark_matches = array_filter($matches[2]); /** @var array 3: $n matches */ $numbered_matches = array_filter($matches[3]); + // count matches + $count_named = count($named_matches); + $count_qmark = count($qmark_matches); + $count_numbered = count($numbered_matches); // throw if mixed - if (count($named_matches) && count($qmark_matches) && count($numbered_matches)) { + if ( + ($count_named && $count_qmark) || + ($count_named && $count_numbered) || + ($count_qmark && $count_numbered) + ) { throw new \OutOfRangeException('Cannot have named, question mark and numbered in the same query', 200); } // rebuild @@ -77,7 +92,7 @@ class ConvertPlaceholder $query_new = ''; $params_new = []; $params_lookup = []; - if (count($named_matches) && $convert_to == 'pg') { + if ($count_named && $convert_to == 'pg') { $type = 'named'; $matches_return = $named_matches; // only check for :named @@ -113,7 +128,7 @@ class ConvertPlaceholder }, $query ); - } elseif (count($qmark_matches) && $convert_to == 'pg') { + } elseif ($count_qmark && $convert_to == 'pg') { $type = 'question_mark'; $matches_return = $qmark_matches; // order and data stays the same @@ -143,7 +158,7 @@ class ConvertPlaceholder $query ); // for each ?:DTN: -> replace with $1 ... $n, any remaining :DTN: remove - } elseif (count($numbered_matches) && $convert_to == 'pdo') { + } elseif ($count_numbered && $convert_to == 'pdo') { // convert numbered to named $type = 'numbered'; $matches_return = $numbered_matches; @@ -190,8 +205,8 @@ class ConvertPlaceholder ], // type found, empty if nothing was done 'type' => $type, - // int|null: found, not found; false: problem - 'found' => $found, + // int: found, not found; -1: problem (set from false) + 'found' => (int)$found, 'matches' => $matches_return, // old to new lookup check 'params_lookup' => $params_lookup,