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;
+ }
+}