From abcadb5cf876f32fbfb9ec67710f06a660c0568e Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 16 Jul 2026 15:04:19 +0200 Subject: [PATCH] :sparkles: Add Component nodes and IsInstanceOf edges --- backend/src/app/graph/debug.clj | 25 ++++--- backend/src/app/graph/ingest.clj | 2 +- backend/src/app/graph/ladybug.clj | 43 +++++++++++- backend/src/app/graph/project/document.clj | 71 ++++++++++++++------ backend/src/app/graph/project/transforms.clj | 35 ++++++++-- backend/src/app/graph/schema/nodes.clj | 26 ++++++- backend/src/app/graph/stats.clj | 5 +- backend/src/app/graph/sync.clj | 58 ++++++++++++---- 8 files changed, 212 insertions(+), 53 deletions(-) diff --git a/backend/src/app/graph/debug.clj b/backend/src/app/graph/debug.clj index 2e75bf0ce9..e2eb7da68a 100644 --- a/backend/src/app/graph/debug.clj +++ b/backend/src/app/graph/debug.clj @@ -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` diff --git a/backend/src/app/graph/ingest.clj b/backend/src/app/graph/ingest.clj index b5bfb9dac7..f5984e2788 100644 --- a/backend/src/app/graph/ingest.clj +++ b/backend/src/app/graph/ingest.clj @@ -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))}))) diff --git a/backend/src/app/graph/ladybug.clj b/backend/src/app/graph/ladybug.clj index b60f3ef160..a4bcc042ad 100644 --- a/backend/src/app/graph/ladybug.clj +++ b/backend/src/app/graph/ladybug.clj @@ -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] diff --git a/backend/src/app/graph/project/document.clj b/backend/src/app/graph/project/document.clj index 817a340b80..952a16f4f8 100644 --- a/backend/src/app/graph/project/document.clj +++ b/backend/src/app/graph/project/document.clj @@ -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]))) diff --git a/backend/src/app/graph/project/transforms.clj b/backend/src/app/graph/project/transforms.clj index 9d64f1b224..9485853c29 100644 --- a/backend/src/app/graph/project/transforms.clj +++ b/backend/src/app/graph/project/transforms.clj @@ -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})) diff --git a/backend/src/app/graph/schema/nodes.clj b/backend/src/app/graph/schema/nodes.clj index 740ec4dbd4..087f055087 100644 --- a/backend/src/app/graph/schema/nodes.clj +++ b/backend/src/app/graph/schema/nodes.clj @@ -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))) \ No newline at end of file diff --git a/backend/src/app/graph/stats.clj b/backend/src/app/graph/stats.clj index 9a7d8d2f38..24d1686854 100644 --- a/backend/src/app/graph/stats.clj +++ b/backend/src/app/graph/stats.clj @@ -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." diff --git a/backend/src/app/graph/sync.clj b/backend/src/app/graph/sync.clj index a06d3aa01c..c1ad02cb91 100644 --- a/backend/src/app/graph/sync.clj +++ b/backend/src/app/graph/sync.clj @@ -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})))