Add Component nodes and IsInstanceOf edges

This commit is contained in:
Alejandro Alonso 2026-07-16 15:04:19 +02:00 committed by Álvaro Tejero Cantero
parent f234f383f5
commit abcadb5cf8
No known key found for this signature in database
8 changed files with 212 additions and 53 deletions

View File

@ -223,14 +223,23 @@
(defn- export-edges
[conn]
(let [stmt (str "MATCH (a)-[r:IsChildOf]->(b) "
"RETURN a.id AS source, b.id AS target, r.position AS position;")
{:keys [rows truncated?]}
(ladybug/query-on-connection! conn stmt :max-rows export-max-rows)]
{:edges (mapv (fn [[source target position]]
{:source (str source) :target (str target) :position position})
rows)
:truncated? truncated?}))
(let [child-stmt (str "MATCH (a)-[r:IsChildOf]->(b) "
"RETURN a.id AS source, b.id AS target, r.position AS position, "
"'IsChildOf' AS rel;")
inst-stmt (str "MATCH (a)-[r:IsInstanceOf]->(b) "
"RETURN a.id AS source, b.id AS target, NULL AS position, "
"'IsInstanceOf' AS rel;")
child (ladybug/query-on-connection! conn child-stmt :max-rows export-max-rows)
inst (ladybug/query-on-connection! conn inst-stmt :max-rows export-max-rows)
->edge (fn [[source target position rel]]
(cond-> {:source (str source)
:target (str target)
:rel (str rel)}
(some? position) (assoc :position position)))]
{:edges (into (mapv ->edge (:rows child))
(map ->edge)
(:rows inst))
:truncated? (boolean (or (:truncated? child) (:truncated? inst)))}))
(defn export-graph-data!
"Export the node/edge inventory of the in-memory graph for `profile-id`

View File

@ -66,7 +66,7 @@
:projection {:stats stats
:nodes nodes
:edges edges}
:transforms (project.transforms/apply-transforms! system db-path data file)
:transforms (project.transforms/apply-transforms! system conn data file)
:stats (when-not skip-stats?
(stats/summarize-connection conn))})))

View File

@ -81,13 +81,50 @@
(coll? v) (format-json v)
:else (format-string (str v))))
(defn- format-list-element
"Format one element of a Cypher LIST literal for typed `elem-type`."
[elem-type v]
(case elem-type
"UUID" (format-uuid v)
"STRING" (format-string (str v))
"JSON" (format-json v)
"INT64" (format-int v)
"DOUBLE" (format-number v)
"BOOLEAN" (if v "true" "false")
(format-value v)))
(defn- format-list
"Cypher LIST literal for Ladybug LIST columns (`UUID[]`, `STRING[]`, ).
Must not use `json(...)`: assigning a JSON value to `UUID[]` yields
`Conversion exception: Invalid UUID` (e.g. Frame.`shapes` on component
instantiate via sync)."
[ladybug-type v]
(let [elem-type (subs ladybug-type 0 (- (count ladybug-type) 2))
elems (if (coll? v) (seq v) [v])]
(str "["
(str/join ", " (map #(format-list-element elem-type %) elems))
"]")))
(defn format-typed-value
[ladybug-type v]
(cond
(= ladybug-type "JSON") (format-json v)
(nil? v)
"NULL"
(= ladybug-type "JSON")
(format-json v)
;; Coerce string ids from transit edge-cases into UUID literals.
(= ladybug-type "UUID")
(format-uuid v)
(and (string? ladybug-type)
(str/ends-with? ladybug-type "[]")) (format-json v)
:else (format-value v)))
(str/ends-with? ladybug-type "[]"))
(format-list ladybug-type v)
:else
(format-value v)))
(defn- ensure-semicolon
[statement]

View File

@ -7,8 +7,8 @@
(ns app.graph.project.document
"Project a Penpot file-data map into Ladybug nodes and structural edges.
Projects Document, Page, the full shape tree (skipping the root frame),
and `IsChildOf` edges from shapes to their page or container parent."
Projects Document, Page, Component, the full shape tree (skipping the root
frame), and `IsChildOf` edges from shapes/pages/components to their parent."
(:require
[app.common.logging :as l]
[app.common.uuid :as uuid]
@ -29,6 +29,13 @@
(dissoc :objects)
(cond-> (some? index) (assoc :index (long index)))))
(defn- component-attrs
[component]
(-> component
(dissoc :objects)
;; schema:component requires :path; some legacy rows omit it
(update :path #(or % ""))))
(defn- shape-table
[shape]
(nodes/table-for-type (:type shape)))
@ -51,7 +58,7 @@
[]
{:nodes {}
:edges []
:stats {:documents 0 :pages 0 :shapes 0}})
:stats {:documents 0 :pages 0 :components 0 :shapes 0}})
(declare project-shape-ids)
@ -108,26 +115,52 @@
(project-shape-ids objects acc' "Page" page-id top-level-ids)
acc')))
(defn- project-component
[acc doc-id component position]
(if (:deleted component)
acc
(let [comp-id (:id component)
node (nodes/project-attrs "Component" (component-attrs component))]
(-> acc
(update-in [:nodes "Component"] (fnil conj []) node)
(update :edges conj {:from-table "Component"
:from-id comp-id
:to-table "Document"
:to-id doc-id
:position position})
(update-in [:stats :components] inc)))))
(defn- project-components
[acc doc-id components]
(reduce (fn [acc [position [_id component]]]
(project-component acc doc-id component position))
acc
(map-indexed vector components)))
(defn projection-data
"Build node/edge rows for projecting `data` into Ladybug.
Returns `{:nodes {table [attrs ...]} :edges [...] :stats {...}}`."
[data file]
(let [doc-id (or (:id data) (:id file))
(let [doc-id (or (:id data) (:id file))
doc-node (nodes/project-attrs "Document" (document-attrs file data))
pages (seq (reverse (:pages data)))
acc0 (-> (initial-acc)
(update-in [:nodes "Document"] (fnil conj []) doc-node)
(assoc-in [:stats :documents] 1))
acc (if (empty? pages)
acc0
(reduce (fn [acc [position page-id]]
(if-let [page (get-in data [:pages-index page-id])]
(project-page acc doc-id page position)
(do
(l/wrn :hint "missing page in pages-index"
:page-id (str page-id))
acc)))
acc0
(map-indexed vector pages)))]
pages (seq (reverse (:pages data)))
comps (seq (:components data))
acc0 (-> (initial-acc)
(update-in [:nodes "Document"] (fnil conj []) doc-node)
(assoc-in [:stats :documents] 1))
acc (cond-> acc0
(seq comps)
(project-components doc-id comps))
acc (if (empty? pages)
acc
(reduce (fn [acc [position page-id]]
(if-let [page (get-in data [:pages-index page-id])]
(project-page acc doc-id page position)
(do
(l/wrn :hint "missing page in pages-index"
:page-id (str page-id))
acc)))
acc
(map-indexed vector pages)))]
(select-keys acc [:nodes :edges :stats])))

View File

@ -7,9 +7,36 @@
(ns app.graph.project.transforms
"Derived graph links (instances, tokens, nested containment, etc.).
Stub for now: beadpot's `apply_transformations` will be ported here.")
Ports beadpot's post-projection transforms. Currently:
- `LinkComponentInstances` `IsInstanceOf` (Frame Component)."
(:require
[app.common.logging :as l]
[app.graph.ladybug :as ladybug])
(:import
com.ladybugdb.Connection))
(defn- link-component-instances!
"Create `IsInstanceOf` edges from Frame instance heads to Component nodes.
Same semantics as beadpot `LinkComponentInstances`: every frame with
`:component-id` matching a non-deleted Component in this graph (main
instance and copy roots)."
[^Connection conn]
(let [n (or (ladybug/query-scalar-on-connection!
conn
(str "MATCH (f:Frame), (c:Component) "
"WHERE f.`component-id` = c.id "
"AND NOT COALESCE(c.deleted, false) "
"MERGE (f)-[:IsInstanceOf]->(c) "
"RETURN count(*);"))
0)]
(l/inf :hint "graph transform IsInstanceOf"
:edges n)
n))
(defn apply-transforms!
"Apply derived transformations to an already projected graph."
[_system _db-path _data _file]
{:transforms 0})
"Apply derived transformations on an already loaded graph."
[_system ^Connection conn _data _file]
(let [n (link-component-instances! conn)]
{:transforms 1
:IsInstanceOf n}))

View File

@ -13,6 +13,7 @@
(:require
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.types.component :as ctk]
[app.common.types.file :as ctf]
[app.common.types.page :as ctp]
[app.graph.schema.projection :as projection]
@ -20,7 +21,7 @@
[clojure.string :as str]))
(def schema-version
"penpot-graph-slice-2")
"penpot-graph-slice-3")
;; beadpot/graph/schemas.py drop_fields
(def ^:private document-projection
@ -31,6 +32,14 @@
{:source ctp/schema:page
:drop [:objects]})
(def ^:private component-projection
{:source ctk/schema:component
:drop [:objects]
;; Soft-delete flag used at runtime; not in schema:component.
:extra [:map
[:deleted {:optional true} :boolean]
[:annotation {:optional true} :string]]})
(def ^:private shape-projection
{:drop [:type]})
@ -77,7 +86,11 @@
{:table "Page"
:pk :id
:projection page-projection
:schema (resolve-schema page-projection)}]
:schema (resolve-schema page-projection)}
{:table "Component"
:pk :id
:projection component-projection
:schema (resolve-schema component-projection)}]
(map shape-node-entry shape-node-types)))
(def ^:private by-table
@ -216,6 +229,7 @@
[]
(str "CREATE REL TABLE `IsChildOf` ("
"FROM `Page` TO `Document`, "
"FROM `Component` TO `Document`, "
(str/join ", "
(concat
(map (fn [shape]
@ -226,7 +240,13 @@
(str "FROM `" shape "` TO `" container "`"))))
", `position` INT64);"))
(defn is-instance-of-ddl
"Frame instance heads → Component (beadpot `IsInstanceOf`)."
[]
"CREATE REL TABLE `IsInstanceOf` (FROM `Frame` TO `Component`);")
(defn ddl-statements
[]
(conj (mapv create-node-table-ddl node-types)
(is-child-of-ddl)))
(is-child-of-ddl)
(is-instance-of-ddl)))

View File

@ -25,7 +25,10 @@
(map :table nodes/node-types)))
:edges {:IsChildOf (count-on-connection
conn
"MATCH ()-[e:IsChildOf]->() RETURN count(e) AS IsChildOf_c;")}})
"MATCH ()-[e:IsChildOf]->() RETURN count(e) AS IsChildOf_c;")
:IsInstanceOf (count-on-connection
conn
"MATCH ()-[e:IsInstanceOf]->() RETURN count(e) AS IsInstanceOf_c;")}})
(defn summarize
"Return node/edge counts from the graph database."

View File

@ -147,6 +147,30 @@
"(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) "
"CREATE (s)-[:IsChildOf {position: " (ladybug/format-int position) "}]->(p);"))
(defn- create-instance-of-statement
"Link a Frame instance head to its Component (beadpot `IsInstanceOf`).
No-op when the Component is absent (e.g. library component not ingested)."
[frame-id component-id]
(str "MATCH (f:Frame {id: " (ladybug/format-uuid frame-id) "}), "
"(c:Component {id: " (ladybug/format-uuid component-id) "}) "
"WHERE NOT COALESCE(c.deleted, false) "
"MERGE (f)-[:IsInstanceOf]->(c);"))
(defn- delete-instance-of-statement
[frame-id]
(str "MATCH (f:Frame {id: " (ladybug/format-uuid frame-id) "})"
"-[r:IsInstanceOf]->(:Component) "
"DELETE r;"))
(defn- instance-of-statements
"Cypher to (re)link `IsInstanceOf` after add/mod of a Frame's component-id."
[table shape-id component-id]
(when (= table "Frame")
(cond-> [(delete-instance-of-statement shape-id)]
(some? component-id)
(conj (create-instance-of-statement shape-id component-id)))))
(defn- delete-edge-statement
[{:keys [from-table from-id to-table to-id]}]
(str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "})"
@ -345,7 +369,10 @@
:from-id id
:to-table (:parent-table parent)
:to-id (:parent-id parent)
:position position})]
:position position})
stmts (into [(create-node-statement table attrs)
(create-edge-statement edge)]
(instance-of-statements table id (:component-id attrs)))]
{:index (index-add-shape! index
{:id id
:name (:name attrs)
@ -355,27 +382,30 @@
:position position
:page-id (or page-id (when (= (:parent-table parent) "Page")
(:parent-id parent)))})
:statements [(create-node-statement table attrs)
(create-edge-statement edge)]
:statements stmts
:applied? true}))))))
(defn- apply-mod-obj
[index {:keys [id operations]}]
(if-let [shape (get-in index [:shapes id])]
(let [table (:table shape)
(let [table (:table shape)
syncable (mod-attrs-for-table table)
set-ops (filter #(and (= :set (:type %))
(contains? syncable (:attr %)))
operations)]
set-ops (filter #(and (= :set (:type %))
(contains? syncable (:attr %)))
operations)]
(if (empty? set-ops)
{:index index :statements [] :applied? false :reason :unsupported-operations}
(let [updates (into {} (map (juxt :attr :val) set-ops))
statements (for [[attr value] updates]
(set-node-attr-statement table id attr value))
index' (reduce (fn [idx [attr value]]
(assoc-in idx [:shapes id attr] value))
index
updates)]
(let [updates (into {} (map (juxt :attr :val) set-ops))
statements
(into (vec (for [[attr value] updates]
(set-node-attr-statement table id attr value)))
;; Relink when component-id is among the synced attrs.
(when (contains? updates :component-id)
(instance-of-statements table id (:component-id updates))))
index' (reduce (fn [idx [attr value]]
(assoc-in idx [:shapes id attr] value))
index
updates)]
{:index index'
:statements statements
:applied? true})))