Skip to content
Open
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
5 changes: 5 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ services:
resource: '../src/*'
exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'

App\Doctrine\SQLiteForeignKeyMiddleware:
autoconfigure: false
tags:
- { name: doctrine.middleware, priority: 100 }

App\Services\Utils:
arguments:
$authRealm: "%env(AUTH_REALM)%"
Expand Down
79 changes: 79 additions & 0 deletions migrations/Version20260720103000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260720103000 extends AbstractMigration
{
private const UNIQUE_INDEXES = [
'uniq_addressbooks_principal_uri' => ['addressbooks', ['principaluri', 'uri'], false],
'uniq_calendarinstances_principal_uri' => ['calendarinstances', ['principaluri', 'uri'], true],
'uniq_calendarinstances_calendar_principal' => ['calendarinstances', ['calendarid', 'principaluri'], true],
'uniq_calendarinstances_calendar_share' => ['calendarinstances', ['calendarid', 'share_href'], true],
'uniq_calendarobjects_calendar_uri' => ['calendarobjects', ['calendarid', 'uri'], true],
'uniq_calendarsubscriptions_principal_uri' => ['calendarsubscriptions', ['principaluri', 'uri'], false],
'uniq_cards_addressbook_uri' => ['cards', ['addressbookid', 'uri'], true],
'uniq_propertystorage_path_name' => ['propertystorage', ['path', 'name'], false],
'uniq_schedulingobjects_principal_uri' => ['schedulingobjects', ['principaluri', 'uri'], true],
];

public function getDescription(): string
{
return 'Restore DAV uniqueness constraints and add sync query indexes';
}

public function up(Schema $schema): void
{
foreach (self::UNIQUE_INDEXES as $name => [$table, $columns, $nullable]) {
$where = $nullable
? ' WHERE '.implode(' AND ', array_map(static fn (string $column): string => $column.' IS NOT NULL', $columns))
: '';
$columnList = implode(', ', $columns);
$duplicate = $this->connection->fetchOne(sprintf(
'SELECT 1 FROM (SELECT 1 FROM %s%s GROUP BY %s HAVING COUNT(*) > 1) duplicate_rows',
$table,
$where,
$columnList,
));

$this->abortIf(false !== $duplicate, sprintf(
'Cannot create %s: duplicate (%s) values exist in %s. Resolve them and rerun the migration.',
$name,
$columnList,
$table,
));

$this->addSql(sprintf('CREATE UNIQUE INDEX %s ON %s (%s)', $name, $table, $columnList));
}

$this->addSql('CREATE INDEX idx_calendarchanges_calendar_sync ON calendarchanges (calendarid, synctoken)');
$this->addSql('CREATE INDEX idx_addressbookchanges_book_sync ON addressbookchanges (addressbookid, synctoken)');
}

public function down(Schema $schema): void
{
$engine = $this->connection->getDatabasePlatform()->getName();

$this->dropIndex('idx_addressbookchanges_book_sync', 'addressbookchanges', $engine);
$this->dropIndex('idx_calendarchanges_calendar_sync', 'calendarchanges', $engine);

foreach (array_reverse(self::UNIQUE_INDEXES, true) as $name => [$table]) {
$this->dropIndex($name, $table, $engine);
}
}

private function dropIndex(string $name, string $table, string $engine): void
{
if ('mysql' === $engine) {
$this->addSql(sprintf('DROP INDEX %s ON %s', $name, $table));

return;
}

$this->addSql(sprintf('DROP INDEX %s', $name));
}
}
102 changes: 102 additions & 0 deletions migrations/Version20260720104000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260720104000 extends AbstractMigration
{
private const FOREIGN_KEYS = [
'cards' => [
'FK_4C258FD8B26C2E9' => ['addressbookid', 'addressbooks'],
],
'addressbookchanges' => [
'FK_EB122CD58B26C2E9' => ['addressbookid', 'addressbooks'],
],
'calendarobjects' => [
'FK_E14F332CB8CB7204' => ['calendarid', 'calendars'],
],
'calendarinstances' => [
'FK_51856561B8CB7204' => ['calendarid', 'calendars'],
],
'calendarchanges' => [
'FK_737547E2B8CB7204' => ['calendarid', 'calendars'],
],
'groupmembers' => [
'FK_6F15EDAC474870EE' => ['principal_id', 'principals'],
'FK_6F15EDAC7597D3FE' => ['member_id', 'principals'],
],
];

public function getDescription(): string
{
return 'Enforce foreign keys consistently across supported databases';
}

public function up(Schema $schema): void
{
foreach (self::FOREIGN_KEYS as $table => $foreignKeys) {
foreach ($foreignKeys as $name => [$column, $parentTable]) {
$orphan = $this->connection->fetchOne(sprintf(
'SELECT 1 FROM %s child LEFT JOIN %s parent ON parent.id = child.%s WHERE parent.id IS NULL LIMIT 1',
$table,
$parentTable,
$column,
));

$this->abortIf(false !== $orphan, sprintf(
'Cannot create %s: %s.%s contains values missing from %s.id. Resolve orphaned rows and rerun the migration.',
$name,
$table,
$column,
$parentTable,
));
}
}

$this->changeForeignKeys(true);
}

public function down(Schema $schema): void
{
$this->changeForeignKeys(false);
}

private function changeForeignKeys(bool $enable): void
{
$schemaManager = $this->connection->createSchemaManager();
$currentSchema = $schemaManager->introspectSchema();
$comparator = $schemaManager->createComparator();
$platform = $this->connection->getDatabasePlatform();
$remove = !$enable && 'sqlite' === $platform->getName();

foreach (self::FOREIGN_KEYS as $tableName => $foreignKeys) {
$currentTable = $currentSchema->getTable($tableName);
$targetTable = clone $currentTable;

foreach ($foreignKeys as $name => [$column, $parentTable]) {
if ($targetTable->hasForeignKey($name)) {
$targetTable->removeForeignKey($name);
}

if (!$remove) {
$targetTable->addForeignKeyConstraint(
$parentTable,
[$column],
['id'],
$enable ? ['onDelete' => 'CASCADE'] : [],
$name,
);
}
}

$diff = $comparator->compareTables($currentTable, $targetTable);
foreach ($platform->getAlterTableSQL($diff) as $sql) {
$this->addSql($sql);
}
}
}
}
20 changes: 20 additions & 0 deletions src/Doctrine/SQLiteForeignKeyMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Doctrine;

use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\AbstractSQLiteDriver\Middleware\EnableForeignKeys;
use Doctrine\DBAL\Driver\Middleware;
use Doctrine\DBAL\Platforms\SqlitePlatform;

final class SQLiteForeignKeyMiddleware implements Middleware
{
public function wrap(Driver $driver): Driver
{
return $driver->getDatabasePlatform() instanceof SqlitePlatform
? (new EnableForeignKeys())->wrap($driver)
: $driver;
}
}
1 change: 1 addition & 0 deletions src/Entity/AddressBook.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

#[ORM\Entity()]
#[ORM\Table(name: 'addressbooks')]
#[ORM\UniqueConstraint(name: 'uniq_addressbooks_principal_uri', columns: ['principaluri', 'uri'])]
#[UniqueEntity(fields: ['principalUri', 'uri'], errorPath: 'uri', message: 'form.uri.unique')]
class AddressBook
{
Expand Down
3 changes: 2 additions & 1 deletion src/Entity/AddressBookChange.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#[ORM\Entity()]
#[ORM\Table(name: 'addressbookchanges')]
#[ORM\Index(name: 'idx_addressbookchanges_book_sync', columns: ['addressbookid', 'synctoken'])]
class AddressBookChange
{
#[ORM\Id]
Expand All @@ -20,7 +21,7 @@ class AddressBookChange
private $synctoken;

#[ORM\ManyToOne(targetEntity: "App\Entity\AddressBook", inversedBy: 'changes')]
#[ORM\JoinColumn(name: 'addressbookid', nullable: false)]
#[ORM\JoinColumn(name: 'addressbookid', nullable: false, onDelete: 'CASCADE')]
private $addressBook;

#[ORM\Column(type: 'integer')]
Expand Down
3 changes: 2 additions & 1 deletion src/Entity/CalendarChange.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#[ORM\Entity()]
#[ORM\Table(name: 'calendarchanges')]
#[ORM\Index(name: 'idx_calendarchanges_calendar_sync', columns: ['calendarid', 'synctoken'])]
class CalendarChange
{
#[ORM\Id]
Expand All @@ -20,7 +21,7 @@ class CalendarChange
private $synctoken;

#[ORM\ManyToOne(targetEntity: "App\Entity\Calendar", inversedBy: 'changes')]
#[ORM\JoinColumn(name: 'calendarid', nullable: false)]
#[ORM\JoinColumn(name: 'calendarid', nullable: false, onDelete: 'CASCADE')]
private $calendar;

#[ORM\Column(type: 'smallint')]
Expand Down
5 changes: 4 additions & 1 deletion src/Entity/CalendarInstance.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

#[ORM\Entity(repositoryClass: "App\Repository\CalendarInstanceRepository")]
#[ORM\Table(name: 'calendarinstances')]
#[ORM\UniqueConstraint(name: 'uniq_calendarinstances_principal_uri', columns: ['principaluri', 'uri'])]
#[ORM\UniqueConstraint(name: 'uniq_calendarinstances_calendar_principal', columns: ['calendarid', 'principaluri'])]
#[ORM\UniqueConstraint(name: 'uniq_calendarinstances_calendar_share', columns: ['calendarid', 'share_href'])]
#[UniqueEntity(fields: ['principalUri', 'uri'], errorPath: 'uri', message: 'form.uri.unique')]
class CalendarInstance
{
Expand All @@ -27,7 +30,7 @@ public static function getOwnerAccesses(): array
private $id;

#[ORM\ManyToOne(targetEntity: "App\Entity\Calendar", cascade: ['persist'], inversedBy: 'instances')]
#[ORM\JoinColumn(name: 'calendarid', nullable: false)]
#[ORM\JoinColumn(name: 'calendarid', nullable: false, onDelete: 'CASCADE')]
private $calendar;

#[ORM\Column(name: 'principaluri', type: 'string', length: 255, nullable: true)]
Expand Down
3 changes: 2 additions & 1 deletion src/Entity/CalendarObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#[ORM\Entity()]
#[ORM\Table(name: 'calendarobjects')]
#[ORM\UniqueConstraint(name: 'uniq_calendarobjects_calendar_uri', columns: ['calendarid', 'uri'])]
class CalendarObject
{
#[ORM\Id]
Expand All @@ -23,7 +24,7 @@ class CalendarObject
private $uri;

#[ORM\ManyToOne(targetEntity: "App\Entity\Calendar", inversedBy: 'objects')]
#[ORM\JoinColumn(name: 'calendarid', nullable: false)]
#[ORM\JoinColumn(name: 'calendarid', nullable: false, onDelete: 'CASCADE')]
private $calendar;

#[ORM\Column(name: 'lastmodified', type: 'bigint', nullable: true)]
Expand Down
1 change: 1 addition & 0 deletions src/Entity/CalendarSubscription.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#[ORM\Entity()]
#[ORM\Table(name: 'calendarsubscriptions')]
#[ORM\UniqueConstraint(name: 'uniq_calendarsubscriptions_principal_uri', columns: ['principaluri', 'uri'])]
class CalendarSubscription
{
#[ORM\Id]
Expand Down
3 changes: 2 additions & 1 deletion src/Entity/Card.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#[ORM\Entity()]
#[ORM\Table(name: 'cards')]
#[ORM\UniqueConstraint(name: 'uniq_cards_addressbook_uri', columns: ['addressbookid', 'uri'])]
class Card
{
#[ORM\Id]
Expand All @@ -14,7 +15,7 @@ class Card
private $id;

#[ORM\ManyToOne(targetEntity: "App\Entity\AddressBook", inversedBy: 'cards')]
#[ORM\JoinColumn(name: 'addressbookid', nullable: false)]
#[ORM\JoinColumn(name: 'addressbookid', nullable: false, onDelete: 'CASCADE')]
private $addressBook;

/**
Expand Down
4 changes: 2 additions & 2 deletions src/Entity/Principal.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ class Principal

#[ORM\ManyToMany(targetEntity: 'Principal')]
#[ORM\JoinTable(name: 'groupmembers')]
#[ORM\JoinColumn(name: 'principal_id', referencedColumnName: 'id')]
#[ORM\InverseJoinColumn(name: 'member_id', referencedColumnName: 'id')]
#[ORM\JoinColumn(name: 'principal_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'member_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
private $delegees;

public function __construct()
Expand Down
1 change: 1 addition & 0 deletions src/Entity/PropertyStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#[ORM\Entity()]
#[ORM\Table(name: 'propertystorage')]
#[ORM\UniqueConstraint(name: 'uniq_propertystorage_path_name', columns: ['path', 'name'])]
class PropertyStorage
{
#[ORM\Id]
Expand Down
1 change: 1 addition & 0 deletions src/Entity/SchedulingObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#[ORM\Entity()]
#[ORM\Table(name: 'schedulingobjects')]
#[ORM\UniqueConstraint(name: 'uniq_schedulingobjects_principal_uri', columns: ['principaluri', 'uri'])]
class SchedulingObject
{
#[ORM\Id]
Expand Down
26 changes: 26 additions & 0 deletions tests/Functional/SQLiteForeignKeyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace App\Tests\Functional;

use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class SQLiteForeignKeyTest extends KernelTestCase
{
public function testForeignKeysAreEnforced(): void
{
self::bootKernel();

$connection = self::getContainer()->get(Connection::class);
if ('sqlite' !== $connection->getDatabasePlatform()->getName()) {
self::markTestSkipped('SQLite-specific connection invariant.');
}

self::assertSame(1, (int) $connection->fetchOne('PRAGMA foreign_keys'));

$foreignKey = $connection->fetchAssociative("SELECT * FROM pragma_foreign_key_list('cards')");
self::assertSame('addressbooks', $foreignKey['table']);
self::assertSame('addressbookid', $foreignKey['from']);
self::assertSame('CASCADE', $foreignKey['on_delete']);
}
}