Repository: ibis-project/ibis

Issue:
visualize.py `to_graph(...)` adds same node multiple times to graphviz graph AND doesn't display all nodes
When running the "example" from:
https://github.com/ibis-project/ibis/blob/9edbbc0cc22fc8d2db8c5e5614c6c5a0c40c33a0/ibis/expr/visualize.py#L124-L138

...and setting a breakpoint at the end of the `to_graph(...)`
https://github.com/ibis-project/ibis/blob/9edbbc0cc22fc8d2db8c5e5614c6c5a0c40c33a0/ibis/expr/visualize.py#L75-L106

1) You will see that the graphviz `DOT code` (aka `print(g.source)`) generated by the `graphviz` lib via `ibis/ibis/expr/visualize.py`s' `to_graph(...)` contains duplicated nodes (highlighted with red in below debugging screenshot).

![image](https://user-images.githubusercontent.com/10154357/190893103-4ac38a6d-f913-4fa7-91d8-da4ee8f8584c.png)


These duplicated nodes do NOT impact the visual representation because they have the same "node id" and **graphviz basically takes into consideration only the LAST declaration of a node**, `BUT` they impact a bit the performance:

```
the same `node` should be present (added/"seen") only once in the graphviz graph.
```

_The same `edge` (from, to) can be present more than once if we want to see it duplicated._

One solution would be to check if the node has already been added to the `g` graph BEFORE adding it, BUT...

2. ....Also there seem to be different nodes with the same id (with green in the screenshot above), which can IMPACT the visual representation, ex:
```
1st appearance:
-7147190702382226628 [label=<<I>having</I>: <B>NodeList</B> ⟶ ∅>]
...
2nd appearance with:
-7147190702382226628 [label=<<I>sort_keys</I>: <B>NodeList</B> ⟶ ∅>]
```
Reusing the same node ID for a different subsequent node _- (at least) before creating an edge with that ID -_ will cause the 1st node not to be rendered -> ex. indeed searching for "having" in the graphviz svg of the above-mentioned example will yield NO RESULTS (`"having" is missing`).

I'll try to investigate why is `hash(...)` returning the same thing for different instances and get back to you...



Discussion:
@ams1 Great find, great writeup!

This does seem like a bug. Slated for 4.0.0.

Resolving patch:
diff --git a/ibis/expr/types/core.py b/ibis/expr/types/core.py
--- a/ibis/expr/types/core.py
+++ b/ibis/expr/types/core.py
@@ -114,14 +114,24 @@ def _repr_png_(self) -> bytes | None:
                 # so fallback to the default text representation.
                 return None
 
-    def visualize(self, format: str = 'svg') -> None:
-        """Visualize an expression in the browser as an SVG image.
+    def visualize(
+        self,
+        format: str = "svg",
+        *,
+        label_edges: bool = False,
+        verbose: bool = False,
+    ) -> None:
+        """Visualize an expression in the browser.
 
         Parameters
         ----------
         format
             Image output format. These are specified by the ``graphviz`` Python
             library.
+        label_edges
+            Show operation input names as edge labels
+        verbose
+            Print the graphviz DOT code to stderr if [`True`][True]
 
         Notes
         -----
@@ -135,7 +145,11 @@ def visualize(self, format: str = 'svg') -> None:
         """
         import ibis.expr.visualize as viz
 
-        path = viz.draw(viz.to_graph(self), format=format)
+        path = viz.draw(
+            viz.to_graph(self, label_edges=label_edges),
+            format=format,
+            verbose=verbose,
+        )
         webbrowser.open(f'file://{os.path.abspath(path)}')
 
     def pipe(self, f, *args: Any, **kwargs: Any) -> Expr:
diff --git a/ibis/expr/visualize.py b/ibis/expr/visualize.py
--- a/ibis/expr/visualize.py
+++ b/ibis/expr/visualize.py
@@ -1,3 +1,4 @@
+import sys
 import tempfile
 from html import escape
 
@@ -6,7 +7,6 @@
 import ibis
 import ibis.common.exceptions as com
 import ibis.expr.operations as ops
-import ibis.expr.types as ir
 
 
 def get_type(node):
@@ -41,7 +41,7 @@ def get_type(node):
 
     return (
         ''.join(
-            '<BR ALIGN="LEFT" />  <I>{}</I>: {}'.format(
+            '<BR ALIGN="LEFT" /> <I>{}</I>: {}'.format(
                 escape(name), escape(str(type))
             )
             for name, type in zip(schema.names, schema.types)
@@ -50,63 +50,85 @@ def get_type(node):
     )
 
 
-def get_label(node, argname=None):
+def get_label(node):
     typename = get_type(node)  # Already an escaped string
     name = type(node).__name__
-    nodename = getattr(node, 'name', argname)
+    nodename = (
+        node.name
+        if isinstance(
+            node, (ops.Literal, ops.TableColumn, ops.Alias, ops.PhysicalTable)
+        )
+        else None
+    )
     if nodename is not None:
         if isinstance(node, ops.TableNode):
             label_fmt = '<<I>{}</I>: <B>{}</B>{}>'
         else:
-            label_fmt = '<<I>{}</I>: <B>{}</B> \u27f6 {}>'
+            label_fmt = '<<I>{}</I>: <B>{}</B><BR ALIGN="LEFT" />:: {}>'
         label = label_fmt.format(escape(nodename), escape(name), typename)
     else:
         if isinstance(node, ops.TableNode):
             label_fmt = '<<B>{}</B>{}>'
         else:
-            label_fmt = '<<B>{}</B> \u27f6 {}>'
+            label_fmt = '<<B>{}</B><BR ALIGN="LEFT" />:: {}>'
         label = label_fmt.format(escape(name), typename)
     return label
 
 
 DEFAULT_NODE_ATTRS = {'shape': 'box', 'fontname': 'Deja Vu Sans Mono'}
+DEFAULT_EDGE_ATTRS = {'fontname': 'Deja Vu Sans Mono'}
 
 
-def to_graph(op, node_attr=None, edge_attr=None):
-    if isinstance(op, ir.Expr):
-        return to_graph(op.op())
-    elif not isinstance(op, ops.Node):
-        raise TypeError(op)
+def to_graph(expr, node_attr=None, edge_attr=None, label_edges: bool = False):
+    graph = ibis.util.to_op_dag(expr.op())
 
-    stack = [(op, op.name if isinstance(op, ops.Named) else None)]
-    seen = set()
     g = gv.Digraph(
-        node_attr=node_attr or DEFAULT_NODE_ATTRS, edge_attr=edge_attr or {}
+        node_attr=node_attr or DEFAULT_NODE_ATTRS,
+        edge_attr=edge_attr or DEFAULT_EDGE_ATTRS,
     )
 
     g.attr(rankdir='BT')
 
-    while stack:
-        op, name = stack.pop()
-
-        if op not in seen:
-            seen.add(op)
-
-            vlabel = get_label(op, argname=name)
-            vhash = str(hash(op))
-            g.node(vhash, label=vlabel)
-
-            for name, arg in zip(op.argnames, op.args):
-                if isinstance(arg, ops.Node):
-                    uhash = str(hash(arg))
-                    ulabel = get_label(arg, argname=name)
-                    g.node(uhash, label=ulabel)
-                    g.edge(uhash, vhash)
-                    stack.append((arg, name))
+    seen = set()
+    edges = set()
+
+    for v, us in graph.items():
+        if isinstance(v, ops.NodeList) and not v:
+            continue
+
+        vhash = str(hash(v))
+        if v not in seen:
+            g.node(vhash, label=get_label(v))
+            seen.add(v)
+
+        for u in us:
+            if isinstance(u, ops.NodeList) and not u:
+                continue
+
+            uhash = str(hash(u))
+            if u not in seen:
+                g.node(uhash, label=get_label(u))
+                seen.add(u)
+            if (edge := (u, v)) not in edges:
+                if not label_edges:
+                    label = None
+                else:
+                    index = v.args.index(u)
+                    if isinstance(v, ops.NodeList):
+                        arg_name = f"values[{index}]"
+                    else:
+                        arg_name = v.argnames[index]
+                    label = f"<.{arg_name}>"
+
+                g.edge(uhash, vhash, label=label)
+                edges.add(edge)
     return g
 
 
-def draw(graph, path=None, format='png'):
+def draw(graph, path=None, format='png', verbose: bool = False):
+    if verbose:
+        print(graph.source, file=sys.stderr)
+
     piped_source = graph.pipe(format=format)
 
     if path is None:
@@ -122,17 +144,38 @@ def draw(graph, path=None, format='png'):
 
 
 if __name__ == '__main__':
-    t = ibis.table(
-        [('a', 'int64'), ('b', 'double'), ('c', 'string')], name='t'
+    from argparse import ArgumentParser
+
+    from ibis import _
+
+    p = ArgumentParser(
+        description="Render a GraphViz SVG of an example ibis expression."
     )
-    left = ibis.table([('a', 'int64'), ('b', 'string')])
-    right = ibis.table([('b', 'string'), ('c', 'int64'), ('d', 'string')])
-    joined = left.inner_join(right, left.b == right.b)
-    df = joined[left.a, right.c.name('b'), right.d.name('c')]
-    a = df.a
-    b = df.b
-    filt = df[(a + b * 2 * b / b**3 > 4) & (b > 5)]
-    expr = filt.groupby(filt.c).aggregate(
-        amean=filt.a.mean(), bsum=filt.b.sum()
+
+    p.add_argument(
+        "-v",
+        "--verbose",
+        action="count",
+        default=0,
+        help="Print GraphViz DOT code to stderr.",
+    )
+    p.add_argument(
+        "-l",
+        "--label-edges",
+        action="store_true",
+        help="Show operation inputs as edge labels.",
+    )
+
+    args = p.parse_args()
+
+    left = ibis.table(dict(a="int64", b="string"), name="left")
+    right = ibis.table(dict(b="string", c="int64", d="string"), name="right")
+    expr = (
+        left.inner_join(right, "b")
+        .select(left.a, b=right.c, c=right.d)
+        .filter((_.a + _.b * 2 * _.b / _.b**3 > 4) & (_.b > 5))
+        .groupby(_.c)
+        .having(_.a.mean() > 0.0)
+        .aggregate(a_mean=_.a.mean(), b_sum=_.b.sum())
     )
-    expr.visualize()
+    expr.visualize(verbose=args.verbose > 0, label_edges=args.label_edges)


Test patch: