Skip to content

Commit c10aabd

Browse files
lukesneeringerlandrito
authored andcommitted
Add a .one and .one_or_none method. (googleapis#3784)
1 parent 3e23b70 commit c10aabd

File tree

2 files changed

+101
-5
lines changed

2 files changed

+101
-5
lines changed

spanner/google/cloud/spanner/streamed.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from google.protobuf.struct_pb2 import ListValue
1818
from google.protobuf.struct_pb2 import Value
19+
from google.cloud import exceptions
1920
from google.cloud.proto.spanner.v1 import type_pb2
2021
import six
2122

@@ -169,6 +170,48 @@ def __iter__(self):
169170
while iter_rows:
170171
yield iter_rows.pop(0)
171172

173+
def one(self):
174+
"""Return exactly one result, or raise an exception.
175+
176+
:raises: :exc:`NotFound`: If there are no results.
177+
:raises: :exc:`ValueError`: If there are multiple results.
178+
:raises: :exc:`RuntimeError`: If consumption has already occurred,
179+
in whole or in part.
180+
"""
181+
answer = self.one_or_none()
182+
if answer is None:
183+
raise exceptions.NotFound('No rows matched the given query.')
184+
return answer
185+
186+
def one_or_none(self):
187+
"""Return exactly one result, or None if there are no results.
188+
189+
:raises: :exc:`ValueError`: If there are multiple results.
190+
:raises: :exc:`RuntimeError`: If consumption has already occurred,
191+
in whole or in part.
192+
"""
193+
# Sanity check: Has consumption of this query already started?
194+
# If it has, then this is an exception.
195+
if self._metadata is not None:
196+
raise RuntimeError('Can not call `.one` or `.one_or_none` after '
197+
'stream consumption has already started.')
198+
199+
# Consume the first result of the stream.
200+
# If there is no first result, then return None.
201+
iterator = iter(self)
202+
try:
203+
answer = next(iterator)
204+
except StopIteration:
205+
return None
206+
207+
# Attempt to consume more. This should no-op; if we get additional
208+
# rows, then this is an error case.
209+
try:
210+
next(iterator)
211+
raise ValueError('Expected one result; got more.')
212+
except StopIteration:
213+
return answer
214+
172215

173216
class Unmergeable(ValueError):
174217
"""Unable to merge two values.

spanner/tests/unit/test_streamed.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def test_fields_unset(self):
5353
iterator = _MockCancellableIterator()
5454
streamed = self._make_one(iterator)
5555
with self.assertRaises(AttributeError):
56-
_ = streamed.fields
56+
streamed.fields
5757

5858
@staticmethod
5959
def _make_scalar_field(name, type_):
@@ -243,13 +243,24 @@ def test__merge_chunk_string_w_bytes(self):
243243
self._make_scalar_field('image', 'BYTES'),
244244
]
245245
streamed._metadata = self._make_result_set_metadata(FIELDS)
246-
streamed._pending_chunk = self._make_value(u'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA\n')
247-
chunk = self._make_value(u'B3RJTUUH4QQGFwsBTL3HMwAAABJpVFh0Q29tbWVudAAAAAAAU0FNUExFMG3E+AAAAApJREFUCNdj\nYAAAAAIAAeIhvDMAAAAASUVORK5CYII=\n')
246+
streamed._pending_chunk = self._make_value(
247+
u'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA'
248+
u'6fptVAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA\n',
249+
)
250+
chunk = self._make_value(
251+
u'B3RJTUUH4QQGFwsBTL3HMwAAABJpVFh0Q29tbWVudAAAAAAAU0FNUExF'
252+
u'MG3E+AAAAApJREFUCNdj\nYAAAAAIAAeIhvDMAAAAASUVORK5CYII=\n',
253+
)
248254

249255
merged = streamed._merge_chunk(chunk)
250256

251-
self.assertEqual(merged.string_value, u'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA\nB3RJTUUH4QQGFwsBTL3HMwAAABJpVFh0Q29tbWVudAAAAAAAU0FNUExFMG3E+AAAAApJREFUCNdj\nYAAAAAIAAeIhvDMAAAAASUVORK5CYII=\n')
252-
self.assertIsNone(streamed._pending_chunk)
257+
self.assertEqual(
258+
merged.string_value,
259+
u'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACXBIWXMAAAsTAAAL'
260+
u'EwEAmpwYAAAA\nB3RJTUUH4QQGFwsBTL3HMwAAABJpVFh0Q29tbWVudAAAAAAAU0'
261+
u'FNUExFMG3E+AAAAApJREFUCNdj\nYAAAAAIAAeIhvDMAAAAASUVORK5CYII=\n',
262+
)
263+
self.assertIsNone(streamed._pending_chunk)
253264

254265
def test__merge_chunk_array_of_bool(self):
255266
iterator = _MockCancellableIterator()
@@ -591,6 +602,48 @@ def test_merge_values_partial_and_filled_plus(self):
591602
self.assertEqual(streamed.rows, [VALUES[0:3], VALUES[3:6]])
592603
self.assertEqual(streamed._current_row, VALUES[6:])
593604

605+
def test_one_or_none_no_value(self):
606+
streamed = self._make_one(_MockCancellableIterator())
607+
with mock.patch.object(streamed, 'consume_next') as consume_next:
608+
consume_next.side_effect = StopIteration
609+
self.assertIsNone(streamed.one_or_none())
610+
611+
def test_one_or_none_single_value(self):
612+
streamed = self._make_one(_MockCancellableIterator())
613+
streamed._rows = ['foo']
614+
with mock.patch.object(streamed, 'consume_next') as consume_next:
615+
consume_next.side_effect = StopIteration
616+
self.assertEqual(streamed.one_or_none(), 'foo')
617+
618+
def test_one_or_none_multiple_values(self):
619+
streamed = self._make_one(_MockCancellableIterator())
620+
streamed._rows = ['foo', 'bar']
621+
with self.assertRaises(ValueError):
622+
streamed.one_or_none()
623+
624+
def test_one_or_none_consumed_stream(self):
625+
streamed = self._make_one(_MockCancellableIterator())
626+
streamed._metadata = object()
627+
with self.assertRaises(RuntimeError):
628+
streamed.one_or_none()
629+
630+
def test_one_single_value(self):
631+
streamed = self._make_one(_MockCancellableIterator())
632+
streamed._rows = ['foo']
633+
with mock.patch.object(streamed, 'consume_next') as consume_next:
634+
consume_next.side_effect = StopIteration
635+
self.assertEqual(streamed.one(), 'foo')
636+
637+
def test_one_no_value(self):
638+
from google.cloud import exceptions
639+
640+
iterator = _MockCancellableIterator(['foo'])
641+
streamed = self._make_one(iterator)
642+
with mock.patch.object(streamed, 'consume_next') as consume_next:
643+
consume_next.side_effect = StopIteration
644+
with self.assertRaises(exceptions.NotFound):
645+
streamed.one()
646+
594647
def test_consume_next_empty(self):
595648
iterator = _MockCancellableIterator()
596649
streamed = self._make_one(iterator)

0 commit comments

Comments
 (0)