diff --git a/hazelcast/protocol/codec/custom/sql_error_codec.py b/hazelcast/protocol/codec/custom/sql_error_codec.py index 37bea690ef..01dd277669 100644 --- a/hazelcast/protocol/codec/custom/sql_error_codec.py +++ b/hazelcast/protocol/codec/custom/sql_error_codec.py @@ -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: @@ -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) diff --git a/hazelcast/sql.py b/hazelcast/sql.py index 2e6ba1f358..b00a9e3d45 100644 --- a/hazelcast/sql.py +++ b/hazelcast/sql.py @@ -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.""" @@ -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.""" @@ -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. @@ -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. @@ -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. @@ -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.""" @@ -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): diff --git a/tests/integration/backward_compatible/sql_test.py b/tests/integration/backward_compatible/sql_test.py index 3b5b6abf68..488dab730b 100644 --- a/tests/integration/backward_compatible/sql_test.py +++ b/tests/integration/backward_compatible/sql_test.py @@ -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: @@ -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" diff --git a/tests/unit/sql_test.py b/tests/unit/sql_test.py index 9e25c91e4a..c0aa33eb92 100644 --- a/tests/unit/sql_test.py +++ b/tests/unit/sql_test.py @@ -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) @@ -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):