mentat/src/datomish/query.cljc
Richard Newman 1197764949 Implement :limit and :order-by-vars. Fixes #37.
We'd like this to be part of the query syntax itself, but doing so
requires extending DataScript's parser.

Instead we generalize our `args` to `options`, and take `:limit`
and `:order-by-vars`. The former must be an integer or nil, and the
latter is an array of `[var direction]` pairs.

This commit includes descriptive error messages and tests for success
and failure.
2016-09-02 08:32:45 -07:00

201 lines
6.5 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/.
(ns datomish.query
(:require
[datomish.query.clauses :as clauses]
[datomish.query.context :as context]
[datomish.query.projection :as projection]
[datomish.query.transforms :as transforms]
[datomish.util :as util #?(:cljs :refer-macros :clj :refer) [raise raise-str cond-let]]
[datascript.parser :as dp
#?@(:cljs
[:refer [
BindScalar
Constant
DefaultSrc
Pattern
Placeholder
SrcVar
Variable
]])]
[clojure.string :as str]
[honeysql.core :as sql]
)
#?(:clj
(:import
[datascript.parser
BindScalar
Constant
DefaultSrc
Pattern
Placeholder
SrcVar
Variable
])))
;; Setting this to something else will make your output more readable,
;; but not automatically safe for use.
(def sql-quoting-style :ansi)
(defn- validated-order-by [projection order-by]
(let [ordering-vars (set (map first order-by))
projected-vars (set (map second projection))]
(when-not (every? #{:desc :asc} (map second order-by))
(raise-str "Ordering expressions must be :asc or :desc."))
(when-not
(clojure.set/subset? ordering-vars projected-vars)
(raise "Ordering vars " ordering-vars " not a subset of projected vars " projected-vars
{:projected projected-vars
:ordering ordering-vars}))
order-by))
(defn- limit-and-order [limit projection order-by]
(when (or limit order-by)
(util/assoc-if {}
:limit limit
:order-by (validated-order-by projection order-by))))
(defn context->sql-clause [context]
(let [inner-projection (projection/sql-projection-for-relation context)
inner
(merge
{:select inner-projection
;; Always SELECT DISTINCT, because Datalog is set-based.
;; TODO: determine from schema analysis whether we can avoid
;; the need to do this.
:modifiers [:distinct]}
(clauses/cc->partial-subquery (:cc context)))
limit (:limit context)
order-by (:order-by-vars context)]
(if (:has-aggregates? context)
(let [outer-projection (projection/sql-projection-for-aggregation context :preag)]
;; Validate the projected vars against the ordering clauses.
(merge
(limit-and-order limit outer-projection order-by)
(when-not (empty? (:group-by-vars context))
;; We shouldn't need to account for types here, until we account for
;; `:or` clauses that bind from different attributes.
{:group-by (map util/var->sql-var (:group-by-vars context))})
{:select outer-projection
:modifiers [:distinct]
:from [:preag]
:with {:preag inner}}))
;; Otherwise, validate against the inner.
(merge
(limit-and-order limit inner-projection order-by)
inner))))
(defn context->sql-string [context args]
(->
context
context->sql-clause
(sql/format args :quoting sql-quoting-style)))
(defn- validate-with [with]
(when-not (or (nil? with)
(every? #(instance? Variable %1) with))
(raise "Complex :with not supported." {:with with})))
(defn- validate-in [in]
(when (nil? in)
(raise ":in expression cannot be nil." {:binding in}))
(when-not (= "$" (name (-> in first :variable :symbol)))
(raise "Non-default sources not supported." {:binding in}))
(when-not (every? (partial instance? BindScalar) (rest in))
(raise "Non-scalar bindings not supported." {:binding in})))
(defn in->bindings
"Take an `:in` list and return a bindings map suitable for use
as external bindings in a CC."
[in]
(reduce
(fn [m b]
(or
(when (instance? BindScalar b)
(let [var (:variable b)]
(when (instance? Variable var)
(let [v (:symbol var)]
(assoc m v [(sql/param (util/var->sql-var v))])))))
m))
{}
in))
(defn options-into-context
[context limit order-by]
(when-not (or (and (integer? limit)
(pos? limit))
(nil? limit))
(raise "Invalid limit " limit {:limit limit}))
(assoc context :limit limit :order-by-vars order-by))
(defn find-into-context
"Take a parsed `find` expression and return a fully populated
Context. You'll want this so you can get access to the
projection, amongst other things."
[context find]
(let [{:keys [find in with where]} find] ; Destructure the Datalog query.
(validate-with with)
(validate-in in)
(let [external-bindings (in->bindings in)
elements (:elements find)
known-types {}
group-by-vars (projection/extract-group-by-vars elements with)]
(assoc context
:elements elements
:group-by-vars group-by-vars
:has-aggregates? (not (nil? group-by-vars))
:cc (clauses/patterns->cc (:default-source context) where known-types external-bindings)))))
(defn find->sql-clause
"Take a parsed `find` expression and turn it into a structured SQL
expression that can be formatted by honeysql."
[context find]
(->> find
(find-into-context context)
context->sql-clause))
(defn find->sql-string
"Take a parsed `find` expression and turn it into SQL."
[context find args]
(->
(find->sql-clause context find)
(sql/format args :quoting sql-quoting-style)))
(defn parse
"Parse a Datalog query array into a structured `find` expression."
[q]
(dp/parse-query q))
#_
(def sql-quoting-style nil)
#_
(datomish.query/find->sql-string
(datomish.query.context/make-context (datomish.query.source/datoms-source nil))
(datomish.query/parse
'[:find ?timestampMicros ?page :in $ ?latest :where
[?page :page/starred true ?t]
[?t :db/txInstant ?timestampMicros]
(not [(> ?t ?latest)]) ])
{:latest 5})
#_
(datomish.query/find->sql-string
(datomish.query.context/make-context (datomish.query.source/datoms-source nil))
(datomish.query/parse
'[:find ?page :in $ ?latest :where
[?page :page/url "http://example.com/"]
[(fulltext $ :page/title "Some title") [[?page ?title _ _]]]
(or
[?entity :page/likes ?page]
[?entity :page/loves ?page])
])
{})