Repository: mdowds/python-mock-firestore

Issue:
Incorrect support of merging document
I got an error, while I'm trying to set not existing document with flag `merge=True`. The same code works ok for real Firestore.

```python
from google.cloud import firestore
from mockfirestore import MockFirestore


COLLECTION = 'test'

db = firestore.Client.from_service_account_json('firestore.json')
pk = 'test3'

v_not_exist = db.collection(COLLECTION).document(pk).get()
print(f'v_not_exist: {v_not_exist.exists}')

db.collection(COLLECTION).document(pk).set({'first': 1, 'v': 1}, merge=True)

v1 = db.collection(COLLECTION).document(pk).get()
print(f'v1: {v1._data}')

db.collection(COLLECTION).document(pk).set({'second': 2, 'v': 2}, merge=True)
v2 = db.collection(COLLECTION).document(pk).get()
print(f'v2: {v2._data}')

print('---')

mock_db = MockFirestore()
v_not_exist = mock_db.collection(COLLECTION).document(pk).get()
print(f'm v_not_exist: {v_not_exist.exists}')

mock_db.collection(COLLECTION).document(pk).set({'first': 1, 'v': 1}, merge=True)

v1 = mock_db.collection(COLLECTION).document(pk).get()
print(f'm v1: {v1.to_dict()}')


mock_db.collection(COLLECTION).document(pk).set({'second': 2, 'v': 2}, merge=True)
v2 = mock_db.collection(COLLECTION).document(pk).get()
print(f'm v2: {v2.to_dict()}')
```
Output:

```
v_not_exist: True
v1: {'second': 2, 'v': 1, 'first': 1}
v2: {'second': 2, 'v': 2, 'first': 1}
---
m v_not_exist: False
Traceback (most recent call last):
  File "/home/peter/projects/af/notes/personal/firestore_learn/personal.py", line 39, in <module>
    mock_db.collection(COLLECTION).document(pk).set({'first': 1, 'v': 1}, merge=True)
  File "/home/peter/.pyenv/versions/firestore_learn_py3.8.1/lib/python3.8/site-packages/mockfirestore/document.py", line 56, in set
    self.update(deepcopy(data))
  File "/home/peter/.pyenv/versions/firestore_learn_py3.8.1/lib/python3.8/site-packages/mockfirestore/document.py", line 63, in update
    raise NotFound('No document to update: {}'.format(self._path))
google.api_core.exceptions.NotFound: 404 No document to update: ['learn', 'test3']
```

Discussion:

Patch:
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..1d16e20
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,26 @@
+name: Deploy
+on:
+  release:
+    types:
+      - created
+
+jobs:
+  deploy:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python
+      uses: actions/setup-python@v2
+      with:
+        python-version: '3.x'
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install setuptools wheel twine
+    - name: Build and publish
+      env:
+        TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
+        TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
+      run: |
+        python setup.py sdist bdist_wheel
+        twine upload dist/*
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 43c68b2..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-language: python
-dist: xenial
-matrix:
-  include:
-    - python: 3.5
-      env: TOXENV=py35
-    - python: 3.6
-      env: TOXENV=py36
-    - python: 3.7
-      env: TOXENV=py37
-      sudo: required  # https://github.com/travis-ci/travis-ci/issues/9069
-    - python: 3.8
-      env: TOXENV=py38
-script: make test
-install:
-  - pip install tox
-deploy:
-  provider: pypi
-  user: mdowds
-  password:
-    secure: hMHcNxxGsbGEXLm7q8wBAWE507idZstaxmoTFJUczBIs1YmLZerFfJnuot0aATtJ/sGoOHKwTB+YJDWDC6fXCpl82qNRw0GbHodwelN/VKVBVuRyHTx43r/tcvkjevrM3hRELuiZ422DlDvRGzc/MfCSFEo7ettlzoqTv52TEnw6mQoP2D0/GWemk0Gg6+6nOfuPW1Py8vSzxo6YNuE28o8u4CpepuFyvjIRHF2p68RqIbDpAyD94YXj5BbvKaXN0X8LBRuodOXYbGwN0FMHV0Y1Oa59v5V+H4hZgNbE2RsZD9ZtN9Hxi50B5ja20PQ3U3MOKOk7uhQ+VrHair6bWFk4EDp9Uq7M1QQJNsrHW8mkn666M3WtUy5t8ZZjPzhtEsIGT8jT3e3wGGYQLZOE+1Bwwm4EEr7x0Dayyo5/SWU/ns2ShN05Ljkf1CguCWif7RMfyM3wwu0Gl6cDCxLBfhu/NMN1gjKxISeHXpvec4F96ny5xKVwVer5Kzte0+6bZNRUfIqsM7Ve165GEJX2RKlNHPjE21U7hK07aE2gadKoIwXNxCFUVUs5F151F8Vd21xWrD3f093LMFDxUKF/0iUdiDsa8ZT1ZAfyws9/QNuN2CsOx2fYzUkeKkHtGYRFLKUodYBHH5G0o3OyxSY9AoLGQvbftUaiFhyaIShGQmU=
-  on:
-    branch: master
-    tags: true
-    condition: $TRAVIS_PYTHON_VERSION = "3.5"
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 0e6fe37..0000000
--- a/Makefile
+++ /dev/null
@@ -1,9 +0,0 @@
-.PHONY: test
-test: development
-	tox
-
-development: venv/bin/activate
-venv/bin/activate: requirements-dev-minimal.txt
-	test -d venv || virtualenv venv
-	venv/bin/pip install -r requirements-dev-minimal.txt
-	touch venv/bin/activate
diff --git a/README.md b/README.md
index 91d0790..50d9c75 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,9 @@ mock_db.collection('users').document('alovelace').collection('friends')
 mock_db.collection('users').document('alovelace').delete()
 mock_db.collection('users').add({'first': 'Ada', 'last': 'Lovelace'}, 'alovelace')
 mock_db.get_all([mock_db.collection('users').document('alovelace')])
+mock_db.document('users/alovelace')
+mock_db.document('users/alovelace').update({'born': 1815})
+mock_db.collection('users/alovelace/friends')
 
 # Querying
 mock_db.collection('users').order_by('born').get()
@@ -92,6 +95,11 @@ transaction.delete(mock_db.collection('users').document('alovelace'))
 transaction.commit()
 ```
 
+## Running the tests
+* Create and activate a virtualenv with a Python version of at least 3.5
+* Install dependencies with `pip install -r requirements-dev-minimal.txt`
+* Run tests with `python -m unittest discover tests -t /`
+
 ## Contributors
 
 * [Matt Dowds](https://github.com/mdowds)
@@ -106,3 +114,4 @@ transaction.commit()
 * [Anna Melnikov](https://github.com/notnami)
 * [Carl Chipperfield](https://github.com/carl-chipperfield)
 * [Aaron Loo](https://github.com/domanchi)
+* [Kristof Krenn](https://github.com/KrennKristof)
diff --git a/mockfirestore/client.py b/mockfirestore/client.py
index 50bbbb4..07fd398 100644
--- a/mockfirestore/client.py
+++ b/mockfirestore/client.py
@@ -9,10 +9,40 @@ class MockFirestore:
     def __init__(self) -> None:
         self._data = {}
 
-    def collection(self, name: str) -> CollectionReference:
-        if name not in self._data:
-            self._data[name] = {}
-        return CollectionReference(self._data, [name])
+    def _ensure_path(self, path):
+        current_position = self
+
+        for el in path[:-1]:
+            if type(current_position) in (MockFirestore, DocumentReference):
+                current_position = current_position.collection(el)
+            else:
+                current_position = current_position.document(el)
+
+        return current_position
+
+    def document(self, path: str) -> DocumentReference:
+        path = path.split("/")
+
+        if len(path) % 2 != 0:
+            raise Exception("Cannot create document at path {}".format(path))
+        current_position = self._ensure_path(path)
+
+        return current_position.document(path[-1])
+
+    def collection(self, path: str) -> CollectionReference:
+        path = path.split("/")
+
+        if len(path) % 2 != 1:
+            raise Exception("Cannot create collection at path {}".format(path))
+
+        name = path[-1]
+        if len(path) > 1:
+            current_position = self._ensure_path(path)
+            return current_position.collection(name)
+        else:
+            if name not in self._data:
+                self._data[name] = {}
+            return CollectionReference(self._data, [name])
 
     def reset(self):
         self._data = {}
diff --git a/mockfirestore/document.py b/mockfirestore/document.py
index ddd59cd..cacb121 100644
--- a/mockfirestore/document.py
+++ b/mockfirestore/document.py
@@ -70,7 +70,10 @@ class DocumentReference:
 
     def set(self, data: Dict, merge=False):
         if merge:
-            self.update(deepcopy(data))
+            try:
+                self.update(deepcopy(data))
+            except NotFound:
+                self.set(data)
         else:
             set_by_path(self._data, self._path, deepcopy(data))
 
diff --git a/setup.py b/setup.py
index b58c66a..34443ce 100644
--- a/setup.py
+++ b/setup.py
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
 
 setuptools.setup(
     name="mock-firestore",
-    version="0.8.0",
+    version="0.9.0",
     author="Matt Dowds",
     description="In-memory implementation of Google Cloud Firestore for use in tests",
     long_description=long_description,
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 6eca17c..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,11 +0,0 @@
-[tox]
-project = mockfirestore
-# These should match the travis env list
-envlist = py35,py36,py37
-skip_missing_interpreters = true
-
-[testenv]
-passenv = SSH_AUTH_SOCK
-deps = -rrequirements-dev-minimal.txt
-commands =
-    python -m unittest discover tests -t /
\ No newline at end of file


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..42c3b29
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,23 @@
+name: Test
+on: [push, pull_request]
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: [ 3.5, 3.6, 3.7, 3.8 ]
+
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements-dev-minimal.txt
+      - name: Run tests
+        run: |
+          python -m unittest discover tests -t /
diff --git a/tests/test_collection_reference.py b/tests/test_collection_reference.py
index 451abdc..59397be 100644
--- a/tests/test_collection_reference.py
+++ b/tests/test_collection_reference.py
@@ -33,6 +33,19 @@ class TestCollectionReference(TestCase):
         docs = list(fs.collection('foo').document('first').collection('bar').stream())
         self.assertEqual({'id': 1.1}, docs[0].to_dict())
 
+    def test_collection_get_nestedCollection_by_path(self):
+        fs = MockFirestore()
+        fs._data = {'foo': {
+            'first': {
+                'id': 1,
+                'bar': {
+                    'first_nested': {'id': 1.1}
+                }
+            }
+        }}
+        docs = list(fs.collection('foo/first/bar').stream())
+        self.assertEqual({'id': 1.1}, docs[0].to_dict())
+
     def test_collection_get_nestedCollection_collectionDoesNotExist(self):
         fs = MockFirestore()
         fs._data = {'foo': {
@@ -41,6 +54,14 @@ class TestCollectionReference(TestCase):
         docs = list(fs.collection('foo').document('first').collection('bar').stream())
         self.assertEqual([], docs)
 
+    def test_collection_get_nestedCollection_by_path_collectionDoesNotExist(self):
+        fs = MockFirestore()
+        fs._data = {'foo': {
+            'first': {'id': 1}
+        }}
+        docs = list(fs.collection('foo/first/bar').stream())
+        self.assertEqual([], docs)
+
     def test_collection_get_ordersByAscendingDocumentId_byDefault(self):
         fs = MockFirestore()
         fs._data = {'foo': {
diff --git a/tests/test_document_reference.py b/tests/test_document_reference.py
index 864c98f..025b020 100644
--- a/tests/test_document_reference.py
+++ b/tests/test_document_reference.py
@@ -6,6 +6,24 @@ from mockfirestore import MockFirestore, NotFound
 
 
 class TestDocumentReference(TestCase):
+
+    def test_get_document_by_path(self):
+        fs = MockFirestore()
+        fs._data = {'foo': {
+            'first': {'id': 1}
+        }}
+        doc = fs.document('foo/first').get()
+        self.assertEqual({'id': 1}, doc.to_dict())
+        self.assertEqual('first', doc.id)
+
+    def test_set_document_by_path(self):
+        fs = MockFirestore()
+        fs._data = {}
+        doc_content = {'id': 'bar'}
+        fs.document('foo/doc1/bar/doc2').set(doc_content)
+        doc = fs.document('foo/doc1/bar/doc2').get().to_dict()
+        self.assertEqual(doc_content, doc)
+        
     def test_document_get_returnsDocument(self):
         fs = MockFirestore()
         fs._data = {'foo': {
@@ -87,6 +105,12 @@ class TestDocumentReference(TestCase):
         doc = fs.collection('foo').document('first').get().to_dict()
         self.assertEqual({'id': 1, 'updated': True}, doc)
 
+    def test_document_set_mergeNewValueForNonExistentDoc(self):
+        fs = MockFirestore()
+        fs.collection('foo').document('first').set({'updated': True}, merge=True)
+        doc = fs.collection('foo').document('first').get().to_dict()
+        self.assertEqual({'updated': True}, doc)
+
     def test_document_set_overwriteValue(self):
         fs = MockFirestore()
         fs._data = {'foo': {