Skip to content

Commit a65f56b

Browse files
committed
Add lazy models for BSON documents and arrays
1 parent 9393c7f commit a65f56b

File tree

6 files changed

+1091
-1
lines changed

6 files changed

+1091
-1
lines changed

psalm-baseline.xml

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
</MixedArrayAccess>
7070
</file>
7171
<file src="src/Model/AsListIterator.php">
72-
<InvalidTemplateParam occurrences="1">
72+
<InvalidTemplateParam>
7373
<code>AsListIterator</code>
7474
</InvalidTemplateParam>
7575
</file>
@@ -173,6 +173,66 @@
173173
<code><![CDATA[$this->index['name']]]></code>
174174
</MixedReturnStatement>
175175
</file>
176+
<file src="src/Model/LazyBSONArray.php">
177+
<MixedArgument>
178+
<code>$offset</code>
179+
<code>$offset</code>
180+
<code>$offset</code>
181+
</MixedArgument>
182+
<MixedArgumentTypeCoercion>
183+
<code><![CDATA[new CallbackFilterIterator(
184+
$itemIterator,
185+
/** @param TValue $value */
186+
function ($value, int $offset) use (&$seen): bool {
187+
return ! isset($this->unset[$offset]) && ! isset($seen[$offset]);
188+
},
189+
)]]></code>
190+
<code><![CDATA[new CallbackIterator(
191+
// Skip keys that were unset or handled in a previous iterator
192+
new CallbackFilterIterator(
193+
$itemIterator,
194+
/** @param TValue $value */
195+
function ($value, int $offset) use (&$seen): bool {
196+
return ! isset($this->unset[$offset]) && ! isset($seen[$offset]);
197+
},
198+
),
199+
/**
200+
* @param TValue $value
201+
* @return TValue
202+
*/
203+
function ($value, int $offset) use (&$seen) {
204+
// Mark key as seen, skipping any future occurrences
205+
$seen[$offset] = true;
206+
207+
// Return actual value (potentially overridden by offsetSet)
208+
return $this->offsetGet($offset);
209+
},
210+
)]]></code>
211+
</MixedArgumentTypeCoercion>
212+
<MixedArrayAssignment>
213+
<code>$seen[$offset]</code>
214+
</MixedArrayAssignment>
215+
<RedundantConditionGivenDocblockType>
216+
<code>is_array($input)</code>
217+
</RedundantConditionGivenDocblockType>
218+
<RedundantFunctionCallGivenDocblockType>
219+
<code>array_values</code>
220+
</RedundantFunctionCallGivenDocblockType>
221+
</file>
222+
<file src="src/Model/LazyBSONDocument.php">
223+
<MismatchingDocblockReturnType>
224+
<code><![CDATA[Iterator<string, TValue>]]></code>
225+
</MismatchingDocblockReturnType>
226+
<MixedAssignment>
227+
<code>$value</code>
228+
</MixedAssignment>
229+
<MixedInferredReturnType>
230+
<code><![CDATA[Iterator<string, TValue>]]></code>
231+
</MixedInferredReturnType>
232+
<RedundantConditionGivenDocblockType>
233+
<code>is_object($input)</code>
234+
</RedundantConditionGivenDocblockType>
235+
</file>
176236
<file src="src/Operation/Aggregate.php">
177237
<MixedArgument>
178238
<code><![CDATA[$this->options['typeMap']]]></code>

src/Model/LazyBSONArray.php

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
<?php
2+
/*
3+
* Copyright 2023-present MongoDB, Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
namespace MongoDB\Model;
19+
20+
use AppendIterator;
21+
use ArrayAccess;
22+
use ArrayIterator;
23+
use CallbackFilterIterator;
24+
use IteratorAggregate;
25+
use MongoDB\BSON\PackedArray;
26+
use MongoDB\Exception\InvalidArgumentException;
27+
use ReturnTypeWillChange;
28+
29+
use function array_key_exists;
30+
use function array_keys;
31+
use function array_map;
32+
use function array_values;
33+
use function is_array;
34+
use function is_numeric;
35+
use function max;
36+
use function MongoDB\recursive_copy;
37+
use function sprintf;
38+
use function trigger_error;
39+
40+
use const E_USER_WARNING;
41+
42+
/**
43+
* Model class for a BSON array.
44+
*
45+
* The internal data will be filtered through array_values() during BSON
46+
* serialization to ensure that it becomes a BSON array.
47+
*
48+
* @template TValue
49+
* @template-implements ArrayAccess<int, TValue>
50+
* @template-implements IteratorAggregate<int, TValue>
51+
*/
52+
class LazyBSONArray implements ArrayAccess, IteratorAggregate
53+
{
54+
/** @var PackedArray<TValue> */
55+
private PackedArray $bson;
56+
57+
/** @var array<int, TValue> */
58+
private array $read = [];
59+
60+
/** @var array<int, bool> */
61+
private array $exists = [];
62+
63+
/** @var array<int, TValue> */
64+
private array $set = [];
65+
66+
/** @var array<int, true> */
67+
private array $unset = [];
68+
69+
private bool $entirePackedArrayRead = false;
70+
71+
/**
72+
* Deep clone this lazy array.
73+
*/
74+
public function __clone()
75+
{
76+
$this->bson = clone $this->bson;
77+
78+
foreach ($this->set as $key => $value) {
79+
$this->set[$key] = recursive_copy($value);
80+
}
81+
}
82+
83+
/**
84+
* Constructs a lazy BSON array.
85+
*
86+
* @param PackedArray<TValue>|list<TValue>|null $input An input for a lazy array.
87+
* When given a BSON array, this is treated as input. For lists
88+
* this constructs a new BSON array using fromPHP.
89+
*/
90+
public function __construct($input = null)
91+
{
92+
if ($input === null) {
93+
$this->bson = PackedArray::fromPHP([]);
94+
} elseif ($input instanceof PackedArray) {
95+
$this->bson = $input;
96+
} elseif (is_array($input)) {
97+
$this->bson = PackedArray::fromPHP([]);
98+
$this->set = array_values($input);
99+
$this->exists = array_map(
100+
/** @param TValue $value */
101+
fn ($value): bool => true,
102+
$this->set,
103+
);
104+
} else {
105+
throw InvalidArgumentException::invalidType('input', $input, [PackedArray::class, 'array', 'null']);
106+
}
107+
}
108+
109+
/** @return AsListIterator<TValue> */
110+
public function getIterator(): AsListIterator
111+
{
112+
$itemIterator = new AppendIterator();
113+
// Iterate through all fields in the BSON array
114+
$itemIterator->append($this->bson->getIterator());
115+
// Then iterate over all fields that were set
116+
$itemIterator->append(new ArrayIterator($this->set));
117+
118+
/** @var array<int, bool> $seen */
119+
$seen = [];
120+
121+
// Use AsListIterator to ensure we're indexing from 0 without gaps
122+
return new AsListIterator(
123+
new CallbackIterator(
124+
// Skip keys that were unset or handled in a previous iterator
125+
new CallbackFilterIterator(
126+
$itemIterator,
127+
/** @param TValue $value */
128+
function ($value, int $offset) use (&$seen): bool {
129+
return ! isset($this->unset[$offset]) && ! isset($seen[$offset]);
130+
},
131+
),
132+
/**
133+
* @param TValue $value
134+
* @return TValue
135+
*/
136+
function ($value, int $offset) use (&$seen) {
137+
// Mark key as seen, skipping any future occurrences
138+
$seen[$offset] = true;
139+
140+
// Return actual value (potentially overridden by offsetSet)
141+
return $this->offsetGet($offset);
142+
},
143+
),
144+
);
145+
}
146+
147+
/** @param mixed $offset */
148+
public function offsetExists($offset): bool
149+
{
150+
if (! is_numeric($offset)) {
151+
return false;
152+
}
153+
154+
$offset = (int) $offset;
155+
156+
// If we've looked for the value, return the cached result
157+
if (isset($this->exists[$offset])) {
158+
return $this->exists[$offset];
159+
}
160+
161+
return $this->exists[$offset] = $this->bson->has($offset);
162+
}
163+
164+
/**
165+
* @param mixed $offset
166+
* @return TValue
167+
*/
168+
#[ReturnTypeWillChange]
169+
public function offsetGet($offset)
170+
{
171+
if (! is_numeric($offset)) {
172+
trigger_error(sprintf('Undefined offset: %s', $offset), E_USER_WARNING);
173+
174+
return null;
175+
}
176+
177+
$offset = (int) $offset;
178+
$this->readFromBson($offset);
179+
180+
if (isset($this->unset[$offset]) || ! $this->exists[$offset]) {
181+
trigger_error(sprintf('Undefined offset: %d', $offset), E_USER_WARNING);
182+
183+
return null;
184+
}
185+
186+
return array_key_exists($offset, $this->set) ? $this->set[$offset] : $this->read[$offset];
187+
}
188+
189+
/**
190+
* @param mixed $offset
191+
* @param TValue $value
192+
*/
193+
public function offsetSet($offset, $value): void
194+
{
195+
if ($offset === null) {
196+
$this->readEntirePackedArray();
197+
198+
$existingItems = [
199+
...array_keys($this->read),
200+
...array_keys($this->set),
201+
];
202+
203+
$offset = $existingItems === [] ? 0 : max($existingItems) + 1;
204+
} elseif (! is_numeric($offset)) {
205+
trigger_error(sprintf('Unsupported offset: %s', $offset), E_USER_WARNING);
206+
207+
return;
208+
} else {
209+
$offset = (int) $offset;
210+
}
211+
212+
$this->set[$offset] = $value;
213+
unset($this->unset[$offset]);
214+
$this->exists[$offset] = true;
215+
}
216+
217+
/** @param mixed $offset */
218+
public function offsetUnset($offset): void
219+
{
220+
if (! is_numeric($offset)) {
221+
trigger_error(sprintf('Undefined offset: %s', $offset), E_USER_WARNING);
222+
223+
return;
224+
}
225+
226+
$offset = (int) $offset;
227+
$this->unset[$offset] = true;
228+
$this->exists[$offset] = false;
229+
unset($this->set[$offset]);
230+
}
231+
232+
private function readEntirePackedArray(): void
233+
{
234+
if ($this->entirePackedArrayRead) {
235+
return;
236+
}
237+
238+
foreach ($this->bson as $offset => $value) {
239+
$this->read[$offset] = $value;
240+
241+
if (! isset($this->exists[$offset])) {
242+
$this->exists[$offset] = true;
243+
}
244+
}
245+
246+
$this->entirePackedArrayRead = true;
247+
}
248+
249+
private function readFromBson(int $offset): void
250+
{
251+
if (array_key_exists($offset, $this->read)) {
252+
return;
253+
}
254+
255+
// Read value if it's present in the BSON structure
256+
$found = false;
257+
if ($this->bson->has($offset)) {
258+
$found = true;
259+
$this->read[$offset] = $this->bson->get($offset);
260+
}
261+
262+
// Mark the offset as "existing" if it wasn't previously marked already
263+
if (! isset($this->exists[$offset])) {
264+
$this->exists[$offset] = $found;
265+
}
266+
}
267+
}

0 commit comments

Comments
 (0)