diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 93c7b1a..6308093 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -10,8 +10,11 @@ jobs: - uses: actions/checkout@v3 - uses: php-actions/composer@v6 + with: + php_version: "8.2" - name: PHPUnit Tests - uses: php-actions/phpunit@v3 + uses: php-actions/phpunit@v4 with: - configuration: phpunit.xml \ No newline at end of file + configuration: phpunit.xml + php_version: "8.2" diff --git a/lib/Abstracts/JunctionTable.php b/lib/Abstracts/JunctionTable.php index dd05f48..882821f 100644 --- a/lib/Abstracts/JunctionTable.php +++ b/lib/Abstracts/JunctionTable.php @@ -104,7 +104,7 @@ public function getUnprefixedName(): string */ public function getLeftColumnName(): string { - return $this->tableSchemaService->getJunctionColumnNameFromTable($this->leftTable); + return $this->tableSchemaService->getJunctionColumnNameFromTableUncached($this->leftTable); } /** @@ -114,7 +114,7 @@ public function getLeftColumnName(): string */ public function getRightColumnName(): string { - return $this->tableSchemaService->getJunctionColumnNameFromTable($this->rightTable); + return $this->tableSchemaService->getJunctionColumnNameFromTableUncached($this->rightTable); } /** @@ -146,7 +146,7 @@ protected function buildForeignKeyFor(string $columnName, TableInterface $primar { try { // Find the corresponding primary column by the column name - $primaryColumn = $this->tableSchemaService->getPrimaryColumnNameForTable($primaryTable); + $primaryColumn = $this->tableSchemaService->getPrimaryColumnNameForTableUncached($primaryTable); }catch(ColumnNotFoundException $e){ $this->logger->logException($e, '', [], LoggerLevel::Emergency); throw $e; @@ -183,4 +183,4 @@ public function getFieldsForIdentity(): array { return [$this->getLeftColumnName(), $this->getRightColumnName()]; } -} \ No newline at end of file +} diff --git a/lib/Services/TableSchemaService.php b/lib/Services/TableSchemaService.php index 469360b..a3c9f26 100644 --- a/lib/Services/TableSchemaService.php +++ b/lib/Services/TableSchemaService.php @@ -33,6 +33,53 @@ public function getPrimaryColumnsForTable(TableInterface $table): array ); } + /** + * Read the supplied descriptor without consulting or changing shared cache. + * Returns the same primary-column metadata that the cached lookup computes + * after a cache miss. + * Descriptor failures propagate unchanged. This does not inspect storage. + * Custom descriptors must provide stable, side-effect-free metadata. + * + * @return Column[] + */ + public function getPrimaryColumnsForTableUncached(TableInterface $table): array + { + return $this->findPrimaryColumns($table); + } + + /** + * Return the descriptor's sole primary Column without shared cache access. + * Preserves the cached helper's cardinality rule and original Column object. + * + * @throws ColumnNotFoundException When there is not exactly one primary column. + */ + public function getPrimaryColumnNameForTableUncached(TableInterface $table): Column + { + $primaryColumns = $this->getPrimaryColumnsForTableUncached($table); + + if (count($primaryColumns) !== 1) { + throw new ColumnNotFoundException('Junction Tables must have exactly one primary key column.'); + } + + /** @var Column $primaryColumn */ + $primaryColumn = Arr::first($primaryColumns); + + return $primaryColumn; + } + + /** + * Compose the existing junction-column name without shared cache access. + * Descriptor failures propagate unchanged. + * + * @throws ColumnNotFoundException When there is not exactly one primary column. + */ + public function getJunctionColumnNameFromTableUncached(TableInterface $table): string + { + $primaryColumn = $this->getPrimaryColumnNameForTableUncached($table); + + return $table->getSingularUnprefixedName() . ucfirst($primaryColumn->getName()); + } + /** * Locates the primary columns used for this table. * @@ -132,4 +179,4 @@ protected function getCacheContext(string $id): array { return ['for' => get_called_class(), 'id' => $id]; } -} \ No newline at end of file +} diff --git a/phpunit.xml b/phpunit.xml index d5bc659..2653df5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -4,6 +4,9 @@ ./tests/Unit + + ./tests/Integration + @@ -13,4 +16,4 @@ ./lib - \ No newline at end of file + diff --git a/tests/Integration/UncachedJunctionSchemaContractTest.php b/tests/Integration/UncachedJunctionSchemaContractTest.php new file mode 100644 index 0000000..e06f86f --- /dev/null +++ b/tests/Integration/UncachedJunctionSchemaContractTest.php @@ -0,0 +1,129 @@ +createMock(CacheableService::class); + foreach (['getWithCache', 'get', 'set', 'delete', 'exists'] as $method) { + $cache->expects(self::never())->method($method); + } + $this->schema = new TableSchemaService($cache); + } + + public function testBuiltInJunctionMetadataAvoidsTheSharedCacheBoundary(): void + { + $left = $this->table('program', 'externalKey'); + $right = $this->table('distributor', 'id'); + $junction = $this->junction($left, $right); + + self::assertSame($left, $junction->getLeftTable()); + self::assertSame($right, $junction->getRightTable()); + self::assertSame('programExternalKey', $junction->getLeftColumnName()); + self::assertSame('distributorId', $junction->getRightColumnName()); + self::assertSame(['programExternalKey', 'distributorId'], $junction->getFieldsForIdentity()); + self::assertSame([ + ['programExternalKey', 'BIGINT'], ['distributorId', 'BIGINT'], + ], array_map(static fn(Column $column): array => [$column->getName(), $column->getType()], $junction->getColumns())); + self::assertSame([ + ['PRIMARY KEY', ['distributorId', 'programExternalKey'], []], + ['FOREIGN KEY', ['programExternalKey'], ['REFERENCES global_local_programs(externalKey)']], + ['FOREIGN KEY', ['distributorId'], ['REFERENCES global_local_distributors(id)']], + ], array_map(static fn(Index $index): array => [$index->getType(), $index->getColumns(), $index->getAttributes()], $junction->getIndices())); + self::assertSame(['programExternalKey', 'distributorId'], array_map( + static fn(Column $column): string => $column->getName(), $this->schema->getPrimaryColumnsForTableUncached($junction) + )); + } + + public function testNestedJunctionMetadataRetainsItsCompoundKeyRefusalWithoutSharedCacheAccess(): void + { + $inner = $this->junction($this->table('program', 'id'), $this->table('distributor', 'id')); + $outer = $this->junction($inner, $this->table('account', 'id')); + + $this->expectException(ColumnNotFoundException::class); + $this->expectExceptionMessage('Junction Tables must have exactly one primary key column.'); + $outer->getColumns(); + } + + /** @dataProvider descriptorFailures */ + public function testLeafDescriptorFailureReachesTheCallerUnchanged(string $kind, string $side): void + { + $failure = $kind === 'error' ? new Error('Leaf metadata failure') : new RuntimeException('Leaf metadata failure'); + $left = $this->table('program', 'id', $side !== 'left'); + $right = $this->table('distributor', 'id', $side !== 'right'); + $leaf = $side === 'left' ? $left : $right; + $leaf->expects(self::once())->method('getColumns')->willThrowException($failure); + $junction = $this->junction($left, $right); + $caught = null; + try { + $junction->getColumns(); + } catch (RuntimeException|Error $actual) { + $caught = $actual; + } + self::assertSame($failure, $caught); + } + + /** @return array */ + public static function descriptorFailures(): array + { + return [ + 'left exception' => ['exception', 'left'], 'left error' => ['error', 'left'], + 'right exception' => ['exception', 'right'], 'right error' => ['error', 'right'], + ]; + } + + /** @return Table&MockObject */ + private function table(string $singular, string $identity, bool $withColumns = true): Table + { + $table = $this->getMockForAbstractClass(Table::class, $this->tableArguments()); + $table->method('getUnprefixedName')->willReturn($singular . 's'); + $table->method('getSingularUnprefixedName')->willReturn($singular); + if ($withColumns) { + $table->method('getColumns')->willReturn([new Column($identity, 'BIGINT', null, 'PRIMARY KEY')]); + } + $table->method('getIndices')->willReturn([]); + return $table; + } + + private function junction(Table $left, Table $right): JunctionTable + { + $arguments = [...$this->tableArguments(), $left, $right, $this->createMock(LoggerStrategy::class)]; + return new class(...$arguments) extends JunctionTable { + public function getTableVersion(): string { return '1'; } + public function getSingularUnprefixedName(): string { return 'link'; } + }; + } + + /** @return array{HasLocalDatabasePrefix, HasGlobalDatabasePrefix, HasCharsetProvider, HasCollateProvider, TableSchemaService} */ + private function tableArguments(): array + { + $local = $this->createMock(HasLocalDatabasePrefix::class); + $local->method('getLocalDatabasePrefix')->willReturn('local'); + $global = $this->createMock(HasGlobalDatabasePrefix::class); + $global->method('getGlobalDatabasePrefix')->willReturn('global'); + return [$local, $global, $this->createMock(HasCharsetProvider::class), $this->createMock(HasCollateProvider::class), $this->schema]; + } +} diff --git a/tests/Unit/Contracts/UncachedPrimarySchemaContractTest.php b/tests/Unit/Contracts/UncachedPrimarySchemaContractTest.php new file mode 100644 index 0000000..42d7cdb --- /dev/null +++ b/tests/Unit/Contracts/UncachedPrimarySchemaContractTest.php @@ -0,0 +1,254 @@ + [[$ordinary, $id], [], [1 => $id]], + 'compound index' => [[$ordinary, $tenant, $plainId], [new Index(['tenantId', 'id'], null, 'PRIMARY KEY')], [$tenant, $plainId]], + 'mixed declarations' => [[$ordinary, $id, $tenant], [new Index(['tenantId'], null, 'PRIMARY KEY')], [$id, $tenant]], + 'unique only' => [[$ordinary, $plainId], [new Index(['id'], 'unique_id', 'UNIQUE')], []], + 'no primary key' => [[$ordinary], [], []], + }; + $table = $this->createMock(Table::class); + $table->method('getColumns')->willReturn($columns); + $table->method('getIndices')->willReturn($indices); + $schema = $this->withoutCacheAccess(); + + self::assertSame($expected, $schema->getPrimaryColumnsForTableUncached($table)); + } + + public function testEveryCallUsesItsCurrentDescriptorEvenWhenPhysicalNamesMatch(): void + { + $firstColumn = new Column('firstId', 'BIGINT', null, 'PRIMARY KEY'); + $secondColumn = new Column('secondId', 'BIGINT', null, 'PRIMARY KEY'); + $first = $this->createMock(Table::class); + $first->method('getName')->willReturn('same_physical_name'); + $first->method('getColumns')->willReturn([$firstColumn]); + $first->method('getIndices')->willReturn([]); + $second = $this->createMock(Table::class); + $second->method('getName')->willReturn('same_physical_name'); + $second->method('getColumns')->willReturn([$secondColumn]); + $second->method('getIndices')->willReturn([]); + $schema = $this->withoutCacheAccess(); + + self::assertSame([$firstColumn], $schema->getPrimaryColumnsForTableUncached($first)); + self::assertSame([$secondColumn], $schema->getPrimaryColumnsForTableUncached($second)); + self::assertSame([$firstColumn], $schema->getPrimaryColumnsForTableUncached($first)); + } + + /** @dataProvider uncachedLookups */ + public function testEachLookupReadsMetadataChangesOnTheSameDescriptor(string $lookup): void + { + $first = new Column('firstId', 'BIGINT', null, 'PRIMARY KEY'); + $second = new Column('secondId', 'VARCHAR', [64], 'PRIMARY KEY'); + $ordinary = new Column('score', 'BIGINT'); + $columns = [$ordinary, $first]; + $singular = 'first'; + $table = $this->createMock(Table::class); + $table->method('getName')->willReturn('same_physical_name'); + $table->method('getColumns')->willReturnCallback(static function () use (&$columns): array { return $columns; }); + $table->method('getIndices')->willReturn([]); + $table->method('getSingularUnprefixedName')->willReturnCallback(static function () use (&$singular): string { return $singular; }); + $schema = $this->withoutCacheAccess(); + + $expected = match ($lookup) { + 'getPrimaryColumnsForTableUncached' => [1 => $first], + 'getPrimaryColumnNameForTableUncached' => $first, + default => 'firstFirstId', + }; + self::assertSame($expected, $schema->$lookup($table)); + + $columns = [$second, $ordinary]; + $singular = 'second'; + $expected = match ($lookup) { + 'getPrimaryColumnsForTableUncached' => [$second], + 'getPrimaryColumnNameForTableUncached' => $second, + default => 'secondSecondId', + }; + self::assertSame($expected, $schema->$lookup($table)); + } + + /** @return array */ + public static function uncachedLookups(): array + { + return [ + 'columns' => ['getPrimaryColumnsForTableUncached'], + 'primary column' => ['getPrimaryColumnNameForTableUncached'], + 'junction name' => ['getJunctionColumnNameFromTableUncached'], + ]; + } + + /** @dataProvider descriptorFailures */ + public function testDescriptorFailuresPropagateUnchangedWithoutCacheAccess(string $method, string $kind, string $lookup): void + { + $original = $kind === 'error' ? new Error('Descriptor unavailable') : new RuntimeException('Descriptor unavailable'); + $table = $this->createMock(Table::class); + if ($method !== 'getColumns') { + $table->method('getColumns')->willReturn([new Column('id', 'BIGINT', null, 'PRIMARY KEY')]); + } + if ($method !== 'getIndices') { + $table->method('getIndices')->willReturn([]); + } + $table->expects(self::once())->method($method)->willThrowException($original); + $schema = $this->withoutCacheAccess(); + $caught = null; + try { + $schema->$lookup($table); + } catch (RuntimeException|Error $failure) { + $caught = $failure; + } + + self::assertSame($original, $caught); + } + + /** @dataProvider cachedLookups */ + public function testTheExistingCachedLookupStillUsesItsConfiguredCache(string $method): void + { + $column = new Column('cachedId', 'BIGINT', null, 'PRIMARY KEY'); + $cache = $this->createMock(CacheableService::class); + $cache->expects(self::once())->method('getWithCache')->with( + Operation::Read, ['for' => TableSchemaService::class, 'id' => 'existingPrimaryColumns'], self::isType('callable') + )->willReturn([$column]); + $table = $this->createMock(Table::class); + $table->method('getName')->willReturn('existing'); + $table->method('getSingularUnprefixedName')->willReturn('existing'); + $table->expects(self::never())->method('getColumns'); + $table->expects(self::never())->method('getIndices'); + + $expected = match ($method) { + 'getPrimaryColumnsForTable' => [$column], + 'getPrimaryColumnNameForTable' => $column, + default => 'existingCachedId', + }; + self::assertSame($expected, (new TableSchemaService($cache))->$method($table)); + } + + /** @return array */ + public static function cachedLookups(): array + { + return [ + 'columns' => ['getPrimaryColumnsForTable'], + 'primary name' => ['getPrimaryColumnNameForTable'], + 'junction name' => ['getJunctionColumnNameFromTable'], + ]; + } + + public function testUncachedNameHelpersPreserveTheColumnAndExistingJunctionNaming(): void + { + $column = new Column('externalKey', 'VARCHAR', [64], 'PRIMARY KEY'); + $table = $this->createMock(Table::class); + $table->method('getColumns')->willReturn([$column]); + $table->method('getIndices')->willReturn([]); + $table->method('getSingularUnprefixedName')->willReturn('program'); + $schema = $this->withoutCacheAccess(); + + self::assertSame($column, $schema->getPrimaryColumnNameForTableUncached($table)); + self::assertSame('programExternalKey', $schema->getJunctionColumnNameFromTableUncached($table)); + } + + /** @dataProvider invalidNameCardinality */ + public function testUncachedNameHelpersRetainTheExactlyOneColumnRule(string $method, bool $compound): void + { + $table = $this->createMock(Table::class); + $table->method('getColumns')->willReturn($compound ? [new Column('tenantId', 'BIGINT'), new Column('id', 'BIGINT')] : []); + $table->method('getIndices')->willReturn($compound ? [new Index(['tenantId', 'id'], null, 'PRIMARY KEY')] : []); + $schema = $this->withoutCacheAccess(); + + $this->expectException(ColumnNotFoundException::class); + $this->expectExceptionMessage('Junction Tables must have exactly one primary key column.'); + $schema->$method($table); + } + + public function testNameHelpersDoNotReuseAnotherDescriptorWithTheSamePhysicalName(): void + { + $firstColumn = new Column('firstId', 'BIGINT', null, 'PRIMARY KEY'); + $secondColumn = new Column('secondId', 'BIGINT', null, 'PRIMARY KEY'); + $first = $this->createMock(Table::class); + $first->method('getName')->willReturn('same_physical_name'); + $first->method('getSingularUnprefixedName')->willReturn('first'); + $first->method('getColumns')->willReturn([$firstColumn]); + $first->method('getIndices')->willReturn([]); + $second = $this->createMock(Table::class); + $second->method('getName')->willReturn('same_physical_name'); + $second->method('getSingularUnprefixedName')->willReturn('second'); + $second->method('getColumns')->willReturn([$secondColumn]); + $second->method('getIndices')->willReturn([]); + $schema = $this->withoutCacheAccess(); + + self::assertSame($firstColumn, $schema->getPrimaryColumnNameForTableUncached($first)); + self::assertSame($secondColumn, $schema->getPrimaryColumnNameForTableUncached($second)); + self::assertSame('firstFirstId', $schema->getJunctionColumnNameFromTableUncached($first)); + self::assertSame('secondSecondId', $schema->getJunctionColumnNameFromTableUncached($second)); + } + + /** @return array */ + public static function invalidNameCardinality(): array + { + return [ + 'primary absent' => ['getPrimaryColumnNameForTableUncached', false], + 'primary compound' => ['getPrimaryColumnNameForTableUncached', true], + 'junction absent' => ['getJunctionColumnNameFromTableUncached', false], + 'junction compound' => ['getJunctionColumnNameFromTableUncached', true], + ]; + } + + private function withoutCacheAccess(): TableSchemaService + { + $cache = $this->createMock(CacheableService::class); + foreach (['getWithCache', 'get', 'set', 'delete', 'exists'] as $method) { + $cache->expects(self::never())->method($method); + } + return new TableSchemaService($cache); + } + + /** @return array */ + public static function primaryDeclarations(): array + { + return [ + 'attribute' => ['attribute'], 'compound index' => ['compound index'], + 'mixed declarations' => ['mixed declarations'], 'unique only' => ['unique only'], + 'no primary key' => ['no primary key'], + ]; + } + + /** @return array */ + public static function descriptorFailures(): array + { + $cases = []; + foreach (['getPrimaryColumnsForTableUncached', 'getPrimaryColumnNameForTableUncached', 'getJunctionColumnNameFromTableUncached'] as $lookup) { + foreach (['getColumns', 'getIndices'] as $method) { + foreach (['exception', 'error'] as $kind) { + $cases[$lookup . ' ' . $method . ' ' . $kind] = [$method, $kind, $lookup]; + } + } + } + foreach (['exception', 'error'] as $kind) { + $cases['junction singular name ' . $kind] = ['getSingularUnprefixedName', $kind, 'getJunctionColumnNameFromTableUncached']; + } + return $cases; + } +}