mirror of
https://github.com/penpot/penpot.git
synced 2026-08-08 05:48:50 +00:00
✨ Add debug graph console for in-memory Cypher queries
This commit is contained in:
parent
445e4970a7
commit
726eb440d9
@ -233,6 +233,7 @@ Debug Main Page
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="submit" value="Download .lbug" />
|
||||
<a href="/dbg/graph">Open graph console</a>
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
108
backend/resources/app/templates/graph-console.tmpl
Normal file
108
backend/resources/app/templates/graph-console.tmpl
Normal file
@ -0,0 +1,108 @@
|
||||
{% extends "app/templates/base.tmpl" %}
|
||||
|
||||
{% block title %}
|
||||
Graph Console
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<div class="title">
|
||||
<h1>GRAPH CONSOLE (VERSION: {{version}})</h1>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="dashboard">
|
||||
<section class="widget">
|
||||
<p><a href="/dbg">← Back to debug</a></p>
|
||||
|
||||
<fieldset>
|
||||
<legend>Load graph in memory</legend>
|
||||
<desc>
|
||||
Projects the Penpot file into an in-memory Ladybug database for this
|
||||
admin session. Loading a new file replaces the previous one.
|
||||
</desc>
|
||||
<form method="post" action="/dbg/actions/graph-load">
|
||||
<div class="row">
|
||||
<input type="text" style="width:420px" name="file-id"
|
||||
placeholder="file-id"
|
||||
value="{% if session %}{{session.file-id}}{% endif %}" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="submit" value="Load" />
|
||||
</div>
|
||||
</form>
|
||||
{% if session %}
|
||||
<form method="post" action="/dbg/actions/graph-unload">
|
||||
<div class="row">
|
||||
<input type="submit" value="Unload" />
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
|
||||
{% if session %}
|
||||
<fieldset>
|
||||
<legend>Loaded session</legend>
|
||||
<desc>
|
||||
<p>
|
||||
File: <b>{{session.name}}</b> ({{session.file-id}})<br />
|
||||
Revision: <b>{{session.revn}}</b><br />
|
||||
Schema: <b>{{session.schema-version}}</b><br />
|
||||
Loaded at: <b>{{session.loaded-at}}</b>
|
||||
</p>
|
||||
{% if session.projection.stats %}
|
||||
<p>
|
||||
Projection:
|
||||
documents={{session.projection.stats.documents}},
|
||||
pages={{session.projection.stats.pages}},
|
||||
shapes={{session.projection.stats.shapes}}
|
||||
</p>
|
||||
{% endif %}
|
||||
</desc>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Cypher query</legend>
|
||||
<form method="post" action="/dbg/actions/graph-query">
|
||||
<div class="row">
|
||||
<textarea name="query" rows="8" style="width:100%; font-family: monospace;">{{query}}</textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="submit" value="Run query" />
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<fieldset>
|
||||
<legend>Error</legend>
|
||||
<pre>{{error}}</pre>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
|
||||
{% if query-result %}
|
||||
<fieldset>
|
||||
<legend>Results ({{query-result.row-count}} rows{% if query-result.truncated? %}, truncated{% endif %})</legend>
|
||||
<table border="1" cellpadding="4" cellspacing="0" style="border-collapse: collapse; width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for column in query-result.columns %}
|
||||
<th>{{column}}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in query-result.rows %}
|
||||
<tr>
|
||||
{% for cell in row %}
|
||||
<td><code>{{cell}}</code></td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@ -72,8 +72,8 @@
|
||||
(.write w "from,to,position\n")
|
||||
(doseq [{:keys [from-id to-id position]} edges]
|
||||
(.write w (str (csv-cell from-id) ","
|
||||
(csv-cell to-id) ","
|
||||
(csv-cell position) "\n")))))
|
||||
(csv-cell to-id) ","
|
||||
(csv-cell position) "\n")))))
|
||||
|
||||
(defn- delete-tree!
|
||||
[path]
|
||||
|
||||
117
backend/src/app/graph/debug.clj
Normal file
117
backend/src/app/graph/debug.clj
Normal file
@ -0,0 +1,117 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.graph.debug
|
||||
"In-memory Ladybug sessions for the debug graph console."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.time :as ct]
|
||||
[app.graph.ingest :as graph.ingest]
|
||||
[app.graph.ladybug :as ladybug]
|
||||
[clojure.string :as str])
|
||||
(:import
|
||||
com.ladybugdb.Connection
|
||||
com.ladybugdb.Database))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def default-query
|
||||
"MATCH (n:Document) RETURN n.id AS id, n.name AS name;")
|
||||
|
||||
(defonce ^:private sessions
|
||||
(atom {}))
|
||||
|
||||
(defn- session-key
|
||||
[profile-id]
|
||||
(str profile-id))
|
||||
|
||||
(defn- destroy-session!
|
||||
[{:keys [conn db]}]
|
||||
(when conn
|
||||
(ex/ignoring (.close ^Connection conn)))
|
||||
(when db
|
||||
(ex/ignoring (.close ^Database db))))
|
||||
|
||||
(defn- format-cell
|
||||
[value]
|
||||
(cond
|
||||
(nil? value) "NULL"
|
||||
(string? value) value
|
||||
:else (str value)))
|
||||
|
||||
(defn- format-query-result
|
||||
[{:keys [columns rows truncated?]}]
|
||||
{:columns (mapv str columns)
|
||||
:rows (mapv (fn [row]
|
||||
(mapv format-cell row))
|
||||
rows)
|
||||
:truncated? truncated?
|
||||
:row-count (count rows)})
|
||||
|
||||
(defn session-info
|
||||
"Return a public view of the current session for `profile-id`, if any."
|
||||
[profile-id]
|
||||
(when-let [{:keys [file-id meta loaded-at]} (get @sessions (session-key profile-id))]
|
||||
{:file-id file-id
|
||||
:name (:name meta)
|
||||
:revn (:revn meta)
|
||||
:schema-version (:schema-version meta)
|
||||
:projection (:projection meta)
|
||||
:loaded-at (ct/format-inst loaded-at :iso)}))
|
||||
|
||||
(defn unload-session!
|
||||
"Close and discard the in-memory graph for `profile-id`."
|
||||
[profile-id]
|
||||
(when-let [session (get @sessions (session-key profile-id))]
|
||||
(destroy-session! session))
|
||||
(swap! sessions dissoc (session-key profile-id)))
|
||||
|
||||
(defn load-session!
|
||||
"Ingest `file-id` into a new in-memory Ladybug database for `profile-id`."
|
||||
[cfg profile-id file-id]
|
||||
(unload-session! profile-id)
|
||||
(let [^Database db (Database.)
|
||||
^Connection conn (Connection. db)]
|
||||
(.setQueryTimeout conn 0)
|
||||
(try
|
||||
(let [meta (graph.ingest/ingest-on-connection! cfg conn file-id
|
||||
:db-path ":memory:"
|
||||
:skip-stats? true
|
||||
:skip-validation? true)]
|
||||
(swap! sessions assoc (session-key profile-id)
|
||||
{:db db
|
||||
:conn conn
|
||||
:file-id file-id
|
||||
:meta meta
|
||||
:loaded-at (ct/now)})
|
||||
meta)
|
||||
(catch Throwable cause
|
||||
(destroy-session! {:conn conn :db db})
|
||||
(throw cause)))))
|
||||
|
||||
(defn query-session!
|
||||
"Run `statement` against the in-memory graph for `profile-id`."
|
||||
[profile-id statement]
|
||||
(when (str/blank? statement)
|
||||
(ex/raise :type :validation
|
||||
:code :missing-query
|
||||
:hint "cypher query is required"))
|
||||
(if-let [{:keys [conn]} (get @sessions (session-key profile-id))]
|
||||
(-> (ladybug/query-on-connection! conn statement)
|
||||
format-query-result)
|
||||
(ex/raise :type :not-found
|
||||
:code :graph-session-not-loaded
|
||||
:hint "load a file graph before running queries")))
|
||||
|
||||
(defn console-context
|
||||
"Build template data for the graph debug console page."
|
||||
[profile-id & {:keys [query query-result error message]}]
|
||||
{:session (session-info profile-id)
|
||||
:query (or query default-query)
|
||||
:query-result query-result
|
||||
:error error
|
||||
:message message
|
||||
:default-query default-query})
|
||||
@ -18,14 +18,14 @@
|
||||
[app.graph.project.transforms :as project.transforms]
|
||||
[app.graph.schema :as schema]
|
||||
[app.graph.stats :as stats]
|
||||
[app.srepl.helpers :as h]))
|
||||
[app.srepl.helpers :as h])
|
||||
(:import
|
||||
com.ladybugdb.Connection))
|
||||
|
||||
(defn ingest-file!
|
||||
[system file-id & {:keys [db-path reset-db? skip-stats?]
|
||||
:or {reset-db? true}}]
|
||||
(defn- fetch-file!
|
||||
[system file-id]
|
||||
(let [file-id (h/parse-uuid file-id)
|
||||
file (db/run! system #(bfc/get-file % file-id :realize? true))
|
||||
db-path (or db-path (ladybug/db-path-for-file file-id))]
|
||||
file (db/run! system #(bfc/get-file % file-id :realize? true))]
|
||||
(when-not file
|
||||
(ex/raise :type :not-found
|
||||
:code :file-not-found
|
||||
@ -35,30 +35,48 @@
|
||||
:code :file-without-data
|
||||
:hint "file has no data to project"
|
||||
:file-id (str file-id)))
|
||||
(ctf/check-file-data (:data file))
|
||||
(when reset-db?
|
||||
(ladybug/reset-db-path! db-path))
|
||||
[file-id file]))
|
||||
|
||||
(defn ingest-on-connection!
|
||||
"Project `file-id` into an already open Ladybug `conn`."
|
||||
[system ^Connection conn file-id & {:keys [db-path skip-stats? skip-validation?]
|
||||
:or {skip-stats? true}}]
|
||||
(let [[file-id file] (fetch-file! system file-id)
|
||||
db-path (or db-path (ladybug/db-path-for-file file-id))
|
||||
data (:data file)]
|
||||
(when-not skip-validation?
|
||||
(ctf/check-file-data data))
|
||||
(l/inf :hint "graph ingest"
|
||||
:file-id (str file-id)
|
||||
:revn (:revn file)
|
||||
:db-path db-path
|
||||
:schema schema/schema-version)
|
||||
(let [data (:data file)
|
||||
ddl (schema/ddl-statements)
|
||||
(let [ddl (schema/ddl-statements)
|
||||
{:keys [nodes edges stats]}
|
||||
(project.document/projection-data data file)
|
||||
staging-path (bulk/staging-dir db-path file-id)]
|
||||
(ladybug/with-connection! db-path
|
||||
(fn [conn]
|
||||
(ladybug/exec-on-connection! conn ddl)
|
||||
(bulk/load-projection! conn {:nodes nodes :edges edges} staging-path)
|
||||
(ladybug/exec-on-connection! conn ["CHECKPOINT;"])
|
||||
{:file-id file-id
|
||||
:revn (:revn file)
|
||||
:name (or (:name data) (:name file))
|
||||
:db-path db-path
|
||||
:schema-version schema/schema-version
|
||||
:projection {:stats stats}
|
||||
:transforms (project.transforms/apply-transforms! system db-path data file)
|
||||
:stats (when-not skip-stats?
|
||||
(stats/summarize-connection conn))})))))
|
||||
staging-path (bulk/staging-dir db-path file-id)]
|
||||
(ladybug/exec-on-connection! conn ddl)
|
||||
(bulk/load-projection! conn {:nodes nodes :edges edges} staging-path)
|
||||
(ladybug/exec-on-connection! conn ["CHECKPOINT;"])
|
||||
{:file-id file-id
|
||||
:revn (:revn file)
|
||||
:name (or (:name data) (:name file))
|
||||
:db-path db-path
|
||||
:schema-version schema/schema-version
|
||||
:projection {:stats stats}
|
||||
:transforms (project.transforms/apply-transforms! system db-path data file)
|
||||
:stats (when-not skip-stats?
|
||||
(stats/summarize-connection conn))})))
|
||||
|
||||
(defn ingest-file!
|
||||
[system file-id & {:keys [db-path reset-db? skip-stats? skip-validation?]
|
||||
:or {reset-db? true}}]
|
||||
(let [db-path (or db-path (ladybug/db-path-for-file (h/parse-uuid file-id)))]
|
||||
(when reset-db?
|
||||
(ladybug/reset-db-path! db-path))
|
||||
(ladybug/with-connection! db-path
|
||||
(fn [conn]
|
||||
(ingest-on-connection! system conn file-id
|
||||
:db-path db-path
|
||||
:skip-stats? skip-stats?
|
||||
:skip-validation? skip-validation?)))))
|
||||
|
||||
@ -92,6 +92,46 @@
|
||||
:statement statement
|
||||
:err err))))
|
||||
|
||||
(defn- query-columns
|
||||
[^QueryResult result]
|
||||
(let [ncols (.getNumColumns result)]
|
||||
(vec (for [i (range ncols)]
|
||||
(.getColumnName result (long i))))))
|
||||
|
||||
(defn- query-row
|
||||
[^FlatTuple tuple ncols]
|
||||
(vec (for [i (range ncols)]
|
||||
(with-open [^Value value (.getValue tuple (long i))]
|
||||
(value->clj value)))))
|
||||
|
||||
(def ^:private default-query-max-rows 200)
|
||||
|
||||
(defn- read-query-rows
|
||||
[^QueryResult result ncols max-rows]
|
||||
(loop [rows [] n 0]
|
||||
(if (and (< n max-rows) (.hasNext result))
|
||||
(let [row (with-open [^FlatTuple tuple (.getNext result)]
|
||||
(query-row tuple ncols))]
|
||||
(recur (conj rows row) (inc n)))
|
||||
rows)))
|
||||
|
||||
(defn query-on-connection!
|
||||
"Execute a Cypher query on `conn` and return tabular results.
|
||||
|
||||
Returns `{:columns [...] :rows [[...] ...] :truncated? bool}`."
|
||||
[^Connection conn statement & {:keys [max-rows]
|
||||
:or {max-rows default-query-max-rows}}]
|
||||
(let [cypher (ensure-semicolon statement)]
|
||||
(with-open [^QueryResult result (.query conn cypher)]
|
||||
(check-success! result cypher)
|
||||
(let [ncols (long (.getNumColumns result))
|
||||
columns (query-columns result)
|
||||
rows (read-query-rows result ncols max-rows)
|
||||
total (long (.getNumTuples result))]
|
||||
{:columns columns
|
||||
:rows rows
|
||||
:truncated? (and (pos? total) (> total (count rows)))}))))
|
||||
|
||||
(def ^:private default-query-timeout-ms
|
||||
"0 disables query timeout (recommended for bulk COPY ingest)."
|
||||
0)
|
||||
|
||||
@ -30,8 +30,8 @@
|
||||
"RETURN count(n) AS " name "_c;"))])
|
||||
schema/node-tables))
|
||||
:edges {:IsChildOf (count-on-connection
|
||||
conn
|
||||
"MATCH ()-[e:IsChildOf]->() RETURN count(e) AS IsChildOf_c;")}})
|
||||
conn
|
||||
"MATCH ()-[e:IsChildOf]->() RETURN count(e) AS IsChildOf_c;")}})
|
||||
|
||||
(defn summarize
|
||||
"Return node/edge counts from the graph database."
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.features.file-migrations :as feat.fmig]
|
||||
[app.graph.debug :as graph.debug]
|
||||
[app.graph.ingest :as graph.ingest]
|
||||
[app.http.session :as session]
|
||||
[app.rpc.commands.auth :as auth]
|
||||
@ -350,6 +351,52 @@
|
||||
::yres/headers {"content-type" "application/octet-stream"
|
||||
"content-disposition" (str "attachment; filename=" file-id ".lbug")}})))
|
||||
|
||||
(defn- graph-console-response
|
||||
[profile-id data]
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "text/html; charset=utf-8"
|
||||
"x-robots-tag" "noindex"}
|
||||
::yres/body (-> (io/resource "app/templates/graph-console.tmpl")
|
||||
(tmpl/render (assoc data :version (:full cf/version))))})
|
||||
|
||||
(defn graph-console-handler
|
||||
[_cfg {:keys [::session/profile-id]}]
|
||||
(graph-console-response profile-id
|
||||
(graph.debug/console-context profile-id)))
|
||||
|
||||
(defn graph-load-handler
|
||||
[cfg {:keys [params ::session/profile-id]}]
|
||||
(let [file-id (some-> (:file-id params) parse-uuid)]
|
||||
(when-not file-id
|
||||
(ex/raise :type :validation
|
||||
:code :missing-arguments
|
||||
:hint "missing file-id"))
|
||||
(graph.debug/load-session! cfg profile-id file-id)
|
||||
{::yres/status 302
|
||||
::yres/headers {"location" "/dbg/graph"}}))
|
||||
|
||||
(defn graph-unload-handler
|
||||
[_cfg {:keys [::session/profile-id]}]
|
||||
(graph.debug/unload-session! profile-id)
|
||||
{::yres/status 302
|
||||
::yres/headers {"location" "/dbg/graph"}})
|
||||
|
||||
(defn graph-query-handler
|
||||
[_cfg {:keys [params ::session/profile-id]}]
|
||||
(let [query (:query params)]
|
||||
(try
|
||||
(let [result (graph.debug/query-session! profile-id query)]
|
||||
(graph-console-response profile-id
|
||||
(graph.debug/console-context profile-id
|
||||
:query query
|
||||
:query-result result)))
|
||||
(catch Throwable e
|
||||
(graph-console-response profile-id
|
||||
(graph.debug/console-context profile-id
|
||||
:query query
|
||||
:error (or (:hint (ex-data e))
|
||||
(ex-message e))))))))
|
||||
|
||||
(defn import-handler
|
||||
[{:keys [::db/pool] :as cfg} {:keys [params ::session/profile-id] :as request}]
|
||||
(when-not (contains? params :file)
|
||||
@ -587,6 +634,7 @@
|
||||
["" {:handler (partial index-handler cfg)}]
|
||||
["/health" {:handler (partial health-handler cfg)}]
|
||||
["/changelog" {:handler (partial changelog-handler cfg)}]
|
||||
["/graph" {:handler (partial graph-console-handler cfg)}]
|
||||
["/error/:id" {:handler (partial error-handler cfg)}]
|
||||
["/error" {:handler (partial error-list-handler cfg)}]
|
||||
["/actions" {:middleware [[errors]]}
|
||||
@ -598,6 +646,9 @@
|
||||
{:handler (partial handle-team-features cfg)}]
|
||||
["/file-export" {:handler (partial export-handler cfg)}]
|
||||
["/graph-export" {:handler (partial graph-export-handler cfg)}]
|
||||
["/graph-load" {:handler (partial graph-load-handler cfg)}]
|
||||
["/graph-query" {:handler (partial graph-query-handler cfg)}]
|
||||
["/graph-unload" {:handler (partial graph-unload-handler cfg)}]
|
||||
["/file-import" {:handler (partial import-handler cfg)}]
|
||||
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]])
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user