Skip to content

Commit 504c45e

Browse files
authored
Merge branch 'main' into fix-one-way-relationship-twowaykey
2 parents 4174c5c + f121418 commit 504c45e

File tree

9 files changed

+356
-26
lines changed

9 files changed

+356
-26
lines changed

src/Database/Adapter.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,13 @@ abstract public function getSupportForAttributes(): bool;
958958
*/
959959
abstract public function getSupportForSchemaAttributes(): bool;
960960

961+
/**
962+
* Are schema indexes supported?
963+
*
964+
* @return bool
965+
*/
966+
abstract public function getSupportForSchemaIndexes(): bool;
967+
961968
/**
962969
* Is index supported?
963970
*
@@ -1365,6 +1372,17 @@ abstract public function getInternalIndexesKeys(): array;
13651372
*/
13661373
abstract public function getSchemaAttributes(string $collection): array;
13671374

1375+
/**
1376+
* Get Schema Indexes
1377+
*
1378+
* Returns physical index definitions from the database schema.
1379+
*
1380+
* @param string $collection
1381+
* @return array<Document>
1382+
* @throws DatabaseException
1383+
*/
1384+
abstract public function getSchemaIndexes(string $collection): array;
1385+
13681386
/**
13691387
* Get the expected column type for a given attribute type.
13701388
*

src/Database/Adapter/MariaDB.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1845,6 +1845,58 @@ public function getSupportForSchemaAttributes(): bool
18451845
return true;
18461846
}
18471847

1848+
public function getSupportForSchemaIndexes(): bool
1849+
{
1850+
return true;
1851+
}
1852+
1853+
public function getSchemaIndexes(string $collection): array
1854+
{
1855+
$schema = $this->getDatabase();
1856+
$collection = $this->getNamespace() . '_' . $this->filter($collection);
1857+
1858+
try {
1859+
$stmt = $this->getPDO()->prepare('
1860+
SELECT
1861+
INDEX_NAME as indexName,
1862+
COLUMN_NAME as columnName,
1863+
NON_UNIQUE as nonUnique,
1864+
SEQ_IN_INDEX as seqInIndex,
1865+
INDEX_TYPE as indexType,
1866+
SUB_PART as subPart
1867+
FROM INFORMATION_SCHEMA.STATISTICS
1868+
WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table
1869+
ORDER BY INDEX_NAME, SEQ_IN_INDEX
1870+
');
1871+
$stmt->bindParam(':schema', $schema);
1872+
$stmt->bindParam(':table', $collection);
1873+
$stmt->execute();
1874+
$rows = $stmt->fetchAll();
1875+
$stmt->closeCursor();
1876+
1877+
$grouped = [];
1878+
foreach ($rows as $row) {
1879+
$name = $row['indexName'];
1880+
if (!isset($grouped[$name])) {
1881+
$grouped[$name] = [
1882+
'$id' => $name,
1883+
'indexName' => $name,
1884+
'indexType' => $row['indexType'],
1885+
'nonUnique' => (int)$row['nonUnique'],
1886+
'columns' => [],
1887+
'lengths' => [],
1888+
];
1889+
}
1890+
$grouped[$name]['columns'][] = $row['columnName'];
1891+
$grouped[$name]['lengths'][] = $row['subPart'] !== null ? (int)$row['subPart'] : null;
1892+
}
1893+
1894+
return \array_map(fn ($idx) => new Document($idx), \array_values($grouped));
1895+
} catch (PDOException $e) {
1896+
throw new DatabaseException('Failed to get schema indexes', $e->getCode(), $e);
1897+
}
1898+
}
1899+
18481900
/**
18491901
* Set max execution time
18501902
* @param int $milliseconds

src/Database/Adapter/Mongo.php

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,8 +414,10 @@ public function createCollection(string $name, array $attributes = [], array $in
414414
{
415415
$id = $this->getNamespace() . '_' . $this->filter($name);
416416

417-
// For metadata collections outside transactions, check if exists first
418-
if (!$this->inTransaction && $name === Database::METADATA && $this->exists($this->getNamespace(), $name)) {
417+
// In shared-tables mode or for metadata, the physical collection may
418+
// already exist for another tenant. Return early to avoid a
419+
// "Collection Exists" exception from the client.
420+
if (!$this->inTransaction && ($this->getSharedTables() || $name === Database::METADATA) && $this->exists($this->getNamespace(), $name)) {
419421
return true;
420422
}
421423

@@ -428,6 +430,16 @@ public function createCollection(string $name, array $attributes = [], array $in
428430
if ($e instanceof DuplicateException) {
429431
return true;
430432
}
433+
// Client throws code-0 "Collection Exists" when its pre-check
434+
// finds the collection. In shared-tables/metadata context this
435+
// is a no-op; otherwise re-throw as DuplicateException so
436+
// Database::createCollection() can run orphan reconciliation.
437+
if ($e->getCode() === 0 && stripos($e->getMessage(), 'Collection Exists') !== false) {
438+
if ($this->getSharedTables() || $name === Database::METADATA) {
439+
return true;
440+
}
441+
throw new DuplicateException('Collection already exists', $e->getCode(), $e);
442+
}
431443
throw $e;
432444
}
433445

@@ -3599,6 +3611,16 @@ public function getSchemaAttributes(string $collection): array
35993611
return [];
36003612
}
36013613

3614+
public function getSupportForSchemaIndexes(): bool
3615+
{
3616+
return false;
3617+
}
3618+
3619+
public function getSchemaIndexes(string $collection): array
3620+
{
3621+
return [];
3622+
}
3623+
36023624
/**
36033625
* @param string $collection
36043626
* @param array<int|string> $tenants

src/Database/Adapter/Pool.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,16 @@ public function getSchemaAttributes(string $collection): array
563563
return $this->delegate(__FUNCTION__, \func_get_args());
564564
}
565565

566+
public function getSupportForSchemaIndexes(): bool
567+
{
568+
return $this->delegate(__FUNCTION__, \func_get_args());
569+
}
570+
571+
public function getSchemaIndexes(string $collection): array
572+
{
573+
return $this->delegate(__FUNCTION__, \func_get_args());
574+
}
575+
566576
public function getTenantQuery(string $collection, string $alias = ''): string
567577
{
568578
return $this->delegate(__FUNCTION__, \func_get_args());

src/Database/Adapter/Postgres.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,11 @@ public function deleteCollection(string $id): bool
439439
$sql = "DROP TABLE {$this->getSQLTable($id)}, {$this->getSQLTable($id . '_perms')}";
440440
$sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql);
441441

442-
return $this->getPDO()->prepare($sql)->execute();
442+
try {
443+
return $this->getPDO()->prepare($sql)->execute();
444+
} catch (PDOException $e) {
445+
throw $this->processException($e);
446+
}
443447
}
444448

445449
/**
@@ -2142,6 +2146,11 @@ public function getSupportForSchemaAttributes(): bool
21422146
return false;
21432147
}
21442148

2149+
public function getSupportForSchemaIndexes(): bool
2150+
{
2151+
return false;
2152+
}
2153+
21452154
public function getSupportForUpserts(): bool
21462155
{
21472156
return true;
@@ -2229,6 +2238,11 @@ protected function processException(PDOException $e): \Exception
22292238
return new LimitException('Datetime field overflow', $e->getCode(), $e);
22302239
}
22312240

2241+
// Unknown table
2242+
if ($e->getCode() === '42P01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) {
2243+
return new NotFoundException('Collection not found', $e->getCode(), $e);
2244+
}
2245+
22322246
// Unknown column
22332247
if ($e->getCode() === "42703" && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) {
22342248
return new NotFoundException('Attribute not found', $e->getCode(), $e);

src/Database/Adapter/SQL.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2328,6 +2328,16 @@ public function getSchemaAttributes(string $collection): array
23282328
return [];
23292329
}
23302330

2331+
public function getSchemaIndexes(string $collection): array
2332+
{
2333+
return [];
2334+
}
2335+
2336+
public function getSupportForSchemaIndexes(): bool
2337+
{
2338+
return false;
2339+
}
2340+
23312341
public function getTenantQuery(
23322342
string $collection,
23332343
string $alias = '',

src/Database/Adapter/SQLite.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,11 @@ public function getSupportForSchemaAttributes(): bool
974974
return false;
975975
}
976976

977+
public function getSupportForSchemaIndexes(): bool
978+
{
979+
return false;
980+
}
981+
977982
/**
978983
* Is upsert supported?
979984
*
@@ -1312,6 +1317,16 @@ protected function processException(PDOException $e): \Exception
13121317
return new TimeoutException('Query timed out', $e->getCode(), $e);
13131318
}
13141319

1320+
// Table/index already exists (SQLITE_ERROR with "already exists" message)
1321+
if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1 && stripos($e->getMessage(), 'already exists') !== false) {
1322+
return new DuplicateException('Collection already exists', $e->getCode(), $e);
1323+
}
1324+
1325+
// Table not found (SQLITE_ERROR with "no such table" message)
1326+
if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1 && stripos($e->getMessage(), 'no such table') !== false) {
1327+
return new NotFoundException('Collection not found', $e->getCode(), $e);
1328+
}
1329+
13151330
// Duplicate - SQLite uses various error codes for constraint violations:
13161331
// - Error code 19 is SQLITE_CONSTRAINT (includes UNIQUE violations)
13171332
// - Error code 1 is also used for some duplicate cases
@@ -1320,7 +1335,6 @@ protected function processException(PDOException $e): \Exception
13201335
($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && ($e->errorInfo[1] === 1 || $e->errorInfo[1] === 19)) ||
13211336
$e->getCode() === '23000'
13221337
) {
1323-
// Check if it's actually a duplicate/unique constraint violation
13241338
$message = $e->getMessage();
13251339
if (
13261340
(isset($e->errorInfo[1]) && $e->errorInfo[1] === 19) ||

src/Database/Database.php

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1790,19 +1790,31 @@ public function createCollection(string $id, array $attributes = [], array $inde
17901790
}
17911791
}
17921792

1793+
$createdPhysicalTable = false;
1794+
17931795
try {
17941796
$this->adapter->createCollection($id, $attributes, $indexes);
1797+
$createdPhysicalTable = true;
17951798
} catch (DuplicateException $e) {
1796-
// Metadata check (above) already verified collection is absent
1797-
// from metadata. A DuplicateException from the adapter means the
1798-
// collection exists only in physical schema — an orphan from a prior
1799-
// partial failure. Drop and recreate to ensure schema matches.
1800-
try {
1801-
$this->adapter->deleteCollection($id);
1802-
} catch (NotFoundException) {
1803-
// Already removed by a concurrent reconciler.
1799+
if ($this->adapter->getSharedTables()
1800+
&& ($id === self::METADATA || $this->adapter->exists($this->adapter->getDatabase(), $id))) {
1801+
// In shared-tables mode the physical table is reused across
1802+
// tenants. A DuplicateException simply means the table already
1803+
// exists for another tenant — not an orphan.
1804+
} else {
1805+
// Metadata check (above) already verified collection is absent
1806+
// from metadata. A DuplicateException from the adapter means
1807+
// the collection exists only in physical schema — an orphan
1808+
// from a prior partial failure. Drop and recreate to ensure
1809+
// schema matches.
1810+
try {
1811+
$this->adapter->deleteCollection($id);
1812+
} catch (NotFoundException) {
1813+
// Already removed by a concurrent reconciler.
1814+
}
1815+
$this->adapter->createCollection($id, $attributes, $indexes);
1816+
$createdPhysicalTable = true;
18041817
}
1805-
$this->adapter->createCollection($id, $attributes, $indexes);
18061818
}
18071819

18081820
if ($id === self::METADATA) {
@@ -1812,10 +1824,12 @@ public function createCollection(string $id, array $attributes = [], array $inde
18121824
try {
18131825
$createdCollection = $this->silent(fn () => $this->createDocument(self::METADATA, $collection));
18141826
} catch (\Throwable $e) {
1815-
try {
1816-
$this->cleanupCollection($id);
1817-
} catch (\Throwable $e) {
1818-
Console::error("Failed to rollback collection '{$id}': " . $e->getMessage());
1827+
if ($createdPhysicalTable) {
1828+
try {
1829+
$this->cleanupCollection($id);
1830+
} catch (\Throwable $e) {
1831+
Console::error("Failed to rollback collection '{$id}': " . $e->getMessage());
1832+
}
18191833
}
18201834
throw new DatabaseException("Failed to create collection metadata for '{$id}': " . $e->getMessage(), previous: $e);
18211835
}
@@ -4562,18 +4576,49 @@ public function createIndex(string $collection, string $id, string $type, array
45624576
}
45634577

45644578
$created = false;
4579+
$existsInSchema = false;
45654580

4566-
try {
4567-
$created = $this->adapter->createIndex($collection->getId(), $id, $type, $attributes, $lengths, $orders, $indexAttributesWithTypes, [], $ttl);
4581+
if ($this->adapter->getSupportForSchemaIndexes()
4582+
&& !($this->adapter->getSharedTables() && $this->isMigrating())) {
4583+
$schemaIndexes = $this->getSchemaIndexes($collection->getId());
4584+
$filteredId = $this->adapter->filter($id);
45684585

4569-
if (!$created) {
4570-
throw new DatabaseException('Failed to create index');
4586+
foreach ($schemaIndexes as $schemaIndex) {
4587+
if (\strtolower($schemaIndex->getId()) === \strtolower($filteredId)) {
4588+
$schemaColumns = $schemaIndex->getAttribute('columns', []);
4589+
$schemaLengths = $schemaIndex->getAttribute('lengths', []);
4590+
4591+
$filteredAttributes = \array_map(fn ($a) => $this->adapter->filter($a), $attributes);
4592+
$match = ($schemaColumns === $filteredAttributes && $schemaLengths === $lengths);
4593+
4594+
if ($match) {
4595+
$existsInSchema = true;
4596+
} else {
4597+
// Orphan index with wrong definition — drop so it
4598+
// gets recreated with the correct shape.
4599+
try {
4600+
$this->adapter->deleteIndex($collection->getId(), $id);
4601+
} catch (NotFoundException) {
4602+
}
4603+
}
4604+
break;
4605+
}
4606+
}
4607+
}
4608+
4609+
if (!$existsInSchema) {
4610+
try {
4611+
$created = $this->adapter->createIndex($collection->getId(), $id, $type, $attributes, $lengths, $orders, $indexAttributesWithTypes, [], $ttl);
4612+
4613+
if (!$created) {
4614+
throw new DatabaseException('Failed to create index');
4615+
}
4616+
} catch (DuplicateException) {
4617+
// Metadata check (lines above) already verified index is absent
4618+
// from metadata. A DuplicateException from the adapter means the
4619+
// index exists only in physical schema — an orphan from a prior
4620+
// partial failure. Skip creation and proceed to metadata update.
45714621
}
4572-
} catch (DuplicateException $e) {
4573-
// Metadata check (lines above) already verified index is absent
4574-
// from metadata. A DuplicateException from the adapter means the
4575-
// index exists only in physical schema — an orphan from a prior
4576-
// partial failure. Skip creation and proceed to metadata update.
45774622
}
45784623

45794624
$collection->setAttribute('indexes', $index, Document::SET_TYPE_APPEND);
@@ -9208,6 +9253,15 @@ public function getSchemaAttributes(string $collection): array
92089253
return $this->adapter->getSchemaAttributes($collection);
92099254
}
92109255

9256+
/**
9257+
* @param string $collection
9258+
* @return array<Document>
9259+
*/
9260+
public function getSchemaIndexes(string $collection): array
9261+
{
9262+
return $this->adapter->getSchemaIndexes($collection);
9263+
}
9264+
92119265
/**
92129266
* @param string $collectionId
92139267
* @param string|null $documentId

0 commit comments

Comments
 (0)