docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,457 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Migration\Exception\MemoryThresholdExceededException;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationCollection;
use App\Framework\Database\Migration\MigrationRunner;
use App\Framework\Database\Migration\MigrationVersion;
use App\Framework\Database\Migration\ValueObjects\MemoryThresholds;
use App\Framework\Database\Platform\MySQLPlatform;
use App\Framework\Database\ResultInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DateTime\SystemClock;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Performance\OperationTracker;
use App\Framework\Performance\PerformanceReporter;
describe('Migration Performance Integration', function () {
beforeEach(function () {
$this->connection = new MigrationPerformanceMockConnection();
$this->platform = new MySQLPlatform();
$this->clock = new SystemClock();
$this->memoryMonitor = new MemoryMonitor();
$this->operationTracker = new OperationTracker($this->clock, $this->memoryMonitor);
$this->migrationRunner = new MigrationRunner(
$this->connection,
$this->platform,
$this->clock,
null, // tableConfig
null, // logger
$this->operationTracker,
$this->memoryMonitor
);
});
test('migration runner integrates with performance monitoring', function () {
$migration = new MigrationPerformanceTestMigration();
$migrations = new MigrationCollection($migration);
// Set no applied migrations initially
$this->connection->setAppliedMigrations([]);
$result = $this->migrationRunner->migrate($migrations);
expect($result)->toContain('2024_01_01_000000');
expect($migration->wasExecuted())->toBeTrue();
// Verify performance tracking was used
expect($this->operationTracker)->toBeInstanceOf(OperationTracker::class);
expect($this->memoryMonitor)->toBeInstanceOf(MemoryMonitor::class);
});
test('memory monitor provides memory summary', function () {
$memorySummary = $this->memoryMonitor->getSummary();
expect($memorySummary)->toHaveProperties([
'current',
'peak',
'limit',
'usagePercentage',
'isApproachingLimit',
]);
expect($memorySummary->getCurrentHumanReadable())->toBeString();
expect($memorySummary->getPeakHumanReadable())->toBeString();
expect($memorySummary->getUsagePercentageFormatted())->toBeString();
});
test('operation tracker can track operations', function () {
$operationId = 'test_migration_batch_' . uniqid();
$snapshot = $this->operationTracker->startOperation(
$operationId,
\App\Framework\Performance\PerformanceCategory::DATABASE,
['operation_type' => 'migration_test']
);
expect($snapshot)->toBeInstanceOf(\App\Framework\Performance\ValueObjects\PerformanceSnapshot::class);
// Complete the operation
$finalSnapshot = $this->operationTracker->completeOperation($operationId);
expect($finalSnapshot)->toBeInstanceOf(\App\Framework\Performance\ValueObjects\PerformanceSnapshot::class);
expect($finalSnapshot->duration)->toBeInstanceOf(\App\Framework\Core\ValueObjects\Duration::class);
});
test('memory thresholds can be configured with custom values', function () {
$customThresholds = new MemoryThresholds(
warning: Percentage::from(60.0),
critical: Percentage::from(75.0),
abort: Percentage::from(90.0)
);
expect($customThresholds->warning->getValue())->toBe(60.0);
expect($customThresholds->critical->getValue())->toBe(75.0);
expect($customThresholds->abort->getValue())->toBe(90.0);
});
test('memory thresholds default configuration is valid', function () {
$defaultThresholds = MemoryThresholds::default();
expect($defaultThresholds->warning->getValue())->toBe(75.0);
expect($defaultThresholds->critical->getValue())->toBe(85.0);
expect($defaultThresholds->abort->getValue())->toBe(95.0);
});
test('memory thresholds conservative configuration is valid', function () {
$conservativeThresholds = MemoryThresholds::conservative();
expect($conservativeThresholds->warning->getValue())->toBe(60.0);
expect($conservativeThresholds->critical->getValue())->toBe(70.0);
expect($conservativeThresholds->abort->getValue())->toBe(80.0);
});
test('memory thresholds relaxed configuration is valid', function () {
$relaxedThresholds = MemoryThresholds::relaxed();
expect($relaxedThresholds->warning->getValue())->toBe(80.0);
expect($relaxedThresholds->critical->getValue())->toBe(90.0);
expect($relaxedThresholds->abort->getValue())->toBe(98.0);
});
test('memory threshold exception can be created for migration', function () {
$currentUsage = Percentage::from(96.0);
$threshold = Percentage::from(95.0);
$currentMemory = \App\Framework\Core\ValueObjects\Byte::fromBytes(256 * 1024 * 1024);
$memoryLimit = \App\Framework\Core\ValueObjects\Byte::fromBytes(256 * 1024 * 1024);
$exception = MemoryThresholdExceededException::forMigration(
'2024_01_01_000001',
$currentUsage,
$threshold,
$currentMemory,
$memoryLimit
);
expect($exception->getMessage())->toContain('Memory threshold exceeded during migration 2024_01_01_000001');
expect($exception->getMessage())->toContain('96.0%');
expect($exception->getMessage())->toContain('95.0%');
});
test('memory threshold exception can be created for batch abort', function () {
$currentUsage = Percentage::from(97.0);
$abortThreshold = Percentage::from(95.0);
$currentMemory = \App\Framework\Core\ValueObjects\Byte::fromBytes(248 * 1024 * 1024);
$memoryLimit = \App\Framework\Core\ValueObjects\Byte::fromBytes(256 * 1024 * 1024);
$exception = MemoryThresholdExceededException::batchAborted(
$currentUsage,
$abortThreshold,
$currentMemory,
$memoryLimit,
3,
10
);
expect($exception->getMessage())->toContain('Migration batch aborted due to critical memory usage');
expect($exception->getMessage())->toContain('97.0%');
expect($exception->getMessage())->toContain('95.0%');
});
test('migration runner with memory thresholds can handle normal operation', function () {
$memoryThresholds = MemoryThresholds::conservative();
$migrationRunner = new MigrationRunner(
$this->connection,
$this->platform,
$this->clock,
null, // tableConfig
null, // logger
$this->operationTracker,
$this->memoryMonitor,
null, // performanceReporter
$memoryThresholds
);
$migration = new MigrationPerformanceTestMigration();
$migrations = new MigrationCollection($migration);
// Set no applied migrations initially
$this->connection->setAppliedMigrations([]);
$result = $migrationRunner->migrate($migrations);
expect($result)->toContain('2024_01_01_000000');
expect($migration->wasExecuted())->toBeTrue();
});
test('rollback operation includes performance tracking', function () {
$migration = new MigrationPerformanceTestMigration();
$migrations = new MigrationCollection($migration);
// Set migration as already applied
$this->connection->setAppliedMigrations([
['version' => '2024_01_01_000000', 'description' => 'Performance Test Migration'],
]);
$result = $this->migrationRunner->rollback($migrations, 1);
expect($result)->toContain($migration);
// Verify performance tracking was used during rollback
expect($this->operationTracker)->toBeInstanceOf(OperationTracker::class);
expect($this->memoryMonitor)->toBeInstanceOf(MemoryMonitor::class);
});
test('operation tracker can track multiple operations', function () {
$operationId1 = 'test_migration_1';
$operationId2 = 'test_migration_2';
// Start first operation
$snapshot1 = $this->operationTracker->startOperation(
$operationId1,
\App\Framework\Performance\PerformanceCategory::DATABASE,
['operation_type' => 'migration_execution']
);
// Start second operation
$snapshot2 = $this->operationTracker->startOperation(
$operationId2,
\App\Framework\Performance\PerformanceCategory::DATABASE,
['operation_type' => 'migration_execution']
);
usleep(1000); // Small delay to ensure duration > 0
// Complete both operations
$finalSnapshot1 = $this->operationTracker->completeOperation($operationId1);
$finalSnapshot2 = $this->operationTracker->completeOperation($operationId2);
expect($finalSnapshot1)->toBeInstanceOf(\App\Framework\Performance\ValueObjects\PerformanceSnapshot::class);
expect($finalSnapshot2)->toBeInstanceOf(\App\Framework\Performance\ValueObjects\PerformanceSnapshot::class);
expect($finalSnapshot1->operationId)->toBe($operationId1);
expect($finalSnapshot2->operationId)->toBe($operationId2);
});
test('migration runner validates memory threshold order', function () {
expect(function () {
new MemoryThresholds(
warning: Percentage::from(90.0), // Warning higher than critical
critical: Percentage::from(80.0),
abort: Percentage::from(95.0)
);
})->toThrow(\InvalidArgumentException::class, 'Warning threshold cannot be higher than critical threshold');
expect(function () {
new MemoryThresholds(
warning: Percentage::from(70.0),
critical: Percentage::from(90.0), // Critical higher than abort
abort: Percentage::from(80.0)
);
})->toThrow(\InvalidArgumentException::class, 'Critical threshold cannot be higher than abort threshold');
});
test('migration runner can handle pre-flight checks', function () {
$migration = new MigrationPerformanceTestMigration();
$migrations = new MigrationCollection($migration);
// Set no applied migrations initially
$this->connection->setAppliedMigrations([]);
// Should pass pre-flight checks for mock connection
$result = $this->migrationRunner->migrate($migrations);
expect($result)->toContain('2024_01_01_000000');
expect($migration->wasExecuted())->toBeTrue();
});
});
// Test fixtures
class MigrationPerformanceTestMigration implements Migration
{
private bool $executed = false;
public function up(ConnectionInterface $connection): void
{
$this->executed = true;
// Simulate migration execution
$connection->execute(SqlQuery::create('CREATE TABLE performance_test (id INT)'));
}
public function down(ConnectionInterface $connection): void
{
$connection->execute(SqlQuery::create('DROP TABLE performance_test'));
}
public function getDescription(): string
{
return 'Performance Test Migration';
}
public function getVersion(): MigrationVersion
{
return MigrationVersion::fromTimestamp('2024_01_01_000000');
}
public function wasExecuted(): bool
{
return $this->executed;
}
}
class MigrationPerformanceMockConnection implements ConnectionInterface
{
private array $queries = [];
private array $appliedMigrations = [];
private bool $inTransaction = false;
public function setAppliedMigrations(array $migrations): void
{
$this->appliedMigrations = $migrations;
}
public function execute(SqlQuery $query): int
{
$this->queries[] = ['type' => 'execute', 'sql' => $query->sql, 'params' => $query->parameters->toArray()];
return 1;
}
public function query(SqlQuery $query): ResultInterface
{
$this->queries[] = ['type' => 'query', 'sql' => $query->sql, 'params' => $query->parameters->toArray()];
return new MigrationPerformanceMockResult($this->appliedMigrations);
}
public function queryOne(SqlQuery $query): ?array
{
return null;
}
public function queryColumn(SqlQuery $query): array
{
$this->queries[] = ['type' => 'queryColumn', 'sql' => $query->sql, 'params' => $query->parameters->toArray()];
return array_column($this->appliedMigrations, 'version');
}
public function queryScalar(SqlQuery $query): mixed
{
$this->queries[] = ['type' => 'queryScalar', 'sql' => $query->sql, 'params' => $query->parameters->toArray()];
// Return '1' for connectivity test (SELECT 1)
if (str_contains($query->sql, 'SELECT 1')) {
return '1';
}
return null;
}
public function beginTransaction(): void
{
$this->inTransaction = true;
}
public function commit(): void
{
$this->inTransaction = false;
}
public function rollback(): void
{
$this->inTransaction = false;
}
public function inTransaction(): bool
{
return $this->inTransaction;
}
public function lastInsertId(): string
{
return '1';
}
public function getPdo(): \PDO
{
return new class () extends \PDO {
public function __construct()
{
// Skip parent constructor to avoid actual DB connection
}
public function getAttribute(int $attribute): mixed
{
return match($attribute) {
\PDO::ATTR_DRIVER_NAME => 'mysql',
default => null
};
}
};
}
public function getQueries(): array
{
return $this->queries;
}
}
class MigrationPerformanceMockResult implements ResultInterface
{
private array $data;
public function __construct(array $data)
{
$this->data = $data;
}
public function fetch(): ?array
{
return $this->data[0] ?? null;
}
public function fetchAll(): array
{
return $this->data;
}
public function fetchOne(): ?array
{
return $this->data[0] ?? null;
}
public function fetchColumn(int $column = 0): array
{
return array_column($this->data, $column);
}
public function fetchScalar(): mixed
{
$row = $this->fetchOne();
return $row ? array_values($row)[0] : null;
}
public function rowCount(): int
{
return count($this->data);
}
public function getIterator(): \Traversable
{
return new \ArrayIterator($this->data);
}
public function count(): int
{
return count($this->data);
}
}

View File

@@ -5,12 +5,31 @@ declare(strict_types=1);
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Database\Migration\Migration;
use App\Framework\Database\Migration\MigrationCollection;
use App\Framework\Database\Migration\MigrationRunner;
use App\Framework\Database\Platform\MySqlPlatform;
use App\Framework\Database\ResultInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DateTime\SystemClock;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Performance\OperationTracker;
beforeEach(function () {
$this->connection = new TestConnection();
$this->migrationRunner = new MigrationRunner($this->connection, 'test_migrations');
$this->platform = new MySqlPlatform();
$this->clock = new SystemClock();
$this->memoryMonitor = new MemoryMonitor();
$this->operationTracker = new OperationTracker();
$this->migrationRunner = new MigrationRunner(
$this->connection,
$this->platform,
$this->clock,
null, // tableConfig
null, // logger
$this->operationTracker,
$this->memoryMonitor
);
});
test('constructor creates migrations table', function () {
@@ -25,16 +44,12 @@ test('constructor creates migrations table', function () {
test('migrate runs pending migrations', function () {
// Mock migration
$migration = new TestMigration();
$migrationData = (object) [
'version' => '2024_01_01_000000',
'description' => 'Test Migration',
'instance' => $migration,
];
$migrations = new MigrationCollection($migration);
// Set no applied migrations initially
$this->connection->setAppliedMigrations([]);
$result = $this->migrationRunner->migrate([$migrationData]);
$result = $this->migrationRunner->migrate($migrations);
expect($result)->toContain('2024_01_01_000000');
expect($migration->wasExecuted())->toBeTrue();
@@ -44,7 +59,7 @@ test('migrate runs pending migrations', function () {
$insertQueries = array_filter(
$queries,
fn ($q) =>
$q['type'] === 'execute' && str_contains($q['sql'], 'INSERT INTO test_migrations')
$q['type'] === 'execute' && str_contains($q['sql'], 'INSERT INTO')
);
expect($insertQueries)->toHaveCount(1);
});
@@ -219,38 +234,38 @@ class TestConnection implements ConnectionInterface
$this->shouldFail = $fail;
}
public function execute(string $sql, array $parameters = []): int
public function execute(SqlQuery $query): int
{
$this->queries[] = ['type' => 'execute', 'sql' => $sql, 'params' => $parameters];
$this->queries[] = ['type' => 'execute', 'sql' => $query->sql, 'params' => $query->parameters->toArray()];
if ($this->shouldFail && strpos($sql, 'INSERT INTO') !== false) {
if ($this->shouldFail && strpos($query->sql, 'INSERT INTO') !== false) {
throw new DatabaseException('Simulated database failure');
}
return 1;
}
public function query(string $sql, array $parameters = []): ResultInterface
public function query(SqlQuery $query): ResultInterface
{
$this->queries[] = ['type' => 'query', 'sql' => $sql, 'params' => $parameters];
$this->queries[] = ['type' => 'query', 'sql' => $query->sql, 'params' => $query->parameters->toArray()];
return new TestResult($this->appliedMigrations);
}
public function queryOne(string $sql, array $parameters = []): ?array
public function queryOne(SqlQuery $query): ?array
{
return null;
}
public function queryColumn(string $sql, array $parameters = []): array
public function queryColumn(SqlQuery $query): array
{
$this->queries[] = ['type' => 'queryColumn', 'sql' => $sql, 'params' => $parameters];
$this->queries[] = ['type' => 'queryColumn', 'sql' => $query->sql, 'params' => $query->parameters->toArray()];
// Return the versions from applied migrations
return array_column($this->appliedMigrations, 'version');
}
public function queryScalar(string $sql, array $parameters = []): mixed
public function queryScalar(SqlQuery $query): mixed
{
return null;
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Database\ValueObjects;
use App\Framework\Database\ValueObjects\QueryParameters;
use InvalidArgumentException;
describe('QueryParameters Value Object', function () {
it('can be created empty', function () {
$params = QueryParameters::empty();
expect($params->isEmpty())->toBeTrue();
expect($params->count())->toBe(0);
expect($params->toArray())->toBe([]);
});
it('can be created from array', function () {
$data = ['id' => 123, 'name' => 'John'];
$params = QueryParameters::fromArray($data);
expect($params->isEmpty())->toBeFalse();
expect($params->count())->toBe(2);
expect($params->get('id'))->toBe(123);
expect($params->get('name'))->toBe('John');
});
it('can add parameters immutably', function () {
$params = QueryParameters::empty();
$newParams = $params->with('id', 123);
expect($params->has('id'))->toBeFalse(); // Original unchanged
expect($newParams->has('id'))->toBeTrue();
expect($newParams->get('id'))->toBe(123);
});
it('can merge parameters', function () {
$params1 = QueryParameters::fromArray(['id' => 123]);
$params2 = $params1->merge(['name' => 'John', 'active' => true]);
expect($params1->count())->toBe(1); // Original unchanged
expect($params2->count())->toBe(3);
expect($params2->get('id'))->toBe(123);
expect($params2->get('name'))->toBe('John');
expect($params2->get('active'))->toBeTrue();
});
it('can remove parameters immutably', function () {
$params = QueryParameters::fromArray(['id' => 123, 'name' => 'John']);
$newParams = $params->without('name');
expect($params->has('name'))->toBeTrue(); // Original unchanged
expect($newParams->has('name'))->toBeFalse();
expect($newParams->has('id'))->toBeTrue();
});
it('gets parameters with defaults', function () {
$params = QueryParameters::fromArray(['id' => 123]);
expect($params->get('id'))->toBe(123);
expect($params->get('missing'))->toBeNull();
expect($params->get('missing', 'default'))->toBe('default');
});
it('normalizes parameter names', function () {
$params = QueryParameters::fromArray([':id' => 123, 'name' => 'John']);
expect($params->has('id'))->toBeTrue();
expect($params->has(':id'))->toBeTrue(); // Both work
expect($params->get('id'))->toBe(123);
expect($params->get(':id'))->toBe(123);
});
it('converts to PDO array with colon prefixes', function () {
$params = QueryParameters::fromArray(['id' => 123, ':name' => 'John']);
$pdoArray = $params->toPdoArray();
expect($pdoArray)->toBe([':id' => 123, ':name' => 'John']);
});
it('finds used parameters in SQL', function () {
$params = QueryParameters::fromArray(['id' => 123, 'name' => 'John', 'unused' => 'test']);
$sql = 'SELECT * FROM users WHERE id = :id AND name = :name';
$used = $params->getUsedParameters($sql);
expect($used)->toBe(['id', 'name']);
});
it('finds unused parameters', function () {
$params = QueryParameters::fromArray(['id' => 123, 'name' => 'John', 'unused' => 'test']);
$sql = 'SELECT * FROM users WHERE id = :id';
$unused = $params->getUnusedParameters($sql);
expect($unused)->toBe(['name', 'unused']);
});
it('validates SQL parameter requirements', function () {
$params = QueryParameters::fromArray(['id' => 123]);
$sql = 'SELECT * FROM users WHERE id = :id AND name = :name';
expect(fn () => $params->validateForSql($sql))->toThrow(InvalidArgumentException::class);
});
it('validates SQL parameter requirements successfully', function () {
$params = QueryParameters::fromArray(['id' => 123, 'name' => 'John']);
$sql = 'SELECT * FROM users WHERE id = :id AND name = :name';
expect(fn () => $params->validateForSql($sql))->not->toThrow(InvalidArgumentException::class);
});
it('determines PDO parameter types', function () {
$params = QueryParameters::fromArray([
'null_val' => null,
'bool_val' => true,
'int_val' => 123,
'string_val' => 'text',
]);
expect($params->getPdoType('null_val'))->toBe(\PDO::PARAM_NULL);
expect($params->getPdoType('bool_val'))->toBe(\PDO::PARAM_BOOL);
expect($params->getPdoType('int_val'))->toBe(\PDO::PARAM_INT);
expect($params->getPdoType('string_val'))->toBe(\PDO::PARAM_STR);
});
it('throws exception for non-string parameter names', function () {
expect(fn () => QueryParameters::fromArray([123 => 'value']))->toThrow(InvalidArgumentException::class);
});
it('throws exception for empty parameter names', function () {
expect(fn () => QueryParameters::fromArray(['' => 'value']))->toThrow(InvalidArgumentException::class);
expect(fn () => QueryParameters::fromArray([':' => 'value']))->toThrow(InvalidArgumentException::class);
});
it('throws exception for invalid parameter names', function () {
expect(fn () => QueryParameters::fromArray(['1invalid' => 'value']))->toThrow(InvalidArgumentException::class);
expect(fn () => QueryParameters::fromArray(['invalid-name' => 'value']))->toThrow(InvalidArgumentException::class);
expect(fn () => QueryParameters::fromArray(['invalid name' => 'value']))->toThrow(InvalidArgumentException::class);
});
it('allows valid parameter names', function () {
expect(fn () => QueryParameters::fromArray(['valid' => 'value']))->not->toThrow(InvalidArgumentException::class);
expect(fn () => QueryParameters::fromArray(['valid_name' => 'value']))->not->toThrow(InvalidArgumentException::class);
expect(fn () => QueryParameters::fromArray(['valid123' => 'value']))->not->toThrow(InvalidArgumentException::class);
expect(fn () => QueryParameters::fromArray(['_valid' => 'value']))->not->toThrow(InvalidArgumentException::class);
expect(fn () => QueryParameters::fromArray([':valid' => 'value']))->not->toThrow(InvalidArgumentException::class);
});
it('throws exception for non-scalar parameter values', function () {
expect(fn () => QueryParameters::fromArray(['array' => [1, 2, 3]]))->toThrow(InvalidArgumentException::class);
expect(fn () => QueryParameters::fromArray(['object' => new \stdClass()]))->toThrow(InvalidArgumentException::class);
});
it('allows scalar and null parameter values', function () {
expect(fn () => QueryParameters::fromArray([
'string' => 'text',
'int' => 123,
'float' => 12.34,
'bool' => true,
'null' => null,
]))->not->toThrow(InvalidArgumentException::class);
});
});

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Database\ValueObjects;
use App\Framework\Database\ValueObjects\QueryParameters;
use App\Framework\Database\ValueObjects\SqlQuery;
use InvalidArgumentException;
describe('SqlQuery Value Object', function () {
it('can be created with SQL and parameters', function () {
$params = QueryParameters::fromArray(['id' => 123]);
$query = new SqlQuery('SELECT * FROM users WHERE id = :id', $params);
expect($query->sql)->toBe('SELECT * FROM users WHERE id = :id');
expect($query->parameters->get('id'))->toBe(123);
});
it('can be created with create factory method', function () {
$query = SqlQuery::create('SELECT * FROM users', ['limit' => 10]);
expect($query->sql)->toBe('SELECT * FROM users');
expect($query->parameters->get('limit'))->toBe(10);
});
it('can create SELECT queries', function () {
$query = SqlQuery::select('users', ['id', 'name'], 'active = 1');
expect($query->sql)->toBe('SELECT id, name FROM users WHERE active = 1');
expect($query->isSelect())->toBeTrue();
});
it('can create INSERT queries', function () {
$query = SqlQuery::insert('users', ['name' => 'John', 'email' => 'john@test.com']);
expect($query->sql)->toBe('INSERT INTO users (name, email) VALUES (:name, :email)');
expect($query->parameters->get('name'))->toBe('John');
expect($query->parameters->get('email'))->toBe('john@test.com');
expect($query->isInsert())->toBeTrue();
});
it('can create UPDATE queries', function () {
$query = SqlQuery::update('users', ['name' => 'Jane'], 'id = :id');
expect($query->sql)->toBe('UPDATE users SET name = :name WHERE id = :id');
expect($query->parameters->get('name'))->toBe('Jane');
expect($query->isUpdate())->toBeTrue();
});
it('can create DELETE queries', function () {
$query = SqlQuery::delete('users', 'id = :id');
expect($query->sql)->toBe('DELETE FROM users WHERE id = :id');
expect($query->isDelete())->toBeTrue();
});
it('can add parameters immutably', function () {
$query = SqlQuery::create('SELECT * FROM users WHERE id = :id');
$newQuery = $query->withParameter('id', 123);
expect($query->parameters->has('id'))->toBeFalse(); // Original unchanged
expect($newQuery->parameters->get('id'))->toBe(123);
});
it('can add multiple parameters', function () {
$query = SqlQuery::create('SELECT * FROM users');
$newQuery = $query->withParameters(['limit' => 10, 'offset' => 20]);
expect($newQuery->parameters->get('limit'))->toBe(10);
expect($newQuery->parameters->get('offset'))->toBe(20);
});
it('detects query types correctly', function () {
expect(SqlQuery::create('SELECT * FROM users')->isSelect())->toBeTrue();
expect(SqlQuery::create('INSERT INTO users VALUES (1)')->isInsert())->toBeTrue();
expect(SqlQuery::create('UPDATE users SET name = "test"')->isUpdate())->toBeTrue();
expect(SqlQuery::create('DELETE FROM users')->isDelete())->toBeTrue();
expect(SqlQuery::create('CREATE TABLE test (id INT)')->isDDL())->toBeTrue();
expect(SqlQuery::create('ALTER TABLE test ADD column')->isDDL())->toBeTrue();
expect(SqlQuery::create('DROP TABLE test')->isDDL())->toBeTrue();
});
it('detects modifying queries', function () {
expect(SqlQuery::create('SELECT * FROM users')->isModifying())->toBeFalse();
expect(SqlQuery::create('INSERT INTO users VALUES (1)')->isModifying())->toBeTrue();
expect(SqlQuery::create('UPDATE users SET name = "test"')->isModifying())->toBeTrue();
expect(SqlQuery::create('DELETE FROM users')->isModifying())->toBeTrue();
expect(SqlQuery::create('CREATE TABLE test (id INT)')->isModifying())->toBeTrue();
});
it('generates debug string correctly', function () {
$query = SqlQuery::create('SELECT * FROM users WHERE id = :id AND name = :name', [
'id' => 123,
'name' => 'John',
]);
$debug = $query->toDebugString();
expect($debug)->toContain('123');
expect($debug)->toContain("'John'");
expect($debug)->not->toContain(':id');
expect($debug)->not->toContain(':name');
});
it('converts to string', function () {
$query = SqlQuery::create('SELECT * FROM users');
expect($query->toString())->toBe('SELECT * FROM users');
expect($query->__toString())->toBe('SELECT * FROM users');
});
it('throws exception for empty SQL', function () {
expect(fn () => new SqlQuery(''))->toThrow(InvalidArgumentException::class);
expect(fn () => new SqlQuery(' '))->toThrow(InvalidArgumentException::class);
});
it('throws exception for too large SQL', function () {
$largeSql = str_repeat('SELECT * FROM users; ', 100000); // > 1MB
expect(fn () => new SqlQuery($largeSql))->toThrow(InvalidArgumentException::class);
});
it('throws exception for empty insert data', function () {
expect(fn () => SqlQuery::insert('users', []))->toThrow(InvalidArgumentException::class);
});
it('throws exception for empty update data', function () {
expect(fn () => SqlQuery::update('users', [], 'id = 1'))->toThrow(InvalidArgumentException::class);
});
});