From c7f04721ddbeafd514f01345a057b273871d4f06 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 25 Oct 2026 09:09:55 -0700 Subject: [PATCH 1/5] fix(gfql): fail loud on unsupported remote DAG clients --- graphistry/compute/chain_remote.py | 17 +++++++---- .../compute/test_remote_error_surface.py | 29 ++++++++++++++++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/graphistry/compute/chain_remote.py b/graphistry/compute/chain_remote.py index 8beaf9e32e..a4a34340b4 100644 --- a/graphistry/compute/chain_remote.py +++ b/graphistry/compute/chain_remote.py @@ -20,7 +20,8 @@ DEFAULT_STRICT_LEVEL, StrictInput, resolve_strict_level, schema_declared_names, strictness_scope) from graphistry.compute.gfql_validate import gfql_validate as gfql_preflight_validate from graphistry.io.metadata import deserialize_plottable_metadata -from graphistry.compute.exceptions import ErrorCode, GFQLSyntaxError, GFQLTypeError +from graphistry.compute.exceptions import ( + ErrorCode, GFQLSyntaxError, GFQLTypeError, GFQLValidationError) from graphistry.compute.remote_df_io import ( require_supported_frame_library, resolve_csv_reader, @@ -202,6 +203,14 @@ def chain_remote_generic( elif isinstance(chain, dict): chain_json = chain is_let = chain_json.get('type') == 'Let' + if not is_let and chain_json.get('type') != 'Chain': + raise GFQLValidationError( + ErrorCode.E105, + "Remote query dict must be a serialized Let or Chain", + field="type", + value=chain_json.get('type'), + suggestion="Use Chain, List[ASTObject], ASTLet, Cypher string, or a dict with type='Chain' or type='Let'", + ) else: raise TypeError(f"gfql_remote() query must be Chain, List, ASTLet, Dict, or str. Got {type(chain)}") @@ -245,14 +254,12 @@ def chain_remote_generic( if is_let: warnings.warn( "gfql_remote() is sending a Let/DAG query. Servers that do not support " - "the gfql_query field will receive an empty gfql_operations array and " - "return the original graph unchanged. Upgrade to a server that reads " - "gfql_query for full Let/DAG support.", + "gfql_query will reject it. Upgrade to a server that reads " + "gfql_query for Let/DAG support.", UserWarning, stacklevel=2, ) request_body: Dict[str, Any] = { - "gfql_operations": [], "gfql_query": chain_json, "format": format } diff --git a/graphistry/tests/compute/test_remote_error_surface.py b/graphistry/tests/compute/test_remote_error_surface.py index 40151c9db7..8f0c5ea647 100644 --- a/graphistry/tests/compute/test_remote_error_surface.py +++ b/graphistry/tests/compute/test_remote_error_surface.py @@ -11,7 +11,14 @@ import graphistry from graphistry.compute.ast import n from graphistry.compute.chain_remote import chain_remote_generic -from graphistry.compute.exceptions import GFQLRemoteError, GFQLSchemaError, GFQLSyntaxError, GFQLTypeError +from graphistry.compute.exceptions import ( + ErrorCode, + GFQLRemoteError, + GFQLSchemaError, + GFQLSyntaxError, + GFQLTypeError, + GFQLValidationError, +) from graphistry.compute.predicates.numeric import gt from graphistry.compute.python_remote import python_remote_generic @@ -340,6 +347,26 @@ def test_shape_variant_accepts_cypher_params(): # --- #1960 4: a column subset must not strand the result's own bindings ----- +# --- #1955: Let/DAG cannot use the valid identity program as a compatibility sentinel --- + + +def test_let_wire_body_omits_identity_fallback_operations(): + let = {'type': 'Let', 'bindings': {'foo': {'type': 'Chain', 'chain': [{'type': 'Node'}]}}} + transport = Transport(resp(200, json.dumps({'nodes': [], 'edges': []}).encode(), 'application/json')) + with transport: + with pytest.warns(UserWarning, match=r'gfql_query'): + gfql(bound_graph(), chain=let) + assert 'gfql_operations' not in transport.bodies[0] + assert transport.bodies[0]['gfql_query'] == let + + +def test_unrecognized_remote_dict_is_declined_before_the_wire(): + with Transport(resp(200, json.dumps({'nodes': [], 'edges': []}).encode(), 'application/json')): + with pytest.raises(GFQLValidationError) as error_info: + gfql(bound_graph(), chain={}) + assert error_info.value.code == ErrorCode.E105 + + @pytest.mark.parametrize('fmt,payload_key', [('json', 'json'), ('parquet', 'zip')]) def test_node_col_subset_dropping_the_bound_id_is_typed(fmt, payload_key): if payload_key == 'json': From 03dc4754a772b96848e8733285806b5e502ee6b8 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 25 Oct 2026 09:21:04 -0700 Subject: [PATCH 2/5] fix(gfql): classify remote query dict errors --- graphistry/compute/chain_remote.py | 14 +++++++++++--- .../tests/compute/test_chain_remote_v2.py | 8 ++++---- .../tests/compute/test_remote_error_surface.py | 18 ++++++++++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/graphistry/compute/chain_remote.py b/graphistry/compute/chain_remote.py index a4a34340b4..2fedc4a971 100644 --- a/graphistry/compute/chain_remote.py +++ b/graphistry/compute/chain_remote.py @@ -204,9 +204,17 @@ def chain_remote_generic( chain_json = chain is_let = chain_json.get('type') == 'Let' if not is_let and chain_json.get('type') != 'Chain': - raise GFQLValidationError( - ErrorCode.E105, - "Remote query dict must be a serialized Let or Chain", + if 'type' not in chain_json: + raise GFQLValidationError( + ErrorCode.E105, + "Remote query dict must be a serialized Let or Chain", + field="type", + value=chain_json.get('type'), + suggestion="Use Chain, List[ASTObject], ASTLet, Cypher string, or a dict with type='Chain' or type='Let'", + ) + raise GFQLTypeError( + ErrorCode.E205, + f"Remote query dict has invalid type: {chain_json.get('type')!r}", field="type", value=chain_json.get('type'), suggestion="Use Chain, List[ASTObject], ASTLet, Cypher string, or a dict with type='Chain' or type='Let'", diff --git a/graphistry/tests/compute/test_chain_remote_v2.py b/graphistry/tests/compute/test_chain_remote_v2.py index ea7c6379d4..aa4104a973 100644 --- a/graphistry/tests/compute/test_chain_remote_v2.py +++ b/graphistry/tests/compute/test_chain_remote_v2.py @@ -116,12 +116,12 @@ def test_astlet(self) -> None: body = _send(ASTLet({"people": ASTNode(filter_dict={"type": "person"})})) assert body["gfql_query"]["type"] == "Let" assert "people" in body["gfql_query"]["bindings"] - assert body["gfql_operations"] == [] + assert "gfql_operations" not in body def test_let_dict(self) -> None: body = _send({"type": "Let", "bindings": {"people": {"type": "Node", "filter_dict": {"type": "person"}}}}) assert body["gfql_query"]["type"] == "Let" - assert body["gfql_operations"] == [] + assert "gfql_operations" not in body def test_let_with_output(self) -> None: body = _send( @@ -149,7 +149,7 @@ def test_graph_binding_with_use(self) -> None: assert body["gfql_query"]["type"] == "Let" assert "g1" in body["gfql_query"]["bindings"] assert "__result__" in body["gfql_query"]["bindings"] - assert body["gfql_operations"] == [] + assert "gfql_operations" not in body def test_graph_constructor_with_call_write(self) -> None: body = _send( @@ -206,7 +206,7 @@ def test_structural_compiled_query_with_bindings(self) -> None: body = _send("MATCH (n) RETURN n") assert body["gfql_query"]["type"] == "Let" - assert body["gfql_operations"] == [] + assert "gfql_operations" not in body binding = body["gfql_query"]["bindings"]["g1"] assert binding["type"] == "Call" assert binding["function"] == "graphistry.degree.write" diff --git a/graphistry/tests/compute/test_remote_error_surface.py b/graphistry/tests/compute/test_remote_error_surface.py index 8f0c5ea647..67c1052aee 100644 --- a/graphistry/tests/compute/test_remote_error_surface.py +++ b/graphistry/tests/compute/test_remote_error_surface.py @@ -367,6 +367,24 @@ def test_unrecognized_remote_dict_is_declined_before_the_wire(): assert error_info.value.code == ErrorCode.E105 +def test_missing_remote_dict_type_declines_empty_dict_as_e105_without_http(): + transport = Transport(resp(200, json.dumps({'nodes': [], 'edges': []}).encode(), 'application/json')) + with transport: + with pytest.raises(GFQLValidationError) as error_info: + gfql(bound_graph(), chain={}) + assert error_info.value.code == ErrorCode.E105 + assert transport.bodies == [] + + +def test_unknown_remote_dict_type_declines_as_e205_without_http(): + transport = Transport(resp(200, json.dumps({'nodes': [], 'edges': []}).encode(), 'application/json')) + with transport: + with pytest.raises(GFQLTypeError) as error_info: + gfql(bound_graph(), chain={'type': 'UnknownQuery'}) + assert error_info.value.code == ErrorCode.E205 + assert transport.bodies == [] + + @pytest.mark.parametrize('fmt,payload_key', [('json', 'json'), ('parquet', 'zip')]) def test_node_col_subset_dropping_the_bound_id_is_typed(fmt, payload_key): if payload_key == 'json': From f590ff3e05f606ff47264669276bdc54772ff602 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 25 Oct 2026 09:32:41 -0700 Subject: [PATCH 3/5] fix(gfql): preserve flat-chain remote compatibility --- graphistry/compute/chain_remote.py | 26 +++++++++++-------- .../compute/test_remote_error_surface.py | 9 +++++++ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/graphistry/compute/chain_remote.py b/graphistry/compute/chain_remote.py index 2fedc4a971..1d31f9fd66 100644 --- a/graphistry/compute/chain_remote.py +++ b/graphistry/compute/chain_remote.py @@ -204,21 +204,25 @@ def chain_remote_generic( chain_json = chain is_let = chain_json.get('type') == 'Let' if not is_let and chain_json.get('type') != 'Chain': - if 'type' not in chain_json: - raise GFQLValidationError( - ErrorCode.E105, - "Remote query dict must be a serialized Let or Chain", + is_legacy_flat_chain = ( + 'type' not in chain_json and isinstance(chain_json.get('chain'), list) + ) + if not is_legacy_flat_chain: + if 'type' not in chain_json: + raise GFQLValidationError( + ErrorCode.E105, + "Remote query dict must be a serialized Let or Chain", + field="type", + value=chain_json.get('type'), + suggestion="Use Chain, List[ASTObject], ASTLet, Cypher string, or a dict with type='Chain' or type='Let'", + ) + raise GFQLTypeError( + ErrorCode.E205, + f"Remote query dict has invalid type: {chain_json.get('type')!r}", field="type", value=chain_json.get('type'), suggestion="Use Chain, List[ASTObject], ASTLet, Cypher string, or a dict with type='Chain' or type='Let'", ) - raise GFQLTypeError( - ErrorCode.E205, - f"Remote query dict has invalid type: {chain_json.get('type')!r}", - field="type", - value=chain_json.get('type'), - suggestion="Use Chain, List[ASTObject], ASTLet, Cypher string, or a dict with type='Chain' or type='Let'", - ) else: raise TypeError(f"gfql_remote() query must be Chain, List, ASTLet, Dict, or str. Got {type(chain)}") diff --git a/graphistry/tests/compute/test_remote_error_surface.py b/graphistry/tests/compute/test_remote_error_surface.py index 67c1052aee..612306fe61 100644 --- a/graphistry/tests/compute/test_remote_error_surface.py +++ b/graphistry/tests/compute/test_remote_error_surface.py @@ -376,6 +376,15 @@ def test_missing_remote_dict_type_declines_empty_dict_as_e105_without_http(): assert transport.bodies == [] +def test_legacy_flat_chain_dict_body_retains_operations(): + chain = {'chain': []} + transport = Transport(resp(200, json.dumps({'nodes': [], 'edges': []}).encode(), 'application/json')) + with transport: + gfql(bound_graph(), chain=chain) + assert transport.bodies[0]['gfql_operations'] == [] + assert transport.bodies[0]['gfql_query'] == chain + + def test_unknown_remote_dict_type_declines_as_e205_without_http(): transport = Transport(resp(200, json.dumps({'nodes': [], 'edges': []}).encode(), 'application/json')) with transport: From 6c6b44ded6a5f12b05123cb7e0fa0599c86e5820 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 25 Oct 2026 09:42:41 -0700 Subject: [PATCH 4/5] test(gfql): cover remote Let 422 boundary --- CHANGELOG.md | 1 + .../tests/compute/test_remote_error_surface.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f0f54dcb..3e7bbd77e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed +- **GFQL remote Let/DAG compatibility**: `gfql_remote` no longer substitutes an empty `gfql_operations` array for a Let/DAG query, so unsupported older servers receive `gfql_query` and reject it explicitly instead of silently running an identity program. - **GFQL temporal scalar filters**: an empty timestamp or duration string no longer silently becomes a `NaT` bound; it raises the same typed `GFQLSchemaError` E302 as an unparseable bound. Naive bounds are handled as `Timestamp` values before timezone localization, and optional `pytz` exception discovery is confined to aware-column localization. - **GFQL Cypher null-equality conjunctions**: comparisons such as `n.v = null` now preserve three-valued `UNKNOWN` when combined with `AND`, instead of being replaced as if no predicate existed. `NeverMatch` predicates now round-trip through GFQL JSON, and raw JSON `filter_dict` null matching is unchanged. - **GFQL Cypher grouped aggregates after scalar reentry**: when a multirow scalar `WITH` prefix feeds another `MATCH`, grouping now covers all joined suffix rows together. For example, `WITH s MATCH (b)-[]->(s) RETURN s.type, count(*)` returns one aggregate per group instead of one per carried prefix row (#2235). diff --git a/graphistry/tests/compute/test_remote_error_surface.py b/graphistry/tests/compute/test_remote_error_surface.py index 612306fe61..b80b048895 100644 --- a/graphistry/tests/compute/test_remote_error_surface.py +++ b/graphistry/tests/compute/test_remote_error_surface.py @@ -360,6 +360,21 @@ def test_let_wire_body_omits_identity_fallback_operations(): assert transport.bodies[0]['gfql_query'] == let +def test_let_rejected_by_old_server_is_typed_remote_error(): + let = {'type': 'Let', 'bindings': {'foo': {'type': 'Chain', 'chain': [{'type': 'Node'}]}}} + transport = Transport(resp(422, b'{"status":"error","msg":"gfql_query is required"}', + 'application/json')) + with transport: + with pytest.warns(UserWarning, match=r'gfql_query'): + with pytest.raises(GFQLRemoteError) as error_info: + gfql(bound_graph(), chain=let) + assert transport.bodies == [{'gfql_query': let, 'format': 'json', 'engine': 'pandas', + 'strictness': 'warn'}] + assert not isinstance(error_info.value, requests.exceptions.RequestException) + assert error_info.value.context['status_code'] == 422 + assert 'gfql_query is required' in str(error_info.value) + + def test_unrecognized_remote_dict_is_declined_before_the_wire(): with Transport(resp(200, json.dumps({'nodes': [], 'edges': []}).encode(), 'application/json')): with pytest.raises(GFQLValidationError) as error_info: From 917009cbe20797dfd04783aed636adb17ddbd91d Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Sun, 25 Oct 2026 09:46:25 -0700 Subject: [PATCH 5/5] test(gfql): preserve nonempty flat-chain wire body --- graphistry/tests/compute/test_remote_error_surface.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/graphistry/tests/compute/test_remote_error_surface.py b/graphistry/tests/compute/test_remote_error_surface.py index b80b048895..e2a633972b 100644 --- a/graphistry/tests/compute/test_remote_error_surface.py +++ b/graphistry/tests/compute/test_remote_error_surface.py @@ -400,6 +400,15 @@ def test_legacy_flat_chain_dict_body_retains_operations(): assert transport.bodies[0]['gfql_query'] == chain +def test_nonempty_legacy_flat_chain_dict_body_retains_operation(): + chain = {'chain': [{'type': 'Node'}]} + transport = Transport(resp(200, json.dumps({'nodes': [], 'edges': []}).encode(), 'application/json')) + with transport: + gfql(bound_graph(), chain=chain) + assert transport.bodies[0]['gfql_operations'] == chain['chain'] + assert transport.bodies[0]['gfql_query'] == chain + + def test_unknown_remote_dict_type_declines_as_e205_without_http(): transport = Transport(resp(200, json.dumps({'nodes': [], 'edges': []}).encode(), 'application/json')) with transport: