Skip to content

Commit

Permalink
Add lazy models for BSON documents and arrays
Browse files Browse the repository at this point in the history
  • Loading branch information
alcaeus committed Jul 19, 2023
1 parent 9ce2cbf commit 835a2b7
Show file tree
Hide file tree
Showing 5 changed files with 1,064 additions and 1 deletion.
62 changes: 61 additions & 1 deletion psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
</MixedArrayAccess>
</file>
<file src="src/Model/AsListIterator.php">
<InvalidTemplateParam occurrences="1">
<InvalidTemplateParam>
<code>AsListIterator</code>
</InvalidTemplateParam>
</file>
Expand Down Expand Up @@ -173,6 +173,66 @@
<code><![CDATA[$this->index['name']]]></code>
</MixedReturnStatement>
</file>
<file src="src/Model/LazyBSONArray.php">
<MixedArgument>
<code>$offset</code>
<code>$offset</code>
<code>$offset</code>
</MixedArgument>
<MixedArgumentTypeCoercion>
<code><![CDATA[new CallbackFilterIterator(
$itemIterator,
/** @param TValue $value */
function ($value, int $offset) use (&$seen): bool {
return ! isset($this->unset[$offset]) && ! isset($seen[$offset]);
},
)]]></code>
<code><![CDATA[new CallbackIterator(
// Skip keys that were unset or handled in a previous iterator
new CallbackFilterIterator(
$itemIterator,
/** @param TValue $value */
function ($value, int $offset) use (&$seen): bool {
return ! isset($this->unset[$offset]) && ! isset($seen[$offset]);
},
),
/**
* @param TValue $value
* @return TValue
*/
function ($value, int $offset) use (&$seen) {
// Mark key as seen, skipping any future occurrences
$seen[$offset] = true;
// Return actual value (potentially overridden by offsetSet)
return $this->offsetGet($offset);
},
)]]></code>
</MixedArgumentTypeCoercion>
<MixedArrayAssignment>
<code>$seen[$offset]</code>
</MixedArrayAssignment>
<RedundantConditionGivenDocblockType>
<code>is_array($input)</code>
</RedundantConditionGivenDocblockType>
<RedundantFunctionCallGivenDocblockType>
<code>array_values</code>
</RedundantFunctionCallGivenDocblockType>
</file>
<file src="src/Model/LazyBSONDocument.php">
<MismatchingDocblockReturnType>
<code><![CDATA[Iterator<string, TValue>]]></code>
</MismatchingDocblockReturnType>
<MixedAssignment>
<code>$value</code>
</MixedAssignment>
<MixedInferredReturnType>
<code><![CDATA[Iterator<string, TValue>]]></code>
</MixedInferredReturnType>
<RedundantConditionGivenDocblockType>
<code>is_object($input)</code>
</RedundantConditionGivenDocblockType>
</file>
<file src="src/Operation/Aggregate.php">
<MixedArgument>
<code><![CDATA[$this->options['typeMap']]]></code>
Expand Down
270 changes: 270 additions & 0 deletions src/Model/LazyBSONArray.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
<?php
/*
* Copyright 2023-present MongoDB, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace MongoDB\Model;

use AppendIterator;
use ArrayAccess;
use ArrayIterator;
use CallbackFilterIterator;
use IteratorAggregate;
use MongoDB\BSON\PackedArray;
use MongoDB\Exception\InvalidArgumentException;
use ReturnTypeWillChange;

use function array_key_exists;
use function array_keys;
use function array_map;
use function array_merge;
use function array_values;
use function is_array;
use function is_numeric;
use function max;
use function MongoDB\recursive_copy;
use function sprintf;
use function trigger_error;

use const E_USER_WARNING;

/**
* Model class for a BSON array.
*
* The internal data will be filtered through array_values() during BSON
* serialization to ensure that it becomes a BSON array.
*
* @template TValue
* @template-implements ArrayAccess<int, TValue>
* @template-implements IteratorAggregate<int, TValue>
*/
class LazyBSONArray implements ArrayAccess, IteratorAggregate
{
/** @var PackedArray<TValue> */
private PackedArray $bson;

/** @var array<int, TValue> */
private array $read = [];

/** @var array<int, bool> */
private array $exists = [];

/** @var array<int, TValue> */
private array $set = [];

/** @var array<int, true> */
private array $unset = [];

private bool $entirePackedArrayRead = false;

/**
* Deep clone this lazy array.
*/
public function __clone()
{
$this->bson = clone $this->bson;

foreach ($this->set as $key => $value) {
$this->set[$key] = recursive_copy($value);
}
}

/**
* Constructs a lazy BSON array.
*
* @param PackedArray<TValue>|list<TValue>|null $input An input for a lazy array.
* When given a BSON array, this is treated as input. For lists
* this constructs a new BSON array using fromPHP.
*/
public function __construct($input = null)
{
if ($input === null) {
$this->bson = PackedArray::fromPHP([]);
} elseif ($input instanceof PackedArray) {
$this->bson = $input;
} elseif (is_array($input)) {
$this->bson = PackedArray::fromPHP([]);
$this->set = array_values($input);
$this->exists = array_map(
/** @param TValue $value */
function ($value): bool {
return true;
},
$this->set,
);
} else {
throw InvalidArgumentException::invalidType('input', $input, [PackedArray::class, 'array', 'null']);
}
}

/** @return AsListIterator<TValue> */
public function getIterator(): AsListIterator
{
$itemIterator = new AppendIterator();
// Iterate through all fields in the BSON array
$itemIterator->append($this->bson->getIterator());
// Then iterate over all fields that were set
$itemIterator->append(new ArrayIterator($this->set));

/** @var array<int, bool> $seen */
$seen = [];

// Use AsListIterator to ensure we're indexing from 0 without gaps
return new AsListIterator(
new CallbackIterator(
// Skip keys that were unset or handled in a previous iterator
new CallbackFilterIterator(
$itemIterator,
/** @param TValue $value */
function ($value, int $offset) use (&$seen): bool {
return ! isset($this->unset[$offset]) && ! isset($seen[$offset]);
},
),
/**
* @param TValue $value
* @return TValue
*/
function ($value, int $offset) use (&$seen) {
// Mark key as seen, skipping any future occurrences
$seen[$offset] = true;

// Return actual value (potentially overridden by offsetSet)
return $this->offsetGet($offset);
},
),
);
}

/** @param mixed $offset */
public function offsetExists($offset): bool
{
if (! is_numeric($offset)) {
return false;
}

$offset = (int) $offset;

// If we've looked for the value, return the cached result
if (isset($this->exists[$offset])) {
return $this->exists[$offset];
}

return $this->exists[$offset] = $this->bson->has($offset);
}

/**
* @param mixed $offset
* @return TValue
*/
#[ReturnTypeWillChange]
public function offsetGet($offset)
{
if (! is_numeric($offset)) {
trigger_error(sprintf('Undefined offset: %s', $offset), E_USER_WARNING);

return null;
}

$offset = (int) $offset;
$this->readFromBson($offset);

if (isset($this->unset[$offset]) || ! $this->exists[$offset]) {
trigger_error(sprintf('Undefined offset: %d', $offset), E_USER_WARNING);

return null;
}

return array_key_exists($offset, $this->set) ? $this->set[$offset] : $this->read[$offset];
}

/**
* @param mixed $offset
* @param TValue $value
*/
public function offsetSet($offset, $value): void
{
if ($offset === null) {
$this->readEntirePackedArray();

$existingItems = array_merge(
array_keys($this->read),
array_keys($this->set),
);

$offset = $existingItems === [] ? 0 : max($existingItems) + 1;
} elseif (! is_numeric($offset)) {
trigger_error(sprintf('Unsupported offset: %s', $offset), E_USER_WARNING);

return;
} else {
$offset = (int) $offset;
}

$this->set[$offset] = $value;
unset($this->unset[$offset]);
$this->exists[$offset] = true;
}

/** @param mixed $offset */
public function offsetUnset($offset): void
{
if (! is_numeric($offset)) {
trigger_error(sprintf('Undefined offset: %s', $offset), E_USER_WARNING);

return;
}

$offset = (int) $offset;
$this->unset[$offset] = true;
$this->exists[$offset] = false;
unset($this->set[$offset]);
}

private function readEntirePackedArray(): void
{
if ($this->entirePackedArrayRead) {
return;
}

foreach ($this->bson as $offset => $value) {
$this->read[$offset] = $value;

if (! isset($this->exists[$offset])) {
$this->exists[$offset] = true;
}
}

$this->entirePackedArrayRead = true;
}

private function readFromBson(int $offset): void
{
if (array_key_exists($offset, $this->read)) {
return;
}

// Read value if it's present in the BSON structure
$found = false;
if ($this->bson->has($offset)) {
$found = true;
$this->read[$offset] = $this->bson->get($offset);
}

// Mark the offset as "existing" if it wasn't previously marked already
if (! isset($this->exists[$offset])) {
$this->exists[$offset] = $found;
}
}
}
Loading

0 comments on commit 835a2b7

Please sign in to comment.