Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Actions and Doctrine to Schema Changes #4

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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