From 3756e1e66374a29a6bbb3d891327a91847a3f5a1 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 16 Oct 2026 18:09:35 +1300 Subject: [PATCH] fix(index): read a stored index that covers a deleted attribute 7.x left null in a stored index's attributes when an attribute it covered was deleted, and the engine dropped that column from the index. 8.x refused such metadata with 'Index attributes must be strings', so every read of the collection failed. On production that made a console teams index (_key_billingPlan, [null]) fail organization creation, and two customer collections unreadable. Index::fromDocument() now drops null attributes from stored metadata together with the lengths and orders at their positions. Index::fromArray(), which takes caller input, still refuses them. Co-Authored-By: Claude Opus 5.5 --- src/Database/Index.php | 33 +++++++++- .../LegacyDroppedAttributeIndexTest.php | 62 +++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 tests/unit/Indexes/LegacyDroppedAttributeIndexTest.php diff --git a/src/Database/Index.php b/src/Database/Index.php index de4012e00..2b7e3dc29 100644 --- a/src/Database/Index.php +++ b/src/Database/Index.php @@ -132,12 +132,18 @@ public static function ttl(string $key, string $attribute, int $ttl): self */ public static function fromDocument(Document $document): self { - return self::hydrate( - self::storedKey($document), - $document->getAttribute(self::TYPE, IndexType::Key->value), + [$attributes, $lengths, $orders] = self::withoutDroppedAttributes( $document->getAttribute(self::ATTRIBUTES, []), $document->getAttribute(self::LENGTHS, []), $document->getAttribute(self::ORDERS, []), + ); + + return self::hydrate( + self::storedKey($document), + $document->getAttribute(self::TYPE, IndexType::Key->value), + $attributes, + $lengths, + $orders, $document->getAttribute(self::TTL), ); } @@ -176,6 +182,27 @@ private static function storedKey(Document $document): mixed return \is_string($key) && $id === '_'.$key && \str_starts_with($id, '_index_') ? $id : $key; } + /** + * 7.x left null in a stored index's attributes when an attribute it covered was deleted, while the engine dropped + * that column from the index. Such an index is read as covering the attributes it still has, with the lengths and + * orders at their positions. + * + * @return array{mixed, mixed, mixed} + */ + private static function withoutDroppedAttributes(mixed $attributes, mixed $lengths, mixed $orders): array + { + if (! \is_array($attributes) || ! \in_array(null, $attributes, true)) { + return [$attributes, $lengths, $orders]; + } + + $kept = \array_flip(\array_keys(\array_filter($attributes, static fn (mixed $attribute): bool => $attribute !== null))); + $alongside = static fn (mixed $values): mixed => \is_array($values) && \count($values) === \count($attributes) + ? \array_values(\array_intersect_key($values, $kept)) + : $values; + + return [\array_values(\array_intersect_key($attributes, $kept)), $alongside($lengths), $alongside($orders)]; + } + /** * @internal */ diff --git a/tests/unit/Indexes/LegacyDroppedAttributeIndexTest.php b/tests/unit/Indexes/LegacyDroppedAttributeIndexTest.php new file mode 100644 index 000000000..070ef34f6 --- /dev/null +++ b/tests/unit/Indexes/LegacyDroppedAttributeIndexTest.php @@ -0,0 +1,62 @@ + 'category', 'key' => 'category', 'type' => 'fulltext', 'attributes' => [null], 'orders' => ['ASC'], 'lengths' => [200]])); + + $this->assertSame('category', $index->key); + $this->assertSame([], $index->attributes); + $this->assertSame([], $index->lengths); + $this->assertSame([], $index->orders); + } + + public function testAUniqueIndexKeepsTheAttributesItStillCoversWithTheirLengthsAndOrders(): void + { + $index = Index::fromDocument(new Document(['$id' => 'index_2', 'key' => 'index_2', 'type' => 'unique', 'attributes' => ['playId', null], 'orders' => ['ASC', 'ASC'], 'lengths' => [null, null]])); + + $this->assertSame(['playId'], $index->attributes); + $this->assertSame([null], $index->lengths); + $this->assertSame([OrderDirection::Asc], $index->orders); + } + + public function testADroppedAttributeBetweenOthersKeepsEachLengthAndOrderAtItsAttribute(): void + { + $index = Index::fromDocument(new Document(['$id' => 'mixed', 'key' => 'mixed', 'type' => 'key', 'attributes' => ['a', null, 'c'], 'orders' => ['ASC', 'DESC', 'DESC'], 'lengths' => [10, 20, 30]])); + + $this->assertSame(['a', 'c'], $index->attributes); + $this->assertSame([10, 30], $index->lengths); + $this->assertSame([OrderDirection::Asc, OrderDirection::Desc], $index->orders); + } + + public function testACollectionHoldingSuchAnIndexIsRead(): void + { + $collection = Collection::fromDocument(new Document([ + '$id' => 'plays', + 'name' => 'plays', + 'attributes' => [Attribute::string(key: 'playId', size: 36)->toDocument()], + 'indexes' => [new Document(['$id' => 'index_2', 'key' => 'index_2', 'type' => 'unique', 'attributes' => ['playId', null], 'orders' => ['ASC', 'ASC'], 'lengths' => [null, null]])], + ])); + + $this->assertSame(['playId'], $collection->indexes()[0]->attributes); + } + + public function testAnIndexDefinedByACallerStillRefusesAnAttributeThatIsNotAString(): void + { + $this->expectException(IndexException::class); + $this->expectExceptionMessage('Index attributes must be strings'); + + Index::fromArray(['$id' => 'bad', 'type' => 'key', 'attributes' => ['playId', null]]); + } +}