Skip to content

Commit 835a2b7

Browse files
committed
Add lazy models for BSON documents and arrays
1 parent 9ce2cbf commit 835a2b7

File tree

5 files changed

+1064
-1
lines changed

5 files changed

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

0 commit comments

Comments
 (0)