diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c1cbb29 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +# GitHub Action +name: Tests +on: [push, pull_request] + +jobs: + tests: + name: Tests PHP ${{ matrix.php-versions }} + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ubuntu-latest] + php-versions: ['8.1'] + steps: + - name: Checkout + uses: actions/checkout@v3 + + # Docs: https://github.com/shivammathur/setup-php + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + coverage: xdebug + + - name: Get Composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache Composer dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --no-progress --prefer-dist --no-interaction --optimize-autoloader + + - name: Run unit tests + run: vendor/bin/phpunit -c phpunit.xml.dist --coverage-clover=report/logs/clover.xml + + - name: Upload coverage results to Coveralls + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + composer global require php-coveralls/php-coveralls -n + php-coveralls --coverage_clover=report/logs/clover.xml --json_path=report/logs/coveralls-upload.json -v diff --git a/composer.json b/composer.json index c6461e0..5ef7e5f 100644 --- a/composer.json +++ b/composer.json @@ -17,16 +17,16 @@ } ], "require": { - "php": "^7.2", + "php": "^8.1", "ext-pdo": "*", "ext-pdo_mysql": "*", - "monolog/monolog": "^2.0" + "monolog/monolog": "^2.0 || ^3.0" }, "require-dev": { - "phpunit/phpunit": "^8.5", - "doctrine/dbal": "^2.10", - "psy/psysh": "^0.10.4", - "fzaninotto/faker": "^1.9" + "phpunit/phpunit": "^9.5", + "doctrine/dbal": "^3.5", + "psy/psysh": "^0.11.10", + "fakerphp/faker": "^1.21" }, "autoload": { "psr-4": { diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index 7972d5c..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - ./tests - - - - - ./app - - - diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..44cf4fe --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,13 @@ + + + + + ./src + + + + + ./tests + + + diff --git a/src/MySQLHandler.php b/src/MySQLHandler.php index 92e6047..e92d68c 100644 --- a/src/MySQLHandler.php +++ b/src/MySQLHandler.php @@ -5,7 +5,8 @@ namespace MySQLHandler; use Monolog\Handler\AbstractProcessingHandler; -use Monolog\Logger; +use Monolog\Level; +use Monolog\LogRecord; use PDO; use PDOStatement; @@ -51,7 +52,7 @@ class MySQLHandler extends AbstractProcessingHandler * @param string $table Table in the database to store the logs in * @param array $additionalFields Additional Context Parameters to store in database * @param bool $initialize Defines whether attempts to alter database should be skipped - * @param bool|int $level Debug level which this handler should store + * @param int|string|Level $level Debug level which this handler should store * @param bool $bubble */ public function __construct( @@ -59,7 +60,7 @@ public function __construct( string $table, array $additionalFields = [], bool $initialize = false, - int $level = Logger::DEBUG, + int|string|Level $level = Level::Debug, bool $bubble = true ) { parent::__construct($level, $bubble); @@ -76,13 +77,13 @@ private function initialize(): void { $this->pdo->exec(" CREATE TABLE IF NOT EXISTS `{$this->mySQLRecord->getTable()}` ( - id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - channel VARCHAR(255), - level INTEGER, - message LONGTEXT, - time INTEGER UNSIGNED, - INDEX(channel) USING HASH, - INDEX(level) USING HASH, + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + channel VARCHAR(255), + level INTEGER, + message LONGTEXT, + time INTEGER UNSIGNED, + INDEX(channel) USING HASH, + INDEX(level) USING HASH, INDEX(time) USING BTREE ); "); @@ -123,7 +124,7 @@ private function initialize(): void } /** - * Prepare the sql statment depending on the fields that should be written to the database + * Prepare the sql statement depending on the fields that should be written to the database * @param array $content */ private function prepareStatement(array $content): void @@ -155,10 +156,10 @@ private function prepareStatement(array $content): void /** * Writes the record down to the log of the implementing handler * - * @param array $record + * @param LogRecord $record * @return void */ - protected function write(array $record): void + protected function write(LogRecord $record): void { if (! $this->initialized) { $this->initialize(); @@ -169,19 +170,19 @@ protected function write(array $record): void * getting added to $record['extra'] * @see https://github.com/Seldaek/monolog/blob/master/doc/02-handlers-formatters-processors.md */ - if (isset($record['extra'])) { - $record['context'] = array_merge($record['context'], $record['extra']); - } - $content = $this->mySQLRecord->filterContent(array_merge([ 'channel' => $record['channel'], 'level' => $record['level'], 'message' => $record['message'], 'time' => $record['datetime']->format('U'), - ], $record['context'])); + ], $record['context'], $record['extra'])); $this->prepareStatement($content); + if (array_key_exists('id', $content)) { + unset($content['id']); + } + $this->statement->execute($content); } } diff --git a/tests/MySQLHandlerTest.php b/tests/MySQLHandlerTest.php new file mode 100644 index 0000000..225517b --- /dev/null +++ b/tests/MySQLHandlerTest.php @@ -0,0 +1,198 @@ +createMock(\PDO::class); + $columnsStmt = $this->createMock(\PDOStatement::class); + $insertStmt = $this->createMock(\PDOStatement::class); + + $isHandling = $record['level'] >= $level; + $expectedResult = (!$isHandling) ? false : false === $bubble; + $mysqlRecord = new MySQLRecord($table, $additionalFields); + $addedColumns = array_diff($additionalFields, $initialColumns); + $removedColumns = array_diff($initialColumns, $mysqlRecord->getDefaultColumns(), $additionalFields); + + if ($isHandling) { + if (!$initialize) { + $pdo->expects($this->exactly(1 + count($removedColumns) + count($addedColumns))) + ->method('exec') + ->withConsecutive( + [sprintf(static::$createSql, $table)], + ...array_map(static function (string $c) use ($table) { + return ["ALTER TABLE `{$table}` DROP `{$c}`;"]; + }, $removedColumns), + ...array_map(static function (string $c) use ($table) { + return ["ALTER TABLE `{$table}` ADD `{$c}` TEXT NULL DEFAULT NULL;"]; + }, $addedColumns) + ) + ->willReturn(0); + + $pdo->expects($this->once()) + ->method('query') + ->with("SELECT * FROM `{$table}` LIMIT 0;") + ->willReturn($columnsStmt); + $columnsStmt->expects($this->exactly(count($initialColumns) + 1)) + ->method('columnCount') + ->willReturn(count($initialColumns)); + + $columnsStmt->expects($this->exactly(count($initialColumns))) + ->method('getColumnMeta') + ->willReturn(...array_map(static function (string $c) { + return ['name' => $c]; + }, $initialColumns)); + } else { + $pdo->expects($this->never())->method('exec'); + $pdo->expects($this->never())->method('query'); + } + + $pdo->expects($this->once()) + ->method('prepare') + ->with($expectedInsert) + ->willReturn($insertStmt); + + $insertStmt->expects($this->once()) + ->method('execute') + ->with($expectedParams) + ->willReturn(true); + } + + $handler = new MySQLHandler($pdo, $table, $additionalFields, $initialize, $level, $bubble); + $this->assertEquals($expectedResult, $handler->handle($record)); + } + + public function provideFakeData(): array + { + $faker = Factory::create(); + $faker->seed('1234'); + // Monolog 3.2.0 contains enum Level class while old static method deprecated + $loggerLevels = class_exists(\Monolog\Level::class) ? \Monolog\Level::VALUES : \Monolog\Logger::getLevels(); + $data = []; + + while (count($data) < 50) { + $initialColumns = $faker->unique()->words(5); + $additionalFields = $faker->unique()->words(5); + $table = $faker->unique()->word(); + $time = $faker->unixTime(); + $channel = $faker->unique()->word(); + $level = $faker->randomElement($loggerLevels); + $msg = $faker->text; + $context = array_reduce( + $faker->randomElements(array_merge($faker->unique()->words(5), $additionalFields), 3), + static function (array $carry, string $f) use ($faker) { + $carry[$f] = $faker->word(); + return $carry; + }, + ['id' => 'foobar'], + ); + $extra = array_reduce( + $faker->randomElements(array_merge($faker->unique()->words(5), $additionalFields), 3), + static function (array $carry, string $f) use ($faker) { + $carry[$f] = $faker->word(); + return $carry; + }, + ['id' => 'foobaz'] + ); + + $record = new LogRecord( + \DateTimeImmutable::createFromFormat('U', strval($time)), + $channel, + \Monolog\Logger::toMonologLevel($level), + $msg, + $context, + $extra + ); + + $params = array_merge( + [ + 'channel' => $channel, + 'level' => $level, + 'message' => $msg, + 'time' => strval($time), + ], + array_filter( + $context, + static function ($v, $k) use ($additionalFields) { + return in_array($k, $additionalFields); + }, + ARRAY_FILTER_USE_BOTH + ), + array_filter( + $extra, + static function ($v, $k) use ($additionalFields) { + return in_array($k, $additionalFields); + }, + ARRAY_FILTER_USE_BOTH + ), + ); + + if (array_key_exists('id', $params)) { + unset($params['id']); + } + + $insertSql = sprintf( + 'INSERT INTO `%s` (%s) VALUES (%s);', + $table, + implode(', ', array_keys($params)), + ':' . implode(', :', array_keys($params)) + ); + + $data[] = [ + $insertSql, + $params, + $initialColumns, + $record, + $table, + $additionalFields, // additional fields + $faker->boolean(), // initialize, + $faker->randomElement($loggerLevels), // level + $faker->boolean(), // bubble, + ]; + } + + return $data; + } +} diff --git a/tests/MySQLRecordTest.php b/tests/MySQLRecordTest.php index 46f43d7..e4f71fe 100644 --- a/tests/MySQLRecordTest.php +++ b/tests/MySQLRecordTest.php @@ -5,7 +5,6 @@ namespace Tests; use Faker\Factory; -use Monolog\Logger; use MySQLHandler\MySQLRecord; use PHPUnit\Framework\TestCase; @@ -15,6 +14,14 @@ */ class MySQLRecordTest extends TestCase { + protected static array $defaultColumns = [ + 'id', + 'channel', + 'level', + 'message', + 'time', + ]; + /** * @test * @return void @@ -25,14 +32,11 @@ public function get_columns_will_equals_default_columns_and_additional_columns_m $table = strtolower($faker->unique()->word); $columns = array_pad([], 5, strtolower($faker->unique()->word)); $record = new MySQLRecord($table, $columns); + $this->assertEquals(static::$defaultColumns, $record->getDefaultColumns()); + $this->assertEquals($columns, $record->getAdditionalColumns()); - $this->assertEquals(array_merge([ - 'id', - 'channel', - 'level', - 'message', - 'time', - ], $columns), $record->getColumns()); + $this->assertEquals(array_merge(static::$defaultColumns, $columns), $record->getColumns()); + $this->assertEquals($table, $record->getTable()); } /** @@ -45,10 +49,14 @@ public function filter_content_will_equals_argument(): void $table = strtolower($faker->unique()->word); $columns = array_pad([], 5, strtolower($faker->unique()->word)); $record = new MySQLRecord($table, $columns); + $this->assertEquals(static::$defaultColumns, $record->getDefaultColumns()); + $this->assertEquals($columns, $record->getAdditionalColumns()); + // Monolog 3.2.0 contains enum Level class while old static method deprecated + $loggerLevels = class_exists(\Monolog\Level::class) ? \Monolog\Level::VALUES : \Monolog\Logger::getLevels(); $data = array_merge([ 'channel' => strtolower($faker->unique()->word), - 'level' => $faker->randomElement(Logger::getLevels()), + 'level' => $faker->randomElement($loggerLevels), 'message' => $faker->text, 'time' => $faker->dateTime, ], array_fill_keys($columns, $faker->text)); @@ -56,6 +64,7 @@ public function filter_content_will_equals_argument(): void $content = $record->filterContent($data); $this->assertEquals($data, $content); + $this->assertEquals($table, $record->getTable()); } /** @@ -69,10 +78,14 @@ public function filter_content_will_exclude_out_of_columns(): void $columns = array_pad([], 5, strtolower($faker->unique()->word)); $outOfColumns = array_pad([], 5, strtolower($faker->unique()->word)); $record = new MySQLRecord($table, $columns); + $this->assertEquals(static::$defaultColumns, $record->getDefaultColumns()); + $this->assertEquals($columns, $record->getAdditionalColumns()); + // Monolog 3.2.0 contains enum Level class while old static method deprecated + $loggerLevels = class_exists(\Monolog\Level::class) ? \Monolog\Level::VALUES : \Monolog\Logger::getLevels(); $data = array_merge([ 'channel' => strtolower($faker->unique()->word), - 'level' => $faker->randomElement(Logger::getLevels()), + 'level' => $faker->randomElement($loggerLevels), 'message' => $faker->text, 'time' => $faker->dateTime, ], array_fill_keys($outOfColumns, $faker->text)); @@ -87,5 +100,6 @@ public function filter_content_will_exclude_out_of_columns(): void array_map(function ($key) use ($content) { $this->assertArrayNotHasKey($key, $content); }, $outOfColumns); + $this->assertEquals($table, $record->getTable()); } -} \ No newline at end of file +}