breadsystems / bread-cms

A powerful, opinionated Clojure/Script library for building custom Content Management Systems
Eclipse Public License 1.0
18 stars 2 forks source link

A Unified Theory of Everything [Related to Query Inference] #76

Closed acobster closed 8 months ago

acobster commented 1 year ago

Query inference is currently very ad-hoc and error prone. Lots of opinions sneak their way in, such as supporting only map-style Datalog queries, or the assumption that a namespaced keyword is a db attr (and not just a normal keyword in a :query/key).

What if we:

A simple example

;; components
(defc PageLayout [...]
  {:extra {:main-nav {:query/name ::navigation/posts
                      :query [{:menu/items [{:translatable/fields [*]}]}]}}}
  ...)

(defc Page [...]
  {:routes [{:name ::page
             :dispatcher/type ::post/page
             :path "/{post/slug}"}]
   :key :post
   :query [:post/slug
           {:translatable/fields [*]}]
   :extends PageLayout}
  ...)

;; at startup time, we build up our routing table:

(def router
  (r/router
    ["{field/lang}"
     ["/{post/slug}" {:name ::page
                      :dispatcher/type ::post/page
                      :dispatcher/component Page}]]))

;; also at startup time, these queries get parsed and stored in hook data for
;; introspection:

{::bread/hooks
 {[::bread/queries ::page]
  [{:action/name ::query/infer
    :before [{:query/name ::db/query
              :query/db '(db/database req)
              :query/key [:post]
              :query/args
              '[{:find [(pull ?e [:post/slug
                                  ;; this gets parsed out into its own
                                  ;; lang-specific query
                                  {:translatable/fields [*]}])]
                 :in [$ ?slug]
                 :where [[?e :post/slug ?slug]]}
                (:post/slug params)]}]
    :after [;; First query, just for post data
            {:query/name ::db/query
             :query/db '(db/database req)
             :query/key [:post]
             :query/args
             ['{:find [(pull ?e [:db/id :post/slug :translatable/fields])]
                :in [$ ?slug]
                :where [[?e :post/slug ?slug]]}
              '(:post/slug params)]}
            ;; Second lang-specific query for field data, uses :field/lang from
            ;; route
            {:query/key [:post :translatable/fields]
             :query/args
             ['{:find [(pull ?e [:db/id :field/key :field/content])]
                :in [$ ?e0 ?lang]
                :where [[?e0 :translatable/fields ?e]
                        [?e :field/lang ?lang]]}
              '(:field/lang params)]}]
    ;; This function basically takes route params and other request data (e.g.
    ;; the database itself) as input, and returns the equivalent of :after
    ;; with the request-specific query input values put in the correct spots.
    :infer (fn [,,,] ,,,)}]
  [::bread/queries :extra :main-nav]
  [{:action/name ::query/infer
    ;; ...same deal for the menu query, and any others
    }]}}

;; Now, when a request comes in, the ::post/page dispatcher sees that it has
;; to return queries for the ::page route and also for the "extra" :main-nav
;; data. So it runs the corresponding hooks, returning the concatenated list
;; of all the resulting queries:

(mapcat (fn [k] (bread/hook req (vec (cons ::bread/queries k))))
        [[::page]
         [:extra :main-nav]])
acobster commented 1 year ago

Pragmatically, we don't even necessarily have to do all this on ::bread/init. That can be an optimization that comes later. But as a first pass, we can still parse the query and run inference in a cleaner, more holistic way by leveraging libraries like Meander and Specter.

The important insight here is that routes and other data underpinning queries come from the components themselves. Just from reading the components and combining them with the concrete routing table, we have everything we need to build: