Skip to content

Commit

Permalink
Provide suggestions for the HazelcastSqlError (#473)
Browse files Browse the repository at this point in the history
Recently, an enhancement is implemented on the server-side to provide
suggestions to execute in case of SqlErrors. The main use case is to
provide the `CREATE MAPPING` query suggestions in case there exists
a map and there are some entries on it.

The documentation of this feature will be provided in the upcoming
SQL documentation update PR.
  • Loading branch information
mdumandag authored Sep 16, 2021
1 parent 823a9f4 commit f89fe19
Show file tree
Hide file tree
Showing 4 changed files with 58 additions and 13 deletions.
8 changes: 7 additions & 1 deletion hazelcast/protocol/codec/custom/sql_error_codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def encode(buf, sql_error, is_final=False):
FixSizedTypesCodec.encode_uuid(initial_frame_buf, _ORIGINATING_MEMBER_ID_ENCODE_OFFSET, sql_error.originating_member_id)
buf.extend(initial_frame_buf)
CodecUtil.encode_nullable(buf, sql_error.message, StringCodec.encode)
CodecUtil.encode_nullable(buf, sql_error.suggestion, StringCodec.encode)
if is_final:
buf.extend(END_FINAL_FRAME_BUF)
else:
Expand All @@ -31,5 +32,10 @@ def decode(msg):
code = FixSizedTypesCodec.decode_int(initial_frame.buf, _CODE_DECODE_OFFSET)
originating_member_id = FixSizedTypesCodec.decode_uuid(initial_frame.buf, _ORIGINATING_MEMBER_ID_DECODE_OFFSET)
message = CodecUtil.decode_nullable(msg, StringCodec.decode)
is_suggestion_exists = False
suggestion = None
if not msg.peek_next_frame().is_end_frame():
suggestion = CodecUtil.decode_nullable(msg, StringCodec.decode)
is_suggestion_exists = True
CodecUtil.fast_forward_to_end_frame(msg)
return _SqlError(code, message, originating_member_id)
return _SqlError(code, message, originating_member_id, is_suggestion_exists, suggestion)
33 changes: 24 additions & 9 deletions hazelcast/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,9 @@ def __repr__(self):
class _SqlError(object):
"""Server-side error that is propagated to the client."""

__slots__ = ("code", "message", "originating_member_uuid")
__slots__ = ("code", "message", "originating_member_uuid", "suggestion")

def __init__(self, code, message, originating_member_uuid):
def __init__(self, code, message, originating_member_uuid, _, suggestion):
self.code = code
"""_SqlErrorCode: The error code."""

Expand All @@ -262,6 +262,9 @@ def __init__(self, code, message, originating_member_uuid):
self.originating_member_uuid = originating_member_uuid
"""uuid.UUID: UUID of the member that caused or initiated an error condition."""

self.suggestion = suggestion
"""str: Suggested SQL statement to remediate experienced error."""


class _SqlPage(object):
"""A finite set of rows returned to the user."""
Expand Down Expand Up @@ -409,11 +412,6 @@ class _SqlErrorCode(object):
A problem with partition distribution.
"""

MAP_DESTROYED = 1006
"""
An error caused by a concurrent destroy of a map.
"""

MAP_LOADING_IN_PROGRESS = 1007
"""
Map loading is not finished yet.
Expand All @@ -429,6 +427,11 @@ class _SqlErrorCode(object):
An error caused by an attempt to query an index that is not valid.
"""

OBJECT_NOT_FOUND = 1010
"""
Object (mapping/table) not found.
"""

DATA_EXCEPTION = 2000
"""
An error with data conversion or transformation.
Expand All @@ -438,9 +441,10 @@ class _SqlErrorCode(object):
class HazelcastSqlError(HazelcastError):
"""Represents an error occurred during the SQL query execution."""

def __init__(self, originating_member_uuid, code, message, cause):
def __init__(self, originating_member_uuid, code, message, cause, suggestion=None):
super(HazelcastSqlError, self).__init__(message, cause)
self._originating_member_uuid = originating_member_uuid
self._suggestion = suggestion

# TODO: This is private API, might be good to make it public or
# remove this information altogether.
Expand All @@ -451,6 +455,11 @@ def originating_member_uuid(self):
"""uuid.UUID: UUID of the member that caused or initiated an error condition."""
return self._originating_member_uuid

@property
def suggestion(self):
"""str: Suggested SQL statement to remediate experienced error."""
return self._suggestion


class SqlRowMetadata(object):
"""Metadata for the returned rows."""
Expand Down Expand Up @@ -1216,7 +1225,13 @@ def _handle_response_error(error):
``None`` otherwise.
"""
if error:
return HazelcastSqlError(error.originating_member_uuid, error.code, error.message, None)
return HazelcastSqlError(
error.originating_member_uuid,
error.code,
error.message,
None,
error.suggestion,
)
return None

def _on_execute_error(self, error):
Expand Down
21 changes: 21 additions & 0 deletions tests/integration/backward_compatible/sql_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
compare_client_version,
skip_if_server_version_older_than,
skip_if_server_version_newer_than_or_equal,
skip_if_client_version_older_than,
)

try:
Expand Down Expand Up @@ -315,6 +316,26 @@ def test_execute_statement_with_expected_result_type_of_update_count_when_rows_a
# Can't test the schema, because the IMDG SQL engine does not support
# specifying a schema yet.

def test_provided_suggestions(self):
skip_if_client_version_older_than(self, "5.0")
skip_if_server_version_older_than(self, self.client, "5.0")

# We don't create a mapping intentionally to get suggestions
self.map.put(1, "value-1")
select_all_query = 'SELECT * FROM "%s"' % self.map_name
with self.assertRaises(HazelcastSqlError) as cm:
with self.client.sql.execute(select_all_query) as result:
result.update_count().result()

with self.client.sql.execute(cm.exception.suggestion) as result:
result.update_count().result()

with self.client.sql.execute(select_all_query) as result:
self.assertEqual(
[(1, "value-1")],
[(r.get_object("__key"), r.get_object("this")) for r in result],
)


@unittest.skipIf(
compare_client_version("4.2") < 0, "Tests the features added in 4.2 version of the client"
Expand Down
9 changes: 6 additions & 3 deletions tests/unit/sql_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,12 @@ def test_close_when_execute_is_not_done(self):

def test_close_when_close_request_fails(self):
future = self.result.close()
self.set_close_error(HazelcastSqlError(None, _SqlErrorCode.MAP_DESTROYED, "expected", None))
self.set_close_error(HazelcastSqlError(None, _SqlErrorCode.PARSING, "expected", None))

with self.assertRaises(HazelcastSqlError) as cm:
future.result()

self.assertEqual(_SqlErrorCode.MAP_DESTROYED, cm.exception._code)
self.assertEqual(_SqlErrorCode.PARSING, cm.exception._code)

def test_fetch_error(self):
self.set_execute_response_with_rows(is_last=False)
Expand Down Expand Up @@ -184,7 +184,10 @@ def test_close_in_between_fetches(self):
self.assertEqual(_SqlErrorCode.CANCELLED_BY_USER, cm.exception._code)

def set_fetch_response_with_error(self):
response = {"row_page": None, "error": _SqlError(_SqlErrorCode.PARSING, "expected", None)}
response = {
"row_page": None,
"error": _SqlError(_SqlErrorCode.PARSING, "expected", None, None, ""),
}
self.set_future_result_or_exception(response, sql_fetch_codec._REQUEST_MESSAGE_TYPE)

def set_fetch_error(self, error):
Expand Down

0 comments on commit f89fe19

Please sign in to comment.