diff --git a/firestore/google/cloud/firestore_v1beta1/_helpers.py b/firestore/google/cloud/firestore_v1beta1/_helpers.py index ffdb4b1d2477..fb2c21cf2f7a 100644 --- a/firestore/google/cloud/firestore_v1beta1/_helpers.py +++ b/firestore/google/cloud/firestore_v1beta1/_helpers.py @@ -116,6 +116,70 @@ def __ne__(self, other): return not equality_val +class FieldPath(object): + """ Field Path object for client use. + + Args: + parts: (one or more strings) + Indicating path of the key to be used. + """ + simple_field_name = re.compile(r'[A-Za-z_][A-Za-z_0-9]*') + + def __init__(self, *parts): + for part in parts: + if not isinstance(part, six.string_types) or not part: + error = 'One or more components is not a string or is empty.' + raise ValueError(error) + self.parts = tuple(parts) + + @staticmethod + def from_string(string): + """ Creates a FieldPath from a unicode string representation. + + Args: + :type string: str + :param string: A unicode string which cannot contain + `~*/[]` characters, cannot exceed 1500 bytes, + and cannot be empty. + + Returns: + A :class: `FieldPath` instance with the string split on "." + as arguments to `FieldPath`. + """ + invalid_characters = '~*/[]' + for invalid_character in invalid_characters: + if invalid_character in string: + raise ValueError('Invalid characters in string.') + string = string.split('.') + return FieldPath(*string) + + def to_api_repr(self): + """ Returns quoted string representation of the FieldPath + + Returns: :rtype: str + Quoted string representation of the path stored + within this FieldPath conforming to the Firestore API + specification + """ + ans = [] + for part in self.parts: + match = re.match(self.simple_field_name, part) + if match: + ans.append(part) + else: + replaced = part.replace('\\', '\\\\').replace('`', '\\`') + ans.append('`' + replaced + '`') + return '.'.join(ans) + + def __hash__(self): + return hash(self.to_api_repr()) + + def __eq__(self, other): + if isinstance(other, FieldPath): + return self.parts == other.parts + return NotImplemented + + class FieldPathHelper(object): """Helper to convert field names and paths for usage in a request. diff --git a/firestore/tests/unit/test__helpers.py b/firestore/tests/unit/test__helpers.py index b5cd6ce55e75..3a69cf393f28 100644 --- a/firestore/tests/unit/test__helpers.py +++ b/firestore/tests/unit/test__helpers.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Copyright 2017 Google LLC All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -86,6 +87,160 @@ def test___ne__type_differ(self): self.assertIs(geo_pt1.__ne__(geo_pt2), NotImplemented) +class TestFieldPath(unittest.TestCase): + + @staticmethod + def _get_target_class(): + from google.cloud.firestore_v1beta1._helpers import FieldPath + return FieldPath + + def _make_one(self, *args, **kwargs): + klass = self._get_target_class() + return klass(*args, **kwargs) + + def test_none_fails(self): + with self.assertRaises(ValueError): + self._make_one('a', None, 'b') + + def test_empty_string_in_part_fails(self): + with self.assertRaises(ValueError): + self._make_one('a', '', 'b') + + def test_integer_fails(self): + with self.assertRaises(ValueError): + self._make_one('a', 3, 'b') + + def test_iterable_fails(self): + with self.assertRaises(ValueError): + self._make_one('a', ['a'], 'b') + + def test_invalid_chars_in_constructor(self): + parts = '~*/[].' + for part in parts: + field_path = self._make_one(part) + self.assertEqual(field_path.parts, (part, )) + + def test_component(self): + field_path = self._make_one('a..b') + self.assertEquals(field_path.parts, ('a..b',)) + + def test_constructor_iterable(self): + field_path = self._make_one('a', 'b', 'c') + self.assertEqual(field_path.parts, ('a', 'b', 'c')) + + def test_unicode(self): + field_path = self._make_one('一', '二', '三') + self.assertEqual(field_path.parts, ('一', '二', '三')) + + def test_to_api_repr_a(self): + parts = 'a' + field_path = self._make_one(parts) + self.assertEqual('a', field_path.to_api_repr()) + + def test_to_api_repr_backtick(self): + parts = '`' + field_path = self._make_one(parts) + self.assertEqual('`\``', field_path.to_api_repr()) + + def test_to_api_repr_slash(self): + parts = '\\' + field_path = self._make_one(parts) + self.assertEqual(field_path.to_api_repr(), r'`\\`') + + def test_to_api_repr_double_slash(self): + parts = r'\\' + field_path = self._make_one(parts) + self.assertEqual(field_path.to_api_repr(), r'`\\\\`') + + def test_to_api_repr_underscore(self): + parts = '_33132' + field_path = self._make_one(parts) + self.assertEqual(field_path.to_api_repr(), '_33132') + + def test_to_api_repr_unicode_non_simple(self): + parts = '一' + field_path = self._make_one(parts) + self.assertEqual(field_path.to_api_repr(), '`一`') + + def test_to_api_repr_number_non_simple(self): + parts = '03' + field_path = self._make_one(parts) + self.assertEqual(field_path.to_api_repr(), '`03`') + + def test_to_api_repr_simple(self): + parts = 'a0332432' + field_path = self._make_one(parts) + self.assertEqual(field_path.to_api_repr(), 'a0332432') + + def test_to_api_repr_chain(self): + parts = 'a', '`', '\\', '_3', '03', 'a03', '\\\\', 'a0332432', '一' + field_path = self._make_one(*parts) + self.assertEqual(field_path.to_api_repr(), + r'a.`\``.`\\`._3.`03`.a03.`\\\\`.a0332432.`一`') + + def test_from_string(self): + field_path = self._get_target_class().from_string('a.b.c') + self.assertEqual(field_path.parts, ('a', 'b', 'c')) + + def test_list_splat(self): + parts = ['a', 'b', 'c'] + field_path = self._make_one(*parts) + self.assertEqual(field_path.parts, ('a', 'b', 'c')) + + def test_tuple_splat(self): + parts = ('a', 'b', 'c') + field_path = self._make_one(*parts) + self.assertEqual(field_path.parts, ('a', 'b', 'c')) + + def test_invalid_chars_from_string_fails(self): + parts = '~*/[].' + for part in parts: + with self.assertRaises(ValueError): + self._get_target_class().from_string(part) + + def test_empty_string_fails(self): + parts = '' + with self.assertRaises(ValueError): + self._get_target_class().from_string(parts) + + def test_list_fails(self): + parts = ['a', 'b', 'c'] + with self.assertRaises(ValueError): + self._make_one(parts) + + def test_tuple_fails(self): + parts = ('a', 'b', 'c') + with self.assertRaises(ValueError): + self._make_one(parts) + + def test_equality(self): + field_path = self._make_one('a', 'b') + string_path = self._get_target_class().from_string('a.b') + self.assertEqual(field_path, string_path) + + def test_non_equal_types(self): + import mock + mock = mock.Mock() + mock.parts = 'a', 'b' + field_path = self._make_one('a', 'b') + self.assertNotEqual(field_path, mock) + + def test_key(self): + field_path = self._make_one('a321', 'b456') + field_path_same = self._get_target_class().from_string('a321.b456') + field_path_different = self._make_one('a321', 'b457') + keys = { + field_path: '', + field_path_same: '', + field_path_different: '' + } + for key in keys: + if key == field_path_different: + self.assertNotEqual(key, field_path) + else: + self.assertEqual(key, field_path) + + class TestFieldPathHelper(unittest.TestCase): @staticmethod