Fill unset graph columns from the consumer's field defaults

A Penpot file omits an attribute whose value equals its default, so a
shape that is not blocked carries no `blocked` key at all. Projected as
it stands, that column reads NULL, and a reader cannot tell a missing
feature from a false one.

`app.graph.schema.beadpot` reads the schema manifest the parity consumer
exports, checked in at `backend/resources/app/graph/beadpot-schema.json`,
and `nodes/apply-defaults` fills every unset column that the manifest
gives a default for. The default is withheld when the column's Ladybug
type differs from the manifest's, because a default is expressed in its
column's type.

Applied after validation: a default belongs to the graph column, not to
the Penpot schema the attributes were checked against.

This stays off `graph-backend`. Penpot should declare these defaults
from its own model rather than import another project's manifest, which
is W24 on the workplan.

AI-assisted-by: mixed models
This commit is contained in:
Álvaro Tejero Cantero 2026-08-07 01:42:19 +02:00
parent 9432c61637
commit 13229dd94e
No known key found for this signature in database
3 changed files with 8105 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,136 @@
;; 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.schema.beadpot
"beadpot's graph schema, as a checked-in value.
`resources/app/graph/beadpot-schema.json` is produced by
`bp graph schema export` and read back off an empty database beadpot itself
creates, so it states what beadpot *does*: table names, column names in
order, Ladybug types, the legal (from, to) pairs of each relationship family,
and each column's default.
This namespace does not generate the DDL that stays derived from Penpot's
own Malli schemas (`app.graph.schema.nodes`), so the backend remains
self-contained and a change to the design model shows up here as a diff to
review rather than as a silent schema change. What the manifest *is* used for
is the things Penpot cannot know on its own:
- **defaults.** beadpot writes a Pydantic field default where the file has no
such attribute, so a shape with no `blocked` key holds `false`, not NULL.
A consumer told NULL sees a missing feature where beadpot showed it a false
one. The defaults live in beadpot's models; reading them from here keeps
them from being restated in Clojure and drifting.
- **review.** `backend_tests.graph-contract-test` diffs the DDL this backend
emits against the manifest, so a divergence is a failing test naming the
column, not something a training set discovers later.
Regenerate with `bp graph schema export -o
backend/resources/app/graph/beadpot-schema.json` after any change to
beadpot's node or edge models."
(:require
[app.common.json :as json]
[clojure.java.io :as io]
[clojure.string :as str]))
(def ^:private resource-path
"app/graph/beadpot-schema.json")
(defn- read-manifest
[]
(if-let [resource (io/resource resource-path)]
(with-open [reader (io/reader resource)]
(json/read reader :key-fn keyword))
(throw (ex-info "beadpot schema manifest missing from resources"
{:path resource-path}))))
(def manifest
"The parsed manifest. Delayed so a missing resource fails where it is used."
(delay (read-manifest)))
(defn- index-tables
[tables]
(into {} (map (juxt :table identity)) tables))
(def node-tables
(delay (index-tables (:node_tables @manifest))))
(def rel-tables
(delay (index-tables (:rel_tables @manifest))))
(defn table
"The manifest entry for `table-name`, node or rel."
[table-name]
(or (get @node-tables table-name)
(get @rel-tables table-name)))
(defn columns
"Column entries for `table-name`, in beadpot's order."
[table-name]
(:columns (table table-name) []))
(defn column
"The manifest entry for one column, or nil."
[table-name column-name]
(some #(when (= column-name (:name %)) %) (columns table-name)))
(def defaults
"`table -> {column-name -> default}`, omitting columns with no default.
A JSON `null` means \"no default\": beadpot leaves the column NULL when the
attribute is unset, and so should we."
(delay
(into {}
(map (fn [[table-name entry]]
[table-name
(into {}
(keep (fn [{:keys [name default]}]
(when (some? default) [name default])))
(:columns entry))]))
(merge @node-tables @rel-tables))))
(defn column-default
"beadpot's default for `column-name` on `table-name`, or nil."
[table-name column-name]
(get-in @defaults [table-name column-name]))
(def ^:private type-aliases
"Declared type spelling the spelling Ladybug's catalog reports.
The manifest is read back off a real database, so it holds catalog spellings;
DDL is written in whatever Ladybug accepts. `BOOLEAN` is accepted and comes
back as `BOOL`, so a literal comparison would decide the two sides disagree
about every boolean column."
{"BOOLEAN" "BOOL"})
(defn normalize-type
"A Ladybug type string in the spelling the catalog uses.
Replaces aliases anywhere in the string, so a `BOOLEAN` nested inside a
`STRUCT(...)` normalizes too."
[ladybug-type]
(when ladybug-type
(-> (reduce-kv (fn [s declared reported] (str/replace s declared reported))
ladybug-type
type-aliases)
;; STRUCT field names are written backticked (a grid cell has a field
;; called `column`); the catalog reports them bare.
(str/replace "`" ""))))
(defn typed-column-default
"beadpot's default for a column, but only when the types agree.
A default is expressed in the column's type `[]` for a `DOUBLE[2][]`,
`{}` for a `JSON`, `[1,0,0,1,0,0]` for a `DOUBLE[6]` transform. Where this
backend still emits a different type for that column (`app.graph.schema.types`
is coarser than beadpot's encodings in a handful of places, tracked by
`bp graph schema diff`), the default would be written in a shape the column
cannot hold. So it is withheld until the types match, and the set of columns
that get defaults grows as the types converge."
[table-name column-name ladybug-type]
(when (= (normalize-type ladybug-type)
(normalize-type (:type (column table-name column-name))))
(column-default table-name column-name)))

View File

@ -23,6 +23,7 @@
[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.ladybug :as ladybug] [app.graph.ladybug :as ladybug]
[app.graph.schema.beadpot :as beadpot]
[app.graph.schema.contract :as contract] [app.graph.schema.contract :as contract]
[app.graph.schema.projection :as projection] [app.graph.schema.projection :as projection]
[app.graph.schema.types :as types] [app.graph.schema.types :as types]
@ -232,6 +233,30 @@
"; columns=" (count (column-keys table)) "; columns=" (count (column-keys table))
" shape-keys=" (vec (keys attrs))))) " shape-keys=" (vec (keys attrs)))))
(defn- apply-defaults
"Fill columns the document does not set with the consumer's field defaults.
A file omits an attribute equal to its default and the consumer's models
restore it, so a shape with no `blocked` key holds `false` in the graph the
consumer builds. Writing NULL instead hands a reader a *missing* feature
where the consumer hands it a false one, so the defaults are read from the
exported manifest (`app.graph.schema.beadpot`) rather than restated here,
where they would drift.
Applied after validation: a default belongs to the graph column, not to the
Penpot schema the attrs were checked against."
[table attrs]
(reduce (fn [attrs k]
(if (contains? attrs k)
attrs
(let [column (column-name table k)]
(if-some [default (beadpot/typed-column-default
table column (column-ladybug-type table k))]
(assoc attrs k default)
attrs))))
attrs
(column-keys table)))
(defn project-attrs (defn project-attrs
"Select and validate the projected columns for `table` from `attrs`." "Select and validate the projected columns for `table` from `attrs`."
[table attrs] [table attrs]
@ -245,7 +270,7 @@
(column-keys table)))] (column-keys table)))]
(when (empty? projected) (when (empty? projected)
(raise-empty-projection! table attrs)) (raise-empty-projection! table attrs))
(validate-node table projected))) (apply-defaults table (validate-node table projected))))
(defn match-label (defn match-label
"Cypher node label for MATCH; backtick-wrapped when required by Ladybug." "Cypher node label for MATCH; backtick-wrapped when required by Ladybug."