Skip to content

Commit 2f4e95f

Browse files
abnegateclaude
andcommitted
fix(adapter): stop hasFeature() claiming the adapter implements the feature
hasFeature() carried @phpstan-assert-if-true T $this, telling every caller that a true answer means the adapter is an instance of the Feature interface. Pool answers for the adapter it borrows without implementing any Feature interface, so code trusting that narrowing reached for instanceof, which is false on a pool and refused spatial indexes when migrating into Appwrite. The assertion is gone from the public method and the Capability docblock now says to ask hasFeature(), not instanceof. Database still calls Feature methods after asking, so it narrows its own adapter through a private adapterHasFeature(); that holds because Pool declares every Feature method. A PHPStan type-inference test pins that hasFeature() no longer narrows an Adapter or a Pool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 63a6f6c commit 2f4e95f

10 files changed

Lines changed: 104 additions & 43 deletions

File tree

‎src/Database/Adapter.php‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,11 @@ public function supports(Capability $feature): bool
114114
}
115115

116116
/**
117-
* @template T of object
117+
* Whether this adapter offers the optional methods of a Feature interface. A proxy such as Pool answers for
118+
* the adapter it delegates to without implementing the interface itself, so callers ask this rather than
119+
* use instanceof.
118120
*
119-
* @param class-string<T> $feature
120-
*
121-
* @phpstan-assert-if-true T $this
121+
* @param class-string $feature
122122
*/
123123
public function hasFeature(string $feature): bool
124124
{

‎src/Database/Capability.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
/**
66
* Defines the set of optional behavioral capabilities that a database adapter may support.
77
*
8-
* Feature availability (method contracts) is expressed via Feature interfaces
9-
* on the adapter class and checked with instanceof, not capabilities.
8+
* Feature availability (method contracts) is expressed via Feature interfaces on the adapter class and checked
9+
* with Adapter::hasFeature(), not capabilities and not instanceof, which is false on a proxy such as Pool.
1010
*/
1111
enum Capability
1212
{

‎src/Database/Database.php‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ static function (?string $value, Document $document, Database $database) {
573573
if ($value === null) {
574574
return null;
575575
}
576-
if ($database->adapter->hasFeature(Feature\Spatial::class)) {
576+
if ($database->adapterHasFeature(Feature\Spatial::class)) {
577577
return $database->adapter->decodePoint($value);
578578
}
579579

@@ -603,7 +603,7 @@ static function (?string $value, Document $document, Database $database) {
603603
if (is_null($value)) {
604604
return null;
605605
}
606-
if ($database->adapter->hasFeature(Feature\Spatial::class)) {
606+
if ($database->adapterHasFeature(Feature\Spatial::class)) {
607607
return $database->adapter->decodeLinestring($value);
608608
}
609609

@@ -633,7 +633,7 @@ static function (?string $value, Document $document, Database $database) {
633633
if (is_null($value)) {
634634
return null;
635635
}
636-
if ($database->adapter->hasFeature(Feature\Spatial::class)) {
636+
if ($database->adapterHasFeature(Feature\Spatial::class)) {
637637
return $database->adapter->decodePolygon($value);
638638
}
639639

@@ -822,6 +822,21 @@ public function getAdapter(): Adapter
822822
return $this->adapter;
823823
}
824824

825+
/**
826+
* Pool answers for the adapter it borrows without implementing the Feature interface, but declares every
827+
* Feature method, so a true answer makes those methods callable on the adapter either way.
828+
*
829+
* @template T of object
830+
*
831+
* @param class-string<T> $feature
832+
*
833+
* @phpstan-assert-if-true T $this->adapter
834+
*/
835+
private function adapterHasFeature(string $feature): bool
836+
{
837+
return $this->adapter->hasFeature($feature);
838+
}
839+
825840
/**
826841
* Get a utopia-php/query Builder over a collection's table, for statements the document API
827842
* cannot express. Its statements run as written: they check no permissions, read past and never
@@ -842,7 +857,7 @@ public function from(string $collection): \Utopia\Query\Builder
842857
{
843858
$this->requireSkippedAuthorization();
844859

845-
if (! $this->adapter->hasFeature(Feature\QueryBuilder::class)) {
860+
if (! $this->adapterHasFeature(Feature\QueryBuilder::class)) {
846861
throw new DatabaseException('Query builder is not supported by this adapter');
847862
}
848863

@@ -857,7 +872,7 @@ public function from(string $collection): \Utopia\Query\Builder
857872
*/
858873
public function schema(): \Utopia\Query\Schema
859874
{
860-
if (! $this->adapter->hasFeature(Feature\QueryBuilder::class)) {
875+
if (! $this->adapterHasFeature(Feature\QueryBuilder::class)) {
861876
throw new DatabaseException('Schema builder is not supported by this adapter');
862877
}
863878

@@ -878,7 +893,7 @@ public function execute(\Utopia\Query\Builder|\Utopia\Query\Builder\Statement $q
878893
{
879894
$this->requireSkippedAuthorization();
880895

881-
if (! $this->adapter->hasFeature(Feature\RawQuery::class)) {
896+
if (! $this->adapterHasFeature(Feature\RawQuery::class)) {
882897
throw new DatabaseException('Raw queries are not supported by this adapter');
883898
}
884899

@@ -2434,7 +2449,7 @@ public function withRequestTimestamp(?NativeDateTime $requestTimestamp, callable
24342449
*/
24352450
public function getConnectionId(): string
24362451
{
2437-
if (! $this->adapter->hasFeature(Feature\ConnectionId::class)) {
2452+
if (! $this->adapterHasFeature(Feature\ConnectionId::class)) {
24382453
throw new DatabaseException('Adapter does not support connection ids');
24392454
}
24402455

@@ -2671,7 +2686,7 @@ private function convertQueryWithMap(Query $query, array $attributesById, bool $
26712686
foreach ($values as $valueIndex => $value) {
26722687
try {
26732688
/** @var string $value */
2674-
$values[$valueIndex] = $this->adapter->hasFeature(Feature\UTCCasting::class)
2689+
$values[$valueIndex] = $this->adapterHasFeature(Feature\UTCCasting::class)
26752690
? $this->adapter->setUTCDatetime($value)
26762691
: DateTime::setTimezone($value);
26772692
} catch (Throwable $e) {
@@ -2835,7 +2850,7 @@ public function getInternalAttributes(): array
28352850
*/
28362851
public function getSchemaAttributes(string $collection): array
28372852
{
2838-
if (! $this->adapter->hasFeature(Feature\SchemaAttributes::class)) {
2853+
if (! $this->adapterHasFeature(Feature\SchemaAttributes::class)) {
28392854
return [];
28402855
}
28412856

@@ -2850,7 +2865,7 @@ public function getSchemaAttributes(string $collection): array
28502865
*/
28512866
public function getSchemaIndexes(string $collection): array
28522867
{
2853-
if (! $this->adapter->hasFeature(Feature\SchemaIndexes::class)) {
2868+
if (! $this->adapterHasFeature(Feature\SchemaIndexes::class)) {
28542869
return [];
28552870
}
28562871

‎src/Database/Traits/Attributes.php‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ public function createAttribute(string $collection, Attribute $attribute): bool
8787

8888
$existsInSchema = false;
8989

90-
$schemaAttributes = $this->adapter->hasFeature(Feature\SchemaAttributes::class)
90+
$schemaAttributes = $this->adapterHasFeature(Feature\SchemaAttributes::class)
9191
? $this->getSchemaAttributes($collection->getId())
9292
: [];
9393

@@ -182,7 +182,7 @@ public function createAttributes(string $collection, array $attributes): bool
182182
throw new NotFoundException('Collection not found');
183183
}
184184

185-
$schemaAttributes = $this->adapter->hasFeature(Feature\SchemaAttributes::class)
185+
$schemaAttributes = $this->adapterHasFeature(Feature\SchemaAttributes::class)
186186
? $this->getSchemaAttributes($collection->getId())
187187
: [];
188188

@@ -315,7 +315,7 @@ private function reconcileSchemaOnlyColumn(
315315
}
316316
}
317317

318-
if (! $this->adapter->hasFeature(Feature\ColumnTypes::class)) {
318+
if (! $this->adapterHasFeature(Feature\ColumnTypes::class)) {
319319
return true;
320320
}
321321

@@ -416,7 +416,7 @@ private function validateAttribute(
416416
/** @var array<Attribute> $existingAttributes */
417417
$existingAttributes = $collection->getAttribute('attributes', []);
418418

419-
$resolvedSchemaAttributes = $schemaAttributes ?? ($this->adapter->hasFeature(Feature\SchemaAttributes::class)
419+
$resolvedSchemaAttributes = $schemaAttributes ?? ($this->adapterHasFeature(Feature\SchemaAttributes::class)
420420
? $this->getSchemaAttributes($collection->getId())
421421
: []);
422422
$typedSchemaAttrs = [];
@@ -433,9 +433,9 @@ private function validateAttribute(
433433
maxVarcharLength: $this->adapter->getMaxVarcharLength(),
434434
maxIntLength: $this->adapter->getLimitForInt(),
435435
maxBigIntLength: $this->adapter->getLimitForBigInt(),
436-
supportForSchemaAttributes: $this->adapter->hasFeature(Feature\SchemaAttributes::class),
436+
supportForSchemaAttributes: $this->adapterHasFeature(Feature\SchemaAttributes::class),
437437
supportForVectors: $this->adapter->supports(Capability::Vectors),
438-
supportForSpatialAttributes: $this->adapter->hasFeature(Feature\Spatial::class),
438+
supportForSpatialAttributes: $this->adapterHasFeature(Feature\Spatial::class),
439439
supportForObject: $this->adapter->supports(Capability::Objects),
440440
supportUnsignedBigInt: $this->adapter->supports(Capability::UnsignedBigInt),
441441
attributeCountCallback: fn (Document $attrDoc) => $this->adapter->getCountOfAttributes($collectionClone),
@@ -554,7 +554,7 @@ private function unknownType(string $type): DatabaseException
554554
{
555555
$availableTypes = Attribute::availableTypes(
556556
objects: $this->adapter->supports(Capability::Objects),
557-
spatial: $this->adapter->hasFeature(Feature\Spatial::class),
557+
spatial: $this->adapterHasFeature(Feature\Spatial::class),
558558
vectors: $this->adapter->supports(Capability::Vectors),
559559
);
560560

@@ -573,7 +573,7 @@ private function typeValidator(): AttributeValidator
573573
maxIntLength: $this->adapter->getLimitForInt(),
574574
maxBigIntLength: $this->adapter->getLimitForBigInt(),
575575
supportForVectors: $this->adapter->supports(Capability::Vectors),
576-
supportForSpatialAttributes: $this->adapter->hasFeature(Feature\Spatial::class),
576+
supportForSpatialAttributes: $this->adapterHasFeature(Feature\Spatial::class),
577577
supportForObject: $this->adapter->supports(Capability::Objects),
578578
supportUnsignedBigInt: $this->adapter->supports(Capability::UnsignedBigInt),
579579
);
@@ -898,7 +898,7 @@ public function updateAttribute(string $collection, string $id, ColumnType|strin
898898
case ColumnType::Point->value:
899899
case ColumnType::Linestring->value:
900900
case ColumnType::Polygon->value:
901-
if (! $this->adapter->hasFeature(Feature\Spatial::class)) {
901+
if (! $this->adapterHasFeature(Feature\Spatial::class)) {
902902
throw new DatabaseException('Spatial attributes are not supported');
903903
}
904904
if (! empty($size)) {
@@ -1052,7 +1052,7 @@ public function updateAttribute(string $collection, string $id, ColumnType|strin
10521052
$this->adapter->supports(Capability::IdenticalIndexes),
10531053
$this->adapter->supports(Capability::ObjectIndexes),
10541054
$this->adapter->supports(Capability::TrigramIndex),
1055-
$this->adapter->hasFeature(Feature\Spatial::class),
1055+
$this->adapterHasFeature(Feature\Spatial::class),
10561056
$this->adapter->supports(Capability::Index),
10571057
$this->adapter->supports(Capability::UniqueIndex),
10581058
$this->adapter->supports(Capability::Fulltext),

‎src/Database/Traits/Collections.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ public function createCollection(Collection $collection): Collection
150150
$this->adapter->supports(Capability::IdenticalIndexes),
151151
$this->adapter->supports(Capability::ObjectIndexes),
152152
$this->adapter->supports(Capability::TrigramIndex),
153-
$this->adapter->hasFeature(Feature\Spatial::class),
153+
$this->adapterHasFeature(Feature\Spatial::class),
154154
$this->adapter->supports(Capability::Index),
155155
$this->adapter->supports(Capability::UniqueIndex),
156156
$this->adapter->supports(Capability::Fulltext),

‎src/Database/Traits/Documents.php‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2079,7 +2079,7 @@ public function upsertDocumentsWithIncrease(
20792079
?callable $onError = null,
20802080
int $batchSize = self::INSERT_BATCH_SIZE
20812081
): int {
2082-
if (! $this->adapter->hasFeature(Feature\Upserts::class)) {
2082+
if (! $this->adapterHasFeature(Feature\Upserts::class)) {
20832083
throw new DatabaseException('Adapter does not support upserts');
20842084
}
20852085

@@ -2325,7 +2325,7 @@ public function upsertDocumentsWithIncrease(
23252325
Event::DocumentsUpsert,
23262326
\array_map(static fn (Change $change): Document => $change->getNew(), $chunk),
23272327
function () use ($collection, $attribute, $chunk): array {
2328-
if (! $this->adapter->hasFeature(Feature\Upserts::class)) {
2328+
if (! $this->adapterHasFeature(Feature\Upserts::class)) {
23292329
throw new DatabaseException('Adapter does not support upserts');
23302330
}
23312331

@@ -4148,7 +4148,7 @@ public function rawQuery(string $query, array $bindings = []): array
41484148
{
41494149
$this->requireSkippedAuthorization();
41504150

4151-
if (! $this->adapter->hasFeature(Feature\RawQuery::class)) {
4151+
if (! $this->adapterHasFeature(Feature\RawQuery::class)) {
41524152
throw new DatabaseException('Raw queries are not supported by this adapter');
41534153
}
41544154

@@ -5307,7 +5307,7 @@ private function checkQueryTypes(array $queries): void
53075307

53085308
private function castingBefore(Document $collection, Document $document): Document
53095309
{
5310-
if ($this->adapter->hasFeature(Feature\InternalCasting::class)) {
5310+
if ($this->adapterHasFeature(Feature\InternalCasting::class)) {
53115311
return $this->adapter->castingBefore($collection, $document);
53125312
}
53135313

@@ -5316,7 +5316,7 @@ private function castingBefore(Document $collection, Document $document): Docume
53165316

53175317
private function castingAfter(Document $collection, Document $document): Document
53185318
{
5319-
if ($this->adapter->hasFeature(Feature\InternalCasting::class)) {
5319+
if ($this->adapterHasFeature(Feature\InternalCasting::class)) {
53205320
return $this->adapter->castingAfter($collection, $document);
53215321
}
53225322

@@ -5329,7 +5329,7 @@ private function castingAfter(Document $collection, Document $document): Documen
53295329
*/
53305330
private function castingAfterDocuments(Document $collection, array $documents): array
53315331
{
5332-
if ($documents !== [] && $this->adapter->hasFeature(Feature\InternalCasting::class)) {
5332+
if ($documents !== [] && $this->adapterHasFeature(Feature\InternalCasting::class)) {
53335333
return $this->adapter->castingAfterDocuments($collection, $documents);
53345334
}
53355335

‎src/Database/Traits/Indexes.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ public function createIndex(string $collection, Index $index): bool
136136
$this->adapter->supports(Capability::IdenticalIndexes),
137137
$this->adapter->supports(Capability::ObjectIndexes),
138138
$this->adapter->supports(Capability::TrigramIndex),
139-
$this->adapter->hasFeature(Feature\Spatial::class),
139+
$this->adapterHasFeature(Feature\Spatial::class),
140140
$this->adapter->supports(Capability::Index),
141141
$this->adapter->supports(Capability::UniqueIndex),
142142
$this->adapter->supports(Capability::Fulltext),
@@ -195,7 +195,7 @@ public function createIndex(string $collection, Index $index): bool
195195
*/
196196
private function reconcileSchemaOnlyIndex(string $collection, Index $index): bool
197197
{
198-
if (! $this->adapter->hasFeature(Feature\SchemaIndexes::class)
198+
if (! $this->adapterHasFeature(Feature\SchemaIndexes::class)
199199
|| ($this->getSharedTables() && $this->isMigrating())) {
200200
return false;
201201
}

‎src/Database/Traits/Relationships.php‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,10 @@ private function cleanupRelationship(
9090
RelationSide $side = RelationSide::Parent,
9191
int $maxAttempts = 3
9292
): void {
93-
$adapter = $this->adapter;
94-
if (! $adapter->hasFeature(Feature\Relationships::class)) {
93+
if (! $this->adapterHasFeature(Feature\Relationships::class)) {
9594
throw new DatabaseException('Adapter does not support relationships');
9695
}
96+
$adapter = $this->adapter;
9797

9898
$relationshipModel = new Relationship(
9999
collection: $collectionId,
@@ -128,7 +128,7 @@ private function cleanupRelationship(
128128
public function createRelationship(
129129
Relationship $relationship
130130
): bool {
131-
if (! $this->adapter->hasFeature(Feature\Relationships::class)) {
131+
if (! $this->adapterHasFeature(Feature\Relationships::class)) {
132132
throw new DatabaseException('Adapter does not support relationships');
133133
}
134134

@@ -419,7 +419,7 @@ public function updateRelationship(
419419
?bool $twoWay = null,
420420
?ForeignKeyAction $onDelete = null
421421
): bool {
422-
if (! $this->adapter->hasFeature(Feature\Relationships::class)) {
422+
if (! $this->adapterHasFeature(Feature\Relationships::class)) {
423423
throw new DatabaseException('Adapter does not support relationships');
424424
}
425425

@@ -499,7 +499,7 @@ public function updateRelationship(
499499
// Check if the rename already happened in schema (orphan from prior
500500
// partial failure where adapter succeeded but metadata+rollback failed).
501501
// If the new column names already exist, the prior rename completed.
502-
if ($this->adapter->hasFeature(Feature\SchemaAttributes::class)) {
502+
if ($this->adapterHasFeature(Feature\SchemaAttributes::class)) {
503503
$schemaAttributes = $this->getSchemaAttributes($collection->getId());
504504
$filteredNewKey = $this->adapter->filter($actualNewKey);
505505
$newKeyExists = false;
@@ -596,7 +596,7 @@ public function updateRelationship(
596596
}
597597
}
598598

599-
if ($adapterUpdated && $this->adapter->hasFeature(Feature\Relationships::class)) {
599+
if ($adapterUpdated && $this->adapterHasFeature(Feature\Relationships::class)) {
600600
try {
601601
$renamed = new Relationship(
602602
collection: $collection->getId(),
@@ -689,7 +689,7 @@ function ($index) use ($newKey) {
689689
throw new RelationshipException('Invalid relationship type.');
690690
}
691691
} catch (Throwable $error) {
692-
if ($adapterUpdated && $this->adapter->hasFeature(Feature\Relationships::class)) {
692+
if ($adapterUpdated && $this->adapterHasFeature(Feature\Relationships::class)) {
693693
try {
694694
$renamed = new Relationship(
695695
collection: $collection->getId(),
@@ -794,7 +794,7 @@ function ($index) use ($newKey) {
794794
*/
795795
public function deleteRelationship(string $collection, string $id): bool
796796
{
797-
if (! $this->adapter->hasFeature(Feature\Relationships::class)) {
797+
if (! $this->adapterHasFeature(Feature\Relationships::class)) {
798798
throw new DatabaseException('Adapter does not support relationships');
799799
}
800800

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace Tests\Unit\PHPStan\Data\HasFeature;
4+
5+
use Utopia\Database\Adapter;
6+
use Utopia\Database\Adapter\Feature\Spatial;
7+
use Utopia\Database\Adapter\Pool;
8+
9+
use function PHPStan\Testing\assertType;
10+
11+
function adapter(Adapter $adapter): void
12+
{
13+
if ($adapter->hasFeature(Spatial::class)) {
14+
assertType('Utopia\Database\Adapter', $adapter);
15+
}
16+
}
17+
18+
function pool(Pool $pool): void
19+
{
20+
if ($pool->hasFeature(Spatial::class)) {
21+
assertType('Utopia\Database\Adapter\Pool', $pool);
22+
}
23+
}

0 commit comments

Comments
 (0)