Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
configuration: phpunit.xml
php_version: "8.2"
8 changes: 4 additions & 4 deletions lib/Abstracts/JunctionTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
/**
* @var mixed|null
*/
protected LoggerStrategy $logger;

Check failure on line 28 in lib/Abstracts/JunctionTable.php

View workflow job for this annotation

GitHub Actions / phpstan

PHPDoc tag @var for property PHPNomad\Database\Abstracts\JunctionTable::$logger with type mixed is not subtype of native type PHPNomad\Logger\Interfaces\LoggerStrategy.

public function __construct(
HasLocalDatabasePrefix $localPrefixProvider,
Expand All @@ -39,10 +39,10 @@
)
{
$args = func_get_args();
$this->logger = array_pop($args);

Check failure on line 42 in lib/Abstracts/JunctionTable.php

View workflow job for this annotation

GitHub Actions / phpstan

Property PHPNomad\Database\Abstracts\JunctionTable::$logger (PHPNomad\Logger\Interfaces\LoggerStrategy) does not accept mixed.
$this->rightTable = array_pop($args);

Check failure on line 43 in lib/Abstracts/JunctionTable.php

View workflow job for this annotation

GitHub Actions / phpstan

Property PHPNomad\Database\Abstracts\JunctionTable::$rightTable (PHPNomad\Database\Abstracts\Table) does not accept mixed.
$this->leftTable = array_pop($args);

Check failure on line 44 in lib/Abstracts/JunctionTable.php

View workflow job for this annotation

GitHub Actions / phpstan

Property PHPNomad\Database\Abstracts\JunctionTable::$leftTable (PHPNomad\Database\Abstracts\Table) does not accept mixed.
parent::__construct(...$args);

Check failure on line 45 in lib/Abstracts/JunctionTable.php

View workflow job for this annotation

GitHub Actions / phpstan

Parameter #1 $localPrefixProvider of method PHPNomad\Database\Abstracts\Table::__construct() expects PHPNomad\Database\Interfaces\HasLocalDatabasePrefix, mixed given.
}

/**
Expand Down Expand Up @@ -104,7 +104,7 @@
*/
public function getLeftColumnName(): string
{
return $this->tableSchemaService->getJunctionColumnNameFromTable($this->leftTable);
return $this->tableSchemaService->getJunctionColumnNameFromTableUncached($this->leftTable);
}

/**
Expand All @@ -114,7 +114,7 @@
*/
public function getRightColumnName(): string
{
return $this->tableSchemaService->getJunctionColumnNameFromTable($this->rightTable);
return $this->tableSchemaService->getJunctionColumnNameFromTableUncached($this->rightTable);
}

/**
Expand Down Expand Up @@ -146,7 +146,7 @@
{
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;
Expand Down Expand Up @@ -183,4 +183,4 @@
{
return [$this->getLeftColumnName(), $this->getRightColumnName()];
}
}
}
49 changes: 48 additions & 1 deletion lib/Services/TableSchemaService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -132,4 +179,4 @@ protected function getCacheContext(string $id): array
{
return ['for' => get_called_class(), 'id' => $id];
}
}
}
5 changes: 4 additions & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
<testsuite name="Unit">
<directory>./tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>./tests/Integration</directory>
</testsuite>
</testsuites>
<listeners>
<listener class="\Mockery\Adapter\Phpunit\TestListener"/>
Expand All @@ -13,4 +16,4 @@
<directory suffix=".php">./lib</directory>
</include>
</coverage>
</phpunit>
</phpunit>
129 changes: 129 additions & 0 deletions tests/Integration/UncachedJunctionSchemaContractTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

namespace PHPNomad\Database\Tests\Integration;

use Error;
use PHPNomad\Cache\Services\CacheableService;
use PHPNomad\Database\Abstracts\JunctionTable;
use PHPNomad\Database\Abstracts\Table;
use PHPNomad\Database\Exceptions\ColumnNotFoundException;
use PHPNomad\Database\Factories\Column;
use PHPNomad\Database\Factories\Index;
use PHPNomad\Database\Interfaces\HasCharsetProvider;
use PHPNomad\Database\Interfaces\HasCollateProvider;
use PHPNomad\Database\Interfaces\HasGlobalDatabasePrefix;
use PHPNomad\Database\Interfaces\HasLocalDatabasePrefix;
use PHPNomad\Database\Services\TableSchemaService;
use PHPNomad\Database\Tests\TestCase;
use PHPNomad\Logger\Interfaces\LoggerStrategy;
use PHPUnit\Framework\MockObject\MockObject;
use RuntimeException;

/** Wires JunctionTable to TableSchemaService and asserts that no shared-cache method runs. */
final class UncachedJunctionSchemaContractTest extends TestCase
{
private TableSchemaService $schema;

protected function setUp(): void
{
parent::setUp();
$cache = $this->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<string, array{string, string}> */
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];
}
}
Loading
Loading