From 97835eeeb74cfd7b3c0bfe019a62e8784886eca5 Mon Sep 17 00:00:00 2001 From: Gabriel Horner Date: Mon, 23 Feb 2026 17:04:08 -0500 Subject: [PATCH] enhance: export-edn :build/class-properties sorts properties both when building and exporting --- deps/db/src/logseq/db/sqlite/build.cljs | 82 ++++++++++++++++--- deps/db/src/logseq/db/sqlite/export.cljs | 8 +- deps/db/test/logseq/db/sqlite/build_test.cljs | 30 ++++++- 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/deps/db/src/logseq/db/sqlite/build.cljs b/deps/db/src/logseq/db/sqlite/build.cljs index 6a87371f22..0e6e14dccb 100644 --- a/deps/db/src/logseq/db/sqlite/build.cljs +++ b/deps/db/src/logseq/db/sqlite/build.cljs @@ -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 diff --git a/deps/db/src/logseq/db/sqlite/export.cljs b/deps/db/src/logseq/db/sqlite/export.cljs index 62a289a6ec..aa8bca7f2f 100644 --- a/deps/db/src/logseq/db/sqlite/export.cljs +++ b/deps/db/src/logseq/db/sqlite/export.cljs @@ -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) diff --git a/deps/db/test/logseq/db/sqlite/build_test.cljs b/deps/db/test/logseq/db/sqlite/build_test.cljs index 0f04383a98..c618a2ee50 100644 --- a/deps/db/test/logseq/db/sqlite/build_test.cljs +++ b/deps/db/test/logseq/db/sqlite/build_test.cljs @@ -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))))) \ No newline at end of file + (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"))) \ No newline at end of file