feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
use App\Framework\Database\ValueObjects\ColumnName;
use App\Framework\Database\ValueObjects\TableName;
describe('ColumnName', function () {
it('creates column name from string', function () {
$column = ColumnName::fromString('email');
expect($column->value)->toBe('email');
expect($column->toString())->toBe('email');
});
it('validates column name format', function () {
// Valid names
expect(fn() => ColumnName::fromString('email'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => ColumnName::fromString('user_email'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => ColumnName::fromString('_temp_field'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => ColumnName::fromString('column123'))->not->toThrow(\InvalidArgumentException::class);
// Invalid names
expect(fn() => ColumnName::fromString(''))
->toThrow(\InvalidArgumentException::class, 'cannot be empty');
expect(fn() => ColumnName::fromString('123invalid'))
->toThrow(\InvalidArgumentException::class, 'must start with a letter or underscore');
expect(fn() => ColumnName::fromString('invalid-name'))
->toThrow(\InvalidArgumentException::class, 'can only contain letters, numbers, and underscores');
});
it('validates maximum length', function () {
$validName = str_repeat('a', 64);
expect(fn() => ColumnName::fromString($validName))->not->toThrow(\InvalidArgumentException::class);
$tooLong = str_repeat('a', 65);
expect(fn() => ColumnName::fromString($tooLong))
->toThrow(\InvalidArgumentException::class, 'exceeds maximum length');
});
it('detects SQL injection attempts', function () {
// Note: SQL injection attempts are caught by format validation
// since they contain invalid characters (quotes, hyphens, spaces, etc.)
expect(fn() => ColumnName::fromString("email'; DROP TABLE--"))
->toThrow(\InvalidArgumentException::class);
expect(fn() => ColumnName::fromString('email UNION SELECT'))
->toThrow(\InvalidArgumentException::class);
});
it('quotes column names for different platforms', function () {
$column = ColumnName::fromString('email');
expect($column->quoted('mysql'))->toBe('`email`');
expect($column->quoted('postgresql'))->toBe('"email"');
expect($column->quoted('postgres'))->toBe('"email"');
expect($column->quoted('pgsql'))->toBe('"email"');
expect($column->quoted('sqlite'))->toBe('"email"');
expect($column->quoted())->toBe('`email`'); // Default MySQL
});
it('creates qualified column name', function () {
$table = TableName::fromString('users');
$column = ColumnName::fromString('email');
expect($column->qualified($table, 'mysql'))->toBe('`users`.`email`');
expect($column->qualified($table, 'postgresql'))->toBe('"users"."email"');
expect($column->qualified($table, 'sqlite'))->toBe('"users"."email"');
});
it('compares column names for equality', function () {
$col1 = ColumnName::fromString('email');
$col2 = ColumnName::fromString('email');
$col3 = ColumnName::fromString('EMAIL'); // Different case
$col4 = ColumnName::fromString('username');
expect($col1->equals($col2))->toBeTrue();
expect($col1->equals($col3))->toBeTrue(); // Case-insensitive
expect($col1->equals($col4))->toBeFalse();
});
it('matches column name patterns', function () {
$column = ColumnName::fromString('user_email');
expect($column->matches('user_*'))->toBeTrue();
expect($column->matches('*_email'))->toBeTrue();
expect($column->matches('user_email'))->toBeTrue();
expect($column->matches('order_*'))->toBeFalse();
});
it('detects reserved SQL keywords', function () {
$column = ColumnName::fromString('email');
expect($column->isReservedKeyword())->toBeFalse();
});
it('converts to lowercase', function () {
$column = ColumnName::fromString('UserEmail');
expect($column->toLower())->toBe('useremail');
});
it('checks for column name suffix', function () {
$column = ColumnName::fromString('user_id');
expect($column->hasSuffix('_id'))->toBeTrue();
expect($column->hasSuffix('_at'))->toBeFalse();
});
it('detects foreign key columns', function () {
$userId = ColumnName::fromString('user_id');
$orderId = ColumnName::fromString('order_id');
$email = ColumnName::fromString('email');
$id = ColumnName::fromString('id'); // Primary key, not foreign key
expect($userId->isForeignKey())->toBeTrue();
expect($orderId->isForeignKey())->toBeTrue();
expect($email->isForeignKey())->toBeFalse();
expect($id->isForeignKey())->toBeFalse(); // Special case: 'id' itself is not FK
});
it('detects timestamp columns', function () {
$createdAt = ColumnName::fromString('created_at');
$updatedAt = ColumnName::fromString('updated_at');
$deletedAt = ColumnName::fromString('deleted_at');
$publishedAt = ColumnName::fromString('published_at');
$email = ColumnName::fromString('email');
expect($createdAt->isTimestamp())->toBeTrue();
expect($updatedAt->isTimestamp())->toBeTrue();
expect($deletedAt->isTimestamp())->toBeTrue();
expect($publishedAt->isTimestamp())->toBeTrue(); // Any *_at
expect($email->isTimestamp())->toBeFalse();
});
it('detects standard timestamp column names', function () {
// Standard Laravel/framework timestamp columns
$createdAt = ColumnName::fromString('created_at');
$updatedAt = ColumnName::fromString('updated_at');
$deletedAt = ColumnName::fromString('deleted_at');
expect($createdAt->isTimestamp())->toBeTrue();
expect($updatedAt->isTimestamp())->toBeTrue();
expect($deletedAt->isTimestamp())->toBeTrue();
});
it('converts to string via magic method', function () {
$column = ColumnName::fromString('email');
expect((string) $column)->toBe('email');
});
it('is immutable', function () {
$column = ColumnName::fromString('email');
$value = $column->value;
// Value cannot be changed
expect($column->value)->toBe('email');
expect($value)->toBe('email');
});
it('handles edge cases correctly', function () {
// Single character (valid if letter or underscore)
expect(fn() => ColumnName::fromString('a'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => ColumnName::fromString('_'))->not->toThrow(\InvalidArgumentException::class);
// Underscore prefix
$column = ColumnName::fromString('_temp_field');
expect($column->value)->toBe('_temp_field');
// Numbers in name
$column = ColumnName::fromString('field_123');
expect($column->value)->toBe('field_123');
});
it('combines with TableName correctly', function () {
$table = TableName::fromString('users');
$column = ColumnName::fromString('email');
$qualified = $column->qualified($table);
expect($qualified)->toContain('users');
expect($qualified)->toContain('email');
expect($qualified)->toContain('.');
});
it('detects various foreign key naming patterns', function () {
// Standard pattern: table_id
expect(ColumnName::fromString('user_id')->isForeignKey())->toBeTrue();
expect(ColumnName::fromString('order_id')->isForeignKey())->toBeTrue();
expect(ColumnName::fromString('product_id')->isForeignKey())->toBeTrue();
// Non-FK patterns
expect(ColumnName::fromString('id')->isForeignKey())->toBeFalse();
expect(ColumnName::fromString('email')->isForeignKey())->toBeFalse();
expect(ColumnName::fromString('created_at')->isForeignKey())->toBeFalse();
});
it('detects various timestamp naming patterns', function () {
// Suffixed with _at
expect(ColumnName::fromString('created_at')->isTimestamp())->toBeTrue();
expect(ColumnName::fromString('updated_at')->isTimestamp())->toBeTrue();
expect(ColumnName::fromString('deleted_at')->isTimestamp())->toBeTrue();
expect(ColumnName::fromString('published_at')->isTimestamp())->toBeTrue();
expect(ColumnName::fromString('verified_at')->isTimestamp())->toBeTrue();
// Non-timestamp patterns
expect(ColumnName::fromString('email')->isTimestamp())->toBeFalse();
expect(ColumnName::fromString('user_id')->isTimestamp())->toBeFalse();
expect(ColumnName::fromString('status')->isTimestamp())->toBeFalse();
});
});

View File

@@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
use App\Framework\Database\ValueObjects\ConstraintName;
use App\Framework\Database\ValueObjects\TableName;
use App\Framework\Database\ValueObjects\ColumnName;
describe('ConstraintName', function () {
it('creates constraint name from string', function () {
$constraint = ConstraintName::fromString('fk_users_company_id');
expect($constraint->value)->toBe('fk_users_company_id');
expect($constraint->toString())->toBe('fk_users_company_id');
});
it('creates PRIMARY KEY constraint', function () {
$primaryKey = ConstraintName::primaryKey();
expect($primaryKey->value)->toBe('PRIMARY KEY');
expect($primaryKey->isPrimaryKey())->toBeTrue();
});
it('creates UNIQUE constraint with conventional naming', function () {
$table = TableName::fromString('users');
$emailColumn = ColumnName::fromString('email');
$unique = ConstraintName::unique($table, $emailColumn);
expect($unique->value)->toBe('uq_users_email');
expect($unique->isUnique())->toBeTrue();
});
it('creates multi-column UNIQUE constraint', function () {
$table = TableName::fromString('users');
$emailColumn = ColumnName::fromString('email');
$tenantColumn = ColumnName::fromString('tenant_id');
$unique = ConstraintName::unique($table, $emailColumn, $tenantColumn);
expect($unique->value)->toBe('uq_users_email_tenant_id');
expect($unique->isUnique())->toBeTrue();
});
it('creates FOREIGN KEY constraint with conventional naming', function () {
$table = TableName::fromString('orders');
$column = ColumnName::fromString('user_id');
$refTable = TableName::fromString('users');
$fk = ConstraintName::foreignKey($table, $column, $refTable);
expect($fk->value)->toBe('fk_orders_user_id_users');
expect($fk->isForeignKey())->toBeTrue();
});
it('creates CHECK constraint with conventional naming', function () {
$table = TableName::fromString('products');
$check = ConstraintName::check($table, 'positive_price');
expect($check->value)->toBe('chk_products_positive_price');
expect($check->isCheck())->toBeTrue();
});
it('validates constraint name format', function () {
// Valid names - should not throw
ConstraintName::fromString('fk_users_company');
ConstraintName::fromString('uq_email');
ConstraintName::fromString('chk_positive_amount');
expect(true)->toBeTrue(); // Validation passed
// Invalid names - should throw
try {
ConstraintName::fromString('');
expect(false)->toBeTrue('Should have thrown for empty name');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('cannot be empty');
}
try {
ConstraintName::fromString('123invalid');
expect(false)->toBeTrue('Should have thrown for name starting with number');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('must start with a letter or underscore');
}
try {
ConstraintName::fromString('invalid-name');
expect(false)->toBeTrue('Should have thrown for name with hyphen');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('can only contain letters, numbers, and underscores');
}
});
it('allows keyword constraints as special cases', function () {
// These are SQL keywords but valid constraints
ConstraintName::fromString('PRIMARY KEY');
ConstraintName::fromString('UNIQUE');
ConstraintName::fromString('CHECK');
ConstraintName::fromString('FOREIGN KEY');
expect(true)->toBeTrue(); // All keyword constraints created successfully
});
it('validates maximum length', function () {
// Valid length
$validName = 'fk_' . str_repeat('a', 61);
ConstraintName::fromString($validName); // Should not throw
// Too long
$tooLong = 'fk_' . str_repeat('a', 62);
try {
ConstraintName::fromString($tooLong);
expect(false)->toBeTrue('Should have thrown for name exceeding max length');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('exceeds maximum length');
}
});
it('detects SQL injection attempts', function () {
// Note: SQL injection attempts with special characters are caught by format validation
try {
ConstraintName::fromString("fk'; DROP TABLE--");
expect(false)->toBeTrue('Should have thrown for SQL injection attempt');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toBeString();
}
try {
ConstraintName::fromString('fk/*comment*/');
expect(false)->toBeTrue('Should have thrown for SQL comment injection');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toBeString();
}
});
it('quotes constraint names for different platforms', function () {
$constraint = ConstraintName::fromString('fk_users_company');
expect($constraint->quoted('mysql'))->toBe('`fk_users_company`');
expect($constraint->quoted('postgresql'))->toBe('"fk_users_company"');
expect($constraint->quoted('sqlite'))->toBe('"fk_users_company"');
expect($constraint->quoted())->toBe('`fk_users_company`'); // Default MySQL
});
it('never quotes keyword constraints', function () {
$primaryKey = ConstraintName::primaryKey();
$unique = ConstraintName::fromString('UNIQUE');
$check = ConstraintName::fromString('CHECK');
expect($primaryKey->quoted('mysql'))->toBe('PRIMARY KEY');
expect($primaryKey->quoted('postgresql'))->toBe('PRIMARY KEY');
expect($unique->quoted('mysql'))->toBe('UNIQUE');
expect($check->quoted('postgresql'))->toBe('CHECK');
});
it('compares constraint names for equality', function () {
$fk1 = ConstraintName::fromString('fk_users_company');
$fk2 = ConstraintName::fromString('fk_users_company');
$fk3 = ConstraintName::fromString('FK_USERS_COMPANY'); // Different case
$fk4 = ConstraintName::fromString('fk_orders_user');
expect($fk1->equals($fk2))->toBeTrue();
expect($fk1->equals($fk3))->toBeTrue(); // Case-insensitive
expect($fk1->equals($fk4))->toBeFalse();
});
it('matches constraint name patterns', function () {
$constraint = ConstraintName::fromString('fk_users_company_id');
expect($constraint->matches('fk_*'))->toBeTrue();
expect($constraint->matches('*_company_id'))->toBeTrue();
expect($constraint->matches('fk_users_*'))->toBeTrue();
expect($constraint->matches('uq_*'))->toBeFalse();
});
it('detects reserved SQL keywords', function () {
$constraint = ConstraintName::fromString('fk_users_company');
expect($constraint->isReservedKeyword())->toBeFalse();
});
it('converts to lowercase', function () {
$constraint = ConstraintName::fromString('FK_Users_Company');
expect($constraint->toLower())->toBe('fk_users_company');
});
it('checks for constraint name prefix', function () {
$constraint = ConstraintName::fromString('fk_users_company');
expect($constraint->hasPrefix('fk_'))->toBeTrue();
expect($constraint->hasPrefix('uq_'))->toBeFalse();
});
it('checks for constraint name suffix', function () {
$constraint = ConstraintName::fromString('fk_users_company_id');
expect($constraint->hasSuffix('_id'))->toBeTrue();
expect($constraint->hasSuffix('_key'))->toBeFalse();
});
it('detects PRIMARY KEY constraints', function () {
$pk1 = ConstraintName::primaryKey();
expect($pk1->isPrimaryKey())->toBeTrue();
$pk2 = ConstraintName::fromString('PRIMARY');
expect($pk2->isPrimaryKey())->toBeTrue();
$fk = ConstraintName::fromString('fk_users_company');
expect($fk->isPrimaryKey())->toBeFalse();
});
it('detects FOREIGN KEY constraints', function () {
$fk1 = ConstraintName::fromString('fk_users_company');
expect($fk1->isForeignKey())->toBeTrue();
$fk2 = ConstraintName::fromString('FOREIGN KEY');
expect($fk2->isForeignKey())->toBeTrue();
$uq = ConstraintName::fromString('uq_email');
expect($uq->isForeignKey())->toBeFalse();
});
it('detects UNIQUE constraints', function () {
$uq1 = ConstraintName::fromString('uq_email');
expect($uq1->isUnique())->toBeTrue();
$uq2 = ConstraintName::fromString('unique_users_email');
expect($uq2->isUnique())->toBeTrue();
$uq3 = ConstraintName::fromString('UNIQUE');
expect($uq3->isUnique())->toBeTrue();
$fk = ConstraintName::fromString('fk_users_company');
expect($fk->isUnique())->toBeFalse();
});
it('detects CHECK constraints', function () {
$chk1 = ConstraintName::fromString('chk_positive_amount');
expect($chk1->isCheck())->toBeTrue();
$chk2 = ConstraintName::fromString('check_valid_status');
expect($chk2->isCheck())->toBeTrue();
$chk3 = ConstraintName::fromString('CHECK');
expect($chk3->isCheck())->toBeTrue();
$fk = ConstraintName::fromString('fk_users_company');
expect($fk->isCheck())->toBeFalse();
});
it('detects DEFAULT constraints', function () {
$df1 = ConstraintName::fromString('df_created_at');
expect($df1->isDefault())->toBeTrue();
$df2 = ConstraintName::fromString('default_status');
expect($df2->isDefault())->toBeTrue();
$df3 = ConstraintName::fromString('DEFAULT');
expect($df3->isDefault())->toBeTrue();
$fk = ConstraintName::fromString('fk_users_company');
expect($fk->isDefault())->toBeFalse();
});
it('identifies keyword constraints', function () {
$pk = ConstraintName::primaryKey();
expect($pk->isKeywordConstraint())->toBeTrue();
$unique = ConstraintName::fromString('UNIQUE');
expect($unique->isKeywordConstraint())->toBeTrue();
$check = ConstraintName::fromString('CHECK');
expect($check->isKeywordConstraint())->toBeTrue();
$custom = ConstraintName::fromString('fk_users_company');
expect($custom->isKeywordConstraint())->toBeFalse();
});
it('determines constraint type', function () {
$pk = ConstraintName::primaryKey();
expect($pk->getType())->toBe('primary_key');
$fk = ConstraintName::fromString('fk_users_company');
expect($fk->getType())->toBe('foreign_key');
$uq = ConstraintName::fromString('uq_email');
expect($uq->getType())->toBe('unique');
$chk = ConstraintName::fromString('chk_positive');
expect($chk->getType())->toBe('check');
$df = ConstraintName::fromString('df_status');
expect($df->getType())->toBe('default');
$custom = ConstraintName::fromString('my_custom_constraint');
expect($custom->getType())->toBe('custom');
});
it('converts to string via magic method', function () {
$constraint = ConstraintName::fromString('fk_users_company');
expect((string) $constraint)->toBe('fk_users_company');
});
it('is immutable', function () {
$table = TableName::fromString('users');
$column = ColumnName::fromString('email');
$unique = ConstraintName::unique($table, $column);
$value = $unique->value;
// Value cannot be changed
expect($unique->value)->toBe('uq_users_email');
expect($value)->toBe('uq_users_email');
});
it('handles edge cases correctly', function () {
// Single character (valid if letter or underscore)
$c = ConstraintName::fromString('c');
expect($c->value)->toBe('c');
$underscore = ConstraintName::fromString('_');
expect($underscore->value)->toBe('_');
// Underscore prefix
$constraint = ConstraintName::fromString('_temp_constraint');
expect($constraint->value)->toBe('_temp_constraint');
// Numbers in name
$constraint = ConstraintName::fromString('fk_table_123');
expect($constraint->value)->toBe('fk_table_123');
});
it('factory methods create valid constraint names', function () {
$table = TableName::fromString('users');
$column = ColumnName::fromString('email');
$refTable = TableName::fromString('companies');
// All factory methods should produce valid names
ConstraintName::primaryKey();
ConstraintName::unique($table, $column);
ConstraintName::foreignKey($table, $column, $refTable);
ConstraintName::check($table, 'valid_status');
expect(true)->toBeTrue(); // All factory methods succeeded
});
});

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
use App\Framework\Database\ValueObjects\DatabaseName;
describe('DatabaseName', function () {
it('creates database name from string', function () {
$dbName = DatabaseName::fromString('myapp_production');
expect($dbName->value)->toBe('myapp_production');
expect($dbName->toString())->toBe('myapp_production');
});
it('validates database name format', function () {
// Valid names - should not throw
DatabaseName::fromString('myapp');
DatabaseName::fromString('my_app_db');
DatabaseName::fromString('_temp_db');
DatabaseName::fromString('db123');
expect(true)->toBeTrue(); // Validation passed
// Invalid names - should throw
try {
DatabaseName::fromString('');
expect(false)->toBeTrue('Should have thrown for empty name');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('cannot be empty');
}
try {
DatabaseName::fromString('123invalid');
expect(false)->toBeTrue('Should have thrown for name starting with number');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('must start with a letter or underscore');
}
try {
DatabaseName::fromString('invalid-name');
expect(false)->toBeTrue('Should have thrown for name with hyphen');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('can only contain letters, numbers, and underscores');
}
try {
DatabaseName::fromString('invalid name');
expect(false)->toBeTrue('Should have thrown for name with space');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('can only contain letters, numbers, and underscores');
}
});
it('validates maximum length', function () {
// Valid length
$validName = str_repeat('a', 64);
DatabaseName::fromString($validName); // Should not throw
// Too long
$tooLong = str_repeat('a', 65);
try {
DatabaseName::fromString($tooLong);
expect(false)->toBeTrue('Should have thrown for name exceeding max length');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('exceeds maximum length');
}
});
it('detects SQL injection attempts', function () {
// Note: SQL injection attempts are caught by format validation
// since they contain invalid characters (quotes, hyphens, spaces, etc.)
try {
DatabaseName::fromString("myapp'; DROP DATABASE--");
expect(false)->toBeTrue('Should have thrown for SQL injection attempt');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toBeString();
}
try {
DatabaseName::fromString('myapp UNION SELECT');
expect(false)->toBeTrue('Should have thrown for SQL injection attempt');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toBeString();
}
try {
DatabaseName::fromString('myapp/*comment*/');
expect(false)->toBeTrue('Should have thrown for SQL injection attempt');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toBeString();
}
});
it('quotes database names for different platforms', function () {
$dbName = DatabaseName::fromString('myapp');
expect($dbName->quoted('mysql'))->toBe('`myapp`');
expect($dbName->quoted('postgresql'))->toBe('"myapp"');
expect($dbName->quoted('postgres'))->toBe('"myapp"');
expect($dbName->quoted('pgsql'))->toBe('"myapp"');
expect($dbName->quoted('sqlite'))->toBe('"myapp"');
expect($dbName->quoted())->toBe('`myapp`'); // Default MySQL
expect($dbName->quoted('unknown'))->toBe('`myapp`'); // Fallback to MySQL
});
it('compares database names for equality', function () {
$db1 = DatabaseName::fromString('myapp');
$db2 = DatabaseName::fromString('myapp');
$db3 = DatabaseName::fromString('MYAPP'); // Different case
$db4 = DatabaseName::fromString('other_db');
expect($db1->equals($db2))->toBeTrue();
expect($db1->equals($db3))->toBeTrue(); // Case-insensitive
expect($db1->equals($db4))->toBeFalse();
});
it('matches database name patterns', function () {
$dbName = DatabaseName::fromString('myapp_production');
expect($dbName->matches('myapp_*'))->toBeTrue();
expect($dbName->matches('*_production'))->toBeTrue();
expect($dbName->matches('myapp_production'))->toBeTrue();
expect($dbName->matches('other_*'))->toBeFalse();
});
it('detects reserved SQL keywords', function () {
$dbName = DatabaseName::fromString('myapp');
expect($dbName->isReservedKeyword())->toBeFalse();
});
it('converts to lowercase', function () {
$dbName = DatabaseName::fromString('MyApp_Production');
expect($dbName->toLower())->toBe('myapp_production');
});
it('converts to uppercase', function () {
$dbName = DatabaseName::fromString('myapp_production');
expect($dbName->toUpper())->toBe('MYAPP_PRODUCTION');
});
it('checks for database name prefix', function () {
$dbName = DatabaseName::fromString('myapp_production');
expect($dbName->hasPrefix('myapp_'))->toBeTrue();
expect($dbName->hasPrefix('other_'))->toBeFalse();
});
it('checks for database name suffix', function () {
$dbName = DatabaseName::fromString('myapp_production');
expect($dbName->hasSuffix('_production'))->toBeTrue();
expect($dbName->hasSuffix('_staging'))->toBeFalse();
});
it('adds prefix to database name', function () {
$dbName = DatabaseName::fromString('myapp');
$prefixed = $dbName->withPrefix('dev_');
expect($prefixed->value)->toBe('dev_myapp');
expect($prefixed->toString())->toBe('dev_myapp');
// Original unchanged (immutable)
expect($dbName->value)->toBe('myapp');
});
it('removes prefix from database name', function () {
$dbName = DatabaseName::fromString('dev_myapp');
$unprefixed = $dbName->withoutPrefix('dev_');
expect($unprefixed->value)->toBe('myapp');
// Removing non-existent prefix returns same instance
$same = $unprefixed->withoutPrefix('prod_');
expect($same->value)->toBe('myapp');
});
it('adds suffix to database name', function () {
$dbName = DatabaseName::fromString('myapp');
$suffixed = $dbName->withSuffix('_production');
expect($suffixed->value)->toBe('myapp_production');
// Original unchanged (immutable)
expect($dbName->value)->toBe('myapp');
});
it('removes suffix from database name', function () {
$dbName = DatabaseName::fromString('myapp_production');
$unsuffixed = $dbName->withoutSuffix('_production');
expect($unsuffixed->value)->toBe('myapp');
// Removing non-existent suffix returns same instance
$same = $unsuffixed->withoutSuffix('_staging');
expect($same->value)->toBe('myapp');
});
it('detects environment suffixes', function () {
$prodDb = DatabaseName::fromString('myapp_production');
expect($prodDb->getEnvironmentSuffix())->toBe('production');
$stagingDb = DatabaseName::fromString('myapp_staging');
expect($stagingDb->getEnvironmentSuffix())->toBe('staging');
$testDb = DatabaseName::fromString('myapp_test');
expect($testDb->getEnvironmentSuffix())->toBe('test');
$devDb = DatabaseName::fromString('myapp_development');
expect($devDb->getEnvironmentSuffix())->toBe('development');
$localDb = DatabaseName::fromString('myapp_local');
expect($localDb->getEnvironmentSuffix())->toBe('local');
$noEnvDb = DatabaseName::fromString('myapp');
expect($noEnvDb->getEnvironmentSuffix())->toBeNull();
});
it('detects test databases', function () {
$testDb1 = DatabaseName::fromString('myapp_test');
expect($testDb1->isTestDatabase())->toBeTrue();
$testDb2 = DatabaseName::fromString('test_myapp');
expect($testDb2->isTestDatabase())->toBeTrue();
$prodDb = DatabaseName::fromString('myapp_production');
expect($prodDb->isTestDatabase())->toBeFalse();
});
it('converts to string via magic method', function () {
$dbName = DatabaseName::fromString('myapp');
expect((string) $dbName)->toBe('myapp');
});
it('is immutable', function () {
$original = DatabaseName::fromString('myapp');
$prefixed = $original->withPrefix('dev_');
$suffixed = $original->withSuffix('_prod');
// Original remains unchanged
expect($original->value)->toBe('myapp');
// New instances created - values should differ
expect($prefixed->value)->toBe('dev_myapp');
expect($suffixed->value)->toBe('myapp_prod');
// Verify immutability by checking original hasn't changed
expect($original->value)->toBe('myapp');
});
it('handles edge cases correctly', function () {
// Single character (valid if letter or underscore)
$a = DatabaseName::fromString('a');
expect($a->value)->toBe('a');
$underscore = DatabaseName::fromString('_');
expect($underscore->value)->toBe('_');
// Underscore-only prefix
$dbName = DatabaseName::fromString('_temp_myapp');
expect($dbName->value)->toBe('_temp_myapp');
// Numbers in name (but not at start)
$dbName = DatabaseName::fromString('myapp_123_db');
expect($dbName->value)->toBe('myapp_123_db');
});
});

View File

@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
use App\Framework\Database\ValueObjects\IndexName;
use App\Framework\Database\ValueObjects\TableName;
use App\Framework\Database\ValueObjects\ColumnName;
describe('IndexName', function () {
it('creates index name from string', function () {
$index = IndexName::fromString('idx_users_email');
expect($index->value)->toBe('idx_users_email');
expect($index->toString())->toBe('idx_users_email');
});
it('creates PRIMARY KEY index', function () {
$primary = IndexName::primary();
expect($primary->value)->toBe('PRIMARY');
expect($primary->isPrimary())->toBeTrue();
});
it('validates index name format', function () {
// Valid names
expect(fn() => IndexName::fromString('idx_users_email'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => IndexName::fromString('unique_users_email'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => IndexName::fromString('_temp_index'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => IndexName::fromString('index123'))->not->toThrow(\InvalidArgumentException::class);
// Invalid names
expect(fn() => IndexName::fromString(''))
->toThrow(\InvalidArgumentException::class, 'cannot be empty');
expect(fn() => IndexName::fromString('123invalid'))
->toThrow(\InvalidArgumentException::class, 'must start with a letter or underscore');
expect(fn() => IndexName::fromString('invalid-name'))
->toThrow(\InvalidArgumentException::class, 'can only contain letters, numbers, and underscores');
});
it('allows PRIMARY as special case', function () {
// PRIMARY is always valid
expect(fn() => IndexName::fromString('PRIMARY'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => IndexName::fromString('primary'))->not->toThrow(\InvalidArgumentException::class);
});
it('validates maximum length', function () {
$validName = str_repeat('a', 64);
expect(fn() => IndexName::fromString($validName))->not->toThrow(\InvalidArgumentException::class);
$tooLong = str_repeat('a', 65);
expect(fn() => IndexName::fromString($tooLong))
->toThrow(\InvalidArgumentException::class, 'exceeds maximum length');
});
it('detects SQL injection attempts', function () {
// Note: SQL injection attempts are caught by format validation
// since they contain invalid characters (quotes, hyphens, spaces, etc.)
expect(fn() => IndexName::fromString("idx'; DROP TABLE--"))
->toThrow(\InvalidArgumentException::class);
expect(fn() => IndexName::fromString('idx UNION SELECT'))
->toThrow(\InvalidArgumentException::class);
});
it('quotes index names for different platforms', function () {
$index = IndexName::fromString('idx_users_email');
expect($index->quoted('mysql'))->toBe('`idx_users_email`');
expect($index->quoted('postgresql'))->toBe('"idx_users_email"');
expect($index->quoted('postgres'))->toBe('"idx_users_email"');
expect($index->quoted('pgsql'))->toBe('"idx_users_email"');
expect($index->quoted('sqlite'))->toBe('"idx_users_email"');
expect($index->quoted())->toBe('`idx_users_email`'); // Default MySQL
});
it('never quotes PRIMARY KEY', function () {
$primary = IndexName::primary();
expect($primary->quoted('mysql'))->toBe('PRIMARY KEY');
expect($primary->quoted('postgresql'))->toBe('PRIMARY KEY');
expect($primary->quoted('sqlite'))->toBe('PRIMARY KEY');
});
it('compares index names for equality', function () {
$idx1 = IndexName::fromString('idx_users_email');
$idx2 = IndexName::fromString('idx_users_email');
$idx3 = IndexName::fromString('IDX_USERS_EMAIL'); // Different case
$idx4 = IndexName::fromString('idx_orders_id');
expect($idx1->equals($idx2))->toBeTrue();
expect($idx1->equals($idx3))->toBeTrue(); // Case-insensitive
expect($idx1->equals($idx4))->toBeFalse();
});
it('matches index name patterns', function () {
$index = IndexName::fromString('idx_users_email');
expect($index->matches('idx_*'))->toBeTrue();
expect($index->matches('*_email'))->toBeTrue();
expect($index->matches('idx_users_*'))->toBeTrue();
expect($index->matches('unique_*'))->toBeFalse();
});
it('detects reserved SQL keywords', function () {
$index = IndexName::fromString('idx_users_email');
expect($index->isReservedKeyword())->toBeFalse();
});
it('converts to lowercase', function () {
$index = IndexName::fromString('IDX_Users_Email');
expect($index->toLower())->toBe('idx_users_email');
});
it('checks for index name prefix', function () {
$index = IndexName::fromString('idx_users_email');
expect($index->hasPrefix('idx_'))->toBeTrue();
expect($index->hasPrefix('unique_'))->toBeFalse();
});
it('checks for index name suffix', function () {
$index = IndexName::fromString('idx_users_email');
expect($index->hasSuffix('_email'))->toBeTrue();
expect($index->hasSuffix('_id'))->toBeFalse();
});
it('detects unique indexes', function () {
$uniqueIdx1 = IndexName::fromString('unique_users_email');
$uniqueIdx2 = IndexName::fromString('idx_users_email_unique');
$uniqueIdx3 = IndexName::fromString('idx_unique_constraint_users');
$normalIdx = IndexName::fromString('idx_users_email');
expect($uniqueIdx1->isUniqueIndex())->toBeTrue();
expect($uniqueIdx2->isUniqueIndex())->toBeTrue();
expect($uniqueIdx3->isUniqueIndex())->toBeTrue();
expect($normalIdx->isUniqueIndex())->toBeFalse();
});
it('detects full-text indexes', function () {
$fulltextIdx1 = IndexName::fromString('fulltext_posts_content');
$fulltextIdx2 = IndexName::fromString('idx_posts_content_fulltext');
$fulltextIdx3 = IndexName::fromString('idx_fulltext_search');
$normalIdx = IndexName::fromString('idx_posts_title');
expect($fulltextIdx1->isFullTextIndex())->toBeTrue();
expect($fulltextIdx2->isFullTextIndex())->toBeTrue();
expect($fulltextIdx3->isFullTextIndex())->toBeTrue();
expect($normalIdx->isFullTextIndex())->toBeFalse();
});
it('generates conventional index names for columns', function () {
$table = TableName::fromString('users');
$emailColumn = ColumnName::fromString('email');
$index = IndexName::forColumns($table, $emailColumn);
expect($index->value)->toBe('idx_users_email');
expect($index->hasPrefix('idx_'))->toBeTrue();
});
it('generates multi-column index names', function () {
$table = TableName::fromString('users');
$firstNameColumn = ColumnName::fromString('first_name');
$lastNameColumn = ColumnName::fromString('last_name');
$index = IndexName::forColumns($table, $firstNameColumn, $lastNameColumn);
expect($index->value)->toBe('idx_users_first_name_last_name');
});
it('generates unique constraint names', function () {
$table = TableName::fromString('users');
$emailColumn = ColumnName::fromString('email');
$index = IndexName::uniqueFor($table, $emailColumn);
expect($index->value)->toBe('unique_users_email');
expect($index->isUniqueIndex())->toBeTrue();
});
it('generates multi-column unique constraint names', function () {
$table = TableName::fromString('users');
$emailColumn = ColumnName::fromString('email');
$tenantIdColumn = ColumnName::fromString('tenant_id');
$index = IndexName::uniqueFor($table, $emailColumn, $tenantIdColumn);
expect($index->value)->toBe('unique_users_email_tenant_id');
expect($index->isUniqueIndex())->toBeTrue();
});
it('generates foreign key index names', function () {
$table = TableName::fromString('orders');
$userIdColumn = ColumnName::fromString('user_id');
$index = IndexName::foreignKeyFor($table, $userIdColumn);
expect($index->value)->toBe('fk_orders_user_id');
expect($index->hasPrefix('fk_'))->toBeTrue();
});
it('converts to string via magic method', function () {
$index = IndexName::fromString('idx_users_email');
expect((string) $index)->toBe('idx_users_email');
});
it('is immutable', function () {
$index = IndexName::fromString('idx_users_email');
$value = $index->value;
// Value cannot be changed
expect($index->value)->toBe('idx_users_email');
expect($value)->toBe('idx_users_email');
});
it('handles edge cases correctly', function () {
// Single character (valid if letter or underscore)
expect(fn() => IndexName::fromString('a'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => IndexName::fromString('_'))->not->toThrow(\InvalidArgumentException::class);
// Underscore prefix
$index = IndexName::fromString('_temp_index');
expect($index->value)->toBe('_temp_index');
// Numbers in name
$index = IndexName::fromString('idx_123');
expect($index->value)->toBe('idx_123');
});
it('detects PRIMARY in different cases', function () {
$primary1 = IndexName::fromString('PRIMARY');
$primary2 = IndexName::fromString('primary');
$primary3 = IndexName::fromString('Primary');
expect($primary1->isPrimary())->toBeTrue();
expect($primary2->isPrimary())->toBeTrue();
expect($primary3->isPrimary())->toBeTrue();
});
it('factory methods create valid index names', function () {
$table = TableName::fromString('users');
$emailColumn = ColumnName::fromString('email');
// All factory methods should produce valid names
expect(fn() => IndexName::forColumns($table, $emailColumn))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => IndexName::uniqueFor($table, $emailColumn))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => IndexName::foreignKeyFor($table, $emailColumn))->not->toThrow(\InvalidArgumentException::class);
});
it('combines with TableName and ColumnName correctly', function () {
$table = TableName::fromString('users');
$emailColumn = ColumnName::fromString('email');
$statusColumn = ColumnName::fromString('status');
$index = IndexName::forColumns($table, $emailColumn, $statusColumn);
expect($index->value)->toContain('users');
expect($index->value)->toContain('email');
expect($index->value)->toContain('status');
});
});

View File

@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
use App\Framework\Database\ValueObjects\SchemaName;
use App\Framework\Database\ValueObjects\TableName;
describe('SchemaName', function () {
it('creates schema name from string', function () {
$schema = SchemaName::fromString('app_v1');
expect($schema->value)->toBe('app_v1');
expect($schema->toString())->toBe('app_v1');
});
it('creates default public schema', function () {
$public = SchemaName::public();
expect($public->value)->toBe('public');
expect($public->isPublic())->toBeTrue();
});
it('creates information_schema', function () {
$infoSchema = SchemaName::informationSchema();
expect($infoSchema->value)->toBe('information_schema');
expect($infoSchema->isSystemSchema())->toBeTrue();
});
it('creates pg_catalog', function () {
$pgCatalog = SchemaName::pgCatalog();
expect($pgCatalog->value)->toBe('pg_catalog');
expect($pgCatalog->isSystemSchema())->toBeTrue();
});
it('validates schema name format', function () {
// Valid names - should not throw
SchemaName::fromString('app_v1');
SchemaName::fromString('my_schema');
SchemaName::fromString('_temp_schema');
SchemaName::fromString('schema123');
expect(true)->toBeTrue(); // Validation passed
// Invalid names - should throw
try {
SchemaName::fromString('');
expect(false)->toBeTrue('Should have thrown for empty name');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('cannot be empty');
}
try {
SchemaName::fromString('123invalid');
expect(false)->toBeTrue('Should have thrown for name starting with number');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('must start with a letter or underscore');
}
try {
SchemaName::fromString('invalid-name');
expect(false)->toBeTrue('Should have thrown for name with hyphen');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('can only contain letters, numbers, and underscores');
}
});
it('validates maximum length', function () {
// Valid length
$validName = str_repeat('a', 64);
SchemaName::fromString($validName); // Should not throw
// Too long
$tooLong = str_repeat('a', 65);
try {
SchemaName::fromString($tooLong);
expect(false)->toBeTrue('Should have thrown for name exceeding max length');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toContain('exceeds maximum length');
}
});
it('detects SQL injection attempts', function () {
// Note: SQL injection attempts are caught by format validation
try {
SchemaName::fromString("app'; DROP SCHEMA--");
expect(false)->toBeTrue('Should have thrown for SQL injection attempt');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toBeString();
}
try {
SchemaName::fromString('app UNION SELECT');
expect(false)->toBeTrue('Should have thrown for SQL injection attempt');
} catch (\InvalidArgumentException $e) {
expect($e->getMessage())->toBeString();
}
});
it('quotes schema names for different platforms', function () {
$schema = SchemaName::fromString('app_v1');
expect($schema->quoted('postgresql'))->toBe('"app_v1"');
expect($schema->quoted('postgres'))->toBe('"app_v1"');
expect($schema->quoted('pgsql'))->toBe('"app_v1"');
expect($schema->quoted('sqlite'))->toBe('"app_v1"');
expect($schema->quoted('mysql'))->toBe('`app_v1`');
expect($schema->quoted())->toBe('"app_v1"'); // Default PostgreSQL
});
it('qualifies table names', function () {
$schema = SchemaName::fromString('app_v1');
$table = TableName::fromString('users');
expect($schema->qualifyTable($table, 'postgresql'))->toBe('"app_v1"."users"');
expect($schema->qualifyTable($table, 'mysql'))->toBe('`app_v1`.`users`');
});
it('compares schema names for equality', function () {
$schema1 = SchemaName::fromString('app_v1');
$schema2 = SchemaName::fromString('app_v1');
$schema3 = SchemaName::fromString('app_v2');
expect($schema1->equals($schema2))->toBeTrue();
expect($schema1->equals($schema3))->toBeFalse();
// PostgreSQL schemas are case-sensitive
$schemaLower = SchemaName::fromString('app');
$schemaUpper = SchemaName::fromString('APP');
expect($schemaLower->equals($schemaUpper))->toBeFalse();
});
it('matches schema name patterns', function () {
$schema = SchemaName::fromString('app_v1');
expect($schema->matches('app_*'))->toBeTrue();
expect($schema->matches('*_v1'))->toBeTrue();
expect($schema->matches('app_v1'))->toBeTrue();
expect($schema->matches('other_*'))->toBeFalse();
});
it('detects reserved SQL keywords', function () {
$schema = SchemaName::fromString('app_v1');
expect($schema->isReservedKeyword())->toBeFalse();
});
it('converts to lowercase', function () {
$schema = SchemaName::fromString('App_V1');
expect($schema->toLower())->toBe('app_v1');
});
it('checks for schema name prefix', function () {
$schema = SchemaName::fromString('app_v1');
expect($schema->hasPrefix('app_'))->toBeTrue();
expect($schema->hasPrefix('other_'))->toBeFalse();
});
it('checks for schema name suffix', function () {
$schema = SchemaName::fromString('app_v1');
expect($schema->hasSuffix('_v1'))->toBeTrue();
expect($schema->hasSuffix('_v2'))->toBeFalse();
});
it('detects public schema', function () {
$public = SchemaName::public();
expect($public->isPublic())->toBeTrue();
$custom = SchemaName::fromString('app_v1');
expect($custom->isPublic())->toBeFalse();
});
it('detects system schemas', function () {
$infoSchema = SchemaName::informationSchema();
expect($infoSchema->isSystemSchema())->toBeTrue();
$pgCatalog = SchemaName::pgCatalog();
expect($pgCatalog->isSystemSchema())->toBeTrue();
$pgSchema = SchemaName::fromString('pg_temp');
expect($pgSchema->isSystemSchema())->toBeTrue();
$customSchema = SchemaName::fromString('app_v1');
expect($customSchema->isSystemSchema())->toBeFalse();
});
it('detects temporary schemas', function () {
$tempSchema1 = SchemaName::fromString('temp_schema');
expect($tempSchema1->isTemporary())->toBeTrue();
$tempSchema2 = SchemaName::fromString('tmp_data');
expect($tempSchema2->isTemporary())->toBeTrue();
$tempSchema3 = SchemaName::fromString('schema_temp');
expect($tempSchema3->isTemporary())->toBeTrue();
$normalSchema = SchemaName::fromString('app_v1');
expect($normalSchema->isTemporary())->toBeFalse();
});
it('adds prefix to schema name', function () {
$schema = SchemaName::fromString('v1');
$prefixed = $schema->withPrefix('app_');
expect($prefixed->value)->toBe('app_v1');
// Original unchanged (immutable)
expect($schema->value)->toBe('v1');
});
it('removes prefix from schema name', function () {
$schema = SchemaName::fromString('app_v1');
$unprefixed = $schema->withoutPrefix('app_');
expect($unprefixed->value)->toBe('v1');
// Removing non-existent prefix returns same instance
$same = $unprefixed->withoutPrefix('other_');
expect($same->value)->toBe('v1');
});
it('converts to string via magic method', function () {
$schema = SchemaName::fromString('app_v1');
expect((string) $schema)->toBe('app_v1');
});
it('is immutable', function () {
$original = SchemaName::fromString('v1');
$prefixed = $original->withPrefix('app_');
// Original remains unchanged
expect($original->value)->toBe('v1');
// New instance created - verify by value difference
expect($prefixed->value)->toBe('app_v1');
// Removing non-existent prefix returns same instance (optimization)
$unprefixed = $original->withoutPrefix('app_');
expect($unprefixed->value)->toBe('v1');
// But removing actual prefix creates new instance
$prefixedSchema = SchemaName::fromString('app_v1');
$removedPrefix = $prefixedSchema->withoutPrefix('app_');
expect($removedPrefix->value)->toBe('v1');
expect($prefixedSchema->value)->toBe('app_v1'); // Original unchanged
});
it('handles edge cases correctly', function () {
// Single character (valid if letter or underscore)
$a = SchemaName::fromString('a');
expect($a->value)->toBe('a');
$underscore = SchemaName::fromString('_');
expect($underscore->value)->toBe('_');
// Underscore prefix
$schema = SchemaName::fromString('_temp');
expect($schema->value)->toBe('_temp');
// Numbers in name
$schema = SchemaName::fromString('app_123');
expect($schema->value)->toBe('app_123');
});
it('factory methods create valid schemas', function () {
SchemaName::public();
SchemaName::informationSchema();
SchemaName::pgCatalog();
expect(true)->toBeTrue(); // All factory methods succeeded
});
});

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
use App\Framework\Database\ValueObjects\SqlState;
describe('SqlState Value Object', function () {
it('validates SQLSTATE format', function () {
// Valid 5-character alphanumeric codes
expect(fn() => new SqlState('23505'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => new SqlState('08001'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => new SqlState('42S02'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => new SqlState('HY000'))->not->toThrow(\InvalidArgumentException::class);
});
it('rejects invalid SQLSTATE formats', function () {
// Too short
expect(fn() => new SqlState('2350'))->toThrow(\InvalidArgumentException::class);
// Too long
expect(fn() => new SqlState('235050'))->toThrow(\InvalidArgumentException::class);
// Invalid characters
expect(fn() => new SqlState('23-05'))->toThrow(\InvalidArgumentException::class);
expect(fn() => new SqlState('23@05'))->toThrow(\InvalidArgumentException::class);
expect(fn() => new SqlState('abcde'))->toThrow(\InvalidArgumentException::class);
});
it('extracts SQLSTATE class correctly', function () {
$sqlState = new SqlState('23505');
expect($sqlState->getClass())->toBe('23');
$sqlState = new SqlState('08001');
expect($sqlState->getClass())->toBe('08');
$sqlState = new SqlState('42S02');
expect($sqlState->getClass())->toBe('42');
});
it('extracts SQLSTATE subclass correctly', function () {
$sqlState = new SqlState('23505');
expect($sqlState->getSubclass())->toBe('505');
$sqlState = new SqlState('08001');
expect($sqlState->getSubclass())->toBe('001');
$sqlState = new SqlState('42S02');
expect($sqlState->getSubclass())->toBe('S02');
});
it('identifies connection errors', function () {
$connectionError = new SqlState('08001');
expect($connectionError->isConnectionError())->toBeTrue();
$otherError = new SqlState('23505');
expect($otherError->isConnectionError())->toBeFalse();
});
it('identifies constraint violations', function () {
$constraintViolation = new SqlState('23505');
expect($constraintViolation->isConstraintViolation())->toBeTrue();
$otherError = new SqlState('08001');
expect($otherError->isConstraintViolation())->toBeFalse();
});
it('identifies transaction rollbacks', function () {
$transactionRollback = new SqlState('40001');
expect($transactionRollback->isTransactionRollback())->toBeTrue();
$otherError = new SqlState('23505');
expect($otherError->isTransactionRollback())->toBeFalse();
});
it('identifies syntax errors', function () {
$syntaxError = new SqlState('42000');
expect($syntaxError->isSyntaxError())->toBeTrue();
$otherError = new SqlState('23505');
expect($otherError->isSyntaxError())->toBeFalse();
});
it('identifies driver errors', function () {
$driverError = new SqlState('HY000');
expect($driverError->isDriverError())->toBeTrue();
$otherError = new SqlState('23505');
expect($otherError->isDriverError())->toBeFalse();
});
it('identifies unique violations', function () {
$uniqueViolation = new SqlState('23505');
expect($uniqueViolation->isUniqueViolation())->toBeTrue();
$otherConstraint = new SqlState('23503');
expect($otherConstraint->isUniqueViolation())->toBeFalse();
});
it('identifies foreign key violations', function () {
$foreignKeyViolation = new SqlState('23503');
expect($foreignKeyViolation->isForeignKeyViolation())->toBeTrue();
$otherConstraint = new SqlState('23505');
expect($otherConstraint->isForeignKeyViolation())->toBeFalse();
});
it('identifies not null violations', function () {
$notNullViolation = new SqlState('23502');
expect($notNullViolation->isNotNullViolation())->toBeTrue();
$otherConstraint = new SqlState('23505');
expect($otherConstraint->isNotNullViolation())->toBeFalse();
});
it('identifies check constraint violations', function () {
$checkViolation = new SqlState('23514');
expect($checkViolation->isCheckViolation())->toBeTrue();
$otherConstraint = new SqlState('23505');
expect($otherConstraint->isCheckViolation())->toBeFalse();
});
it('identifies table not found errors', function () {
$tableNotFound = new SqlState('42S02');
expect($tableNotFound->isTableNotFound())->toBeTrue();
$otherError = new SqlState('42S22');
expect($otherError->isTableNotFound())->toBeFalse();
});
it('identifies column not found errors', function () {
$columnNotFound = new SqlState('42S22');
expect($columnNotFound->isColumnNotFound())->toBeTrue();
$otherError = new SqlState('42S02');
expect($otherError->isColumnNotFound())->toBeFalse();
});
it('identifies deadlocks', function () {
$deadlock = new SqlState('40001');
expect($deadlock->isDeadlock())->toBeTrue();
$otherError = new SqlState('40002');
expect($otherError->isDeadlock())->toBeFalse();
});
it('identifies serialization failures', function () {
$serializationFailure = new SqlState('40001');
expect($serializationFailure->isSerializationFailure())->toBeTrue();
$otherError = new SqlState('40002');
expect($otherError->isSerializationFailure())->toBeFalse();
});
it('identifies connection failures', function () {
$connectionFailure = new SqlState('08001');
expect($connectionFailure->isConnectionFailure())->toBeTrue();
$otherConnectionError = new SqlState('08003');
expect($otherConnectionError->isConnectionFailure())->toBeFalse();
});
it('identifies connection does not exist', function () {
$connectionDoesNotExist = new SqlState('08003');
expect($connectionDoesNotExist->isConnectionDoesNotExist())->toBeTrue();
$otherConnectionError = new SqlState('08001');
expect($otherConnectionError->isConnectionDoesNotExist())->toBeFalse();
});
it('identifies connection rejected', function () {
$connectionRejected = new SqlState('08004');
expect($connectionRejected->isConnectionRejected())->toBeTrue();
$otherConnectionError = new SqlState('08001');
expect($otherConnectionError->isConnectionRejected())->toBeFalse();
});
it('compares SQLSTATE codes for equality', function () {
$sqlState1 = new SqlState('23505');
$sqlState2 = new SqlState('23505');
$sqlState3 = new SqlState('23503');
expect($sqlState1->equals($sqlState2))->toBeTrue();
expect($sqlState1->equals($sqlState3))->toBeFalse();
});
it('converts to string representation', function () {
$sqlState = new SqlState('23505');
expect($sqlState->toString())->toBe('23505');
expect((string) $sqlState)->toBe('23505');
expect($sqlState->__toString())->toBe('23505');
});
it('handles PostgreSQL-specific codes', function () {
$pgUniqueViolation = new SqlState('23505');
expect($pgUniqueViolation->isUniqueViolation())->toBeTrue();
expect($pgUniqueViolation->isConstraintViolation())->toBeTrue();
$pgTableNotFound = new SqlState('42P01');
expect($pgTableNotFound->isSyntaxError())->toBeTrue();
});
it('handles MySQL-specific codes', function () {
$mysqlTableNotFound = new SqlState('42S02');
expect($mysqlTableNotFound->isTableNotFound())->toBeTrue();
expect($mysqlTableNotFound->isSyntaxError())->toBeTrue();
$mysqlColumnNotFound = new SqlState('42S22');
expect($mysqlColumnNotFound->isColumnNotFound())->toBeTrue();
});
it('handles driver-specific codes', function () {
$driverError = new SqlState('HY000');
expect($driverError->isDriverError())->toBeTrue();
expect($driverError->getClass())->toBe('HY');
});
});

View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
use App\Framework\Database\ValueObjects\TableName;
use App\Framework\Database\ValueObjects\ColumnName;
describe('TableName', function () {
it('creates table name from string', function () {
$tableName = TableName::fromString('users');
expect($tableName->value)->toBe('users');
expect($tableName->toString())->toBe('users');
});
it('validates table name format', function () {
// Valid names
expect(fn() => TableName::fromString('users'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => TableName::fromString('user_profiles'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => TableName::fromString('_temp_table'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => TableName::fromString('table123'))->not->toThrow(\InvalidArgumentException::class);
// Invalid names
expect(fn() => TableName::fromString(''))
->toThrow(\InvalidArgumentException::class, 'cannot be empty');
expect(fn() => TableName::fromString('123invalid'))
->toThrow(\InvalidArgumentException::class, 'must start with a letter or underscore');
expect(fn() => TableName::fromString('invalid-name'))
->toThrow(\InvalidArgumentException::class, 'can only contain letters, numbers, and underscores');
expect(fn() => TableName::fromString('invalid name'))
->toThrow(\InvalidArgumentException::class, 'can only contain letters, numbers, and underscores');
});
it('validates maximum length', function () {
$validName = str_repeat('a', 64);
expect(fn() => TableName::fromString($validName))->not->toThrow(\InvalidArgumentException::class);
$tooLong = str_repeat('a', 65);
expect(fn() => TableName::fromString($tooLong))
->toThrow(\InvalidArgumentException::class, 'exceeds maximum length');
});
it('detects SQL injection attempts', function () {
// Note: Most SQL injection attempts are caught by format validation first,
// since they contain invalid characters (quotes, hyphens, spaces, etc.)
// These all fail format validation (contain invalid characters)
expect(fn() => TableName::fromString("users'; DROP TABLE users--"))
->toThrow(\InvalidArgumentException::class);
expect(fn() => TableName::fromString('users UNION SELECT'))
->toThrow(\InvalidArgumentException::class);
expect(fn() => TableName::fromString('users/*comment*/'))
->toThrow(\InvalidArgumentException::class);
});
it('quotes table names for different platforms', function () {
$tableName = TableName::fromString('users');
expect($tableName->quoted('mysql'))->toBe('`users`');
expect($tableName->quoted('postgresql'))->toBe('"users"');
expect($tableName->quoted('postgres'))->toBe('"users"');
expect($tableName->quoted('pgsql'))->toBe('"users"');
expect($tableName->quoted('sqlite'))->toBe('"users"');
expect($tableName->quoted())->toBe('`users`'); // Default MySQL
expect($tableName->quoted('unknown'))->toBe('`users`'); // Fallback to MySQL
});
it('compares table names for equality', function () {
$table1 = TableName::fromString('users');
$table2 = TableName::fromString('users');
$table3 = TableName::fromString('USERS'); // Different case
$table4 = TableName::fromString('orders');
expect($table1->equals($table2))->toBeTrue();
expect($table1->equals($table3))->toBeTrue(); // Case-insensitive
expect($table1->equals($table4))->toBeFalse();
});
it('matches table name patterns', function () {
$table = TableName::fromString('user_profiles');
expect($table->matches('user_*'))->toBeTrue();
expect($table->matches('*_profiles'))->toBeTrue();
expect($table->matches('user_profiles'))->toBeTrue();
expect($table->matches('order_*'))->toBeFalse();
});
it('detects reserved SQL keywords', function () {
$table = TableName::fromString('users');
expect($table->isReservedKeyword())->toBeFalse();
$reservedTable = TableName::fromString('_select'); // Starts with underscore to be valid
// Note: 'select' itself is reserved, but '_select' is not
expect($reservedTable->isReservedKeyword())->toBeFalse();
});
it('converts to lowercase', function () {
$table = TableName::fromString('UserProfiles');
expect($table->toLower())->toBe('userprofiles');
});
it('checks for table name prefix', function () {
$table = TableName::fromString('wp_users');
expect($table->hasPrefix('wp_'))->toBeTrue();
expect($table->hasPrefix('drupal_'))->toBeFalse();
});
it('adds prefix to table name', function () {
$table = TableName::fromString('users');
$prefixed = $table->withPrefix('wp_');
expect($prefixed->value)->toBe('wp_users');
expect($prefixed->toString())->toBe('wp_users');
// Original unchanged (immutable)
expect($table->value)->toBe('users');
});
it('removes prefix from table name', function () {
$table = TableName::fromString('wp_users');
$unprefixed = $table->withoutPrefix('wp_');
expect($unprefixed->value)->toBe('users');
// Removing non-existent prefix returns same table
$same = $unprefixed->withoutPrefix('drupal_');
expect($same->value)->toBe('users');
});
it('converts to string via magic method', function () {
$table = TableName::fromString('users');
expect((string) $table)->toBe('users');
});
it('is immutable', function () {
$original = TableName::fromString('users');
$prefixed = $original->withPrefix('wp_');
// Original remains unchanged
expect($original->value)->toBe('users');
// New instance created when prefix is added
expect($prefixed)->not->toBe($original);
expect($prefixed->value)->toBe('wp_users');
// Removing non-existent prefix returns same instance (optimization)
$unprefixed = $original->withoutPrefix('wp_');
expect($unprefixed->value)->toBe('users');
// But removing actual prefix creates new instance
$prefixedTable = TableName::fromString('wp_users');
$removedPrefix = $prefixedTable->withoutPrefix('wp_');
expect($removedPrefix)->not->toBe($prefixedTable);
expect($removedPrefix->value)->toBe('users');
});
it('handles edge cases correctly', function () {
// Single character (valid if letter or underscore)
expect(fn() => TableName::fromString('a'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => TableName::fromString('_'))->not->toThrow(\InvalidArgumentException::class);
// Underscore-only prefix
$table = TableName::fromString('_temp_users');
expect($table->value)->toBe('_temp_users');
// Numbers in name (but not at start)
$table = TableName::fromString('table_123_test');
expect($table->value)->toBe('table_123_test');
});
});