Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
29 changes: 24 additions & 5 deletions graphistry/compute/chain_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -202,6 +203,26 @@ 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':
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'",
)
else:
raise TypeError(f"gfql_remote() query must be Chain, List, ASTLet, Dict, or str. Got {type(chain)}")

Expand Down Expand Up @@ -245,14 +266,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
}
Expand Down
8 changes: 4 additions & 4 deletions graphistry/tests/compute/test_chain_remote_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"
Expand Down
80 changes: 79 additions & 1 deletion graphistry/tests/compute/test_remote_error_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -340,6 +347,77 @@ 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_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:
gfql(bound_graph(), chain={})
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_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_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:
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':
Expand Down
Loading