From aafca0153f08157d5fe32aa9eb1834b48dfd4118 Mon Sep 17 00:00:00 2001 From: Clemens Schwaighofer Date: Mon, 28 Feb 2022 18:06:51 +0900 Subject: [PATCH] PHP Unit testing work for DB::IO Also various clean ups for DB::IO - fix PGSQL array to PHP - add bool/literal escape to SQL - fix literal escape to call correct php array - move functions to correct place --- 4dev/tests/CoreLibsDBIOTest.php | 1024 ++++++++++++++++++++++++++--- www/lib/CoreLibs/DB/IO.php | 238 ++++--- www/lib/CoreLibs/DB/SQL/PgSQL.php | 95 ++- 3 files changed, 1167 insertions(+), 190 deletions(-) diff --git a/4dev/tests/CoreLibsDBIOTest.php b/4dev/tests/CoreLibsDBIOTest.php index 3266b72d..e8f1a322 100644 --- a/4dev/tests/CoreLibsDBIOTest.php +++ b/4dev/tests/CoreLibsDBIOTest.php @@ -1,4 +1,31 @@ - 'allow', // allow, disable, require, prefer 'db_debug' => true, ], + // same as valid, but db debug is off + 'valid_debug_false' => [ + 'db_name' => 'corelibs_db_io_test', + 'db_user' => 'corelibs_db_io_test', + 'db_pass' => 'corelibs_db_io_test', + 'db_host' => 'localhost', + 'db_port' => 5432, + 'db_schema' => 'public', + 'db_type' => 'pgsql', + 'db_encoding' => '', + 'db_ssl' => 'allow', // allow, disable, require, prefer + 'db_debug' => false, + ], + // same as valid, but encoding is set + 'valid_with_encoding_utf8' => [ + 'db_name' => 'corelibs_db_io_test', + 'db_user' => 'corelibs_db_io_test', + 'db_pass' => 'corelibs_db_io_test', + 'db_host' => 'localhost', + 'db_port' => 5432, + 'db_schema' => 'public', + 'db_type' => 'pgsql', + 'db_encoding' => 'UTF-8', + 'db_ssl' => 'allow', // allow, disable, require, prefer + 'db_debug' => true, + ], + // invalid (missing db name) 'invalid' => [ 'db_name' => '', 'db_user' => '', @@ -44,8 +98,20 @@ final class CoreLibsDBIOTest extends TestCase ]; private static $log; + /** + * Test if pgsql module loaded + * Check if valid DB connection works + * Check if tables exist + * + * @return void + */ public static function setUpBeforeClass(): void { + if (!extension_loaded('pgsql')) { + self::markTestSkipped( + 'The PgSQL extension is not available.' + ); + } // define basic connection set valid and one invalid self::$log = new \CoreLibs\Debug\Logging([ // 'log_folder' => __DIR__ . DIRECTORY_SEPARATOR . 'log', @@ -55,6 +121,44 @@ final class CoreLibsDBIOTest extends TestCase 'echo_all' => false, 'print_all' => false, ]); + $db = new \CoreLibs\DB\IO( + self::$db_config['valid'], + self::$log + ); + if (!$db->dbGetConnectionStatus()) { + self::markTestSkipped( + 'Cannot connect to valid Test DB.' + ); + } + // check if they already exist, drop them + if ($db->dbShowTableMetaData('table_with_primary_key') !== false) { + $db->dbExec("DROP TABLE table_with_primary_key"); + $db->dbExec("DROP TABLE table_without_primary_key"); + } + $base_table = "row_int INT, " + . "row_numeric NUMERIC, " + . "row_varchar VARCHAR, " + . "row_json JSON, " + . "row_jsonb JSONB, " + . "row_bytea BYTEA, " + . "row_timestamp TIMESTAMP WITHOUT TIME ZONE, " + . "row_date DATE, " + . "row_interval INTERVAL, " + . "row_array_int INT ARRAY, " + . "row_array_varchar VARCHAR ARRAY" + . ") WITHOUT OIDS"; + // create the tables + $db->dbExec( + "CREATE TABLE table_with_primary_key (" + . "row_primary_key SERIAL PRIMARY KEY, " + . $base_table + ); + $db->dbExec( + "CREATE TABLE table_without_primary_key (" + . $base_table + ); + // end connection + $db->dbClose(); } /** @@ -64,14 +168,78 @@ final class CoreLibsDBIOTest extends TestCase */ protected function setUp(): void { - if (!extension_loaded('pgsql')) { - $this->markTestSkipped( - 'The PgSQL extension is not available.' - ); - } // print_r(self::$db_config); } + // - connected version test + // dbVerions, dbCompareVersion + + /** + * Undocumented function + * + * @return array + */ + public function versionProvider(): array + { + return [ + 'compare = ok' => [ '=13.5.0', true ], + 'compare = bad' => [ '=9.2.0', false ], + 'compare < ok' => [ '<20.0.0', true ], + 'compare < bad' => [ '<9.2.0', false ], + 'compare <= ok a' => [ '<=20.0.0', true ], + 'compare <= ok b' => [ '<=13.5.0', true ], + 'compare <= false' => [ '<=9.2.0', false ], + 'compare > ok' => [ '>9.2.0', true ], + 'compare > bad' => [ '>20.2.0', false ], + 'compare >= ok a' => [ '>=13.5.0', true ], + 'compare >= ok b' => [ '>=9.2.0', true ], + 'compare >= bad' => [ '>=20.0.0', false ], + ]; + } + + /** + * NOTE + * Version tests will fail if versions change + * Current base as Version 13.5 for equal check + * I can't mock a function on the same class when it is called in a method + * NOTE + * + * @covers ::dbCompareVersion + * @dataProvider versionProvider + * @testdox Version $input compares as $expected [$_dataName] + * + * @return void + */ + public function testDbVerson(string $input, bool $expected): void + { + // connect to valid DB + $db = new \CoreLibs\DB\IO( + self::$db_config['valid'], + self::$log + ); + + // print "DB VERSION: " . $db->dbVersion() . "\n"; + + // TODO: Mock \CoreLibs\DB\SQL\PgSQL somehow + // Create a stub for the SomeClass class. + // $stub = $this->createMock(\CoreLibs\DB\IO::class); + // $stub->method('dbVersion') + // ->willReturn('13.1.0'); + // print "DB: " . $stub->dbVersion() . "\n"; + // print "TEST: " . ($stub->dbCompareVersion('=13.1.0') ? 'YES' : 'NO') . "\n"; + // print "TEST: " . ($stub->dbCompareVersion('=13.5.0') ? 'YES' : 'NO') . "\n"; + // $mock = $this->getMockBuilder(CoreLibs\DB\IO::class) + // ->addMethods(['dbVersion']) + // ->ge‌​tMock(); + + $this->assertEquals( + $expected, + $db->dbCompareVersion($input) + ); + + // print "IT HAS TO BE 13.1.0: " . $stub->dbVersion() . "\n"; + } + // - connect to DB test (dbGetConnectionStatus) // - connected get dbInfo data check (show true, false) // - disconnect: dbClose @@ -126,12 +294,12 @@ final class CoreLibsDBIOTest extends TestCase self::$log ); $this->assertEquals( + $expected_status, $db->dbGetConnectionStatus(), - $expected_status ); $this->assertEquals( - $db->dbInfo(false, true), - $expected_string + $expected_string, + $db->dbInfo(false, true) ); // print "DB: " . $db->dbInfo(false, true) . "\n"; @@ -139,8 +307,8 @@ final class CoreLibsDBIOTest extends TestCase // db close check $db->dbClose(); $this->assertEquals( - $db->dbGetConnectionStatus(), - false + false, + $db->dbGetConnectionStatus() ); } else { // TODO: error checks @@ -149,99 +317,801 @@ final class CoreLibsDBIOTest extends TestCase } } - // - connected get all default settings via get - // dbGetDebug, dbGetSchema, dbGetEncoding, dbGetMaxQueryCall - // dbGetSetting (name, user, ecnoding, schema, host, port, ssl, debug, password) - // - connected set - // dbSetMaxQueryCall, , - // dbSetDebug, dbToggleDebug, dbSetSchema, dbSetEncoding - - // - db execution tests - // dbReturn, dbDumpData, dbCacheReset, dbExec, dbExecAsync, dbCheckAsync - // dbFetchArray, dbReturnRow, dbReturnArray, dbCursorPos, dbCursorNumRows, - // dbShowTableMetaData, dbPrepare, dbExecute - // - connected stand alone tests - // dbEscapeString, dbEscapeLiteral, dbEscapeBytea, dbSqlEscape, dbArrayParse - // - complex write sets - // dbWriteData, dbWriteDataExt - // - non connection tests - // dbBoolean, dbTimeFormat - // - internal read data (post exec) - // dbGetReturning, dbGetInsertPKName, dbGetInsertPK, dbGetReturningExt, - // dbGetReturningArray, dbGetCursorExt, dbGetNumRows, - // getHadError, getHadWarning, - // dbResetQueryCalled, dbGetQueryCalled - // - deprecated tests - // getInsertReturn, getReturning, getInsertPK, getReturningExt, - // getCursorExt, getNumRows - - // public function testDbSettings(): void - // { - // // - // } - - // - connected version test - // dbVerions, dbCompareVersion + // - debug flag sets + // dbGetDebug, dbSetDebug, dbToggleDebug /** * Undocumented function * * @return array */ - public function versionProvider(): array + public function debugSetProvider(): array { return [ - 'compare = ok' => [ '=13.5.0', true ], - 'compare = bad' => [ '=9.2.0', false ], - 'compare < ok' => [ '<20.0.0', true ], - 'compare < bad' => [ '<9.2.0', false ], - 'compare <= ok a' => [ '<=20.0.0', true ], - 'compare <= ok b' => [ '<=13.5.0', true ], - 'compare <= false' => [ '<=9.2.0', false ], - 'compare > ok' => [ '>9.2.0', true ], - 'compare > bad' => [ '>20.2.0', false ], - 'compare >= ok a' => [ '>=13.5.0', true ], - 'compare >= ok b' => [ '>=9.2.0', true ], - 'compare >= bad' => [ '>=20.0.0', false ], + 'default debug set' => [ + // what base connection + 'valid', + // actions (set) + null, + // set exepected + self::$db_config['valid']['db_debug'], + ], + 'set debug to true' => [ + 'valid_debug_false', + true, + true, + ], + 'set debug to false' => [ + 'valid', + false, + false, + ] ]; } /** - * NOTE - * Version tests will fail if versions change - * Current base as Version 13.5 for equal check - * I can't mock a function on the same class when it is called in a method - * NOTE + * Undocumented function * - * @covers ::dbCompareVersion - * @dataProvider versionProvider - * @testdox Version $input compares as $expected [$_dataName] + * @return array + */ + public function debugToggleProvider(): array + { + return [ + 'default debug set' => [ + // what base connection + 'valid', + // actions + null, + // toggle is inverse + self::$db_config['valid']['db_debug'] ? false : true, + ], + 'toggle debug to true' => [ + 'valid_debug_false', + true, + true, + ], + 'toggle debug to false' => [ + 'valid', + false, + false, + ] + ]; + } + + /** + * Test dbSetDbug, dbGetDebug + * + * @covers ::dbGetDbug + * @covers ::dbSetDebug + * @dataProvider debugSetProvider + * @testdox Setting debug $set will be $expected [$_dataName] * * @return void */ - public function testDbVerson(string $input, bool $expected): void + public function testDbSetDebug( + string $connection, + ?bool $set, + bool $expected, + ): void { + $db = new \CoreLibs\DB\IO( + self::$db_config[$connection], + self::$log + ); + if ($set === null) { + // equals to do nothing + $this->assertEquals( + $expected, + $db->dbSetDebug() + ); + } else { + $this->assertEquals( + $expected, + $db->dbSetDebug($set) + ); + } + // must always match + $this->assertEquals( + $expected, + $db->dbGetDebug() + ); + $db->dbClose(); + } + + /** + * Test dbToggleDebug, dbGetDebug + * + * @covers ::dbGetDbug + * @covers ::dbSetDebug + * @dataProvider debugToggleProvider + * @testdox Toggle debug $toggle will be $expected [$_dataName] + * + * @return void + */ + public function testDbToggleDebug( + string $connection, + ?bool $toggle, + bool $expected, + ): void { + $db = new \CoreLibs\DB\IO( + self::$db_config[$connection], + self::$log + ); + if ($toggle === null) { + // equals to do nothing + $this->assertEquals( + $expected, + $db->dbToggleDebug() + ); + } else { + $this->assertEquals( + $expected, + $db->dbToggleDebug($toggle) + ); + } + // must always match + $this->assertEquals( + $expected, + $db->dbGetDebug() + ); + $db->dbClose(); + } + + // - set max query call sets + // dbSetMaxQueryCall, dbGetMaxQueryCall + + public function maxQueryCallProvider(): array { - // connect to valid DB + return [ + 'set default' => [ + null, + true, + \CoreLibs\DB\IO::DEFAULT_MAX_QUERY_CALL, + // expected warning + '', + // expected error + '', + ], + 'set to -1 with warning' => [ + -1, + true, + -1, + // warning 50 + '50', + '', + ], + 'set to 0 with error' => [ + 0, + false, + \CoreLibs\DB\IO::DEFAULT_MAX_QUERY_CALL, + '', + '51', + ], + 'set to -2 with error' => [ + -2, + false, + \CoreLibs\DB\IO::DEFAULT_MAX_QUERY_CALL, + '', + '51', + ], + 'set to valid value' => [ + 10, + true, + 10, + '', + '', + ] + ]; + } + + /** + * Undocumented function + * + * @covers ::dbSetMaxQueryCall + * @covers ::dbGetMaxQueryCall + * @dataProvider maxQueryCallProvider + * @testdox Set max query call with $max_calls out with $expected_flag and $expected_max_calls (Warning: $warning/Error: $error) [$_dataName] + * + * @param integer|null $max_calls + * @param boolean $expected_flag + * @param integer $expected_max_calls + * @param string $warning + * @param string $error + * @return void + */ + public function testMaxQueryCall( + ?int $max_calls, + bool $expected_flag, + int $expected_max_calls, + string $warning, + string $error + ): void { + $db = new \CoreLibs\DB\IO( + self::$db_config['valid'], + self::$log + ); + $this->assertEquals( + $expected_flag, + $db->dbSetMaxQueryCall($max_calls) + ); + $this->assertEquals( + $expected_max_calls, + $db->dbGetMaxQueryCall() + ); + // if string for warning or error is not empty check + $this->assertEquals( + $warning, + $db->dbGetLastWarning() + ); + $this->assertEquals( + $error, + $db->dbGetLastError() + ); + $db->dbClose(); + } + + // - set and get schema + // dbGetSchema, dbSetSchema, + + // - encoding settings (exclude encoding test, just set) + // dbGetEncoding, dbSetEncoding + + public function encodingProvider(): array + { + // 0: connection + // 1: set encoding + // 2: expected return from set + // 2: expected to get + return [ + 'default set no encoding' => [ + 'valid', + '', + false, + // I expect that the default DB is set to UTF8 + 'UTF8' + ], + 'set to Shift JIS' => [ + 'valid', + 'ShiftJIS', + true, + 'SJIS' + ], + // 'set to Invalid' => [ + // 'valid', + // 'Invalid', + // false, + // 'UTF8' + // ], + // other tests includ perhaps mocking for error? + ]; + } + + /** + * Undocumented function + * + * @covers ::dbSetEncoding + * @covers ::dbGetEncoding + * @dataProvider encodingProvider + * @testdox Set encoding on $connection to $set_encoding expect $expected_set_flag and $expected_get_encoding [$_dataName] + * + * @param string $connection + * @param string $set_encoding + * @param boolean $expected_set_flag + * @param string $expected_get_encoding + * @return void + */ + public function testEncoding( + string $connection, + string $set_encoding, + bool $expected_set_flag, + string $expected_get_encoding + ): void { + $db = new \CoreLibs\DB\IO( + self::$db_config[$connection], + self::$log + ); + $this->assertEquals( + $expected_set_flag, + $db->dbSetEncoding($set_encoding) + ); + $this->assertEquals( + $expected_get_encoding, + $db->dbGetEncoding() + ); + $db->dbClose(); + } + + // - all general data from connection array + // dbGetSetting (name, user, ecnoding, schema, host, port, ssl, debug, password) + + /** + * returns ALL connections sets + * + * @return array + */ + public function connectionCompleteProvider(): array + { + $connections = []; + // return self::$db_config; + foreach (self::$db_config as $connection => $settings) { + $connections['DB Connection: ' . $connection] = [ + $connection, + $settings, + ]; + } + + return $connections; + } + + /** + * Undocumented function + * + * @covers ::dbGetSetting + * @dataProvider connectionCompleteProvider + * @testdox Get settings for connection $connection [$_dataName] + * + * @param string $connection, + * @param array $settings + * @return void + */ + public function testGetSetting(string $connection, array $settings): void + { + $db = new \CoreLibs\DB\IO( + self::$db_config[$connection], + self::$log + ); + + // each must match + foreach ( + [ + 'name' => 'db_name', + 'user' => 'db_user', + 'encoding' => 'db_encoding', + 'schema' => 'db_schema', + 'host' => 'db_host', + 'port' => 'db_port', + 'ssl' => 'db_ssl', + 'debug' => 'db_debug', + 'password' => '***', + ] as $read => $compare + ) { + $this->assertEquals( + $read == 'password' ? $compare : $settings[$compare], + $db->dbGetSetting($read) + ); + } + + $db->dbClose(); + } + + // - test boolean convert + // dbBoolean + + /** + * Undocumented function + * + * @return array + */ + public function booleanProvider(): array + { + return [ + 'source "t" to true' => [ + 't', + true, + false + ], + 'source "true" to true' => [ + 'true', + true, + false + ], + 'source "f" to false' => [ + 'f', + false, + false + ], + 'source "false" to false' => [ + 'false', + false, + false + ], + 'source anything to true' => [ + 'something', + true, + false, + ], + 'source empty to false' => [ + '', + false, + false, + ], + 'source bool true to "t"' => [ + true, + 't', + true, + ], + 'source bool false to "f"' => [ + false, + 'f', + true, + ], + ]; + } + + /** + * Undocumented function + * + * @covers ::dbBoolean + * @dataProvider booleanProvider + * @testdox Have $source and convert ($reverse) to $expected [$_dataName] + * + * @param string|bool $source + * @param string|bool $expected + * @param bool $reverse + * @return void + */ + public function testDbBoolean($source, $expected, bool $reverse): void + { + $db = new \CoreLibs\DB\IO( + self::$db_config['valid'], + self::$log + ); + $this->assertEquals( + $expected, + $db->dbBoolean($source, $reverse) + ); + $db->dbClose(); + } + + // - test interval/age string conversion to + // \CoreLibs\Combined\DateTime::stringToTime/timeStringFormat compatbile + // dbTimeFormat + + /** + * Undocumented function + * + * @return array + */ + public function timeFormatProvider(): array + { + return [ + 'interval a' => [ + '41 years 9 mons 18 days', + false, + '41 years 9 mons 18 days' + ], + 'interval a-1' => [ + '41 years 9 mons 18 days 12:31:11', + false, + '41 years 9 mons 18 days 12h 31m 11s' + ], + 'interval a-2' => [ + '41 years 9 mons 18 days 12:31:11.87418', + false, + '41 years 9 mons 18 days 12h 31m 11s' + ], + 'interval a-2-1' => [ + '41 years 9 mons 18 days 12:31:11.87418', + true, + '41 years 9 mons 18 days 12h 31m 11s 87418ms' + ], + 'interval a-3' => [ + '41 years 9 mons 18 days 12:00:11', + false, + '41 years 9 mons 18 days 12h 11s' + ], + 'interval b' => [ + '1218 days', + false, + '1218 days' + ], + 'interval c' => [ + '1 year 1 day', + false, + '1 year 1 day' + ], + 'interval d' => [ + '12:00:05', + false, + '12h 5s' + ], + 'interval e' => [ + '00:00:00.12345', + true, + '12345ms' + ], + 'interval e-1' => [ + '00:00:00', + true, + '0s' + ], + 'interval a (negative)' => [ + '-41 years 9 mons 18 days 00:05:00', + false, + '-41 years 9 mons 18 days 5m' + ], + ]; + } + + /** + * Undocumented function + * + * @covers ::dbTimeFormat + * @dataProvider timeFormatProvider + * @testdox Have $source and convert ($show_micro) to $expected [$_dataName] + * + * @param string $source + * @param bool $show_micro + * @param string $expected + * @return void + */ + public function testDbTimeFormat(string $source, bool $show_micro, string $expected): void + { + $db = new \CoreLibs\DB\IO( + self::$db_config['valid'], + self::$log + ); + $this->assertEquals( + $expected, + $db->dbTimeFormat($source, $show_micro) + ); + $db->dbClose(); + } + + // - convert PostreSQL arrays into PHP + // dbArrayParse + + public function arrayProvider(): array + { + return [ + 'array 1' => [ + '{1,2,3,"4 this is shit"}', + [1, 2, 3, "4 this is shit"] + ], + 'array 2' => [ + '{{1,2,3},{4,5,6}}', + [[1, 2, 3], [4, 5, 6]] + ], + 'array 3' => [ + '{{{1,2},{3}},{{4},{5,6}}}', + [[[1, 2], [3]], [[4], [5, 6]]] + ], + 'array 4' => [ + '{dfasdf,"qw,,e{q\"we",\'qrer\'}', + ['dfasdf', 'qw,,e{q"we', 'qrer'] + ] + ]; + } + + /** + * Undocumented function + * + * @covers ::dbArrayParse + * @dataProvider arrayProvider + * @testdox Input array string $input to $expected [$_dataName] + * + * @param string $input + * @param array|bool $expected + * @return void + */ + public function testDbArrayParse(string $input, $expected): void + { + $db = new \CoreLibs\DB\IO( + self::$db_config['valid'], + self::$log + ); + $this->assertEquals( + $expected, + $db->dbArrayParse($input) + ); + $db->dbClose(); + } + + // - string escape tests + // dbEscapeString, dbEscapeLiteral, dbEscapeIdentifier, + + public function stringProvider(): array + { + return [ + 'string normal' => [ + 'Foo Bar', + 'Foo Bar', + '\'Foo Bar\'', + '"Foo Bar"', + ], + 'string quotes' => [ + 'Foo \'" Bar', + 'Foo \'\'" Bar', + '\'Foo \'\'" Bar\'', + '"Foo \'"" Bar"', + ], + 'string backslash' => [ + 'Foo \ Bar', + 'Foo \ Bar', + ' E\'Foo \\\\ Bar\'', + '"Foo \ Bar"', + ], + ]; + } + + /** + * Check all string escape functions + * NOTE: + * This depends on the SETTINGS of the DB + * The function should current escape settings to do proper checks + * + * @covers ::dbEscapeString + * @covers ::dbEscapeLiteral + * @covers ::dbEscapeIdentifier + * @dataProvider stringProvider + * @testdox Input string $input to $expected [$_dataName] + * + * @param string $input + * @param string $expected + * @return void + */ + public function testStringEscape( + string $input, + string $expected_string, + string $expected_literal, + string $expected_identifier + ): void { $db = new \CoreLibs\DB\IO( self::$db_config['valid'], self::$log ); - // print "DB VERSION: " . $db->dbVersion() . "\n"; - - // Create a stub for the SomeClass class. - // $stub = $this->createMock(\CoreLibs\DB\IO::class); - // $stub->method('dbVersion') - // ->willReturn('13.1.0'); - + // print "String: " . $input . "\n"; + // print "Escape String: -" . $db->dbEscapeString($input) . "-\n"; + // print "Escape Literal: -" . $db->dbEscapeLiteral($input) . "-\n"; + // print "Escape Identifier: -" . $db->dbEscapeIdentifier($input) . "-\n"; $this->assertEquals( - $db->dbCompareVersion($input), - $expected + $expected_string, + $db->dbEscapeString($input) + ); + $this->assertEquals( + $expected_literal, + $db->dbEscapeLiteral($input) + ); + $this->assertEquals( + $expected_identifier, + $db->dbEscapeIdentifier($input) ); - // print "IT HAS TO BE 13.1.0: " . $stub->dbVersion() . "\n"; + $db->dbClose(); } + // - bytea encoding + // dbEscapeBytea + + /** + * Undocumented function + * + * @return array + */ + public function byteaProvider(): array + { + return [ + 'standard empty string' => [ + '', + '\x' + ], + 'random values' => [ + '""9f8a!1012938123712378a../%(\'%)"!"#0"#$%\'"#$00"#$0"#0$0"#$', + '\x2222396638612131303132393338313233373132333738612e2e2f2528272529222122233022232425272223243030222324302223302430222324' + ] + ]; + } + + /** + * Test bytea escape + * NOTE: + * This depends on bytea encoding settings on the server, + * Currently skip as true + * + * @covers ::dbEscapeBytea + * @dataProvider byteaProvider + * @testdox Input bytea $input to $expected [$_dataName] + * + * @param string $input + * @param string $expected + * @return void + */ + public function testByteaEscape(string $input, string $expected): void + { + $db = new \CoreLibs\DB\IO( + self::$db_config['valid'], + self::$log + ); + + $this->assertEquals( + $expected, + $db->dbEscapeBytea($input) + ); + + $db->dbClose(); + } + + // - string escape catcher + // dbSqlEscape + + /** + * Undocumented function + * + * @return array + */ + public function sqlEscapeProvider(): array + { + return [ + // int + 'integer value' => [1, 'i', 1,], + 'bad integer value' => ['za', 'i', 0,], + 'empty integer value' => ['', 'i', 'NULL',], + // float + 'float value' => [1.1, 'f', 1.1,], + 'bad float value' => ['za', 'f', 0,], + 'empty float value' => ['', 'f', 'NULL',], + // text + 'string value' => ['string value', 't', '\'string value\'',], + 'empty string value' => ['', 't', 'NULL',], + // text literal + 'string value literal' => ['string literal', 'tl', '\'string literal\'',], + 'empty string value literal' => ['', 'tl', 'NULL',], + // ?d + 'string value d' => ['string d', 'd', '\'string d\'',], + 'empty string value d' => ['', 'd', 'NULL',], + // b (bool) + 'bool true value' => [true, 'b', '\'t\'',], + 'bool false value' => [false, 'b', '\'f\'',], + 'empty bool value' => ['', 'b', 'NULL',], + // i2 + 'integer2 value' => [1, 'i2', 1,], + 'bad integer2 value' => ['za', 'i2', 0,], + 'empty integer2 value' => ['', 'i2', 0,], + ]; + } + + /** + * Undocumented function + * + * @covers ::dbSqlEscape + * @dataProvider sqlEscapeProvider + * @testdox Input value $input as $flag to $expected [$_dataName] + * + * @param int|float|string $input + * @param string $flag + * @param int|float|string $expected + * @return void + */ + public function testSqlEscape($input, string $flag, $expected): void + { + $db = new \CoreLibs\DB\IO( + self::$db_config['valid'], + self::$log + ); + + $this->assertEquals( + $expected, + $db->dbSqlEscape($input, $flag) + ); + + $db->dbClose(); + } + + // - db execution tests + // dbReturn, dbDumpData, dbCacheReset, dbExec, dbExecAsync, dbCheckAsync + // dbFetchArray, dbReturnRow, dbReturnArray, dbCursorPos, dbCursorNumRows, + // dbShowTableMetaData, dbPrepare, dbExecute + // - internal read data (post exec) + // dbGetReturning, dbGetInsertPKName, dbGetInsertPK, dbGetReturningExt, + // dbGetReturningArray, dbGetCursorExt, dbGetNumRows, + // getHadError, getHadWarning, + // dbResetQueryCalled, dbGetQueryCalled + // - complex write sets + // dbWriteData, dbWriteDataExt + // - deprecated tests [no need to test perhaps] + // getInsertReturn, getReturning, getInsertPK, getReturningExt, + // getCursorExt, getNumRows + /** * grouped DB IO test * diff --git a/www/lib/CoreLibs/DB/IO.php b/www/lib/CoreLibs/DB/IO.php index a92ad937..c4c273bc 100644 --- a/www/lib/CoreLibs/DB/IO.php +++ b/www/lib/CoreLibs/DB/IO.php @@ -341,13 +341,13 @@ class IO /** @var bool */ protected $db_connection_closed = false; // sub include with the database functions - /** @var \CoreLibs\DB\SQL\PgSQL */ + /** @var \CoreLibs\DB\SQL\PgSQL if we have other DB types we need to add them here */ private $db_functions; // endless loop protection /** @var int */ private $MAX_QUERY_CALL; /** @var int */ - private const DEFAULT_MAX_QUERY_CALL = 20; // default + public const DEFAULT_MAX_QUERY_CALL = 20; // default /** @var array */ private $query_called = []; // error string @@ -449,26 +449,16 @@ class IO '50' => 'Setting max query call to -1 will disable loop protection ' . 'for all subsequent runs', '51' => 'Max query call needs to be set to at least 1', - '60' => 'table not found for reading meta data', - '100' => 'No database sql layer object could be loaded' + '60' => 'table not found for reading meta data' ]; - // based on $this->db_type - // here we need to load the db pgsql include one - // How can we do this dynamic? eg for non PgSQL - // OTOH this whole class is so PgSQL specific - // that non PgSQL doesn't make much sense anymore - if ($this->db_type == 'pgsql') { - $this->db_functions = new \CoreLibs\DB\SQL\PgSQL(); - } else { - // abort error - $this->__dbError(10); - $this->db_connection_closed = true; - } - if (!is_object($this->db_functions)) { - $this->__dbError(100); - $this->db_connection_closed = true; + // load the core DB functions wrapper class + if (($db_functions = $this->__loadDBFunctions()) === null) { + // abort + die(''); } + // write to internal one, once OK + $this->db_functions = $db_functions; // connect to DB if (!$this->__connectToDB()) { @@ -489,6 +479,33 @@ class IO // PRIVATE METHODS // ************************************************************* + /** + * based on $this->db_type + * here we need to load the db pgsql include one + * How can we do this dynamic? eg for non PgSQL + * OTOH this whole class is so PgSQL specific + * that non PgSQL doesn't make much sense anymore + * + * @return \CoreLibs\DB\SQL\PgSQL|null DB functions object or false on error + */ + private function __loadDBFunctions() + { + $db_functions = null; + switch ($this->db_type) { + // list of valid DB function objects + case 'pgsql': + $db_functions = new \CoreLibs\DB\SQL\PgSQL(); + break; + // if non set or none matching abort + default: + // abort error + $this->__dbError(10); + $this->db_connection_closed = true; + break; + } + return $db_functions; + } + /** * internal connection function. Used to connect to the DB if there is no connection done yet. * Called before any execute @@ -1235,73 +1252,6 @@ class IO return $return; } - /** - * only for postgres. pretty formats an age or datetime difference string - * @param string $age age or datetime difference - * @param bool $show_micro micro on off (default false) - * @return string Y/M/D/h/m/s formatted string (like TimeStringFormat) - */ - public function dbTimeFormat(string $age, bool $show_micro = false): string - { - $matches = []; - // in string (datetime diff): 1786 days 22:11:52.87418 - // or (age): 4 years 10 mons 21 days 12:31:11.87418 - // also -09:43:54.781021 or without - prefix - preg_match("/(.*)?(\d{2}):(\d{2}):(\d{2})(\.(\d+))/", $age, $matches); - - $prefix = $matches[1] != '-' ? $matches[1] : ''; - $hour = $matches[2] != '00' ? preg_replace('/^0/', '', $matches[2]) : ''; - $minutes = $matches[3] != '00' ? preg_replace('/^0/', '', $matches[3]) : ''; - $seconds = $matches[4] != '00' ? preg_replace('/^0/', '', $matches[4]) : ''; - $milliseconds = $matches[6]; - - return $prefix - . (!empty($hour) && is_string($hour) ? $hour . 'h ' : '') - . (!empty($minutes) && is_string($minutes) ? $minutes . 'm ' : '') - . (!empty($seconds) && is_string($seconds) ? $seconds . 's' : '') - . ($show_micro && !empty($milliseconds) ? ' ' . $milliseconds . 'ms' : ''); - } - - /** - * clear up any data for valid DB insert - * @param int|float|string $value to escape data - * @param string $kbn escape trigger type - * @return string escaped value - */ - public function dbSqlEscape($value, string $kbn = '') - { - switch ($kbn) { - case 'i': - $value = $value === '' ? 'NULL' : intval($value); - break; - case 'f': - $value = $value === '' ? 'NULL' : floatval($value); - break; - case 't': - $value = $value === '' ? 'NULL' : "'" . $this->dbEscapeString($value) . "'"; - break; - case 'd': - $value = $value === '' ? 'NULL' : "'" . $this->dbEscapeString($value) . "'"; - break; - case 'i2': - $value = $value === '' ? 0 : intval($value); - break; - } - return (string)$value; - } - - /** - * this is only needed for Postgresql. Converts postgresql arrays to PHP - * @param string $text input text to parse to an array - * @return array PHP array of the parsed data - */ - public function dbArrayParse(string $text): array - { - $output = []; - $__db_array_parse = $this->db_functions->__dbArrayParse($text, $output); - return is_array($__db_array_parse) ? $__db_array_parse : []; - } - // *************************** // DEBUG DATA DUMP // *************************** @@ -1354,6 +1304,16 @@ class IO return $this->db_functions->__dbEscapeLiteral($string); } + /** + * string escape for column and table names + * @param string $string string to escape + * @return string escaped string + */ + public function dbEscapeIdentifier($string): string + { + return $this->db_functions->__dbEscapeIdentifier($string); + } + /** * neutral function to escape a bytea for DB writing * @param string $bytea bytea to escape @@ -1364,6 +1324,45 @@ class IO return $this->db_functions->__dbEscapeBytea($bytea); } + /** + * clear up any data for valid DB insert + * @param int|float|string $value to escape data + * @param string $kbn escape trigger type + * @return string escaped value + */ + public function dbSqlEscape($value, string $kbn = '') + { + switch ($kbn) { + case 'i': + $value = $value === '' ? 'NULL' : intval($value); + break; + case 'f': + $value = $value === '' ? 'NULL' : floatval($value); + break; + case 't': + $value = $value === '' ? 'NULL' : "'" . $this->dbEscapeString($value) . "'"; + break; + case 'tl': + $value = $value === '' ? 'NULL' : $this->dbEscapeLiteral($value); + break; + // what is d? + case 'd': + $value = $value === '' ? 'NULL' : "'" . $this->dbEscapeString($value) . "'"; + break; + case 'b': + $value = $value === '' ? 'NULL' : "'" . $this->dbBoolean($value, true) . "'"; + break; + case 'i2': + $value = $value === '' ? 0 : intval($value); + break; + } + return (string)$value; + } + + // *************************** + // DATA READ/WRITE CONVERSION + // *************************** + /** * if the input is a single char 't' or 'f * it will return the boolean value instead @@ -1397,6 +1396,73 @@ class IO } } + // *************************** + // DATA READ CONVERSION + // *************************** + + /** + * only for postgres. pretty formats an age or datetime difference string + * @param string $interval Age or interval/datetime difference + * @param bool $show_micro micro on off (default false) + * @return string Y/M/D/h/m/s formatted string (like timeStringFormat) + */ + public function dbTimeFormat(string $interval, bool $show_micro = false): string + { + $matches = []; + // in string (datetime diff): 1786 days 22:11:52.87418 + // or (age): 4 years 10 mons 21 days 12:31:11.87418 + // also -09:43:54.781021 or without - prefix + // can have missing parts, but day/time must be fully set + preg_match( + "/^(-)?((\d+) year[s]? ?)?((\d+) mon[s]? ?)?((\d+) day[s]? ?)?" + . "((\d{1,2}):(\d{1,2}):(\d{1,2}))?(\.(\d+))?$/", + $interval, + $matches + ); + + // prefix (-) + $prefix = $matches[1] ?? ''; + // date, years (2, 3), month (4, 5), days (6, 7) + $years = $matches[2] ?? ''; + $months = $matches[4] ?? ''; + $days = $matches[6] ?? ''; + // time (8), hour (9), min (10), sec (11) + $hour = $matches[9] ?? ''; + $minutes = $matches[10] ?? ''; + $seconds = $matches[11] ?? ''; + // micro second block (12), ms (13) + $milliseconds = $matches[13] ?? ''; + + // clean up, hide entries that have 00 in the time group + $hour = $hour != '00' ? preg_replace('/^0/', '', $hour) : ''; + $minutes = $minutes != '00' ? preg_replace('/^0/', '', $minutes) : ''; + $seconds = $seconds != '00' ? preg_replace('/^0/', '', $seconds) : ''; + + // strip any leading or trailing spaces + $time_string = trim( + $prefix . $years . $months . $days + . (!empty($hour) && is_string($hour) ? $hour . 'h ' : '') + . (!empty($minutes) && is_string($minutes) ? $minutes . 'm ' : '') + . (!empty($seconds) && is_string($seconds) ? $seconds . 's ' : '') + . ($show_micro && !empty($milliseconds) ? $milliseconds . 'ms' : '') + ); + // if the return string is empty, return 0s instead + return empty($time_string) ? '0s' : $time_string; + } + + /** + * this is only needed for Postgresql. Converts postgresql arrays to PHP + * Recommended to rather user 'array_to_json' instead and convet JSON in PHP + * @param string $text input text to parse to an array + * @return array PHP array of the parsed data + * @deprecated Recommended to use 'array_to_json' in PostgreSQL instead + */ + public function dbArrayParse(string $text): array + { + $__db_array_parse = $this->db_functions->__dbArrayParse($text); + return is_array($__db_array_parse) ? $__db_array_parse : []; + } + // *************************** // TABLE META DATA READ // *************************** diff --git a/www/lib/CoreLibs/DB/SQL/PgSQL.php b/www/lib/CoreLibs/DB/SQL/PgSQL.php index 3b73baea..7f1efaaa 100644 --- a/www/lib/CoreLibs/DB/SQL/PgSQL.php +++ b/www/lib/CoreLibs/DB/SQL/PgSQL.php @@ -530,7 +530,21 @@ class PgSQL if ($this->dbh === false || is_bool($this->dbh)) { return ''; } - return pg_escape_string($this->dbh, (string)$string); + return pg_escape_literal($this->dbh, (string)$string); + } + + /** + * wrapper for pg_escape_identifier + * Only used for table names, column names + * @param string $string any string + * @return string escaped string + */ + public function __dbEscapeIdentifier(string $string): string + { + if ($this->dbh === false || is_bool($this->dbh)) { + return ''; + } + return pg_escape_identifier($this->dbh, (string)$string); } /** @@ -576,37 +590,64 @@ class PgSQL } /** + * NOTE: it is recommended to convert arrays to json and parse the json in + * PHP itself, array_to_json(array) + * This is a fallback for old PostgreSQL versions * postgresql array to php array - * @param string $text array text from PostgreSQL - * @param array $output (internal) recursive pass on for nested arrays - * @param bool|int $limit (internal) max limit to not overshoot - * the end, start with false - * @param integer $offset (internal) shift offset for {} - * @return array|int converted PHP array, interal recusrive int position + * https://stackoverflow.com/a/27964420 + * @param string $array_text Array text from PostgreSQL + * @param int $start Start string position + * @param int|null $end End string position from recursive call + * @return null|array PHP type array */ - public function __dbArrayParse($text, &$output, $limit = false, $offset = 1) - { - if (false === $limit) { - $limit = strlen($text) - 1; - $output = []; + public function __dbArrayParse( + string $array_text, + int $start = 0, + ?int &$end = null + ): ?array { + if (empty($array_text) || $array_text[0] != '{') { + return null; } - if ('{}' != $text) { - do { - if ('{' != $text[$offset]) { - preg_match("/(\\{?\"([^\"\\\\]|\\\\.)*\"|[^,{}]+)+([,}]+)/", $text, $match, 0, $offset); - $offset += strlen($match[0]); - $output[] = '"' != $match[1][0] ? - $match[1] : - stripcslashes(substr($match[1], 1, -1)); - if ('},' == $match[3]) { - return $offset; - } - } else { - $offset = $this->__dbArrayParse($text, $output, $limit, $offset + 1); + $return = []; + $string = false; + $quote = ''; + $len = strlen($array_text); + $v = ''; + // start from offset + for ($array_pos = $start + 1; $array_pos < $len; $array_pos += 1) { + $ch = $array_text[$array_pos]; + // check wher ein the string are we + // end, one down + if (!$string && $ch == '}') { + if ($v !== '' || !empty($return)) { + $return[] = $v; } - } while ($limit > $offset); + $end = $array_pos; + break; + // open new array, jump recusrive up + } elseif (!$string && $ch == '{') { + // full string + poff set and end + $v = $this->__dbArrayParse($array_text, $array_pos, $array_pos); + // next array element + } elseif (!$string && $ch == ',') { + $return[] = $v; + $v = ''; + // flag that this is a string + } elseif (!$string && ($ch == '"' || $ch == "'")) { + $string = true; + $quote = $ch; + // quoted string + } elseif ($string && $ch == $quote && $array_text[$array_pos - 1] == "\\") { + $v = substr($v, 0, -1) . $ch; + } elseif ($string && $ch == $quote && $array_text[$array_pos - 1] != "\\") { + $string = false; + } else { + // build string + $v .= $ch; + } } - return $output; + + return $return; } }