Skip to content

Commit b9dd88c

Browse files
tchapitchapi
andauthored
Fix synctoken to int (#287)
Co-authored-by: tchapi <regbasket@gmail.com>
1 parent e2b7099 commit b9dd88c

9 files changed

Lines changed: 229 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+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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+
* Index the columns sync-collection reports filter and order on.
12+
*
13+
* Adapted from the work of @AnnoyingTechnology in tchapi/davis#276.
14+
*/
15+
final class Version20260910100000 extends AbstractMigration
16+
{
17+
private const INDEXES = [
18+
'idx_calendarchanges_calendar_sync' => ['calendarchanges', 'calendarid, synctoken'],
19+
'idx_addressbookchanges_book_sync' => ['addressbookchanges', 'addressbookid, synctoken'],
20+
];
21+
22+
public function getDescription(): string
23+
{
24+
return 'Add the (collection, synctoken) indexes that sabre/dav sync-collection reports rely on';
25+
}
26+
27+
public function up(Schema $schema): void
28+
{
29+
// Every sync-collection REPORT runs
30+
// WHERE synctoken >= ? AND synctoken < ? AND <collection>id = ? ORDER BY synctoken
31+
// and only the foreign-key column was indexed, which is not selective enough on its
32+
// own: measured on MariaDB with 150k rows, the planner ignored it and did a full scan
33+
// with a filesort (150k rows examined, ~14 ms) where the composite index examines 500
34+
// rows in under 1 ms.
35+
foreach (self::INDEXES as $name => [$table, $columns]) {
36+
$this->addSql(sprintf('CREATE INDEX %s ON %s (%s)', $name, $table, $columns));
37+
}
38+
}
39+
40+
public function down(Schema $schema): void
41+
{
42+
$isMysql = 'mysql' === $this->connection->getDatabasePlatform()->getName();
43+
44+
foreach (self::INDEXES as $name => [$table]) {
45+
$this->addSql($isMysql
46+
? sprintf('DROP INDEX %s ON %s', $name, $table)
47+
: sprintf('DROP INDEX %s', $name));
48+
}
49+
}
50+
}

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: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
#[ORM\Entity()]
88
#[ORM\Table(name: 'addressbookchanges')]
9+
// sync-collection filters on the collection and orders by synctoken
10+
#[ORM\Index(name: 'idx_addressbookchanges_book_sync', columns: ['addressbookid', 'synctoken'])]
911
class AddressBookChange
1012
{
1113
#[ORM\Id]
@@ -16,8 +18,8 @@ class AddressBookChange
1618
#[ORM\Column(type: 'string', length: 255)]
1719
private $uri;
1820

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

2224
#[ORM\ManyToOne(targetEntity: "App\Entity\AddressBook", inversedBy: 'changes')]
2325
#[ORM\JoinColumn(name: 'addressbookid', nullable: false)]
@@ -43,12 +45,12 @@ public function setUri(string $uri): self
4345
return $this;
4446
}
4547

46-
public function getSynctoken(): ?string
48+
public function getSynctoken(): ?int
4749
{
4850
return $this->synctoken;
4951
}
5052

51-
public function setSynctoken(string $synctoken): self
53+
public function setSynctoken(int $synctoken): self
5254
{
5355
$this->synctoken = $synctoken;
5456

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

src/Entity/CalendarChange.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
#[ORM\Entity()]
88
#[ORM\Table(name: 'calendarchanges')]
9+
// sync-collection filters on the collection and orders by synctoken
10+
#[ORM\Index(name: 'idx_calendarchanges_calendar_sync', columns: ['calendarid', 'synctoken'])]
911
class CalendarChange
1012
{
1113
#[ORM\Id]

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)