Skip to content
Open
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
522 changes: 107 additions & 415 deletions README.md

Large diffs are not rendered by default.

66 changes: 65 additions & 1 deletion graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4065,7 +4065,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":

elif cmd == "export":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd not in ("html", "callflow-html", "obsidian", "wiki", "svg", "graphml", "neo4j", "falkordb"):
if subcmd not in ("html", "callflow-html", "obsidian", "wiki", "svg", "graphml", "neo4j", "falkordb", "maludb"):
print("Usage: graphify export <format>", file=sys.stderr)
print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz]", file=sys.stderr)
print(" callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH] [--report PATH] [--sections PATH] [--output HTML]", file=sys.stderr)
Expand All @@ -4078,6 +4078,10 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
print(" (or set NEO4J_PASSWORD instead of --password to keep it off argv)", file=sys.stderr)
print(" falkordb [--graph PATH] [--push URI] [--user U] [--password P]", file=sys.stderr)
print(" (or set FALKORDB_PASSWORD instead of --password to keep it off argv)", file=sys.stderr)
print(" maludb [--graph PATH] [--push URL] [--namespace NS] [--token T]", file=sys.stderr)
print(" [--sql-usage --source-root DIR [--db-schema S] [--datamodel-ns NS]]", file=sys.stderr)
print(" (URL defaults to MALUDB_URL; set MALUDB_TOKEN instead of --token to keep it off argv;", file=sys.stderr)
print(" namespace defaults to the graph's project directory name)", file=sys.stderr)
sys.exit(1)

# Parse shared args
Expand Down Expand Up @@ -4109,8 +4113,19 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
# NEO4J_PASSWORD otherwise.
push_password: str | None = (
os.environ.get("FALKORDB_PASSWORD") if subcmd == "falkordb"
else os.environ.get("MALUDB_TOKEN") if subcmd == "maludb"
else os.environ.get("NEO4J_PASSWORD")
) or None
# maludb-only settings: --push falls back to MALUDB_URL, --token is an
# alias for --password (same F-031 keep-it-off-argv rationale), and the
# namespace defaults to the graph's project directory name.
maludb_namespace: str | None = None
maludb_sql_usage = False
maludb_source_root: str | None = None
maludb_db_schema = "public"
maludb_datamodel_ns = "datamodel"
if subcmd == "maludb" and not push_uri:
push_uri = os.environ.get("MALUDB_URL") or None
i = 0
while i < len(args):
a = args[i]
Expand Down Expand Up @@ -4166,6 +4181,18 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
push_user = args[i + 1]; i += 2
elif a == "--password" and i + 1 < len(args):
push_password = args[i + 1]; i += 2
elif a == "--token" and i + 1 < len(args):
push_password = args[i + 1]; i += 2
elif a == "--namespace" and i + 1 < len(args):
maludb_namespace = args[i + 1]; i += 2
elif a == "--sql-usage":
maludb_sql_usage = True; i += 1
elif a == "--source-root" and i + 1 < len(args):
maludb_source_root = args[i + 1]; i += 2
elif a == "--db-schema" and i + 1 < len(args):
maludb_db_schema = args[i + 1]; i += 2
elif a == "--datamodel-ns" and i + 1 < len(args):
maludb_datamodel_ns = args[i + 1]; i += 2
elif subcmd == "callflow-html" and not a.startswith("-") and not graph_path_explicit:
candidate = Path(a)
if candidate.name == "graph.json" or candidate.suffix.lower() == ".json":
Expand Down Expand Up @@ -4370,6 +4397,43 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
f"import), so load a graph with: graphify export falkordb --push "
f"falkordb://localhost:6379")

elif subcmd == "maludb":
from graphify.export import push_to_maludb as _push_maludb
if not push_uri:
print("error: --push URL (or MALUDB_URL) required, e.g. "
"graphify export maludb --push https://api.example.com", file=sys.stderr)
sys.exit(1)
if not push_password:
print("error: --token (or MALUDB_TOKEN) required — a MaluDb bearer token", file=sys.stderr)
sys.exit(1)
namespace = maludb_namespace
if not namespace:
# graph.json lives in <project>/graphify-out/, so the project
# directory is the graph dir's parent (cwd when defaulted).
project_dir = graph_path.resolve().parent.parent
namespace = re.sub(r"[^A-Za-z0-9._-]", "-", project_dir.name).strip("-") or "graphify"
extra_links = None
push_options = None
if maludb_sql_usage:
from graphify.sql_usage import mine_sql_usage
source_root = maludb_source_root or str(graph_path.resolve().parent.parent)
extra_links = mine_sql_usage(
G, source_root,
datamodel_ns=maludb_datamodel_ns,
default_schema=maludb_db_schema)
push_options = {"resolve_external": True}
print(f"sql-usage: {len(extra_links)} table-reference links mined from {source_root}")
result = _push_maludb(G, base_url=push_uri, token=push_password,
namespace=namespace, communities=communities,
extra_links=extra_links, options=push_options)
n = result.get("nodes", {})
e = result.get("edges", {})
n_skipped = len(result.get("skipped", []))
print(f"Pushed to MaluDb ({push_uri}, namespace '{result.get('namespace', namespace)}'): "
f"{n.get('imported', 0)} nodes ({n.get('created', 0)} new, {n.get('resolved', 0)} updated), "
f"{e.get('imported', 0)} edges ({e.get('created', 0)} new)"
+ (f", {n_skipped} skipped" if n_skipped else ""))

elif cmd == "benchmark":
from graphify.benchmark import run_benchmark, print_benchmark

Expand Down
100 changes: 100 additions & 0 deletions graphify/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -1565,6 +1565,106 @@ def _safe_label(label: str) -> str:
return {"nodes": nodes_pushed, "edges": edges_pushed}


def push_to_maludb(
G: nx.Graph,
base_url: str,
token: str,
namespace: str,
communities: dict[int, list[str]] | None = None,
timeout: int = 300,
extra_links: list[dict] | None = None,
options: dict | None = None,
) -> dict:
"""Push graph to a MaluDb memory database via POST /v1/graph/import.

MaluDb (https://github.com/maludb) persists the graph in PostgreSQL:
nodes become subjects (canonical name "<namespace>/<node id>", label as
alias) and edges become subject-verb-object statements (the relation is
the verb; EXTRACTED/INFERRED/AMBIGUOUS confidence maps to 1.0/0.7/0.4).
The server upserts idempotently, so re-pushing the same graph updates
rather than duplicates — same contract as push_to_neo4j.

No extra dependency: uses stdlib urllib against any MaluDb /v1 API
server. Auth is a bearer token (mint one via the server's /v1/tokens).

Returns the server's import report:
{"namespace", "nodes": {...}, "edges": {...}, "verbs_created",
"chunks", "skipped"}.
"""
import urllib.error
import urllib.request
from urllib.parse import urlparse

# Operator-supplied push target (like neo4j's bolt URI), not untrusted
# content — so only the scheme is enforced. security.validate_url would
# wrongly reject localhost / private-network MaluDb servers here.
if urlparse(base_url).scheme.lower() not in ("http", "https"):
raise ValueError(f"MaluDb base URL must be http(s), got {base_url!r}")

node_community = _node_community_map(communities) if communities else {}

nodes = []
for node_id, data in G.nodes(data=True):
n: dict = {"id": node_id}
for key in ("label", "source_file", "source_location", "file_type"):
value = data.get(key)
if isinstance(value, (str, int, float, bool)):
n[key] = value
cid = node_community.get(node_id, data.get("community"))
if cid is not None:
n["community"] = cid
nodes.append(n)

links = []
for u, v, data in G.edges(data=True):
e: dict = {
"source": u,
"target": v,
"relation": data.get("relation") or "related_to",
}
confidence = data.get("confidence")
if confidence is not None:
e["confidence"] = confidence
links.append(e)

try:
from importlib.metadata import version as _pkg_version
provenance = f"graphify-{_pkg_version('graphifyy')}"
except Exception:
provenance = "graphify"

if extra_links:
links = links + list(extra_links)

payload = {
"namespace": namespace,
"provenance": provenance,
"graph": {"nodes": nodes, "links": links},
}
if options:
payload["options"] = dict(options)

request = urllib.request.Request(
base_url.rstrip("/") + "/v1/graph/import",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # nosec B310 - validate_url enforces http(s)
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(
f"MaluDb import failed: HTTP {exc.code} from {base_url}: {detail}"
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"MaluDb import failed: cannot reach {base_url}: {exc.reason}") from exc


def to_graphml(
G: nx.Graph,
communities: dict[int, list[str]],
Expand Down
95 changes: 89 additions & 6 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -7597,11 +7597,17 @@ def _obj_name(n) -> str | None:
return _read(c)
return None

def _add_node(nid: str, label: str, line: int) -> None:
def _add_node(nid: str, label: str, line: int, *, node_type: str | None = None,
metadata: dict | None = None) -> None:
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({"id": nid, "label": label, "file_type": "code",
"source_file": str_path, "source_location": f"L{line}"})
node = {"id": nid, "label": label, "file_type": "code",
"source_file": str_path, "source_location": f"L{line}"}
if node_type:
node["type"] = node_type
if metadata:
node["metadata"] = sanitize_metadata(metadata)
nodes.append(node)
edges.append({"source": file_nid, "target": nid, "relation": "contains",
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line}", "weight": 1.0})
Expand All @@ -7611,6 +7617,80 @@ def _add_edge(src: str, tgt: str, relation: str, line: int) -> None:
"confidence": "EXTRACTED", "source_file": str_path,
"source_location": f"L{line}", "weight": 1.0})

# Node types that follow a column name but are constraints, not the type.
_NON_TYPE_KINDS = {
"keyword_not", "keyword_null", "keyword_primary", "keyword_key",
"keyword_unique", "keyword_references", "keyword_default",
"keyword_check", "keyword_constraint", "keyword_auto_increment",
"object_reference", "ERROR", "comment", ",",
}

def _constraint_columns(constraint) -> list[str]:
cols: list[str] = []
for oc in constraint.children:
if oc.type == "ordered_columns":
for col in oc.children:
if col.type == "column":
for ci in col.children:
if ci.type == "identifier":
cols.append(_read(ci))
return cols

def _parse_column_metadata(col_defs) -> dict:
"""Columns/pk/unique from a column_definitions subtree → table node metadata."""
columns: list[dict] = []
pk: list[str] = []
unique: list[list[str]] = []
for cd in col_defs.children:
if cd.type == "column_definition":
name: str | None = None
col_type: str | None = None
not_null = False
inline_pk = False
inline_unique = False
for cc in cd.children:
if name is None:
if cc.type == "identifier":
name = _read(cc)
elif col_type is None and cc.type not in _NON_TYPE_KINDS:
col_type = _read(cc)
elif cc.type == "keyword_not":
not_null = True
elif cc.type == "keyword_primary":
inline_pk = True
elif cc.type == "keyword_unique":
inline_unique = True
if name:
columns.append({"name": name, "type": (col_type or "").upper(),
"nullable": not (not_null or inline_pk)})
if inline_pk:
pk.append(name)
if inline_unique:
unique.append([name])
elif cd.type == "constraints":
for constraint in cd.children:
if constraint.type != "constraint":
continue
is_pk = any(c.type == "keyword_primary" for c in constraint.children)
is_unique = any(c.type == "keyword_unique" for c in constraint.children)
if not (is_pk or is_unique):
continue
cols = _constraint_columns(constraint)
if not cols:
continue
if is_pk:
pk.extend(c for c in cols if c not in pk)
else:
unique.append(cols)
meta: dict = {}
if columns:
meta["columns"] = columns
if pk:
meta["pk"] = pk
if unique:
meta["unique"] = unique
return meta

def walk(node) -> None:
t = node.type
line = node.start_point[0] + 1
Expand All @@ -7619,7 +7699,10 @@ def walk(node) -> None:
name = _obj_name(node)
if name:
nid = _make_id(stem, name)
_add_node(nid, name, line)
col_defs = next((c for c in node.children
if c.type == "column_definitions"), None)
meta = _parse_column_metadata(col_defs) if col_defs is not None else {}
_add_node(nid, name, line, node_type="table", metadata=meta or None)
table_nids[name.lower()] = nid
# Foreign key REFERENCES
for col in node.children:
Expand Down Expand Up @@ -7674,7 +7757,7 @@ def walk(node) -> None:
name = _obj_name(node)
if name:
nid = _make_id(stem, name)
_add_node(nid, name, line)
_add_node(nid, name, line, node_type="view")
table_nids[name.lower()] = nid
# FROM/JOIN table references inside view body
_walk_from_refs(node, nid, line)
Expand All @@ -7699,7 +7782,7 @@ def walk(node) -> None:
src_nid = table_nids.get(name.lower())
if not src_nid:
src_nid = _make_id(stem, name)
_add_node(src_nid, name, line)
_add_node(src_nid, name, line, node_type="table")
table_nids[name.lower()] = src_nid
for child in node.children:
if child.type == "add_constraint":
Expand Down
Loading