diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 136ebac0f..760e9e79c 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -1366,7 +1366,7 @@ public function castingAfter(Document $collection, Document $document): Document $value = [$value]; } - foreach ($value as &$node) { + foreach ($value as $index => $node) { switch ($type) { case Database::VAR_INTEGER: case Database::VAR_BIGINT: @@ -1384,8 +1384,8 @@ public function castingAfter(Document $collection, Document $document): Document default: break; } + $value[$index] = $node; } - unset($node); $document->setAttribute($key, ($array) ? $value : $value[0]); } @@ -1468,7 +1468,7 @@ public function castingBefore(Document $collection, Document $document): Documen $value = [$value]; } - foreach ($value as &$node) { + foreach ($value as $index => $node) { switch ($type) { case Database::VAR_DATETIME: if (!($node instanceof UTCDateTime)) { @@ -1485,8 +1485,8 @@ public function castingBefore(Document $collection, Document $document): Documen default: break; } + $value[$index] = $node; } - unset($node); $document->setAttribute($key, ($array) ? $value : $value[0]); } $indexes = $collection->getAttribute('indexes'); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 124112326..3004e9780 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2038,8 +2038,8 @@ protected function decodeArray(array $value): string return '{}'; } - foreach ($value as &$item) { - $item = '"' . str_replace(['"', '(', ')'], ['\"', '\(', '\)'], $item) . '"'; + foreach ($value as $index => $item) { + $value[$index] = '"' . str_replace(['"', '(', ')'], ['\"', '\(', '\)'], $item) . '"'; } return '{' . implode(",", $value) . '}'; diff --git a/src/Database/Database.php b/src/Database/Database.php index 144428665..7b00290ac 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -9315,7 +9315,11 @@ protected function removeUnknownAttributes(Document $collection, Document $docum } $dropped = []; - foreach (\array_keys($document->getArrayCopy()) as $key) { + $documentKeys = []; + foreach ($document as $key => $value) { + $documentKeys[] = $key; + } + foreach ($documentKeys as $key) { if (\str_starts_with($key, '$') || isset($known[$key])) { continue; } @@ -9396,12 +9400,14 @@ public function encode(Document $collection, Document $document, bool $applyDefa $value = ($array) ? $value : [$value]; } - foreach ($value as $index => $node) { - if ($node !== null) { - foreach ($filters as $filter) { - $node = $this->encodeAttribute($filter, $node, $document); + if (!empty($filters)) { + foreach ($value as $index => $node) { + if ($node !== null) { + foreach ($filters as $filter) { + $node = $this->encodeAttribute($filter, $node, $document); + } + $value[$index] = $node; } - $value[$index] = $node; } } @@ -9499,9 +9505,10 @@ public function decode(Document $collection, Document $document, array $selectio || \in_array($key, $selections) || \in_array('*', $selections); - if ($selected || $hasRelationshipSelections) { + if (!empty($filters) && ($selected || $hasRelationshipSelections)) { + $filters = \array_reverse($filters); foreach ($value as $index => $node) { - foreach (\array_reverse($filters) as $filter) { + foreach ($filters as $filter) { $node = $this->decodeAttribute($filter, $node, $document, $key); } $value[$index] = $node; @@ -9573,33 +9580,35 @@ public function casting(Document $collection, Document $document): Document $value = [$value]; } - foreach ($value as $index => $node) { - switch ($type) { - case self::VAR_ID: - // Disabled until Appwrite migrates to use real int ID's for MySQL - //$type = $this->adapter->getIdAttributeType(); - //\settype($node, $type); - $node = (string)$node; - break; - case self::VAR_BOOLEAN: - $node = (bool)$node; - break; - case self::VAR_INTEGER: - $node = (int)$node; - break; - case self::VAR_BIGINT: - if (\is_string($node) && BigIntValidator::fitsPhpInt($node, $signed)) { + if (\in_array($type, [self::VAR_ID, self::VAR_BOOLEAN, self::VAR_INTEGER, self::VAR_BIGINT, self::VAR_FLOAT], true)) { + foreach ($value as $index => $node) { + switch ($type) { + case self::VAR_ID: + // Disabled until Appwrite migrates to use real int ID's for MySQL + //$type = $this->adapter->getIdAttributeType(); + //\settype($node, $type); + $node = (string)$node; + break; + case self::VAR_BOOLEAN: + $node = (bool)$node; + break; + case self::VAR_INTEGER: $node = (int)$node; - } - break; - case self::VAR_FLOAT: - $node = (float)$node; - break; - default: - break; - } + break; + case self::VAR_BIGINT: + if (\is_string($node) && BigIntValidator::fitsPhpInt($node, $signed)) { + $node = (int)$node; + } + break; + case self::VAR_FLOAT: + $node = (float)$node; + break; + default: + break; + } - $value[$index] = $node; + $value[$index] = $node; + } } $document->setAttribute($key, ($array) ? $value : $value[0]); diff --git a/src/Database/Document.php b/src/Database/Document.php index 73bd458cd..e68495613 100644 --- a/src/Database/Document.php +++ b/src/Database/Document.php @@ -45,6 +45,7 @@ public function __construct(array $input = []) continue; } + $converted = false; foreach ($value as $childKey => $child) { // An array value is either a list of nested sub-documents or a list of // plain items (dates, numbers, strings): wrap the former, leave the latter. @@ -52,10 +53,13 @@ public function __construct(array $input = []) // value (e.g. a UTCDateTime), which would otherwise fatal. if (\is_array($child) && (isset($child['$id']) || isset($child['$collection']))) { $value[$childKey] = new self($child); + $converted = true; } } - $input[$key] = $value; + if ($converted) { + $input[$key] = $value; + } } parent::__construct($input); @@ -430,7 +434,7 @@ public function getArrayCopy(array $allow = [], array $disallow = []): array $output = []; - foreach ($array as $key => &$value) { + foreach ($array as $key => $value) { if (!empty($allow) && !\in_array($key, $allow)) { // Export only allow fields continue; } @@ -442,17 +446,12 @@ public function getArrayCopy(array $allow = [], array $disallow = []): array if ($value instanceof self) { $output[$key] = $value->getArrayCopy($allow, $disallow); } elseif (\is_array($value)) { - foreach ($value as $childKey => &$child) { - if ($child instanceof self) { - $output[$key][$childKey] = $child->getArrayCopy($allow, $disallow); - } else { - $output[$key][$childKey] = $child; - } - } + $value = \array_map( + fn ($item) => $item instanceof self ? $item->getArrayCopy($allow, $disallow) : $item, + $value + ); - if (empty($value)) { - $output[$key] = $value; - } + $output[$key] = $value; } else { $output[$key] = $value; } diff --git a/src/Database/Validator/Query/Order.php b/src/Database/Validator/Query/Order.php index 5d9970a01..c14a98539 100644 --- a/src/Database/Validator/Query/Order.php +++ b/src/Database/Validator/Query/Order.php @@ -8,7 +8,7 @@ class Order extends Base { /** - * @var array + * @var array */ protected array $schema = []; @@ -19,7 +19,7 @@ class Order extends Base public function __construct(array $attributes = [], protected bool $supportForAttributes = true) { foreach ($attributes as $attribute) { - $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = $attribute->getArrayCopy(); + $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = true; } } diff --git a/src/Database/Validator/Query/Select.php b/src/Database/Validator/Query/Select.php index 2df21c9a3..27daa79ab 100644 --- a/src/Database/Validator/Query/Select.php +++ b/src/Database/Validator/Query/Select.php @@ -9,7 +9,7 @@ class Select extends Base { /** - * @var array + * @var array */ protected array $schema = []; @@ -34,7 +34,7 @@ class Select extends Base public function __construct(array $attributes = [], protected bool $supportForAttributes = true) { foreach ($attributes as $attribute) { - $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = $attribute->getArrayCopy(); + $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = true; } } diff --git a/tests/unit/DocumentTest.php b/tests/unit/DocumentTest.php index 9a41ab534..5eae1a17a 100644 --- a/tests/unit/DocumentTest.php +++ b/tests/unit/DocumentTest.php @@ -417,4 +417,94 @@ public function testEmptyDocumentSequence(): void $this->assertNull($empty->getSequence()); $this->assertNotSame('', $empty->getSequence()); } + public function testConstructionPreservesScalarArraysAndConvertsOnlyDocuments(): void + { + $object = new \stdClass(); + $input = [ + 'empty' => [], + 'values' => [7 => 'text', 'null' => null, 'bool' => false, 'object' => $object], + 'child' => ['$id' => 'child', 'name' => 'nested'], + 'children' => ['first' => ['$id' => 'first'], 9 => 'plain'], + ]; + $document = new Document($input); + + $this->assertSame([], $document->getAttribute('empty')); + $this->assertSame($input['values'], $document->getAttribute('values')); + $this->assertSame('child', $document->getAttribute('child')->getId()); + $this->assertSame('first', $document->getAttribute('children')['first']->getId()); + $this->assertSame('plain', $document->getAttribute('children')[9]); + $this->assertSame(['$id' => 'first'], $input['children']['first']); + } + + public function testArrayCopyPreservesKeysAndFiltersNestedDocuments(): void + { + $document = new Document([ + 'name' => 'parent', + 'secret' => 'hidden', + 'values' => [7 => 'seven', 'null' => null, 'empty' => []], + 'children' => ['child' => new Document(['name' => 'nested', 'secret' => 'hidden'])], + ]); + $copy = $document->getArrayCopy(['name', 'secret', 'values', 'children'], ['secret']); + + $this->assertSame([ + 'name' => 'parent', + 'values' => [7 => 'seven', 'null' => null, 'empty' => []], + 'children' => ['child' => ['name' => 'nested']], + ], $copy); + $copy['values'][7] = 'changed'; + $copy['children']['child']['name'] = 'changed'; + $this->assertSame('seven', $document->getAttribute('values')[7]); + $this->assertSame('nested', $document->getAttribute('children')['child']->getAttribute('name')); + } + + public function testClonePreservesScalarKeysAndIsolatesNestedDocuments(): void + { + $object = new \stdClass(); + $original = new Document([ + 'empty' => [], + 'values' => [7 => 'seven', 'object' => $object], + 'children' => ['child' => new Document(['name' => 'nested']), 9 => 'plain'], + ]); + $copy = clone $original; + $copy['values'][7] = 'changed'; + $copy->getAttribute('children')['child']->setAttribute('name', 'changed'); + + $this->assertSame([], $copy->getAttribute('empty')); + $this->assertSame([7, 'object'], array_keys($copy->getAttribute('values'))); + $this->assertSame($object, $copy->getAttribute('values')['object']); + $this->assertSame('seven', $original->getAttribute('values')[7]); + $this->assertSame('nested', $original->getAttribute('children')['child']->getAttribute('name')); + $this->assertSame('plain', $copy->getAttribute('children')[9]); + } + + public function testArrayCopyAndCloneDetachReferencedArrayElements(): void + { + $scalar = 'before'; + $nested = ['value' => 'before']; + $document = new Document(['values' => ['first' => &$scalar, 7 => &$nested, 'last' => false]]); + $export = $document->getArrayCopy(); + $clone = clone $document; + $scalar = 'after'; + $nested['value'] = 'after'; + + $expected = ['first' => 'before', 7 => ['value' => 'before'], 'last' => false]; + $this->assertSame($expected, $export['values']); + $this->assertSame($expected, $clone->getAttribute('values')); + $this->assertSame('after', $document->getAttribute('values')['first']); + } + + public function testScalarArrayExportAvoidsReferenceAllocationOverhead(): void + { + $document = new Document(['values' => range(1, 100_000)]); + memory_reset_peak_usage(); + $before = memory_get_usage(); + $copy = $document->getArrayCopy(); + $allocated = memory_get_peak_usage() - $before; + + $this->assertCount(100_000, $copy['values']); + $this->assertLessThan(3 * 1024 * 1024, $allocated, 'Export should copy the array without wrapping every element in a reference'); + $copy['values'][0] = 0; + $this->assertSame(1, $document->getAttribute('values')[0]); + } + }