penpot/backend/src/app/graph/project/transforms.clj
Álvaro Tejero Cantero d8bb1ae39a
📚 State what the graph schema does, not what it mirrors
The graph namespaces explained themselves by citing a separate project
whose Python pipeline reads the graphs this backend writes. A reader of
this repository does not have that project and should not need it, and a
docstring that justifies a choice by pointing elsewhere cannot be checked
here.

Every claim survives; only the framing changes. Column names and types
are Penpot's own decision, recorded with the reason for each divergence
from the snake_case default. The transform registry describes the edges
it materializes. The denormalizations in `app.graph.project.document`
are justified by the walk already holding both answers.

Three corrections fall out of the rewrite:

- `app.graph.schema.contract` claimed a test, `graph_contract_test`,
  that walks a checked-in schema manifest and fails on any divergence.
  No such test exists. The paragraph is gone.
- `app.graph.project.document` pointed at
  `app.graph.meta/projection-transforms`, which does not exist.
- `app.graph.project.transforms/registry` claimed its entries were "in
  application order" while `apply-transforms!` reduced over the literal
  vector. The three registered transforms read disjoint columns, so the
  order is not load-bearing. The docstring now says so, and the one real
  ordering constraint is stated where it applies: `link-swap-slots!`
  strips `swap-slot-*` entries from `touched`, so anything reading
  `touched` has to run before it.

`contract/pending-beadpot-columns` becomes `contract/unprojected-keys`.
It is referenced nowhere else.

AI-assisted-by: mixed models
2026-08-07 17:25:33 +02:00

150 lines
5.9 KiB
Clojure

;; 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.project.transforms
"Derived graph links: edges a reader could compute from the projected
columns, materialized once at build time so a query does not have to.
Each entry in `registry` names the transform, the relationship it produces,
and the function that produces it, so adding one is a single entry and
nothing else has to be told about it."
(:require
[app.common.logging :as l]
[app.graph.ladybug :as ladybug]
[app.graph.schema.nodes :as nodes])
(:import
com.ladybugdb.Connection))
(set! *warn-on-reflection* true)
(defn- run-scalar!
[^Connection conn statement]
(or (ladybug/query-scalar-on-connection! conn statement) 0))
(defn- link-component-instances!
"`IsInstanceOf` from Frame instance heads to their Component.
Every head is linked, the main instance and any copy root alike.
`component-file` is what makes a head a head here, not `component-id` alone.
`app.common.types.component/instance-of?` requires both, and the projection
denormalizes `component-id` down the shape tree
(`app.graph.project.document`), so on its own it no longer distinguishes a
head from a shape that merely lives inside one. `component-file` is not
denormalized and remains the head marker Penpot itself uses."
[^Connection conn]
(run-scalar! conn
(str "MATCH (f:Frame), (c:Component) "
"WHERE f.component_id = c.id "
"AND f.component_file IS NOT NULL "
"AND NOT COALESCE(c.deleted, false) "
"MERGE (f)-[:IsInstanceOf]->(c) "
"RETURN count(*);")))
(defn- shape-pair-statements
"One statement per (from, to) shape-table pair.
Ladybug cannot create a relationship bound by multiple node labels in a
single `MERGE`, a constraint inherited from Kùzu, which it forks (upstream
issue kuzudb/kuzu#5841). The loop over label pairs is that dialect
constraint, not a modelling choice."
[f]
(for [from nodes/shape-tables
to nodes/shape-tables]
(f from to)))
(defn- link-shape-refs!
"`RefersTo` from an instance shape to its homologue in the main instance,
driven by `shape-ref`."
[^Connection conn]
(reduce
(fn [total statement] (+ total (run-scalar! conn statement)))
0
(shape-pair-statements
(fn [from to]
(str "MATCH (s:" (nodes/match-label from) "), (t:" (nodes/match-label to) ") "
"WHERE s.shape_ref = t.id "
"MERGE (s)-[:RefersTo]->(t) "
"RETURN count(*);")))))
(def ^:private swap-slot-prefix "swap-slot-")
(def ^:private slot-uuid-expr
;; Ladybug `substring` is 1-indexed; 36 = RFC 4122 UUID text length.
(str "substring(touched_key, " (inc (count swap-slot-prefix)) ", 36)"))
(defn- link-swap-slots!
"`FillsSwapSlot` from a swapped-in shape to the slot it replaces.
Penpot records a component sub-shape swap as a `swap-slot-<uuid>` entry in
the *replacing* shape's `touched` set, where `<uuid>` names the replaced
slot shape in the main instance. The entries are then stripped from
`touched`, as `app.common.types.component/normal-touched-groups` does, so a
reader of `touched` sees design edits rather than swap bookkeeping.
Stripping makes this the one transform that writes a column another
transform could read. Anything reading `touched` has to run before it."
[^Connection conn]
(let [linked
(reduce
(fn [total statement] (+ total (run-scalar! conn statement)))
0
(shape-pair-statements
(fn [from to]
(str "MATCH (s:" (nodes/match-label from) ") "
"WHERE size(s.touched) > 0 "
"UNWIND s.touched AS touched_key "
"WITH s, touched_key "
"WHERE STARTS_WITH(touched_key, '" swap-slot-prefix "') "
"WITH s, CAST(" slot-uuid-expr ", 'UUID') AS slot_id "
"MATCH (t:" (nodes/match-label to) ") "
"WHERE t.id = slot_id AND s.id <> t.id "
"MERGE (s)-[r:FillsSwapSlot {slot_id: slot_id}]->(t) "
"RETURN count(r);"))))]
;; Strip unconditionally: an entry may name a slot that was garbage
;; collected, so "no edge created" does not mean "nothing to strip".
(doseq [table nodes/shape-tables]
(ladybug/exec-on-connection!
conn
[(str "MATCH (s:" (nodes/match-label table) ") "
"WHERE size(s.touched) > 0 "
"SET s.touched = list_filter(s.touched, x -> "
"NOT STARTS_WITH(x, '" swap-slot-prefix "'));")]))
linked))
(def registry
"Every transform this backend applies.
`:id` names the transform in the ingest report and the log. `:rel` names
the relationship it produces. The three registered here read disjoint
columns, so the vector order is not load-bearing. The one ordering
constraint that exists is stated on `link-swap-slots!`."
[{:id "link-component-instances" :rel :IsInstanceOf :run link-component-instances!}
{:id "link-shape-refs" :rel :RefersTo :run link-shape-refs!}
{:id "link-swap-slots" :rel :FillsSwapSlot :run link-swap-slots!}])
(defn apply-transforms!
"Apply every registered transform to an already loaded graph.
Returns `{:ids [...] :counts {...} :transforms n}`, where `:ids` names what
ran and `:counts` gives the edges each one produced."
[_system ^Connection conn _data _file]
(reduce
(fn [acc {:keys [id rel run]}]
(let [n (run conn)]
(l/inf :hint "graph transform" :transform id :edges n)
(-> acc
(update :ids conj id)
(update :counts assoc rel n)
(assoc rel n))))
{:ids [] :counts {} :transforms (count registry)}
registry))
(defn transform-ids
"Ids of every transform in the registry."
[]
(mapv :id registry))