diff --git a/docs/source/api-reference/from_gql_create.rst b/docs/source/api-reference/from_gql_create.rst new file mode 100644 index 00000000..8fc3ac57 --- /dev/null +++ b/docs/source/api-reference/from_gql_create.rst @@ -0,0 +1,5 @@ +Import from a GQL CREATE query +------------------------------ + +.. automodule:: neo4j_viz.gql_create + :members: diff --git a/docs/source/api-reference/from_neo4j.rst b/docs/source/api-reference/from_neo4j.rst index f079da26..f4c77fdb 100644 --- a/docs/source/api-reference/from_neo4j.rst +++ b/docs/source/api-reference/from_neo4j.rst @@ -1,5 +1,5 @@ Import from Neo4j ------------------------------------------------- +----------------- .. automodule:: neo4j_viz.neo4j :members: diff --git a/docs/source/integration.rst b/docs/source/integration.rst index 31687303..be915422 100644 --- a/docs/source/integration.rst +++ b/docs/source/integration.rst @@ -4,8 +4,9 @@ Integration with other libraries In addition to creating graphs from scratch, with ``neo4j-viz`` as is shown in the :doc:`Getting started section <./getting-started>`, you can also import data directly from external sources. In this section we will cover how to import data from `Pandas DataFrames `_, -`Neo4j Graph Data Science `_ and -`Neo4j Database `_. +`Neo4j Graph Data Science `_, +`Neo4j Database `_ and +`GQL CREATE queries `_. .. contents:: On this page: @@ -159,7 +160,7 @@ more extensive example. Neo4j Database ---------------- +-------------- The ``neo4j-viz`` library provides a convenience method for importing data from Neo4j. It requires and additional dependency to be installed, which you can do by running: @@ -171,15 +172,15 @@ It requires and additional dependency to be installed, which you can do by runni Once you have installed the additional dependency, you can use the :doc:`from_neo4j <./api-reference/from_neo4j>` method to import query results from Neo4j. -The ``from_neo4j`` method takes one mandatory positional parameters: +The ``from_neo4j`` method takes one mandatory positional parameter: * A ``result`` representing the query result either in form of `neo4j.graph.Graph` or `neo4j.Result`. -The ``node_caption`` parameter is also optional, and indicates the node property to use for the caption of each node in the visualization. - We can also provide an optional ``size_property`` parameter, which should refer to a node property, and will be used to determine the sizes of the nodes in the visualization. +The ``node_caption`` and ``relationship_caption`` parameters are also optional, and indicate the node and relationship properties to use for the captions of each element in the visualization. + The last optional property, ``node_radius_min_max``, can be used (and is used by default) to scale the node sizes for the visualization. It is a tuple of two numbers, representing the radii (sizes) in pixels of the smallest and largest nodes respectively in @@ -218,3 +219,54 @@ In this small example, we import a graph from a Neo4j query result. Please see the :doc:`Visualizing Neo4j Graphs tutorial <./tutorials/neo4j-example>` for a more extensive example. + + +GQL ``CREATE`` query +-------------------- + +The ``neo4j-viz`` library provides a convenience method for creating visualization graphs from GQL ``CREATE`` queries via the :doc:`from_neo4j <./api-reference/from_gql_create>` method method. + +The ``from_gql_create`` method takes one mandatory positional parameter: + +* A valid ``query`` representing a GQL ``CREATE`` query as a string. + +We can also provide an optional ``size_property`` parameter, which should refer to a node property, +and will be used to determine the sizes of the nodes in the visualization. + +The ``node_caption`` and ``relationship_caption`` parameters are also optional, and indicate the node and relationship properties to use for the captions of each element in the visualization. + +The last optional property, ``node_radius_min_max``, can be used (and is used by default) to scale the node sizes for +the visualization. +It is a tuple of two numbers, representing the radii (sizes) in pixels of the smallest and largest nodes respectively in +the visualization. +The node sizes will be scaled such that the smallest node will have the size of the first value, and the largest node +will have the size of the second value. +The other nodes will be scaled linearly between these two values according to their relative size. +This can be useful if node sizes vary a lot, or are all very small or very big. + + +Example +~~~~~~~ + +In this small example, we create a visualization graph from a GQL ``CREATE`` query. + +.. code-block:: python + + from neo4j_viz.gql_create import from_gql_create + + query = """ + CREATE + (a:User {name: 'Alice', age: 23}), + (b:User {name: 'Bridget', age: 34}), + (c:User {name: 'Charles', age: 45}), + (d:User {name: 'Dana', age: 56}), + (e:User {name: 'Eve', age: 67}), + (f:User {name: 'Fawad', age: 78}), + + (a)-[:LINK {weight: 0.5}]->(b), + (a)-[:LINK {weight: 4}]->(c), + (e)-[:LINK {weight: 1.1}]->(d), + (e)-[:LINK {weight: -2}]->(f); + """ + + VG = from_gql_create(query) diff --git a/examples/neo4j-example.ipynb b/examples/neo4j-example.ipynb index 604a205b..3fb3d163 100644 --- a/examples/neo4j-example.ipynb +++ b/examples/neo4j-example.ipynb @@ -279,6 +279,14 @@ " )\n", " print(result.summary.counters)" ] + }, + { + "cell_type": "markdown", + "id": "daf2f685c2e13fb9", + "metadata": {}, + "source": [ + "**NOTE:** Since in this example we didn't already have a Neo4j DB populated with data, it would actually have been more convenient to use the serverless `from_gql_create` importer method to create our `VisualizationGraph`." + ] } ], "metadata": { diff --git a/python-wrapper/src/neo4j_viz/gql_create.py b/python-wrapper/src/neo4j_viz/gql_create.py new file mode 100644 index 00000000..83bcbf3e --- /dev/null +++ b/python-wrapper/src/neo4j_viz/gql_create.py @@ -0,0 +1,356 @@ +import re +import uuid +from typing import Any, Optional + +from neo4j_viz import Node, Relationship, VisualizationGraph + + +def _parse_value(value_str: str) -> Any: + value_str = value_str.strip() + if not value_str: + return None + + # Parse map + if value_str.startswith("{") and value_str.endswith("}"): + inner = value_str[1:-1].strip() + result = {} + depth = 0 + in_string = None + start_idx = 0 + for i, ch in enumerate(inner): + if in_string is None: + if ch in ["'", '"']: + in_string = ch + elif ch in ["{", "["]: + depth += 1 + elif ch in ["}", "]"]: + depth -= 1 + elif ch == "," and depth == 0: + segment = inner[start_idx:i].strip() + if ":" not in segment: + return None + k, v = segment.split(":", 1) + k = k.strip().strip("'\"") + result[k] = _parse_value(v) + start_idx = i + 1 + else: + if ch == in_string: + in_string = None + + if inner[start_idx:]: + segment = inner[start_idx:].strip() + if ":" not in segment: + return None + k, v = segment.split(":", 1) + k = k.strip().strip("'\"") + result[k] = _parse_value(v) + + return result + + # Parse list + if value_str.startswith("[") and value_str.endswith("]"): + inner = value_str[1:-1].strip() + items = [] + depth = 0 + in_string = None + start_idx = 0 + for i, ch in enumerate(inner): + if in_string is None: + if ch in ["'", '"']: + in_string = ch + elif ch in ["{", "["]: + depth += 1 + elif ch in ["}", "]"]: + depth -= 1 + elif ch == "," and depth == 0: + items.append(_parse_value(inner[start_idx:i])) + start_idx = i + 1 + else: + if ch == in_string: + in_string = None + + if inner[start_idx:]: + items.append(_parse_value(inner[start_idx:])) + + return items + + # Parse boolean, float, int, or string + if re.match(r"^-?\d+$", value_str): + return int(value_str) + if re.match(r"^-?\d+\.\d+$", value_str): + return float(value_str) + if value_str.lower() == "true": + return True + if value_str.lower() == "false": + return False + if value_str.lower() == "null": + return None + + return value_str.strip("'\"") + + +def _parse_prop_str( + query: str, prop_str: str, prop_start: int, top_level_keys: set[str] +) -> tuple[dict[str, Any], dict[str, Any]]: + top_level: dict[str, Any] = {} + props: dict[str, Any] = {} + depth = 0 + in_string = None + start_idx = 0 + for i, ch in enumerate(prop_str): + if in_string is None: + if ch in ["'", '"']: + in_string = ch + elif ch in ["{", "["]: + depth += 1 + elif ch in ["}", "]"]: + depth -= 1 + elif ch == "," and depth == 0: + pair = prop_str[start_idx:i].strip() + if ":" not in pair: + snippet = _get_snippet(query, prop_start + start_idx) + raise ValueError(f"Property syntax error near: `{snippet}`.") + k, v = pair.split(":", 1) + k = k.strip().strip("'\"") + + if k in top_level_keys: + top_level[k] = _parse_value(v) + else: + props[k] = _parse_value(v) + + start_idx = i + 1 + else: + if ch == in_string: + in_string = None + + if prop_str[start_idx:]: + pair = prop_str[start_idx:].strip() + if ":" not in pair: + snippet = _get_snippet(query, prop_start + start_idx) + raise ValueError(f"Property syntax error near: `{snippet}`.") + k, v = pair.split(":", 1) + k = k.strip().strip("'\"") + + if k in top_level_keys: + top_level[k] = _parse_value(v) + else: + props[k] = _parse_value(v) + + return top_level, props + + +def _parse_labels_and_props( + query: str, s: str, top_level_keys: set[str] +) -> tuple[Optional[str], dict[str, Any], dict[str, Any]]: + prop_match = re.search(r"\{(.*)\}", s) + prop_str = "" + if prop_match: + prop_str = prop_match.group(1) + prop_start = query.index(prop_str, query.index(s)) + s = s[: prop_match.start()].strip() + alias_labels = re.split(r"[:&]", s) + raw_alias = alias_labels[0].strip() + final_alias = raw_alias if raw_alias else None + + if prop_str: + top_level, props = _parse_prop_str(query, prop_str, prop_start, top_level_keys) + else: + top_level = {} + props = {} + + label_list = [lbl.strip() for lbl in alias_labels[1:]] + if "labels" in props: + props["__labels"] = props["labels"] + props["labels"] = sorted(label_list) + + return final_alias, top_level, props + + +def _get_snippet(q: str, idx: int, context: int = 15) -> str: + start = max(0, idx - context) + end = min(len(q), idx + context) + + return q[start:end].replace("\n", " ") + + +def from_gql_create( + query: str, + size_property: Optional[str] = None, + node_caption: Optional[str] = "labels", + relationship_caption: Optional[str] = "type", + node_radius_min_max: Optional[tuple[float, float]] = (3, 60), +) -> VisualizationGraph: + """ + Parse a GQL CREATE query and return a VisualizationGraph object representing the graph it creates. + + All node and relationship properties will be included in the visualization graph. + If the properties are named as the fields of the `Node` or `Relationship` classes, they will be included as + top level fields of the respective objects. Otherwise, they will be included in the `properties` dictionary. + Additionally, a "labels" property will be added for nodes and a "type" property for relationships. + + Please note that this function is not a full GQL parser, it only handles CREATE queries that do not contain + other clauses like MATCH, WHERE, RETURN, etc, or any Cypher function calls. + It also does not handle all possible GQL syntax, but it should work for most common cases. + For more complex cases, we recommend using a Neo4j database and the `from_neo4j` method. + + Parameters + ---------- + query : str + The GQL CREATE query to parse + size_property : str, optional + Property to use for node size, by default None. + node_caption : str, optional + Property to use as the node caption, by default the node labels will be used. + relationship_caption : str, optional + Property to use as the relationship caption, by default the relationship type will be used. + node_radius_min_max : tuple[float, float], optional + Minimum and maximum node radius, by default (3, 60). + To avoid tiny or huge nodes in the visualization, the node sizes are scaled to fit in the given range. + """ + + query = query.strip() + if not re.match(r"(?i)^create\b", query): + raise ValueError("Query must begin with 'CREATE' (case insensitive).") + + query = re.sub(r"(?i)^create\s*", "", query, count=1).rstrip(";").strip() + parts = [] + paren_level = 0 + bracket_level = 0 + current: list[str] = [] + for i, char in enumerate(query): + if char == "(": + paren_level += 1 + elif char == ")": + paren_level -= 1 + if paren_level < 0: + snippet = _get_snippet(query, i) + raise ValueError(f"Unbalanced parentheses near: `{snippet}`.") + if char == "[": + bracket_level += 1 + elif char == "]": + bracket_level -= 1 + if bracket_level < 0: + snippet = _get_snippet(query, i) + raise ValueError(f"Unbalanced square brackets near: `{snippet}`.") + if char == "," and paren_level == 0 and bracket_level == 0: + parts.append("".join(current).strip()) + current = [] + else: + current.append(char) + + parts.append("".join(current).strip()) + if paren_level != 0: + snippet = _get_snippet(query, len(query) - 1) + raise ValueError(f"Unbalanced parentheses near: `{snippet}`.") + if bracket_level != 0: + snippet = _get_snippet(query, len(query) - 1) + raise ValueError(f"Unbalanced square brackets near: `{snippet}`.") + + node_pattern = re.compile(r"^\(([^)]*)\)$") + rel_pattern = re.compile(r"^\(([^)]*)\)-\s*\[\s*:(\w+)\s*(\{[^}]*\})?\s*\]->\(([^)]*)\)$") + + node_top_level_keys = set(Node.model_fields.keys()) + node_top_level_keys.remove("id") + + rel_top_level_keys = set(Relationship.model_fields.keys()) + rel_top_level_keys.remove("id") + rel_top_level_keys.remove("source") + rel_top_level_keys.remove("target") + + nodes = [] + relationships = [] + alias_to_id = {} + anonymous_count = 0 + + for part in parts: + node_m = node_pattern.match(part) + if node_m: + alias_labels_props = node_m.group(1).strip() + alias, top_level, props = _parse_labels_and_props(query, alias_labels_props, node_top_level_keys) + if not alias: + alias = f"_anon_{anonymous_count}" + anonymous_count += 1 + if alias not in alias_to_id: + alias_to_id[alias] = str(uuid.uuid4()) + nodes.append(Node(id=alias_to_id[alias], **top_level, properties=props)) + + continue + + rel_m = rel_pattern.match(part) + if rel_m: + left_node = rel_m.group(1).strip() + right_node = rel_m.group(4).strip() + + # Parse left node pattern + left_alias, left_top_level, left_props = _parse_labels_and_props(query, left_node, node_top_level_keys) + if not left_alias: + left_alias = f"_anon_{anonymous_count}" + anonymous_count += 1 + if left_alias not in alias_to_id: + alias_to_id[left_alias] = str(uuid.uuid4()) + nodes.append(Node(id=alias_to_id[left_alias], **left_top_level, properties=left_props)) + elif left_alias not in alias_to_id: + snippet = _get_snippet(query, query.index(left_node)) + raise ValueError(f"Relationship references unknown node alias: '{left_alias}' near: `{snippet}`.") + + # Parse right node pattern + right_alias, right_top_level, right_props = _parse_labels_and_props(query, right_node, node_top_level_keys) + if not right_alias: + right_alias = f"_anon_{anonymous_count}" + anonymous_count += 1 + if right_alias not in alias_to_id: + alias_to_id[right_alias] = str(uuid.uuid4()) + nodes.append(Node(id=alias_to_id[right_alias], **right_top_level, properties=right_props)) + elif right_alias not in alias_to_id: + snippet = _get_snippet(query, query.index(right_node)) + raise ValueError(f"Relationship references unknown node alias: '{right_alias}' near: `{snippet}`.") + + rel_id = str(uuid.uuid4()) + rel_type = rel_m.group(2).replace(":", "").strip() + rel_props_str = rel_m.group(3) or "" + if rel_props_str: + inner_str = rel_props_str.strip("{}").strip() + prop_start = query.index(inner_str, query.index(inner_str)) + top_level, props = _parse_prop_str(query, inner_str, prop_start, rel_top_level_keys) + else: + top_level = {} + props = {} + if "type" in props: + props["__type"] = props["type"] + props["type"] = rel_type + relationships.append( + Relationship( + id=rel_id, + source=alias_to_id[left_alias], + target=alias_to_id[right_alias], + **top_level, + properties=props, + ) + ) + continue + + snippet = part[:30] + raise ValueError(f"Invalid element in CREATE near: `{snippet}`.") + + if size_property is not None: + for node in nodes: + node.size = node.properties.get(size_property) + if node_caption is not None: + for node in nodes: + if node_caption == "labels": + if len(node.properties["labels"]) > 0: + node.caption = ":".join([label for label in node.properties["labels"]]) + else: + node.caption = str(node.properties.get(node_caption)) + if relationship_caption is not None: + for rel in relationships: + if relationship_caption == "type": + rel.caption = rel.properties["type"] + else: + rel.caption = str(rel.properties.get(relationship_caption)) + + VG = VisualizationGraph(nodes=nodes, relationships=relationships) + if (node_radius_min_max is not None) and (size_property is not None): + VG.resize_nodes(node_radius_min_max=node_radius_min_max) + + return VG diff --git a/python-wrapper/src/neo4j_viz/neo4j.py b/python-wrapper/src/neo4j_viz/neo4j.py index 1ab21955..83b5d75c 100644 --- a/python-wrapper/src/neo4j_viz/neo4j.py +++ b/python-wrapper/src/neo4j_viz/neo4j.py @@ -56,7 +56,7 @@ def from_neo4j( VG = VisualizationGraph(nodes, relationships) - if node_radius_min_max and size_property is not None: + if (node_radius_min_max is not None) and (size_property is not None): VG.resize_nodes(node_radius_min_max=node_radius_min_max) return VG diff --git a/python-wrapper/tests/test_gql_create.py b/python-wrapper/tests/test_gql_create.py new file mode 100644 index 00000000..75b58c04 --- /dev/null +++ b/python-wrapper/tests/test_gql_create.py @@ -0,0 +1,219 @@ +from typing import Any + +import pytest + +from neo4j_viz.gql_create import from_gql_create + + +def test_from_gql_create_syntax() -> None: + query = """ + CREATE + (a:User {name: 'Alice', age: 23, labels: ['Happy'], "id": 42}), + (b:User:person {name: "Bridget", age: 34, "caption": "Bridget"}), + (wizardMan:User {name: 'Charles: The wizard, man', hello: true, height: NULL}), + (d:User), + (a)-[:LINK {weight: 0.5}]->(b), + (e:User {age: 67, my_map: {key: 'value', key2: 3.14, key3: [1, 2, 3], key4: {a: 1, b: null}}}), + (:User {age: 42, pets: ['cat', false, 'dog']}), + (), + (f:User&Person + {name: 'Fawad', age: 78}), + ({age: 29}), + (a)-[:LINK {weight: 4}]->(wizardMan), + (e)-[:LINK]->(d), + (e)-[:OTHER_LINK {weight: -2, type: 1, source: 1337, caption: "Balloon"}]->(f), + ()-[:LINK]->({name: 'Florentin'}); + """ + expected_node_dicts: list[dict[str, dict[str, Any]]] = [ + { + "top_level": {}, + "properties": {"name": "Alice", "age": 23, "labels": ["User"], "__labels": ["Happy"], "id": 42}, + }, + { + "top_level": {"caption": "Bridget"}, + "properties": {"name": "Bridget", "age": 34, "labels": ["User", "person"]}, + }, + { + "top_level": {}, + "properties": {"name": "Charles: The wizard, man", "hello": True, "height": None, "labels": ["User"]}, + }, + {"top_level": {}, "properties": {"labels": ["User"]}}, + { + "top_level": {}, + "properties": { + "age": 67, + "my_map": {"key": "value", "key2": 3.14, "key3": [1, 2, 3], "key4": {"a": 1, "b": None}}, + "labels": ["User"], + }, + }, + {"top_level": {}, "properties": {"age": 42, "pets": ["cat", False, "dog"], "labels": ["User"]}}, + {"top_level": {}, "properties": {"labels": []}}, + {"top_level": {}, "properties": {"name": "Fawad", "age": 78, "labels": ["Person", "User"]}}, + {"top_level": {}, "properties": {"age": 29, "labels": []}}, + {"top_level": {}, "properties": {"labels": []}}, + {"top_level": {}, "properties": {"name": "Florentin", "labels": []}}, + ] + + VG = from_gql_create(query, node_caption=None, relationship_caption=None) + + assert len(VG.nodes) == len(expected_node_dicts) + for i, exp_node in enumerate(expected_node_dicts): + created_node = VG.nodes[i] + + assert created_node.model_dump(exclude_none=True, exclude={"properties", "id"}) == exp_node["top_level"] + assert created_node.properties == exp_node["properties"] + + expected_relationships_dicts: list[dict[str, Any]] = [ + {"source_idx": 0, "target_idx": 1, "top_level": {}, "properties": {"weight": 0.5, "type": "LINK"}}, + {"source_idx": 0, "target_idx": 2, "top_level": {}, "properties": {"weight": 4, "type": "LINK"}}, + {"source_idx": 4, "target_idx": 3, "top_level": {}, "properties": {"type": "LINK"}}, + { + "source_idx": 4, + "target_idx": 7, + "top_level": {"caption": "Balloon"}, + "properties": {"weight": -2, "type": "OTHER_LINK", "__type": 1, "source": 1337}, + }, + {"source_idx": 9, "target_idx": 10, "top_level": {}, "properties": {"type": "LINK"}}, + ] + + assert len(VG.relationships) == len(expected_relationships_dicts) + for i, exp_rel in enumerate(expected_relationships_dicts): + created_rel = VG.relationships[i] + assert created_rel.source == VG.nodes[exp_rel["source_idx"]].id + assert created_rel.target == VG.nodes[exp_rel["target_idx"]].id + assert ( + created_rel.model_dump(exclude_none=True, exclude={"properties", "id", "source", "target"}) + == exp_rel["top_level"] + ) + assert created_rel.properties == exp_rel["properties"] + + +def test_from_gql_create_captions() -> None: + query = """ + CREATE + (a:User {name: 'Alice', age: 23}), + (b:User:person {name: "Bridget", age: 34, "caption": "Bridget"}), + (a)-[:LINK {weight: 0.5}]->(b); + """ + expected_node_dicts: list[dict[str, dict[str, Any]]] = [ + { + "top_level": {"caption": "User"}, + "properties": {"name": "Alice", "age": 23, "labels": ["User"]}, + }, + { + "top_level": {"caption": "User:person"}, + "properties": {"name": "Bridget", "age": 34, "labels": ["User", "person"]}, + }, + ] + + VG = from_gql_create(query) + + assert len(VG.nodes) == len(expected_node_dicts) + for i, exp_node in enumerate(expected_node_dicts): + created_node = VG.nodes[i] + + assert created_node.model_dump(exclude_none=True, exclude={"properties", "id"}) == exp_node["top_level"] + assert created_node.properties == exp_node["properties"] + + expected_relationships_dicts: list[dict[str, Any]] = [ + { + "source_idx": 0, + "target_idx": 1, + "top_level": {"caption": "LINK"}, + "properties": {"weight": 0.5, "type": "LINK"}, + }, + ] + + assert len(VG.relationships) == len(expected_relationships_dicts) + for i, exp_rel in enumerate(expected_relationships_dicts): + created_rel = VG.relationships[i] + assert created_rel.source == VG.nodes[exp_rel["source_idx"]].id + assert created_rel.target == VG.nodes[exp_rel["target_idx"]].id + assert ( + created_rel.model_dump(exclude_none=True, exclude={"properties", "id", "source", "target"}) + == exp_rel["top_level"] + ) + assert created_rel.properties == exp_rel["properties"] + + +def test_from_gql_create_sizes() -> None: + query = """ + CREATE + (a:User {name: 'Alice', age: 23}), + (b:User:person {name: "Bridget", age: 34, "caption": "Bridget"}); + """ + expected_node_dicts: list[dict[str, dict[str, Any]]] = [ + { + "top_level": {"size": 3.0}, + "properties": {"name": "Alice", "age": 23, "labels": ["User"]}, + }, + { + "top_level": {"caption": "Bridget", "size": 60.0}, + "properties": {"name": "Bridget", "age": 34, "labels": ["User", "person"]}, + }, + ] + + VG = from_gql_create(query, size_property="age", node_caption=None, relationship_caption=None) + + assert len(VG.nodes) == len(expected_node_dicts) + for i, exp_node in enumerate(expected_node_dicts): + created_node = VG.nodes[i] + + assert created_node.model_dump(exclude_none=True, exclude={"properties", "id"}) == exp_node["top_level"] + assert created_node.properties == exp_node["properties"] + + +def test_unbalanced_parentheses_snippet() -> None: + query = "CREATE (a:User, (b:User })" + with pytest.raises(ValueError, match=r"Unbalanced parentheses near: `.*\(b:User.*"): + from_gql_create(query) + + +def test_unbalanced_brackets_snippet() -> None: + query = "CREATE (a)-[:LINK {weight: 0.5}->(b)" + with pytest.raises(ValueError, match=r"Unbalanced square brackets near: `eight: 0.5}->\(b\)`."): + from_gql_create(query) + + +def test_node_property_syntax_error_snippet1() -> None: + query = "CREATE (a:User {x, y:4})" + with pytest.raises(ValueError, match=r"Property syntax error near: `.*x, y.*"): + from_gql_create(query) + + +def test_node_property_paren_imbalance() -> None: + query = "CREATE (a:User {a: [1, b: 2, c: (3, 4)})" + with pytest.raises(ValueError, match=r"Unbalanced square brackets near: `: 2, c: \(3, 4\)}"): + from_gql_create(query) + + +def test_node_property_syntax_error_snippet2() -> None: + query = "CREATE (a:User {x:5,, y:4})" + with pytest.raises(ValueError, match=r"Property syntax error near: `.*x:5,, y.*"): + from_gql_create(query) + + +def test_invalid_element_in_create_snippet() -> None: + query = "CREATE [not_a_node]" + with pytest.raises(ValueError, match=r"Invalid element in CREATE near: `\[not_a_node.*"): + from_gql_create(query) + + +def test_rel_property_syntax_error_snippet() -> None: + query = "CREATE (a:User), (b:User), (a)-[:LINK {weight0.5}]->(b)" + with pytest.raises(ValueError, match=r"Property syntax error near: `\), \(a\)-\[:LINK {weight0.5}\]->\(b`."): + from_gql_create(query) + + +def test_unknown_node_alias() -> None: + query = "CREATE (a)-[:LINK {weight0.5}]->(b)" + with pytest.raises( + ValueError, match=r"Relationship references unknown node alias: 'a' near: `\(a\)-\[:LINK {weig`" + ): + from_gql_create(query) + + +def test_no_create_keyword() -> None: + query = "(a:User {y:4})" + with pytest.raises(ValueError, match=r"Query must begin with 'CREATE' \(case insensitive\)."): + from_gql_create(query)