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,66 @@
<?php
declare(strict_types=1);
use App\Framework\Database\Exception\EntityNotFoundException;
use App\Framework\Database\ValueObjects\SqlState;
use App\Framework\Exception\Core\DatabaseErrorCode;
describe('EntityNotFoundException', function () {
it('creates exception by ID with new pattern', function () {
$exception = EntityNotFoundException::byId('App\Domain\User', 123);
expect($exception->getMessage())->toContain('App\Domain\User')
->and($exception->getMessage())->toContain('123')
->and($exception->getErrorCode())->toBe(DatabaseErrorCode::ENTITY_NOT_FOUND)
->and($exception->getContext()->data['entity_class'])->toBe('App\Domain\User')
->and($exception->getContext()->data['entity_id'])->toBe('123')
->and($exception->getContext()->data['lookup_type'])->toBe('by_id')
->and($exception->getCode())->toBe(404);
});
it('creates exception by criteria with new pattern', function () {
$criteria = ['email' => 'test@example.com', 'status' => 'active'];
$exception = EntityNotFoundException::byCriteria('App\Domain\User', $criteria);
expect($exception->getMessage())->toContain('App\Domain\User')
->and($exception->getErrorCode())->toBe(DatabaseErrorCode::ENTITY_NOT_FOUND)
->and($exception->getContext()->data['entity_class'])->toBe('App\Domain\User')
->and($exception->getContext()->data['lookup_type'])->toBe('by_criteria')
->and($exception->getContext()->debug['criteria'])->toBe($criteria)
->and($exception->getCode())->toBe(404);
});
it('preserves previous exception chain', function () {
$pdoException = new \PDOException('Row not found');
$exception = EntityNotFoundException::byId('App\Domain\Product', 789, $pdoException);
expect($exception->getPrevious())->toBe($pdoException);
});
it('is categorized as database error', function () {
$exception = EntityNotFoundException::byId('App\Domain\Category', 1);
expect($exception->isCategory('DB'))->toBeTrue()
->and($exception->getErrorCode()->getCategory())->toBe('DB');
});
it('is not recoverable', function () {
$exception = EntityNotFoundException::byId('App\Domain\Tag', 1);
expect($exception->getErrorCode()->isRecoverable())->toBeFalse();
});
it('has appropriate severity', function () {
$exception = EntityNotFoundException::byId('App\Domain\Comment', 1);
expect($exception->getErrorCode()->getSeverity()->value)->toBe('warning');
});
it('provides recovery hint', function () {
$exception = EntityNotFoundException::byId('App\Domain\Post', 1);
$hint = $exception->getErrorCode()->getRecoveryHint();
expect($hint)->toContain('Verify entity ID');
});
});

View File

@@ -0,0 +1,430 @@
<?php
declare(strict_types=1);
use App\Framework\Database\Exception\ConnectionFailedException;
use App\Framework\Database\Exception\ConstraintViolationException;
use App\Framework\Database\Exception\DeadlockException;
use App\Framework\Database\Exception\PoolExhaustedException;
use App\Framework\Database\Exception\QueryExecutionException;
use App\Framework\Database\Exception\QuerySyntaxException;
use App\Framework\Database\ValueObjects\SqlState;
use App\Framework\Exception\Core\DatabaseErrorCode;
describe('SQLSTATE Exception Classes', function () {
describe('ConnectionFailedException', function () {
it('creates exception for connection failure', function () {
$sqlState = new SqlState('08001');
$exception = ConnectionFailedException::cannotConnect(
'localhost',
'testdb',
$sqlState,
'Connection refused'
);
expect($exception->getMessage())->toContain('localhost')
->and($exception->getMessage())->toContain('testdb')
->and($exception->getSqlState())->toBe($sqlState)
->and($exception->getErrorCode())->toBe(DatabaseErrorCode::CONNECTION_FAILED)
->and($exception->getContext()->data['host'])->toBe('localhost')
->and($exception->getContext()->data['database'])->toBe('testdb');
});
it('creates exception for server rejection', function () {
$sqlState = new SqlState('08004');
$exception = ConnectionFailedException::serverRejectedConnection(
'db.example.com',
'dbuser',
$sqlState,
'Invalid credentials'
);
expect($exception->getMessage())->toContain('db.example.com')
->and($exception->getMessage())->toContain('dbuser')
->and($exception->getSqlState()->code)->toBe('08004')
->and($exception->getContext()->data['host'])->toBe('db.example.com')
->and($exception->getContext()->data['username'])->toBe('dbuser');
});
it('creates exception for connection timeout', function () {
$sqlState = new SqlState('HY000');
$exception = ConnectionFailedException::connectionTimeout(
'remote-db.com',
30,
$sqlState
);
expect($exception->getMessage())->toContain('30 seconds')
->and($exception->getContext()->data['timeout_seconds'])->toBe(30)
->and($exception->getContext()->data['host'])->toBe('remote-db.com');
});
it('creates exception for too many connections', function () {
$sqlState = new SqlState('HY000');
$exception = ConnectionFailedException::tooManyConnections(
'localhost',
100,
$sqlState
);
expect($exception->getMessage())->toContain('100')
->and($exception->getContext()->data['max_connections'])->toBe(100)
->and($exception->getContext()->data['host'])->toBe('localhost');
});
});
describe('ConstraintViolationException', function () {
it('creates exception for unique violation', function () {
$sqlState = new SqlState('23505');
$exception = ConstraintViolationException::uniqueViolation(
'users',
'email',
'test@example.com',
$sqlState
);
expect($exception->getMessage())->toContain('email')
->and($exception->getMessage())->toContain('users')
->and($exception->getSqlState())->toBe($sqlState)
->and($exception->getErrorCode())->toBe(DatabaseErrorCode::CONSTRAINT_VIOLATION)
->and($exception->getContext()->data['table'])->toBe('users')
->and($exception->getContext()->data['column'])->toBe('email');
});
it('creates exception for foreign key violation', function () {
$sqlState = new SqlState('23503');
$exception = ConstraintViolationException::foreignKeyViolation(
'orders',
'user_id',
'users',
999,
$sqlState
);
expect($exception->getMessage())->toContain('orders')
->and($exception->getMessage())->toContain('users')
->and($exception->getContext()->data['table'])->toBe('orders')
->and($exception->getContext()->data['foreign_key'])->toBe('user_id')
->and($exception->getContext()->data['referenced_table'])->toBe('users');
});
it('creates exception for not null violation', function () {
$sqlState = new SqlState('23502');
$exception = ConstraintViolationException::notNullViolation(
'products',
'price',
$sqlState
);
expect($exception->getMessage())->toContain('price')
->and($exception->getMessage())->toContain('products')
->and($exception->getContext()->data['table'])->toBe('products')
->and($exception->getContext()->data['column'])->toBe('price');
});
it('creates exception for composite unique violation', function () {
$sqlState = new SqlState('23505');
$exception = ConstraintViolationException::compositeUniqueViolation(
'user_permissions',
['user_id', 'permission_id'],
[123, 456],
$sqlState
);
expect($exception->getMessage())->toContain('user_id')
->and($exception->getMessage())->toContain('permission_id')
->and($exception->getContext()->data['columns'])->toBe(['user_id', 'permission_id'])
->and($exception->getContext()->debug['duplicate_values'])->toBe(['123', '456']);
});
});
describe('DeadlockException', function () {
it('creates exception for basic deadlock detection', function () {
$sqlState = new SqlState('40001');
$exception = DeadlockException::detected($sqlState);
expect($exception->getMessage())->toContain('Deadlock detected')
->and($exception->getSqlState())->toBe($sqlState)
->and($exception->getErrorCode())->toBe(DatabaseErrorCode::DEADLOCK_DETECTED)
->and($exception->getContext()->data['recoverable'])->toBeTrue()
->and($exception->getContext()->data['retry_recommended'])->toBeTrue();
});
it('creates exception with transaction details', function () {
$sqlState = new SqlState('40001');
$exception = DeadlockException::inTransaction(
'tx_12345',
$sqlState,
attemptNumber: 2
);
expect($exception->getMessage())->toContain('tx_12345')
->and($exception->getMessage())->toContain('attempt 2')
->and($exception->getContext()->data['transaction_id'])->toBe('tx_12345')
->and($exception->getContext()->data['attempt_number'])->toBe(2);
});
it('creates exception for max retries exceeded', function () {
$sqlState = new SqlState('40001');
$exception = DeadlockException::maxRetriesExceeded(
3,
$sqlState,
'tx_99999'
);
expect($exception->getMessage())->toContain('3 retry attempts')
->and($exception->getContext()->data['recoverable'])->toBeFalse()
->and($exception->getContext()->data['retry_recommended'])->toBeFalse()
->and($exception->getContext()->data['max_attempts'])->toBe(3);
});
it('calculates retry delay with exponential backoff', function () {
$delay1 = DeadlockException::getRetryDelay(1);
$delay2 = DeadlockException::getRetryDelay(2);
$delay3 = DeadlockException::getRetryDelay(3);
// Test basic properties of exponential backoff (jitter makes exact comparisons unreliable)
expect($delay1)->toBeGreaterThan(0)
->and($delay2)->toBeGreaterThan(0)
->and($delay3)->toBeGreaterThan(0)
->and($delay3)->toBeLessThanOrEqual(5000); // Max 5 seconds
});
it('determines retry eligibility', function () {
expect(DeadlockException::shouldRetry(1, 3))->toBeTrue()
->and(DeadlockException::shouldRetry(2, 3))->toBeTrue()
->and(DeadlockException::shouldRetry(3, 3))->toBeFalse()
->and(DeadlockException::shouldRetry(4, 3))->toBeFalse();
});
});
describe('QuerySyntaxException', function () {
it('creates exception for basic syntax error', function () {
$sqlState = new SqlState('42601');
$exception = QuerySyntaxException::forQuery(
'SELCT * FROM users',
$sqlState,
'Syntax error near "SELCT"'
);
expect($exception->getMessage())->toContain('Syntax error')
->and($exception->getSqlState())->toBe($sqlState)
->and($exception->getErrorCode())->toBe(DatabaseErrorCode::QUERY_SYNTAX_ERROR);
});
it('creates exception for table not found', function () {
$sqlState = new SqlState('42P01');
$exception = QuerySyntaxException::tableNotFound(
'nonexistent_table',
$sqlState
);
expect($exception->getMessage())->toContain('nonexistent_table')
->and($exception->getContext()->data['table_name'])->toBe('nonexistent_table');
});
it('creates exception for column not found', function () {
$sqlState = new SqlState('42703');
$exception = QuerySyntaxException::columnNotFound(
'invalid_column',
'users',
$sqlState
);
expect($exception->getMessage())->toContain('users.invalid_column')
->and($exception->getContext()->data['column_name'])->toBe('invalid_column')
->and($exception->getContext()->data['table_name'])->toBe('users');
});
it('creates exception for insufficient privileges', function () {
$sqlState = new SqlState('42501');
$exception = QuerySyntaxException::insufficientPrivileges(
'DELETE',
'sensitive_data',
$sqlState
);
expect($exception->getMessage())->toContain('DELETE')
->and($exception->getMessage())->toContain('sensitive_data')
->and($exception->getContext()->data['operation'])->toBe('DELETE')
->and($exception->getContext()->data['object_name'])->toBe('sensitive_data');
});
it('truncates long queries for safety', function () {
$longQuery = str_repeat('SELECT * FROM users WHERE id = 1 AND ', 20) . 'name = "test"';
$sqlState = new SqlState('42601');
$exception = QuerySyntaxException::forQuery($longQuery, $sqlState);
expect(strlen($exception->getContext()->data['query']))->toBeLessThanOrEqual(220); // 200 + " (truncated)"
});
});
describe('QueryExecutionException', function () {
it('creates exception for data type mismatch', function () {
$sqlState = new SqlState('22P02');
$exception = QueryExecutionException::forDataTypeMismatch(
'age',
'integer',
'not_a_number',
$sqlState
);
expect($exception->getMessage())->toContain('age')
->and($exception->getMessage())->toContain('integer')
->and($exception->getContext()->data['column'])->toBe('age')
->and($exception->getContext()->data['expected_type'])->toBe('integer');
});
it('creates exception for data truncation', function () {
$sqlState = new SqlState('22001');
$exception = QueryExecutionException::forDataTruncation(
'description',
255,
500,
$sqlState
);
expect($exception->getMessage())->toContain('description')
->and($exception->getMessage())->toContain('255')
->and($exception->getContext()->data['max_length'])->toBe(255)
->and($exception->getContext()->data['actual_length'])->toBe(500);
});
it('creates exception for division by zero', function () {
$sqlState = new SqlState('22012');
$exception = QueryExecutionException::forDivisionByZero(
'total / count',
$sqlState
);
expect($exception->getMessage())->toContain('Division by zero')
->and($exception->getContext()->data['expression'])->toBe('total / count');
});
it('sanitizes sensitive values in context', function () {
$sqlState = new SqlState('22P02');
$exception = QueryExecutionException::forDataTypeMismatch(
'password',
'string',
'my-secret-password-123',
$sqlState
);
// Value is stored in debug (not data) for security
// Note: sanitizeValue only truncates strings > 100 chars
expect($exception->getContext()->debug)->toHaveKey('actual_value');
expect($exception->getContext()->debug['actual_value'])->toBeString();
});
});
describe('PoolExhaustedException', function () {
it('creates exception when no connections available', function () {
$exception = PoolExhaustedException::noConnectionsAvailable(
currentConnections: 10,
maxConnections: 10,
connectionsInUse: 10
);
expect($exception->getMessage())->toContain('10/10')
->and($exception->getErrorCode())->toBe(DatabaseErrorCode::POOL_EXHAUSTED)
->and($exception->getContext()->data['current_connections'])->toBe(10)
->and($exception->getContext()->data['max_connections'])->toBe(10)
->and($exception->getContext()->data['connections_in_use'])->toBe(10)
->and($exception->getContext()->data['free_connections'])->toBe(0)
->and($exception->getContext()->data['utilization_percent'])->toBe(100.0);
});
it('creates exception when all connections unhealthy', function () {
$exception = PoolExhaustedException::allConnectionsUnhealthy(
maxConnections: 5,
unhealthyConnections: 5
);
expect($exception->getMessage())->toContain('unhealthy')
->and($exception->getContext()->data['health_status'])->toBe('critical');
});
it('provides retry recommendations', function () {
$exception = PoolExhaustedException::withRetryRecommendation(
currentConnections: 8,
maxConnections: 10,
recommendedRetryDelayMs: 500
);
expect($exception->getMessage())->toContain('retry after 500ms')
->and($exception->getRetryAfter())->toBe(1) // Rounded up to 1 second
->and($exception->getContext()->data['recommended_retry_delay_ms'])->toBe(500);
});
it('provides context-aware recommended actions', function () {
$highUtilization = PoolExhaustedException::noConnectionsAvailable(
currentConnections: 100,
maxConnections: 100,
connectionsInUse: 100
);
$unhealthyConnections = PoolExhaustedException::allConnectionsUnhealthy(
maxConnections: 10,
unhealthyConnections: 10
);
expect($highUtilization->getRecommendedAction())->toContain('Increase max_connections');
expect($unhealthyConnections->getRecommendedAction())->toContain('Check database server health');
});
});
describe('Exception Integration', function () {
it('maintains SQLSTATE through exception hierarchy', function () {
$sqlState = new SqlState('23505');
$exception = ConstraintViolationException::uniqueViolation(
'users',
'email',
'test@example.com',
$sqlState
);
expect($exception->getSqlState())->toBe($sqlState)
->and($exception->getSqlState()->code)->toBe('23505')
->and($exception->getSqlState()->isUniqueViolation())->toBeTrue()
->and($exception->getSqlState()->isConstraintViolation())->toBeTrue();
});
it('includes proper error categorization', function () {
$connectionError = ConnectionFailedException::cannotConnect(
'localhost',
'testdb',
new SqlState('08001')
);
$syntaxError = QuerySyntaxException::tableNotFound(
'missing_table',
new SqlState('42P01')
);
$deadlock = DeadlockException::detected(new SqlState('40001'));
expect($connectionError->isCategory('DB'))->toBeTrue();
expect($syntaxError->isCategory('DB'))->toBeTrue();
expect($deadlock->isCategory('DB'))->toBeTrue();
expect($connectionError->getErrorCode())->toBe(DatabaseErrorCode::CONNECTION_FAILED);
expect($syntaxError->getErrorCode())->toBe(DatabaseErrorCode::QUERY_SYNTAX_ERROR);
expect($deadlock->getErrorCode())->toBe(DatabaseErrorCode::DEADLOCK_DETECTED);
});
it('preserves previous exception chain', function () {
$pdoException = new \PDOException('Original PDO error');
$sqlState = new SqlState('08001');
$exception = ConnectionFailedException::cannotConnect(
'localhost',
'testdb',
$sqlState,
'Connection failed',
$pdoException
);
expect($exception->getPrevious())->toBe($pdoException);
});
});
});

View File

@@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
use App\Framework\Database\Services\SqlStateErrorMapper;
use App\Framework\Database\ValueObjects\SqlState;
use App\Framework\Exception\Core\DatabaseErrorCode;
describe('SqlStateErrorMapper Service', function () {
beforeEach(function () {
$this->mapper = new SqlStateErrorMapper();
});
describe('mapToErrorCode()', function () {
it('maps connection errors correctly', function () {
$testCases = [
'08001' => DatabaseErrorCode::CONNECTION_FAILED,
'08003' => DatabaseErrorCode::CONNECTION_FAILED,
'08004' => DatabaseErrorCode::CONNECTION_FAILED,
'08006' => DatabaseErrorCode::CONNECTION_FAILED,
];
foreach ($testCases as $sqlStateCode => $expectedErrorCode) {
$sqlState = new SqlState($sqlStateCode);
$errorCode = $this->mapper->mapToErrorCode($sqlState);
expect($errorCode)->toBe($expectedErrorCode);
}
});
it('maps constraint violations correctly', function () {
$codes = ['23000', '23502', '23503', '23505', '23514'];
foreach ($codes as $code) {
$sqlState = new SqlState($code);
$errorCode = $this->mapper->mapToErrorCode($sqlState);
expect($errorCode)->toBe(DatabaseErrorCode::CONSTRAINT_VIOLATION);
}
});
it('maps transaction errors correctly', function () {
$deadlock = new SqlState('40001');
expect($this->mapper->mapToErrorCode($deadlock))
->toBe(DatabaseErrorCode::DEADLOCK_DETECTED);
$transactionError = new SqlState('40002');
expect($this->mapper->mapToErrorCode($transactionError))
->toBe(DatabaseErrorCode::TRANSACTION_FAILED);
});
it('maps syntax errors correctly', function () {
$codes = ['42000', '42501', '42601', '42P01', '42703', '42S02', '42S22'];
foreach ($codes as $code) {
$sqlState = new SqlState($code);
$errorCode = $this->mapper->mapToErrorCode($sqlState);
expect($errorCode)->toBe(DatabaseErrorCode::QUERY_SYNTAX_ERROR);
}
});
it('maps data exceptions correctly', function () {
$codes = ['22001', '22003', '22007', '22012', '22P02'];
foreach ($codes as $code) {
$sqlState = new SqlState($code);
$errorCode = $this->mapper->mapToErrorCode($sqlState);
expect($errorCode)->toBe(DatabaseErrorCode::QUERY_EXECUTION_FAILED);
}
});
it('falls back to class-based mapping for unknown codes', function () {
// Unknown code in connection class
$unknownConnection = new SqlState('08999');
expect($this->mapper->mapToErrorCode($unknownConnection))
->toBe(DatabaseErrorCode::CONNECTION_FAILED);
// Unknown code in constraint class
$unknownConstraint = new SqlState('23999');
expect($this->mapper->mapToErrorCode($unknownConstraint))
->toBe(DatabaseErrorCode::CONSTRAINT_VIOLATION);
// Unknown code in transaction class
$unknownTransaction = new SqlState('40999');
expect($this->mapper->mapToErrorCode($unknownTransaction))
->toBe(DatabaseErrorCode::TRANSACTION_FAILED);
// Unknown code in syntax class
$unknownSyntax = new SqlState('42999');
expect($this->mapper->mapToErrorCode($unknownSyntax))
->toBe(DatabaseErrorCode::QUERY_SYNTAX_ERROR);
});
});
describe('getHumanReadableMessage()', function () {
it('provides user-friendly messages for connection errors', function () {
$sqlState = new SqlState('08001');
$message = $this->mapper->getHumanReadableMessage($sqlState);
expect($message)->toContain('Unable to establish database connection');
expect($message)->toContain('Please check server status');
});
it('provides user-friendly messages for constraint violations', function () {
$uniqueViolation = new SqlState('23505');
$message = $this->mapper->getHumanReadableMessage($uniqueViolation);
expect($message)->toContain('Duplicate entry');
expect($message)->toContain('already exists');
});
it('provides user-friendly messages for deadlocks', function () {
$deadlock = new SqlState('40001');
$message = $this->mapper->getHumanReadableMessage($deadlock);
expect($message)->toContain('Deadlock detected');
expect($message)->toContain('retry');
});
it('provides user-friendly messages for syntax errors', function () {
$tableNotFound = new SqlState('42S02');
$message = $this->mapper->getHumanReadableMessage($tableNotFound);
expect($message)->toContain('table not found');
expect($message)->toContain('schema');
});
it('provides generic message for unknown codes', function () {
$unknownCode = new SqlState('99999');
$message = $this->mapper->getHumanReadableMessage($unknownCode);
expect($message)->toContain('Database error occurred');
expect($message)->toContain('99999');
});
it('includes SQLSTATE code in fallback messages', function () {
$unknownCode = new SqlState('88888');
$message = $this->mapper->getHumanReadableMessage($unknownCode);
expect($message)->toContain('88888');
});
});
describe('getTechnicalDetails()', function () {
it('provides complete technical information', function () {
$sqlState = new SqlState('23505');
$details = $this->mapper->getTechnicalDetails($sqlState);
expect($details)->toHaveKey('sqlstate');
expect($details)->toHaveKey('class');
expect($details)->toHaveKey('subclass');
expect($details)->toHaveKey('category');
expect($details)->toHaveKey('error_code');
expect($details['sqlstate'])->toBe('23505');
expect($details['class'])->toBe('23');
expect($details['subclass'])->toBe('505');
expect($details['category'])->toBe('Integrity Constraint Violation');
expect($details['error_code'])->toBe('DB003');
});
it('categorizes different SQLSTATE classes', function () {
$testCases = [
['code' => '08001', 'category' => 'Connection Exception'],
['code' => '23505', 'category' => 'Integrity Constraint Violation'],
['code' => '40001', 'category' => 'Transaction Rollback'],
['code' => '42000', 'category' => 'Syntax Error or Access Violation'],
['code' => '22001', 'category' => 'Data Exception'],
];
foreach ($testCases as $testCase) {
$sqlState = new SqlState($testCase['code']);
$details = $this->mapper->getTechnicalDetails($sqlState);
expect($details['category'])->toBe($testCase['category']);
}
});
});
describe('isRetryable()', function () {
it('identifies retryable errors', function () {
$retryableCodes = [
'40001', // Deadlock
'08001', // Connection failed
'08003', // Connection does not exist
'08006', // Connection failure during transaction
'40002', // Transaction integrity constraint
'40003', // Statement completion unknown
];
foreach ($retryableCodes as $code) {
$sqlState = new SqlState($code);
expect($this->mapper->isRetryable($sqlState))
->toBeTrue("SQLSTATE {$code} should be retryable");
}
});
it('identifies non-retryable errors', function () {
$nonRetryableCodes = [
'23505', // Unique constraint
'42000', // Syntax error
'22001', // String data truncation
];
foreach ($nonRetryableCodes as $code) {
$sqlState = new SqlState($code);
expect($this->mapper->isRetryable($sqlState))
->toBeFalse("SQLSTATE {$code} should not be retryable");
}
});
});
describe('getRetryDelay()', function () {
it('recommends immediate retry for deadlocks', function () {
$deadlock = new SqlState('40001');
$delay = $this->mapper->getRetryDelay($deadlock);
expect($delay)->toBe(1); // 1 second for quick retry
});
it('recommends longer delay for connection errors', function () {
$connectionError = new SqlState('08001');
$delay = $this->mapper->getRetryDelay($connectionError);
expect($delay)->toBe(30); // 30 seconds for connection recovery
});
it('recommends moderate delay for transaction errors', function () {
$transactionError = new SqlState('40002');
$delay = $this->mapper->getRetryDelay($transactionError);
expect($delay)->toBe(5); // 5 seconds for transaction retry
});
it('returns null for non-retryable errors', function () {
$syntaxError = new SqlState('42000');
$delay = $this->mapper->getRetryDelay($syntaxError);
expect($delay)->toBeNull();
});
it('provides default delay for retryable errors without specific mapping', function () {
$unknownRetryable = new SqlState('08999');
// This should map to CONNECTION_FAILED via class-based fallback
if ($this->mapper->isRetryable($unknownRetryable)) {
$delay = $this->mapper->getRetryDelay($unknownRetryable);
expect($delay)->toBeGreaterThan(0);
}
});
});
describe('PostgreSQL-specific behavior', function () {
it('handles PostgreSQL unique violation', function () {
$pgUniqueViolation = new SqlState('23505');
expect($this->mapper->mapToErrorCode($pgUniqueViolation))
->toBe(DatabaseErrorCode::CONSTRAINT_VIOLATION);
$message = $this->mapper->getHumanReadableMessage($pgUniqueViolation);
expect($message)->toContain('Duplicate entry');
});
it('handles PostgreSQL table not found', function () {
$pgTableNotFound = new SqlState('42P01');
expect($this->mapper->mapToErrorCode($pgTableNotFound))
->toBe(DatabaseErrorCode::QUERY_SYNTAX_ERROR);
$message = $this->mapper->getHumanReadableMessage($pgTableNotFound);
expect($message)->toContain('table does not exist');
});
});
describe('MySQL-specific behavior', function () {
it('handles MySQL table not found', function () {
$mysqlTableNotFound = new SqlState('42S02');
expect($this->mapper->mapToErrorCode($mysqlTableNotFound))
->toBe(DatabaseErrorCode::QUERY_SYNTAX_ERROR);
$message = $this->mapper->getHumanReadableMessage($mysqlTableNotFound);
expect($message)->toContain('table not found');
});
it('handles MySQL column not found', function () {
$mysqlColumnNotFound = new SqlState('42S22');
expect($this->mapper->mapToErrorCode($mysqlColumnNotFound))
->toBe(DatabaseErrorCode::QUERY_SYNTAX_ERROR);
$message = $this->mapper->getHumanReadableMessage($mysqlColumnNotFound);
expect($message)->toContain('column not found');
});
});
describe('comprehensive error coverage', function () {
it('covers all major SQLSTATE classes', function () {
$classes = [
'08' => 'Connection',
'23' => 'Constraint',
'40' => 'Transaction',
'42' => 'Syntax',
'22' => 'Data',
];
foreach ($classes as $class => $description) {
$sqlState = new SqlState($class . '000');
$errorCode = $this->mapper->mapToErrorCode($sqlState);
expect($errorCode)->toBeInstanceOf(DatabaseErrorCode::class);
}
});
it('provides meaningful messages for all error types', function () {
$testCodes = [
'08001', '23505', '40001', '42000', '22001'
];
foreach ($testCodes as $code) {
$sqlState = new SqlState($code);
$message = $this->mapper->getHumanReadableMessage($sqlState);
expect($message)->not->toBeEmpty();
expect(strlen($message))->toBeGreaterThan(20);
}
});
});
});

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
use App\Framework\Database\Validation\DatabaseIdentifierValidator;
describe('DatabaseIdentifierValidator', function () {
beforeEach(function () {
$this->validator = new DatabaseIdentifierValidator();
});
it('validates correct identifier format', function () {
// Valid identifiers
expect(fn() => $this->validator->validate('users', 'table'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => $this->validator->validate('user_profiles', 'table'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => $this->validator->validate('_temp_table', 'table'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => $this->validator->validate('table123', 'table'))->not->toThrow(\InvalidArgumentException::class);
});
it('throws on empty identifier', function () {
expect(fn() => $this->validator->validate('', 'table'))
->toThrow(\InvalidArgumentException::class, 'Table name cannot be empty');
expect(fn() => $this->validator->validate('', 'column'))
->toThrow(\InvalidArgumentException::class, 'Column name cannot be empty');
});
it('throws on identifier exceeding maximum length', function () {
$validLength = str_repeat('a', 64);
expect(fn() => $this->validator->validate($validLength, 'table'))->not->toThrow(\InvalidArgumentException::class);
$tooLong = str_repeat('a', 65);
expect(fn() => $this->validator->validate($tooLong, 'table'))
->toThrow(\InvalidArgumentException::class, 'exceeds maximum length');
});
it('returns maximum length constant', function () {
expect($this->validator->getMaxLength())->toBe(64);
});
it('throws on identifier not starting with letter or underscore', function () {
expect(fn() => $this->validator->validate('123invalid', 'table'))
->toThrow(\InvalidArgumentException::class, 'must start with a letter or underscore');
expect(fn() => $this->validator->validate('-invalid', 'table'))
->toThrow(\InvalidArgumentException::class, 'must start with a letter or underscore');
expect(fn() => $this->validator->validate('$invalid', 'table'))
->toThrow(\InvalidArgumentException::class, 'must start with a letter or underscore');
});
it('throws on invalid format with special characters', function () {
expect(fn() => $this->validator->validate('invalid-name', 'table'))
->toThrow(\InvalidArgumentException::class, 'can only contain letters, numbers, and underscores');
expect(fn() => $this->validator->validate('invalid name', 'table'))
->toThrow(\InvalidArgumentException::class, 'can only contain letters, numbers, and underscores');
expect(fn() => $this->validator->validate('invalid.name', 'table'))
->toThrow(\InvalidArgumentException::class, 'can only contain letters, numbers, and underscores');
expect(fn() => $this->validator->validate('invalid@name', 'table'))
->toThrow(\InvalidArgumentException::class, 'can only contain letters, numbers, and underscores');
});
it('detects SQL injection patterns', function () {
// Note: Most patterns are caught by format validation (invalid characters)
// SQL injection protection works through:
// 1. Format validation (alphanumeric + underscores only)
// 2. Additional checks for metacharacters
// SQL comments (also fails format validation due to -)
expect(fn() => $this->validator->validate('users--comment', 'table'))
->toThrow(\InvalidArgumentException::class);
// SQL comment block (also fails format validation due to /)
expect(fn() => $this->validator->validate('users/*comment*/', 'table'))
->toThrow(\InvalidArgumentException::class);
// Statement separator (also fails format validation)
expect(fn() => $this->validator->validate('users;DROP', 'table'))
->toThrow(\InvalidArgumentException::class);
// Quotes (fail format validation)
expect(fn() => $this->validator->validate("users'test", 'table'))
->toThrow(\InvalidArgumentException::class);
expect(fn() => $this->validator->validate('users"test', 'table'))
->toThrow(\InvalidArgumentException::class);
// Backslash (fails format validation)
expect(fn() => $this->validator->validate('users\\test', 'table'))
->toThrow(\InvalidArgumentException::class);
});
it('detects reserved SQL keywords', function () {
expect($this->validator->isReservedKeyword('SELECT'))->toBeTrue();
expect($this->validator->isReservedKeyword('select'))->toBeTrue(); // Case-insensitive
expect($this->validator->isReservedKeyword('INSERT'))->toBeTrue();
expect($this->validator->isReservedKeyword('UPDATE'))->toBeTrue();
expect($this->validator->isReservedKeyword('DELETE'))->toBeTrue();
expect($this->validator->isReservedKeyword('DROP'))->toBeTrue();
expect($this->validator->isReservedKeyword('CREATE'))->toBeTrue();
expect($this->validator->isReservedKeyword('ALTER'))->toBeTrue();
expect($this->validator->isReservedKeyword('TABLE'))->toBeTrue();
expect($this->validator->isReservedKeyword('INDEX'))->toBeTrue();
expect($this->validator->isReservedKeyword('FROM'))->toBeTrue();
expect($this->validator->isReservedKeyword('WHERE'))->toBeTrue();
expect($this->validator->isReservedKeyword('UNION'))->toBeTrue();
// Non-reserved words
expect($this->validator->isReservedKeyword('users'))->toBeFalse();
expect($this->validator->isReservedKeyword('email'))->toBeFalse();
expect($this->validator->isReservedKeyword('profile'))->toBeFalse();
});
it('allows special values via allowedValues parameter', function () {
// PRIMARY is a reserved keyword but still a valid identifier format
// allowedValues can be used to explicitly allow values that might be reserved
expect(fn() => $this->validator->validate('PRIMARY', 'index', ['PRIMARY']))
->not->toThrow(\InvalidArgumentException::class);
// UNIQUE can be allowed
expect(fn() => $this->validator->validate('UNIQUE', 'index', ['UNIQUE']))
->not->toThrow(\InvalidArgumentException::class);
// Note: PRIMARY and UNIQUE are valid identifier formats (they pass all validation rules)
// Reserved keyword checking is separate - use isReservedKeyword() for that
expect(fn() => $this->validator->validate('PRIMARY', 'index', []))
->not->toThrow(\InvalidArgumentException::class);
expect($this->validator->isReservedKeyword('PRIMARY'))->toBeTrue();
expect($this->validator->isReservedKeyword('UNIQUE'))->toBeTrue();
});
it('uses custom type name in error messages', function () {
try {
$this->validator->validate('', 'custom_type');
expect(false)->toBeTrue(); // Should not reach here
} catch (InvalidArgumentException $e) {
expect($e->getMessage())->toContain('Custom_type');
}
try {
$this->validator->validate('123invalid', 'my_entity');
expect(false)->toBeTrue();
} catch (InvalidArgumentException $e) {
expect($e->getMessage())->toContain('My_entity');
}
});
it('validates identifiers with underscores correctly', function () {
// Leading underscore
expect(fn() => $this->validator->validate('_temp', 'table'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => $this->validator->validate('__double', 'table'))->not->toThrow(\InvalidArgumentException::class);
// Multiple underscores
expect(fn() => $this->validator->validate('user_email_verified', 'column'))->not->toThrow(\InvalidArgumentException::class);
// Trailing underscore
expect(fn() => $this->validator->validate('temp_', 'table'))->not->toThrow(\InvalidArgumentException::class);
});
it('validates identifiers with numbers correctly', function () {
// Numbers in middle
expect(fn() => $this->validator->validate('table123test', 'table'))->not->toThrow(\InvalidArgumentException::class);
// Numbers at end
expect(fn() => $this->validator->validate('table123', 'table'))->not->toThrow(\InvalidArgumentException::class);
// Only numbers after first character
expect(fn() => $this->validator->validate('t123456789', 'table'))->not->toThrow(\InvalidArgumentException::class);
// But not at start
expect(fn() => $this->validator->validate('123table', 'table'))
->toThrow(\InvalidArgumentException::class);
});
it('is case-insensitive for reserved keywords', function () {
expect($this->validator->isReservedKeyword('select'))->toBeTrue();
expect($this->validator->isReservedKeyword('SELECT'))->toBeTrue();
expect($this->validator->isReservedKeyword('SeLeCt'))->toBeTrue();
expect($this->validator->isReservedKeyword('table'))->toBeTrue();
expect($this->validator->isReservedKeyword('TABLE'))->toBeTrue();
});
it('validates identifiers that contain SQL keywords as substrings', function () {
// Valid identifiers can contain SQL keywords as substrings
// This is fine because they're alphanumeric and will be quoted in SQL
expect(fn() => $this->validator->validate('usersunion', 'table'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => $this->validator->validate('updated_at', 'column'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => $this->validator->validate('created_at', 'column'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => $this->validator->validate('deleted_at', 'column'))->not->toThrow(\InvalidArgumentException::class);
});
it('validates edge cases', function () {
// Single character
expect(fn() => $this->validator->validate('a', 'table'))->not->toThrow(\InvalidArgumentException::class);
expect(fn() => $this->validator->validate('_', 'table'))->not->toThrow(\InvalidArgumentException::class);
// Exactly 64 characters (max length)
$maxLength = str_repeat('a', 64);
expect(fn() => $this->validator->validate($maxLength, 'table'))->not->toThrow(\InvalidArgumentException::class);
});
it('provides contextual error messages', function () {
try {
$this->validator->validate('', 'table');
} catch (InvalidArgumentException $e) {
expect($e->getMessage())->toContain('Table');
expect($e->getMessage())->toContain('cannot be empty');
}
try {
$this->validator->validate('', 'column');
} catch (InvalidArgumentException $e) {
expect($e->getMessage())->toContain('Column');
expect($e->getMessage())->toContain('cannot be empty');
}
try {
$this->validator->validate('', 'index');
} catch (InvalidArgumentException $e) {
expect($e->getMessage())->toContain('Index');
expect($e->getMessage())->toContain('cannot be empty');
}
});
});

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');
});
});