enhance: export-edn :build/class-properties sorts properties

both when building and exporting
This commit is contained in:
Gabriel Horner
2026-02-23 17:04:08 -05:00
parent cbf67bffc8
commit 97835eeeb7
3 changed files with 103 additions and 17 deletions

View File

@@ -233,9 +233,10 @@
:block/refs block-refs})))))))
(defn- build-property-tx
[properties page-uuids all-idents property-db-ids options
[properties page-uuids all-idents property-db-ids class-property-orders options
[prop-name {:build/keys [property-classes] :as prop-m}]]
(let [[new-block & additional-tx]
(let [class-property-order (get class-property-orders prop-name)
[new-block & additional-tx]
(if-let [closed-values (seq (map #(merge {:uuid (random-uuid)} %) (:build/closed-values prop-m)))]
(let [db-ident (get-ident all-idents prop-name)]
(db-property-build/build-closed-values
@@ -245,14 +246,18 @@
{:property-attributes
(merge {:db/id (or (property-db-ids prop-name)
(throw (ex-info "No :db/id for property" {:property prop-name})))}
(when class-property-order
{:block/order class-property-order})
(select-keys prop-m [:build/properties-ref-types :block/created-at :block/updated-at :block/collapsed?]))}))
[(merge (sqlite-util/build-new-property (get-ident all-idents prop-name)
(db-property/get-property-schema prop-m)
{:block-uuid (:block/uuid prop-m)
:title (:block/title prop-m)})
{:db/id (or (property-db-ids prop-name)
(throw (ex-info "No :db/id for property" {:property prop-name})))}
(select-keys prop-m [:build/properties-ref-types :block/created-at :block/updated-at :block/collapsed?]))])
[(cond-> (merge (sqlite-util/build-new-property (get-ident all-idents prop-name)
(db-property/get-property-schema prop-m)
{:block-uuid (:block/uuid prop-m)
:title (:block/title prop-m)})
{:db/id (or (property-db-ids prop-name)
(throw (ex-info "No :db/id for property" {:property prop-name})))}
(select-keys prop-m [:build/properties-ref-types :block/created-at :block/updated-at :block/collapsed?]))
class-property-order
(assoc :block/order class-property-order))])
pvalue-tx-m
(->property-value-tx-m new-block (:build/properties prop-m) properties all-idents)]
(cond-> []
@@ -272,17 +277,70 @@
true
(into additional-tx))))
(defn- build-properties-tx [properties page-uuids all-idents {:keys [build-existing-tx?] :as options}]
(defn- class-properties->ordered-properties
"Returns a deterministic property order inferred from :build/class-properties, using topological sorting"
[classes]
(let [class-properties (->> (vals classes)
(map :build/class-properties)
(filter seq))
;; Create first-seen unique property ids for use as a stable tie-break order
all-properties (vec (distinct (mapcat identity class-properties)))
property-index (zipmap all-properties (range))
sort-by-input-order #(sort-by property-index %)
;; Adjacent pairs encode ordering e.g. [:p2 :p1 :p3]: #{[:p2 :p1] [:p1 :p3]}
edges (->> class-properties
(mapcat #(partition 2 1 %))
(remove (fn [[left right]] (= left right)))
set)
;; Adjacency list by source node
;; Example: #{[:p2 :p1] [:p2 :p3] [:p1 :p3]} -> {:p2 [[:p2 :p1] [:p2 :p3]], :p1 [[:p1 :p3]]}
outgoing (group-by first edges)
;; Count inbound edges for each property e.g. {:p2 0, :p1 1, :p3 2}
incoming-counts (reduce (fn [m [_left right]]
(update m right inc))
(zipmap all-properties (repeat 0))
edges)]
(loop [ordered-properties []
;; Kahn queue: nodes with zero incoming edges, stably sorted
queue (->> all-properties
(filter #(zero? (incoming-counts %)))
sort-by-input-order
vec)
remaining-incoming incoming-counts]
(if-let [property (first queue)]
;; Consume one zero-incoming node, then decrement incoming counts for its neighbors
(let [[next-incoming unlocked]
(reduce (fn [[incoming unlocked*] [_left next-property]]
(let [next-count (dec (incoming next-property))]
[(assoc incoming next-property next-count)
(if (zero? next-count) (conj unlocked* next-property) unlocked*)]))
[remaining-incoming []]
(get outgoing property))
;; Merge newly unlocked nodes into queue with deterministic ordering
next-queue (->> (concat (rest queue) unlocked)
sort-by-input-order
vec)]
(recur (conj ordered-properties property) next-queue next-incoming))
(do
(assert (= (count ordered-properties) (count all-properties))
(str "Cycle detected in :build/class-properties constraints. Ordered "
(count ordered-properties) " of " (count all-properties) " properties."))
ordered-properties)))))
(defn- build-properties-tx [properties classes page-uuids all-idents {:keys [build-existing-tx?] :as options}]
(let [properties' (if build-existing-tx?
(->> properties
(remove (fn [[_ v]] (and (:block/uuid v) (not (:build/keep-uuid? v)))))
(into {}))
properties)
class-property-orders (->> classes
class-properties->ordered-properties
(#(zipmap % (db-order/gen-n-keys (count %) nil nil))))
property-db-ids (->> (keys properties')
(map #(vector % (new-db-id)))
(into {}))
new-properties-tx (vec
(mapcat (partial build-property-tx properties' page-uuids all-idents property-db-ids options)
(mapcat (partial build-property-tx properties' page-uuids all-idents property-db-ids class-property-orders options)
properties'))]
new-properties-tx))
@@ -725,7 +783,7 @@
page-uuids (create-page-uuids pages-and-blocks')
{:keys [classes properties]} (if auto-create-ontology? (auto-create-ontology options) options)
all-idents (create-all-idents properties classes options)
properties-tx (build-properties-tx properties page-uuids all-idents options)
properties-tx (build-properties-tx properties classes page-uuids all-idents options)
classes-tx (build-classes-tx classes properties page-uuids all-idents options)
class-ident->id (->> classes-tx (map (juxt :db/ident :db/id)) (into {}))
;; Replace idents with db-ids to avoid any upsert issues

View File

@@ -184,7 +184,9 @@
(merge (select-keys class-ent [:block/created-at :block/updated-at]))
(and (:logseq.property.class/properties class-ent) (not shallow-copy?))
(assoc :build/class-properties
(mapv :db/ident (:logseq.property.class/properties class-ent)))
(->> (:logseq.property.class/properties class-ent)
(sort-by :block/order)
(mapv :db/ident)))
(and (not shallow-copy?) include-alias? (:block/alias class-ent))
(assoc :block/alias (set (map #(vector :block/uuid (:block/uuid %)) (:block/alias class-ent))))
;; It's caller's responsibility to ensure parent is included in final export
@@ -1138,9 +1140,7 @@
(update :classes update-vals (fn [m]
(cond-> m
(:build/class-extends m)
(update :build/class-extends sort)
(:build/class-properties m)
(update :build/class-properties sort))))
(update :build/class-extends sort))))
(update :properties update-vals (fn [m]
(cond-> m
(:build/property-classes m)

View File

@@ -6,6 +6,7 @@
[logseq.db.frontend.entity-util :as entity-util]
[logseq.db.frontend.property :as db-property]
[logseq.db.sqlite.build :as sqlite-build]
[logseq.db.sqlite.export :as sqlite-export]
[logseq.db.test.helper :as db-test]))
(deftest build-tags
@@ -273,4 +274,31 @@
(is (entity-util/property? (d/entity @conn :user.property/p1)))
(is (entity-util/property? (d/entity @conn :other.property/p1)))
(is (entity-util/class? (d/entity @conn :user.class/C1)))
(is (entity-util/class? (d/entity @conn :other.class/C1)))))
(is (entity-util/class? (d/entity @conn :other.class/C1)))))
(deftest build-preserves-class-property-ordering-for-export
(let [class-properties-c1 [:user.property/p2 :user.property/p1 :user.property/p3]
class-properties-c2 [:user.property/p4 :user.property/p2 :user.property/p3]
another-class-properties-c1 [:user.property/p5]
another-class-properties-c2 [:user.property/p6]
another-class-properties-c3 [:user.property/p6 :user.property/p5]
conn (db-test/create-conn-with-blocks
{:properties {:user.property/p1 {:logseq.property/type :default}
:user.property/p2 {:logseq.property/type :default}
:user.property/p3 {:logseq.property/type :default}
:user.property/p4 {:logseq.property/type :default}
:user.property/p5 {:logseq.property/type :default}
:user.property/p6 {:logseq.property/type :default}}
:classes {:user.class/C1 {:build/class-properties class-properties-c1}
:user.class/C2 {:build/class-properties class-properties-c2}
:user.class/AnotherC1 {:build/class-properties another-class-properties-c1}
:user.class/AnotherC2 {:build/class-properties another-class-properties-c2}
:user.class/AnotherC3 {:build/class-properties another-class-properties-c3}}})
export-map (sqlite-export/build-export @conn {:export-type :graph-ontology})]
(is (= class-properties-c1
(get-in export-map [:classes :user.class/C1 :build/class-properties])))
(is (= class-properties-c2
(get-in export-map [:classes :user.class/C2 :build/class-properties])))
(is (= another-class-properties-c3
(get-in export-map [:classes :user.class/AnotherC3 :build/class-properties]))
"Later class-level ordering constraint :p6 before :p5 is preserved")))