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 (defn- export-edges
[conn] [conn]
(let [stmt (str "MATCH (a)-[r:IsChildOf]->(b) " (let [child-stmt (str "MATCH (a)-[r:IsChildOf]->(b) "
"RETURN a.id AS source, b.id AS target, r.position AS position;") "RETURN a.id AS source, b.id AS target, r.position AS position, "
{:keys [rows truncated?]} "'IsChildOf' AS rel;")
(ladybug/query-on-connection! conn stmt :max-rows export-max-rows)] inst-stmt (str "MATCH (a)-[r:IsInstanceOf]->(b) "
{:edges (mapv (fn [[source target position]] "RETURN a.id AS source, b.id AS target, NULL AS position, "
{:source (str source) :target (str target) :position position}) "'IsInstanceOf' AS rel;")
rows) child (ladybug/query-on-connection! conn child-stmt :max-rows export-max-rows)
:truncated? truncated?})) 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! (defn export-graph-data!
"Export the node/edge inventory of the in-memory graph for `profile-id` "Export the node/edge inventory of the in-memory graph for `profile-id`

View File

@ -66,7 +66,7 @@
:projection {:stats stats :projection {:stats stats
:nodes nodes :nodes nodes
:edges edges} :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 (when-not skip-stats?
(stats/summarize-connection conn))}))) (stats/summarize-connection conn))})))

View File

@ -81,13 +81,50 @@
(coll? v) (format-json v) (coll? v) (format-json v)
:else (format-string (str 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 (defn format-typed-value
[ladybug-type v] [ladybug-type v]
(cond (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) (and (string? ladybug-type)
(str/ends-with? ladybug-type "[]")) (format-json v) (str/ends-with? ladybug-type "[]"))
:else (format-value v))) (format-list ladybug-type v)
:else
(format-value v)))
(defn- ensure-semicolon (defn- ensure-semicolon
[statement] [statement]

View File

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

View File

@ -7,9 +7,36 @@
(ns app.graph.project.transforms (ns app.graph.project.transforms
"Derived graph links (instances, tokens, nested containment, etc.). "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! (defn apply-transforms!
"Apply derived transformations to an already projected graph." "Apply derived transformations on an already loaded graph."
[_system _db-path _data _file] [_system ^Connection conn _data _file]
{:transforms 0}) (let [n (link-component-instances! conn)]
{:transforms 1
:IsInstanceOf n}))

View File

@ -13,6 +13,7 @@
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.types.component :as ctk]
[app.common.types.file :as ctf] [app.common.types.file :as ctf]
[app.common.types.page :as ctp] [app.common.types.page :as ctp]
[app.graph.schema.projection :as projection] [app.graph.schema.projection :as projection]
@ -20,7 +21,7 @@
[clojure.string :as str])) [clojure.string :as str]))
(def schema-version (def schema-version
"penpot-graph-slice-2") "penpot-graph-slice-3")
;; beadpot/graph/schemas.py drop_fields ;; beadpot/graph/schemas.py drop_fields
(def ^:private document-projection (def ^:private document-projection
@ -31,6 +32,14 @@
{:source ctp/schema:page {:source ctp/schema:page
:drop [:objects]}) :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 (def ^:private shape-projection
{:drop [:type]}) {:drop [:type]})
@ -77,7 +86,11 @@
{:table "Page" {:table "Page"
:pk :id :pk :id
:projection page-projection :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))) (map shape-node-entry shape-node-types)))
(def ^:private by-table (def ^:private by-table
@ -216,6 +229,7 @@
[] []
(str "CREATE REL TABLE `IsChildOf` (" (str "CREATE REL TABLE `IsChildOf` ("
"FROM `Page` TO `Document`, " "FROM `Page` TO `Document`, "
"FROM `Component` TO `Document`, "
(str/join ", " (str/join ", "
(concat (concat
(map (fn [shape] (map (fn [shape]
@ -226,7 +240,13 @@
(str "FROM `" shape "` TO `" container "`")))) (str "FROM `" shape "` TO `" container "`"))))
", `position` INT64);")) ", `position` INT64);"))
(defn is-instance-of-ddl
"Frame instance heads → Component (beadpot `IsInstanceOf`)."
[]
"CREATE REL TABLE `IsInstanceOf` (FROM `Frame` TO `Component`);")
(defn ddl-statements (defn ddl-statements
[] []
(conj (mapv create-node-table-ddl node-types) (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))) (map :table nodes/node-types)))
:edges {:IsChildOf (count-on-connection :edges {:IsChildOf (count-on-connection
conn 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 (defn summarize
"Return node/edge counts from the graph database." "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) "}) " "(p:" (nodes/match-label to-table) " {id: " (ladybug/format-uuid to-id) "}) "
"CREATE (s)-[:IsChildOf {position: " (ladybug/format-int position) "}]->(p);")) "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 (defn- delete-edge-statement
[{:keys [from-table from-id to-table to-id]}] [{:keys [from-table from-id to-table to-id]}]
(str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "})" (str "MATCH (s:" (nodes/match-label from-table) " {id: " (ladybug/format-uuid from-id) "})"
@ -345,7 +369,10 @@
:from-id id :from-id id
:to-table (:parent-table parent) :to-table (:parent-table parent)
:to-id (:parent-id 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 {:index (index-add-shape! index
{:id id {:id id
:name (:name attrs) :name (:name attrs)
@ -355,27 +382,30 @@
:position position :position position
:page-id (or page-id (when (= (:parent-table parent) "Page") :page-id (or page-id (when (= (:parent-table parent) "Page")
(:parent-id parent)))}) (:parent-id parent)))})
:statements [(create-node-statement table attrs) :statements stmts
(create-edge-statement edge)]
:applied? true})))))) :applied? true}))))))
(defn- apply-mod-obj (defn- apply-mod-obj
[index {:keys [id operations]}] [index {:keys [id operations]}]
(if-let [shape (get-in index [:shapes id])] (if-let [shape (get-in index [:shapes id])]
(let [table (:table shape) (let [table (:table shape)
syncable (mod-attrs-for-table table) syncable (mod-attrs-for-table table)
set-ops (filter #(and (= :set (:type %)) set-ops (filter #(and (= :set (:type %))
(contains? syncable (:attr %))) (contains? syncable (:attr %)))
operations)] operations)]
(if (empty? set-ops) (if (empty? set-ops)
{:index index :statements [] :applied? false :reason :unsupported-operations} {:index index :statements [] :applied? false :reason :unsupported-operations}
(let [updates (into {} (map (juxt :attr :val) set-ops)) (let [updates (into {} (map (juxt :attr :val) set-ops))
statements (for [[attr value] updates] statements
(set-node-attr-statement table id attr value)) (into (vec (for [[attr value] updates]
index' (reduce (fn [idx [attr value]] (set-node-attr-statement table id attr value)))
(assoc-in idx [:shapes id attr] value)) ;; Relink when component-id is among the synced attrs.
index (when (contains? updates :component-id)
updates)] (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' {:index index'
:statements statements :statements statements
:applied? true}))) :applied? true})))