1<?php
2/*
3 * Copyright 2016-2017 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 *   http://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
18namespace MongoDB\GridFS;
19
20use MongoDB\Collection;
21use MongoDB\Driver\Cursor;
22use MongoDB\Driver\Manager;
23use MongoDB\Driver\ReadPreference;
24use MongoDB\Exception\InvalidArgumentException;
25use MongoDB\UpdateResult;
26use stdClass;
27use function abs;
28use function sprintf;
29
30/**
31 * CollectionWrapper abstracts the GridFS files and chunks collections.
32 *
33 * @internal
34 */
35class CollectionWrapper
36{
37    /** @var string */
38    private $bucketName;
39
40    /** @var Collection */
41    private $chunksCollection;
42
43    /** @var string */
44    private $databaseName;
45
46    /** @var boolean */
47    private $checkedIndexes = false;
48
49    /** @var Collection */
50    private $filesCollection;
51
52    /**
53     * Constructs a GridFS collection wrapper.
54     *
55     * @see Collection::__construct() for supported options
56     * @param Manager $manager           Manager instance from the driver
57     * @param string  $databaseName      Database name
58     * @param string  $bucketName        Bucket name
59     * @param array   $collectionOptions Collection options
60     * @throws InvalidArgumentException
61     */
62    public function __construct(Manager $manager, $databaseName, $bucketName, array $collectionOptions = [])
63    {
64        $this->databaseName = (string) $databaseName;
65        $this->bucketName = (string) $bucketName;
66
67        $this->filesCollection = new Collection($manager, $databaseName, sprintf('%s.files', $bucketName), $collectionOptions);
68        $this->chunksCollection = new Collection($manager, $databaseName, sprintf('%s.chunks', $bucketName), $collectionOptions);
69    }
70
71    /**
72     * Deletes all GridFS chunks for a given file ID.
73     *
74     * @param mixed $id
75     */
76    public function deleteChunksByFilesId($id)
77    {
78        $this->chunksCollection->deleteMany(['files_id' => $id]);
79    }
80
81    /**
82     * Deletes a GridFS file and related chunks by ID.
83     *
84     * @param mixed $id
85     */
86    public function deleteFileAndChunksById($id)
87    {
88        $this->filesCollection->deleteOne(['_id' => $id]);
89        $this->chunksCollection->deleteMany(['files_id' => $id]);
90    }
91
92    /**
93     * Drops the GridFS files and chunks collections.
94     */
95    public function dropCollections()
96    {
97        $this->filesCollection->drop(['typeMap' => []]);
98        $this->chunksCollection->drop(['typeMap' => []]);
99    }
100
101    /**
102     * Finds GridFS chunk documents for a given file ID and optional offset.
103     *
104     * @param mixed   $id        File ID
105     * @param integer $fromChunk Starting chunk (inclusive)
106     * @return Cursor
107     */
108    public function findChunksByFileId($id, $fromChunk = 0)
109    {
110        return $this->chunksCollection->find(
111            [
112                'files_id' => $id,
113                'n' => ['$gte' => $fromChunk],
114            ],
115            [
116                'sort' => ['n' => 1],
117                'typeMap' => ['root' => 'stdClass'],
118            ]
119        );
120    }
121
122    /**
123     * Finds a GridFS file document for a given filename and revision.
124     *
125     * Revision numbers are defined as follows:
126     *
127     *  * 0 = the original stored file
128     *  * 1 = the first revision
129     *  * 2 = the second revision
130     *  * etc…
131     *  * -2 = the second most recent revision
132     *  * -1 = the most recent revision
133     *
134     * @see Bucket::downloadToStreamByName()
135     * @see Bucket::openDownloadStreamByName()
136     * @param string  $filename
137     * @param integer $revision
138     * @return stdClass|null
139     */
140    public function findFileByFilenameAndRevision($filename, $revision)
141    {
142        $filename = (string) $filename;
143        $revision = (integer) $revision;
144
145        if ($revision < 0) {
146            $skip = abs($revision) - 1;
147            $sortOrder = -1;
148        } else {
149            $skip = $revision;
150            $sortOrder = 1;
151        }
152
153        return $this->filesCollection->findOne(
154            ['filename' => $filename],
155            [
156                'skip' => $skip,
157                'sort' => ['uploadDate' => $sortOrder],
158                'typeMap' => ['root' => 'stdClass'],
159            ]
160        );
161    }
162
163    /**
164     * Finds a GridFS file document for a given ID.
165     *
166     * @param mixed $id
167     * @return stdClass|null
168     */
169    public function findFileById($id)
170    {
171        return $this->filesCollection->findOne(
172            ['_id' => $id],
173            ['typeMap' => ['root' => 'stdClass']]
174        );
175    }
176
177    /**
178     * Finds documents from the GridFS bucket's files collection.
179     *
180     * @see Find::__construct() for supported options
181     * @param array|object $filter  Query by which to filter documents
182     * @param array        $options Additional options
183     * @return Cursor
184     */
185    public function findFiles($filter, array $options = [])
186    {
187        return $this->filesCollection->find($filter, $options);
188    }
189
190    /**
191     * Finds a single document from the GridFS bucket's files collection.
192     *
193     * @param array|object $filter  Query by which to filter documents
194     * @param array        $options Additional options
195     * @return array|object|null
196     */
197    public function findOneFile($filter, array $options = [])
198    {
199        return $this->filesCollection->findOne($filter, $options);
200    }
201
202    /**
203     * Return the bucket name.
204     *
205     * @return string
206     */
207    public function getBucketName()
208    {
209        return $this->bucketName;
210    }
211
212    /**
213     * Return the chunks collection.
214     *
215     * @return Collection
216     */
217    public function getChunksCollection()
218    {
219        return $this->chunksCollection;
220    }
221
222    /**
223     * Return the database name.
224     *
225     * @return string
226     */
227    public function getDatabaseName()
228    {
229        return $this->databaseName;
230    }
231
232    /**
233     * Return the files collection.
234     *
235     * @return Collection
236     */
237    public function getFilesCollection()
238    {
239        return $this->filesCollection;
240    }
241
242    /**
243     * Inserts a document into the chunks collection.
244     *
245     * @param array|object $chunk Chunk document
246     */
247    public function insertChunk($chunk)
248    {
249        if (! $this->checkedIndexes) {
250            $this->ensureIndexes();
251        }
252
253        $this->chunksCollection->insertOne($chunk);
254    }
255
256    /**
257     * Inserts a document into the files collection.
258     *
259     * The file document should be inserted after all chunks have been inserted.
260     *
261     * @param array|object $file File document
262     */
263    public function insertFile($file)
264    {
265        if (! $this->checkedIndexes) {
266            $this->ensureIndexes();
267        }
268
269        $this->filesCollection->insertOne($file);
270    }
271
272    /**
273     * Updates the filename field in the file document for a given ID.
274     *
275     * @param mixed  $id
276     * @param string $filename
277     * @return UpdateResult
278     */
279    public function updateFilenameForId($id, $filename)
280    {
281        return $this->filesCollection->updateOne(
282            ['_id' => $id],
283            ['$set' => ['filename' => (string) $filename]]
284        );
285    }
286
287    /**
288     * Create an index on the chunks collection if it does not already exist.
289     */
290    private function ensureChunksIndex()
291    {
292        foreach ($this->chunksCollection->listIndexes() as $index) {
293            if ($index->isUnique() && $index->getKey() === ['files_id' => 1, 'n' => 1]) {
294                return;
295            }
296        }
297
298        $this->chunksCollection->createIndex(['files_id' => 1, 'n' => 1], ['unique' => true]);
299    }
300
301    /**
302     * Create an index on the files collection if it does not already exist.
303     */
304    private function ensureFilesIndex()
305    {
306        foreach ($this->filesCollection->listIndexes() as $index) {
307            if ($index->getKey() === ['filename' => 1, 'uploadDate' => 1]) {
308                return;
309            }
310        }
311
312        $this->filesCollection->createIndex(['filename' => 1, 'uploadDate' => 1]);
313    }
314
315    /**
316     * Ensure indexes on the files and chunks collections exist.
317     *
318     * This method is called once before the first write operation on a GridFS
319     * bucket. Indexes are only be created if the files collection is empty.
320     */
321    private function ensureIndexes()
322    {
323        if ($this->checkedIndexes) {
324            return;
325        }
326
327        $this->checkedIndexes = true;
328
329        if (! $this->isFilesCollectionEmpty()) {
330            return;
331        }
332
333        $this->ensureFilesIndex();
334        $this->ensureChunksIndex();
335    }
336
337    /**
338     * Returns whether the files collection is empty.
339     *
340     * @return boolean
341     */
342    private function isFilesCollectionEmpty()
343    {
344        return null === $this->filesCollection->findOne([], [
345            'readPreference' => new ReadPreference(ReadPreference::RP_PRIMARY),
346            'projection' => ['_id' => 1],
347            'typeMap' => [],
348        ]);
349    }
350}
351