Skip to content

Commit 04bca1f

Browse files
author
tchapi
committed
synctoken
1 parent 02847b8 commit 04bca1f

7 files changed

Lines changed: 175 additions & 12 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
/**
11+
* Store DAV sync tokens as integers.
12+
*
13+
* Adapted from the work of @AnnoyingTechnology in tchapi/davis#277.
14+
*/
15+
final class Version20260909100000 extends AbstractMigration
16+
{
17+
public function getDescription(): string
18+
{
19+
return 'Store DAV sync tokens as integers so that sync-collection range queries compare them numerically';
20+
}
21+
22+
public function up(Schema $schema): void
23+
{
24+
$engine = $this->connection->getDatabasePlatform()->getName();
25+
26+
// sabre/dav compares and orders sync tokens numerically:
27+
// WHERE synctoken >= ? AND synctoken < ? ... ORDER BY synctoken
28+
// Stored as text, '10' sorts before '9', so a client syncing across a decimal-width
29+
// boundary is told the collection advanced but is handed none of the changes.
30+
if ('mysql' === $engine) {
31+
$this->addSql('ALTER TABLE addressbooks CHANGE synctoken synctoken INT DEFAULT 1 NOT NULL');
32+
$this->addSql('ALTER TABLE calendars CHANGE synctoken synctoken INT DEFAULT 1 NOT NULL');
33+
$this->addSql('ALTER TABLE addressbookchanges CHANGE synctoken synctoken INT DEFAULT 1 NOT NULL');
34+
} elseif ('postgresql' === $engine) {
35+
// addressbooks and calendars were already converted by Version20230209142217;
36+
// only addressbookchanges is still text here.
37+
$this->addSql('ALTER TABLE addressbookchanges ALTER COLUMN synctoken TYPE INT USING synctoken::integer');
38+
foreach (['addressbooks', 'calendars', 'addressbookchanges'] as $table) {
39+
$this->addSql(sprintf('ALTER TABLE %s ALTER COLUMN synctoken SET DEFAULT 1', $table));
40+
}
41+
} elseif ('sqlite' === $engine) {
42+
// A VARCHAR column has TEXT affinity in SQLite, so the comparison is textual there
43+
// too. SQLite cannot alter a column in place: add the replacement, copy, swap, drop.
44+
foreach (['addressbooks', 'calendars', 'addressbookchanges'] as $table) {
45+
$this->replaceSyncTokenColumn($table, 'INTEGER', 'INTEGER', '1');
46+
}
47+
}
48+
}
49+
50+
public function down(Schema $schema): void
51+
{
52+
$engine = $this->connection->getDatabasePlatform()->getName();
53+
54+
if ('mysql' === $engine) {
55+
$this->addSql('ALTER TABLE addressbooks CHANGE synctoken synctoken VARCHAR(255) NOT NULL');
56+
$this->addSql('ALTER TABLE calendars CHANGE synctoken synctoken VARCHAR(255) NOT NULL');
57+
$this->addSql('ALTER TABLE addressbookchanges CHANGE synctoken synctoken VARCHAR(255) NOT NULL');
58+
} elseif ('postgresql' === $engine) {
59+
// Only addressbookchanges goes back to text: addressbooks and calendars were
60+
// already integers before this migration, and reverting them would reintroduce
61+
// the error Version20230209142217 fixed (synctoken + 1 on a text column).
62+
foreach (['addressbooks', 'calendars', 'addressbookchanges'] as $table) {
63+
$this->addSql(sprintf('ALTER TABLE %s ALTER COLUMN synctoken DROP DEFAULT', $table));
64+
}
65+
$this->addSql('ALTER TABLE addressbookchanges ALTER COLUMN synctoken TYPE VARCHAR(255) USING synctoken::varchar');
66+
} elseif ('sqlite' === $engine) {
67+
// NB: SQLite refuses to ADD a NOT NULL column without a default, so the restored
68+
// columns keep a harmless DEFAULT '1' that the original schema did not have.
69+
foreach (['addressbooks', 'calendars', 'addressbookchanges'] as $table) {
70+
$this->replaceSyncTokenColumn($table, 'VARCHAR(255)', 'TEXT', "'1'");
71+
}
72+
}
73+
}
74+
75+
private function replaceSyncTokenColumn(string $table, string $type, string $cast, string $default): void
76+
{
77+
$this->addSql(sprintf('ALTER TABLE %s ADD COLUMN new_synctoken %s DEFAULT %s NOT NULL', $table, $type, $default));
78+
$this->addSql(sprintf('UPDATE %s SET new_synctoken = CAST(synctoken AS %s)', $table, $cast));
79+
$this->addSql(sprintf('ALTER TABLE %s RENAME COLUMN synctoken TO old_synctoken', $table));
80+
$this->addSql(sprintf('ALTER TABLE %s RENAME COLUMN new_synctoken TO synctoken', $table));
81+
$this->addSql(sprintf('ALTER TABLE %s DROP COLUMN old_synctoken', $table));
82+
}
83+
}

src/Entity/AddressBook.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class AddressBook
3131
#[ORM\Column(type: 'text', nullable: true)]
3232
private $description;
3333

34-
#[ORM\Column(type: 'string', length: 255)]
34+
#[ORM\Column(type: 'integer', options: ['default' => 1])]
3535
private $synctoken;
3636

3737
#[ORM\Column(type: 'boolean', nullable: true, options: ['default' => false])]
@@ -116,12 +116,12 @@ public function setDescription(string $description): self
116116
return $this;
117117
}
118118

119-
public function getSynctoken(): ?string
119+
public function getSynctoken(): ?int
120120
{
121121
return $this->synctoken;
122122
}
123123

124-
public function setSynctoken(string $synctoken): self
124+
public function setSynctoken(int $synctoken): self
125125
{
126126
$this->synctoken = $synctoken;
127127

src/Entity/AddressBookChange.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ class AddressBookChange
1616
#[ORM\Column(type: 'string', length: 255)]
1717
private $uri;
1818

19-
#[ORM\Column(type: 'string', length: 255)]
20-
private $synctoken;
19+
#[ORM\Column(type: 'integer', options: ['default' => 1])]
20+
private $synctoken = 1;
2121

2222
#[ORM\ManyToOne(targetEntity: "App\Entity\AddressBook", inversedBy: 'changes')]
2323
#[ORM\JoinColumn(name: 'addressbookid', nullable: false)]
@@ -43,12 +43,12 @@ public function setUri(string $uri): self
4343
return $this;
4444
}
4545

46-
public function getSynctoken(): ?string
46+
public function getSynctoken(): ?int
4747
{
4848
return $this->synctoken;
4949
}
5050

51-
public function setSynctoken(string $synctoken): self
51+
public function setSynctoken(int $synctoken): self
5252
{
5353
$this->synctoken = $synctoken;
5454

src/Entity/Calendar.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class Calendar
1919
#[ORM\Column(type: 'integer')]
2020
private $id;
2121

22-
#[ORM\Column(type: 'string', length: 255)]
22+
#[ORM\Column(type: 'integer', options: ['default' => 1])]
2323
private $synctoken;
2424

2525
#[ORM\Column(type: 'string', length: 255, nullable: true)]
@@ -47,12 +47,12 @@ public function getId(): ?int
4747
return $this->id;
4848
}
4949

50-
public function getSynctoken(): ?string
50+
public function getSynctoken(): ?int
5151
{
5252
return $this->synctoken;
5353
}
5454

55-
public function setSynctoken(string $synctoken): self
55+
public function setSynctoken(int $synctoken): self
5656
{
5757
$this->synctoken = $synctoken;
5858

tests/Functional/Commands/SyncBirthdayCalendarTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ private function createAddressBookWithCard(string $username, string $cardUri, st
7171
->setUri('default')
7272
->setDisplayName('Default')
7373
->setDescription('')
74-
->setSynctoken('1')
74+
->setSynctoken(1)
7575
->setIncludedInBirthdayCalendar(true);
7676
$this->em->persist($addressBook);
7777

tests/Functional/Service/BirthdayServiceTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ private function createAddressBook(
5454
->setUri('default')
5555
->setDisplayName('Default')
5656
->setDescription('')
57-
->setSynctoken('1')
57+
->setSynctoken(1)
5858
->setIncludedInBirthdayCalendar($includedInBirthdayCalendar);
5959
$this->em->persist($addressBook);
6060
$this->em->flush();

tests/Functional/SyncTokenTest.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Tests\Functional;
6+
7+
use Doctrine\ORM\EntityManagerInterface;
8+
use Sabre\CardDAV\Backend\PDO as CardDavBackend;
9+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
10+
11+
class SyncTokenTest extends KernelTestCase
12+
{
13+
private const PRINCIPAL = 'principals/test_user';
14+
15+
private EntityManagerInterface $em;
16+
private CardDavBackend $backend;
17+
18+
protected function setUp(): void
19+
{
20+
self::bootKernel();
21+
22+
$this->em = static::getContainer()->get(EntityManagerInterface::class);
23+
$this->backend = new CardDavBackend($this->em->getConnection()->getNativeConnection());
24+
25+
$this->em->getConnection()->beginTransaction();
26+
}
27+
28+
protected function tearDown(): void
29+
{
30+
$this->em->getConnection()->rollBack();
31+
parent::tearDown();
32+
}
33+
34+
/**
35+
* Builds an address book that has already seen ten changes: it now sits at sync token 11,
36+
* with a contact added at token 9 and another modified at token 10.
37+
*/
38+
private function createAddressBookAtTokenEleven(): int
39+
{
40+
$pdo = $this->em->getConnection()->getNativeConnection();
41+
42+
$id = $this->backend->createAddressBook(self::PRINCIPAL, 'sync-token-test', ['{DAV:}displayname' => 'Sync token test']);
43+
$pdo->prepare('UPDATE addressbooks SET synctoken = 11 WHERE id = ?')->execute([$id]);
44+
45+
$insert = $pdo->prepare('INSERT INTO addressbookchanges (addressbookid, uri, synctoken, operation) VALUES (?, ?, ?, ?)');
46+
$insert->execute([$id, 'added.vcf', 9, 1]);
47+
$insert->execute([$id, 'changed.vcf', 10, 2]);
48+
49+
return (int) $id;
50+
}
51+
52+
/**
53+
* Regression test: sync tokens were stored as text, so `synctoken >= 9 AND synctoken < 11`
54+
* was compared lexicographically ('10' sorts before '9'). A client syncing across a
55+
* decimal-width boundary was told the collection had advanced but received no changes at
56+
* all, silently losing contacts.
57+
*/
58+
public function testChangesAcrossADecimalBoundaryAreReported(): void
59+
{
60+
$addressBookId = $this->createAddressBookAtTokenEleven();
61+
62+
$changes = $this->backend->getChangesForAddressBook($addressBookId, '9', 1);
63+
64+
$this->assertSame(11, (int) $changes['syncToken']);
65+
$this->assertSame(['added.vcf'], $changes['added']);
66+
$this->assertSame(['changed.vcf'], $changes['modified']);
67+
$this->assertSame([], $changes['deleted']);
68+
}
69+
70+
public function testChangesAreOrderedNumerically(): void
71+
{
72+
$addressBookId = $this->createAddressBookAtTokenEleven();
73+
74+
// From token 10 the earlier change at 9 must be out of range, the one at 10 in range
75+
$changes = $this->backend->getChangesForAddressBook($addressBookId, '10', 1);
76+
77+
$this->assertSame([], $changes['added']);
78+
$this->assertSame(['changed.vcf'], $changes['modified']);
79+
}
80+
}

0 commit comments

Comments
 (0)