Skip to content

Commit

Permalink
Merge pull request #4 from gangaiamaran/workflow-tests
Browse files Browse the repository at this point in the history
Actions and Doctrine to Schema Changes
  • Loading branch information
alies-dev authored Nov 22, 2024
2 parents d6c6463 + 235eebf commit 6ba4ba6
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 60 deletions.
46 changes: 40 additions & 6 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,42 @@
name: run-tests

on: [ push, pull_request ]
on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test_db
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping --silent"
--health-interval=10s
--health-timeout=5s
--health-retries=3
strategy:
fail-fast: true
matrix:
php: [ 8.1, 8.2, 8.3 ]
laravel: [ 10.* ]
testbench: [ 8.* ]
dependency-version: [ prefer-stable ]
php: [8.1, 8.2, 8.3]
laravel: [10.*, 11.*]
dependency-version: [prefer-stable]
include:
- laravel: 10.*
testbench: 8.*
- laravel: 11.*
testbench: 9.*
exclude:
- laravel: 10.*
php: 8.0
- laravel: 11.*
php: 8.1
- laravel: 11.*
php: 8.0

name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }}

Expand All @@ -23,7 +48,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo, pdo_mysql
coverage: none

- name: Setup problem matchers
Expand All @@ -38,5 +63,14 @@ jobs:
env:
COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}

- name: Set up environment variables
run: |
echo "DB_CONNECTION=mysql" >> $GITHUB_ENV
echo "DB_HOST=127.0.0.1" >> $GITHUB_ENV
echo "DB_PORT=3306" >> $GITHUB_ENV
echo "DB_DATABASE=test_db" >> $GITHUB_ENV
echo "DB_USERNAME=root" >> $GITHUB_ENV
echo "DB_PASSWORD=root" >> $GITHUB_ENV
- name: Execute tests
run: composer test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ auth.json
phpunit.xml
.phpunit.result.cache
.phpunit.cache

.idea
15 changes: 10 additions & 5 deletions src/Console/Commands/FindInvalidDatabaseValues.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,21 @@ final class FindInvalidDatabaseValues extends DatabaseInspectionCommand
private const CHECK_TYPE_LONG_TEXT = 'long_text';
private const CHECK_TYPE_LONG_STRING = 'long_string';

/** @var string The name and signature of the console command. */
/**
* @var string The name and signature of the console command.
*/
protected $signature = 'database:find-invalid-values {connection=default} {--check=* : Check only specific types of issues. Available types: {null, datetime, long_text, long_string}}';

/** @var string The console command description. */
/**
* @var string The console command description.
*/
protected $description = 'Find invalid data created in non-strict SQL mode.';

private int $valuesWithIssuesFound = 0;

/** @throws \Doctrine\DBAL\Exception */
/**
* @throws \Doctrine\DBAL\Exception
*/
public function handle(ConnectionResolverInterface $connections): int
{
$connection = $this->getConnection($connections);
Expand Down Expand Up @@ -106,8 +112,7 @@ private function checkNullOnNotNullableColumn(Column $column, Connection $connec
private function checkForInvalidDatetimeValues(Column $column, Connection $connection, Table $table): void
{
$integerProbablyUsedForTimestamp = in_array($column->getType()->getName(), [Types::INTEGER, Types::BIGINT], true) && (str_contains($column->getName(), 'timestamp') || str_ends_with($column->getName(), '_at'));
if (
$integerProbablyUsedForTimestamp
if ($integerProbablyUsedForTimestamp
|| in_array($column->getType()->getName(), [Types::DATE_MUTABLE, Types::DATE_IMMUTABLE, Types::DATETIME_MUTABLE, Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATETIMETZ_IMMUTABLE], true)
) {
$columnName = $column->getName();
Expand Down
86 changes: 40 additions & 46 deletions src/Console/Commands/FindRiskyDatabaseColumns.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

namespace InteractionDesignFoundation\LaravelDatabaseToolkit\Console\Commands;

use Doctrine\DBAL\Schema\Column;
use Doctrine\DBAL\Schema\Table;
use Illuminate\Database\Connection;
use Illuminate\Database\ConnectionResolverInterface;
use Illuminate\Database\Console\DatabaseInspectionCommand;
use Illuminate\Database\MySqlConnection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\Console\Attribute\AsCommand;
use function Laravel\Prompts\table;

/**
* Inspired by @see https://medium.com/beyn-technology/ill-never-forget-this-number-4294967295-0xffffffff-c9ad4b72f53a
Expand All @@ -26,59 +27,65 @@
#[AsCommand('database:find-risky-columns')]
final class FindRiskyDatabaseColumns extends DatabaseInspectionCommand
{
/** @var string The name and signature of the console command. */
/**
* @var string The name and signature of the console command.
*/
protected $signature = 'database:find-risky-columns {connection=default} {--threshold=70 : Percentage occupied rows number on which the command should treat it as an issue}';

/** @var string The console command description. */
/**
* @var string The console command description.
*/
protected $description = 'Find risky auto-incremental columns on databases which values are close to max possible values.';

/** @var array<string, array{min: int|float, max: int|float}> */
/**
* @var array<string, array{min: int|float, max: int|float}>
*/
private array $columnMinsAndMaxs = [
'integer' => [
'min' => -2_147_483_648,
'max' => 2_147_483_647,
],
'unsigned integer' => [
'int unsigned' => [
'min' => 0,
'max' => 4_294_967_295,
],
'bigint' => [
'min' => -9_223_372_036_854_775_808,
'max' => 9_223_372_036_854_775_807,
],
'unsigned bigint' => [
'bigint unsigned' => [
'min' => 0,
'max' => 18_446_744_073_709_551_615,
],
'tinyint' => [
'min' => -128,
'max' => 127,
],
'unsigned tinyint' => [
'tinyint unsigned' => [
'min' => 0,
'max' => 255,
],
'smallint' => [
'min' => -32_768,
'max' => 32_767,
],
'unsigned smallint' => [
'smallint unsigned' => [
'min' => 0,
'max' => 65_535,
],
'mediumint' => [
'min' => -8_388_608,
'max' => 8_388_607,
],
'unsigned mediumint' => [
'mediumint unsigned' => [
'min' => 0,
'max' => 16_777_215,
],
'decimal' => [
'min' => -99999999999999999999999999999.99999999999999999999999999999,
'max' => 99999999999999999999999999999.99999999999999999999999999999,
],
'unsigned decimal' => [
'decimal unsigned' => [
'min' => 0,
'max' => 99999999999999999999999999999.99999999999999999999999999999,
],
Expand All @@ -87,18 +94,14 @@ final class FindRiskyDatabaseColumns extends DatabaseInspectionCommand
public function handle(ConnectionResolverInterface $connections): int
{
$thresholdAlarmPercentage = (float) $this->option('threshold');

$connection = $this->getConnection($connections);
$schema = $connection->getDoctrineSchemaManager();
$connection = Schema::getConnection();
if (! $connection instanceof MySqlConnection) {
throw new \InvalidArgumentException('Command supports MySQL DBs only.');
}

$this->registerTypeMappings($schema->getDatabasePlatform());

$outputTable = [];

foreach ($schema->listTables() as $table) {
foreach (Schema::getTables() as $table) {
$riskyColumnsInfo = $this->processTable($table, $connection, $thresholdAlarmPercentage);
if (is_array($riskyColumnsInfo)) {
$outputTable = [...$outputTable, ...$riskyColumnsInfo];
Expand All @@ -120,41 +123,45 @@ public function handle(ConnectionResolverInterface $connections): int
return self::FAILURE;
}

/** @return list<array<string, string>>|null */
private function processTable(Table $table, Connection $connection, float $thresholdAlarmPercentage): ?array
/**
* @return list<array<string, string>>|null
*/
private function processTable(array $table, Connection $connection, float $thresholdAlarmPercentage): ?array
{
$this->comment("Table {$connection->getDatabaseName()}.{$table->getName()}: checking...", 'v');
$tableName = Arr::get($table, 'name');
$this->comment("Table {$connection->getDatabaseName()}.{$tableName}: checking...", 'v');

$tableSize = Arr::get($table, 'size');

$tableSize = $this->getTableSize($connection, $table->getName());
if ($tableSize === null) {
$tableSize = -1; // not critical info, we can skip this issue
}

/** @var \Illuminate\Support\Collection<int, \Doctrine\DBAL\Schema\Column> $columns */
$columns = collect($table->getColumns())
->filter(static fn(Column $column): bool => $column->getAutoincrement());
/**
* @var \Illuminate\Support\Collection<int, Schema> $getColumns
*/
$columns = collect(Schema::getColumns($tableName))->filter(
static fn($column): bool => Arr::get($column, 'auto_increment') === true
);

$riskyColumnsInfo = [];

foreach ($columns as $column) {
$columnName = $column->getName();
$columnType = $column->getType()->getName();
if ($column->getUnsigned()) {
$columnType = "unsigned {$columnType}";
}
$columnName = Arr::get($column, 'name');
$columnType = Arr::get($column, 'type');

$this->comment("\t{$columnName} is autoincrement.", 'vvv');

$maxValueForColumnKey = $this->getMaxValueForColumn($columnType);
$currentHighestValue = $this->getCurrentHighestValueForColumn($connection->getDatabaseName(), $table->getName(), $columnName);
$currentHighestValue = $this->getCurrentHighestValueForColumn($connection->getDatabaseName(), $tableName, $columnName);

$percentageUsed = round($currentHighestValue / $maxValueForColumnKey * 100, 4);

if ($percentageUsed >= $thresholdAlarmPercentage) {
$this->error("{$connection->getDatabaseName()}.{$table->getName()}.{$columnName} is full for {$percentageUsed}% (threshold for allowed usage is {$thresholdAlarmPercentage}%)", 'quiet');
$this->error("{$connection->getDatabaseName()}.{$tableName}.{$columnName} is full for {$percentageUsed}% (threshold for allowed usage is {$thresholdAlarmPercentage}%)", 'quiet');

$riskyColumnsInfo[] = [
'table' => "{$connection->getDatabaseName()}.{$table->getName()}",
'table' => "{$connection->getDatabaseName()}.{$tableName}",
'column' => $columnName,
'type' => $columnType,
'size' => $this->formatBytes($tableSize, 2),
Expand All @@ -165,26 +172,13 @@ private function processTable(Table $table, Connection $connection, float $thres
}
}

$this->comment("Table {$connection->getDatabaseName()}.{$table->getName()}: OK", 'vv');
$this->comment("Table {$connection->getDatabaseName()}.{$tableName}: OK", 'vv');

return count($riskyColumnsInfo) > 0
? $riskyColumnsInfo
: null;
}

private function getConnection(ConnectionResolverInterface $connections): Connection
{
$connectionName = $this->argument('connection');
if ($connectionName === 'default') {
$connectionName = config('database.default');
}

$connection = $connections->connection($connectionName);
assert($connection instanceof Connection);

return $connection;
}

private function getMaxValueForColumn(string $columnType): int | float
{
if (array_key_exists($columnType, $this->columnMinsAndMaxs)) {
Expand Down
7 changes: 5 additions & 2 deletions src/DatabaseToolkitServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ final class DatabaseToolkitServiceProvider extends ServiceProvider
{
/**
* Bootstrap any package services.
*
* @see https://laravel.com/docs/master/packages#commands
*/
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
$this->commands(
[
FindInvalidDatabaseValues::class,
FindRiskyDatabaseColumns::class,
]);
]
);
}
}
}
45 changes: 45 additions & 0 deletions tests/Console/Commands/FindRiskyDatabaseColumnsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

namespace Tests\Console\Commands;

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Testing\Concerns\InteractsWithConsole;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Testing\PendingCommand;
use InteractionDesignFoundation\LaravelDatabaseToolkit\Console\Commands\FindRiskyDatabaseColumns;
use PHPUnit\Framework\Attributes\CoversClass;
Expand All @@ -17,9 +20,51 @@ final class FindRiskyDatabaseColumnsTest extends TestCase
#[Test]
public function it_works_with_default_threshold(): void
{
Schema::create(
'dummy_table_1', function (Blueprint $table) {
$table->tinyIncrements('id')->startingValue(100);
$table->string('name')->nullable();
}
);
DB::table('dummy_table_1')->insert(['name' => 'foo']);

$pendingCommand = $this->artisan(FindRiskyDatabaseColumns::class);

assert($pendingCommand instanceof PendingCommand);
$pendingCommand->assertExitCode(0);
}

#[Test]
public function it_works_with_custom_threshold(): void
{
Schema::create(
'dummy_table_2', function (Blueprint $table) {
$table->tinyIncrements('id')->startingValue(130);
$table->string('name')->nullable();
}
);
DB::table('dummy_table_2')->insert(['name' => 'foo']);

$pendingCommand = $this->artisan(FindRiskyDatabaseColumns::class, ['--threshold' => 50]);

assert($pendingCommand instanceof PendingCommand);
$pendingCommand->assertExitCode(1);
}

#[Test]
public function it_fails_with_exceeding_threshold_tinyint(): void
{
Schema::create(
'dummy_table_3', function (Blueprint $table) {
$table->tinyIncrements('id')->startingValue(200);
$table->string('name')->nullable();
}
);
DB::table('dummy_table_3')->insert(['name' => 'foo']);

$pendingCommand = $this->artisan(FindRiskyDatabaseColumns::class);

assert($pendingCommand instanceof PendingCommand);
$pendingCommand->assertExitCode(1);
}
}
Loading

0 comments on commit 6ba4ba6

Please sign in to comment.