Skip to content

Commit f947e61

Browse files
committed
Extract Database methods to CreateEncryptedCollection operation
1 parent a3ffa37 commit f947e61

File tree

5 files changed

+269
-63
lines changed

5 files changed

+269
-63
lines changed

psalm-baseline.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,12 @@
491491
<code>$options['writeConcern']</code>
492492
</MixedAssignment>
493493
</file>
494+
<file src="src/Operation/CreateEncryptedCollection.php">
495+
<DocblockTypeContradiction occurrences="2">
496+
<code>! is_array($encryptedFields['fields'])</code>
497+
<code>! is_array($field) &amp;&amp; ! is_object($field)</code>
498+
</DocblockTypeContradiction>
499+
</file>
494500
<file src="src/Operation/CreateIndexes.php">
495501
<DocblockTypeContradiction occurrences="1">
496502
<code>is_array($index)</code>

src/Database.php

Lines changed: 37 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
namespace MongoDB;
1919

2020
use Iterator;
21-
use MongoDB\BSON\Binary;
2221
use MongoDB\Driver\ClientEncryption;
2322
use MongoDB\Driver\Cursor;
2423
use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException;
@@ -36,7 +35,7 @@
3635
use MongoDB\Model\CollectionInfoIterator;
3736
use MongoDB\Operation\Aggregate;
3837
use MongoDB\Operation\CreateCollection;
39-
use MongoDB\Operation\CreateIndexes;
38+
use MongoDB\Operation\CreateEncryptedCollection;
4039
use MongoDB\Operation\DatabaseCommand;
4140
use MongoDB\Operation\DropCollection;
4241
use MongoDB\Operation\DropDatabase;
@@ -45,13 +44,10 @@
4544
use MongoDB\Operation\ModifyCollection;
4645
use MongoDB\Operation\RenameCollection;
4746
use MongoDB\Operation\Watch;
47+
use Throwable;
4848
use Traversable;
49-
use Exception;
5049

51-
use function array_key_exists;
5250
use function is_array;
53-
use function is_object;
54-
use function property_exists;
5551
use function strlen;
5652

5753
class Database
@@ -263,7 +259,13 @@ public function command($command, array $options = [])
263259
/**
264260
* Create a new collection explicitly.
265261
*
262+
* If the "encryptedFields" option is specified, this method additionally
263+
* creates related metadata collections and an index on the encrypted
264+
* collection.
265+
*
266266
* @see CreateCollection::__construct() for supported options
267+
* @see https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/client-side-encryption.rst#create-collection-helper
268+
* @see https://www.mongodb.com/docs/manual/core/queryable-encryption/fundamentals/manage-collections/
267269
* @return array|object Command result document
268270
* @throws UnsupportedException if options are not supported by the selected server
269271
* @throws InvalidArgumentException for parameter/option parsing errors
@@ -275,90 +277,62 @@ public function createCollection(string $collectionName, array $options = [])
275277
$options['typeMap'] = $this->typeMap;
276278
}
277279

278-
$server = select_server($this->manager, $options);
279-
280280
if (! isset($options['writeConcern']) && ! is_in_transaction($options)) {
281281
$options['writeConcern'] = $this->writeConcern;
282282
}
283283

284-
$encryptedFields = $options['encryptedFields']
285-
?? get_encrypted_fields_from_driver($this->databaseName, $collectionName, $this->manager)
286-
?? null;
287-
288-
if ($encryptedFields !== null) {
289-
// encryptedFields is passed to the create command
290-
$options['encryptedFields'] = $encryptedFields;
291-
292-
$encryptedFields = (array) $encryptedFields;
293-
$enxcolOptions = ['clusteredIndex' => ['key' => ['_id' => 1], 'unique' => true]];
294-
(new CreateCollection($this->databaseName, $encryptedFields['escCollection'] ?? 'enxcol_.' . $collectionName . '.esc', $enxcolOptions))->execute($server);
295-
(new CreateCollection($this->databaseName, $encryptedFields['eccCollection'] ?? 'enxcol_.' . $collectionName . '.ecc', $enxcolOptions))->execute($server);
296-
(new CreateCollection($this->databaseName, $encryptedFields['ecocCollection'] ?? 'enxcol_.' . $collectionName . '.ecoc', $enxcolOptions))->execute($server);
284+
if (! isset($options['encryptedFields'])) {
285+
$options['encryptedFields'] = get_encrypted_fields_from_driver($this->databaseName, $collectionName, $this->manager);
297286
}
298287

299-
$operation = new CreateCollection($this->databaseName, $collectionName, $options);
288+
$operation = isset($options['encryptedFields'])
289+
? new CreateEncryptedCollection($this->databaseName, $collectionName, $options)
290+
: new CreateCollection($this->databaseName, $collectionName, $options);
300291

301-
$result = $operation->execute($server);
302-
303-
if ($encryptedFields !== null) {
304-
(new CreateIndexes($this->databaseName, $collectionName, [['key' => ['__safeContent__' => 1]]]))->execute($server);
305-
}
292+
$server = select_server($this->manager, $options);
306293

307-
return $result;
294+
return $operation->execute($server);
308295
}
309296

310297
/**
311298
* Create a new encrypted collection explicitly.
312299
*
313-
* This function will automatically create data keys for any encrypted
314-
* fields where the "keyId" option is null. This function will return a copy
315-
* of the modified "encryptedFields" option in addition to the result from
316-
* createCollection(). The "encryptedFields" option is required.
300+
* The "encryptedFields" option is required.
301+
*
302+
* This method will automatically create data keys for any encrypted fields
303+
* where "keyId" is null. A copy of the modified "encryptedFields" option
304+
* will be returned in addition to the result from creating the collection.
317305
*
318-
* If any error is encountered while creating data keys or invoking
319-
* createCollection(), a CreateEncryptedCollectionException will be thrown.
320-
* The original exception and modified "encryptedFields" option can be
321-
* accessed via the getPrevious() and getEncryptedFields() methods,
322-
* respectively.
306+
* If any error is encountered while creating data keys or creating the
307+
* collection, a CreateEncryptedCollectionException will be thrown. The
308+
* original exception and modified "encryptedFields" option can be accessed
309+
* via the getPrevious() and getEncryptedFields() methods, respectively.
323310
*
324311
* @see CreateCollection::__construct() for supported options
325-
* @return array A tuple consisting of the createCollection() result and modified "encryptedFields" option
312+
* @return array A tuple consisting of the result from creating the collection and the modified "encryptedFields" option
326313
* @throws InvalidArgumentException for parameter/option parsing errors
327-
* @throws CreateEncryptedCollectionException for any errors creating data keys or invoking createCollection()
314+
* @throws CreateEncryptedCollectionException for any errors creating data keys or creating the collection
328315
*/
329316
public function createEncryptedCollection(string $collectionName, ClientEncryption $clientEncryption, string $kmsProvider, ?array $masterKey, array $options): array
330317
{
331-
if (! isset($options['encryptedFields']) || ! is_array($options['encryptedFields']) && ! is_object($options['encryptedFields'])) {
332-
throw InvalidArgumentException::invalidType('"encryptedFields" option', $options['encryptedFields'] ?? null, ['array', 'object']);
318+
if (! isset($options['typeMap'])) {
319+
$options['typeMap'] = $this->typeMap;
333320
}
334321

335-
/** @var array{fields: list<array{keyId: ?Binary}|object{keyId: ?Binary}>} */
336-
$encryptedFields = (array) recursive_copy($options['encryptedFields']);
322+
if (! isset($options['writeConcern']) && ! is_in_transaction($options)) {
323+
$options['writeConcern'] = $this->writeConcern;
324+
}
337325

338-
$createDataKeyArgs = [
339-
$kmsProvider,
340-
isset($masterKey) ? ['masterKey' => $masterKey] : [],
341-
];
326+
$operation = new CreateEncryptedCollection($this->databaseName, $collectionName, $options);
327+
$server = select_server($this->manager, $options);
342328

343329
try {
344-
/** @psalm-suppress RedundantConditionGivenDocblockType */
345-
if (isset($encryptedFields['fields']) && is_array($encryptedFields['fields'])) {
346-
foreach ($encryptedFields['fields'] as &$field) {
347-
if (is_array($field) && array_key_exists('keyId', $field) && $field['keyId'] === null) {
348-
$field['keyId'] = $clientEncryption->createDataKey(...$createDataKeyArgs);
349-
} elseif (is_object($field) && property_exists($field, 'keyId') && $field->keyId === null) {
350-
$field->keyId = $clientEncryption->createDataKey(...$createDataKeyArgs);
351-
}
352-
}
353-
354-
$options['encryptedFields'] = $encryptedFields;
355-
}
356-
357-
$result = $this->createCollection($collectionName, $options);
330+
$operation->createDataKeys($clientEncryption, $kmsProvider, $masterKey, $encryptedFields);
331+
$result = $operation->execute($server);
358332

359333
return [$result, $encryptedFields];
360-
} catch (Exception $e) {
361-
throw new CreateEncryptedCollectionException($e, $encryptedFields);
334+
} catch (Throwable $e) {
335+
throw new CreateEncryptedCollectionException($e, $encryptedFields ?? []);
362336
}
363337
}
364338

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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\Operation;
19+
20+
// phpcs:disable SlevomatCodingStandard.Namespaces.UnusedUses.UnusedUse
21+
use MongoDB\BSON\Binary;
22+
// phpcs:enable
23+
use MongoDB\Driver\ClientEncryption;
24+
use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException;
25+
use MongoDB\Driver\Server;
26+
use MongoDB\Exception\InvalidArgumentException;
27+
28+
use function array_key_exists;
29+
use function is_array;
30+
use function is_object;
31+
32+
/**
33+
* Create an encrypted collection.
34+
*
35+
* The "encryptedFields" option is required.
36+
*
37+
* This operation additionally creates related metadata collections and an index
38+
* on the encrypted collection.
39+
*
40+
* @internal
41+
* @see \MongoDB\Database::createCollection()
42+
* @see \MongoDB\Database::createEncryptedCollection()
43+
* @see https://github.com/mongodb/specifications/blob/master/source/client-side-encryption/client-side-encryption.rst#create-collection-helper
44+
* @see https://www.mongodb.com/docs/manual/core/queryable-encryption/fundamentals/manage-collections/
45+
*/
46+
class CreateEncryptedCollection implements Executable
47+
{
48+
/** @var CreateCollection */
49+
private $createCollection;
50+
51+
/** @var CreateCollection[] */
52+
private $createMetadataCollections;
53+
54+
/** @var CreateIndexes */
55+
private $createSafeContentIndex;
56+
57+
/** @var string */
58+
private $databaseName;
59+
60+
/** @var string */
61+
private $collectionName;
62+
63+
/** @var array */
64+
private $options;
65+
66+
/**
67+
* @see CreateCollection::__construct() for supported options
68+
* @param string $databaseName Database name
69+
* @param string $collectionName Collection name
70+
* @param array $options CreateCollection options
71+
* @throws InvalidArgumentException for parameter/option parsing errors
72+
*/
73+
public function __construct(string $databaseName, string $collectionName, array $options)
74+
{
75+
if (! isset($options['encryptedFields'])) {
76+
throw new InvalidArgumentException('"encryptedFields" option is required');
77+
}
78+
79+
if (! is_array($options['encryptedFields']) && ! is_object($options['encryptedFields'])) {
80+
throw InvalidArgumentException::invalidType('"encryptedFields" option', $options['encryptedFields'], 'array or object');
81+
}
82+
83+
$this->createCollection = new CreateCollection($databaseName, $collectionName, $options);
84+
85+
/** @psalm-var array{eccCollection?: ?string, ecocCollection?: ?string, escCollection?: ?string} */
86+
$encryptedFields = (array) $options['encryptedFields'];
87+
$enxcolOptions = ['clusteredIndex' => ['key' => ['_id' => 1], 'unique' => true]];
88+
89+
$this->createMetadataCollections = [
90+
new CreateCollection($databaseName, $encryptedFields['escCollection'] ?? 'enxcol_.' . $collectionName . '.esc', $enxcolOptions),
91+
new CreateCollection($databaseName, $encryptedFields['eccCollection'] ?? 'enxcol_.' . $collectionName . '.ecc', $enxcolOptions),
92+
new CreateCollection($databaseName, $encryptedFields['ecocCollection'] ?? 'enxcol_.' . $collectionName . '.ecoc', $enxcolOptions),
93+
];
94+
95+
$this->createSafeContentIndex = new CreateIndexes($databaseName, $collectionName, [['key' => ['__safeContent__' => 1]]]);
96+
97+
$this->databaseName = $databaseName;
98+
$this->collectionName = $collectionName;
99+
$this->options = $options;
100+
}
101+
102+
/**
103+
* @see Executable::execute()
104+
* @return array|object Command result document from creating the encrypted collection
105+
* @throws DriverRuntimeException for other driver errors (e.g. connection errors)
106+
*/
107+
public function execute(Server $server)
108+
{
109+
foreach ($this->createMetadataCollections as $createMetadataCollection) {
110+
$createMetadataCollection->execute($server);
111+
}
112+
113+
$result = $this->createCollection->execute($server);
114+
115+
$this->createSafeContentIndex->execute($server);
116+
117+
return $result;
118+
}
119+
120+
/**
121+
* Create data keys for any encrypted fields where "keyId" is null.
122+
*
123+
* This method should be called before execute(), as it may modify the
124+
* "encryptedFields" option and reconstruct the internal CreateCollection
125+
* operation used for creating the encrypted collection.
126+
*
127+
* The $encryptedFields reference parameter may be used to determine which
128+
* data keys have been created.
129+
*
130+
* @see \MongoDB\Database::createEncryptedCollection()
131+
* @see https://www.php.net/manual/en/mongodb-driver-clientencryption.createdatakey.php
132+
* @throws DriverRuntimeException for errors creating a data key
133+
*/
134+
public function createDataKeys(ClientEncryption $clientEncryption, string $kmsProvider, ?array $masterKey, ?array &$encryptedFields = null): void
135+
{
136+
/** @psalm-var array{fields: list<array{keyId: ?Binary}|object{keyId: ?Binary}>} */
137+
$encryptedFields = (array) $this->options['encryptedFields'];
138+
139+
/* NOP if there are no fields to examine. If the type is invalid, defer
140+
* to the server to raise an error in execute(). */
141+
if (! isset($encryptedFields['fields']) || ! is_array($encryptedFields['fields'])) {
142+
return;
143+
}
144+
145+
$createDataKeyArgs = [
146+
$kmsProvider,
147+
$masterKey !== null ? ['masterKey' => $masterKey] : [],
148+
];
149+
150+
foreach ($encryptedFields['fields'] as $i => $field) {
151+
// Skip invalid types and defer to the server to raise an error
152+
if (! is_array($field) && ! is_object($field)) {
153+
continue;
154+
}
155+
156+
$field = (array) $field;
157+
158+
if (array_key_exists('keyId', $field) && $field['keyId'] === null) {
159+
$field['keyId'] = $clientEncryption->createDataKey(...$createDataKeyArgs);
160+
$encryptedFields['fields'][$i] = $field;
161+
}
162+
}
163+
164+
$this->options['encryptedFields'] = $encryptedFields;
165+
$this->createCollection = new CreateCollection($this->databaseName, $this->collectionName, $this->options);
166+
}
167+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace MongoDB\Tests\Exception;
4+
5+
use Exception;
6+
use MongoDB\Tests\TestCase;
7+
8+
class CreateEncryptedCollectionExceptionTest extends TestCase
9+
{
10+
public function testGetEncryptedFields(): void
11+
{
12+
$encryptedFields = ['fields' => []];
13+
14+
$e = new CreateEncryptedCollection(new Exception(), $encryptedFields);
15+
$this->assertSame($encryptedFields, $e->getEncryptedFields());
16+
}
17+
18+
public function testGetPrevious(): void
19+
{
20+
$previous = new Exception();
21+
22+
$e = new CreateEncryptedCollection($previous, ['fields' => []]);
23+
$this->assertSame($previous, $e->getPrevious());
24+
}
25+
}

0 commit comments

Comments
 (0)