diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bbfd8c..457e405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Add `:create-or-replace-view` to support PostgreSQL's lack of `IF NOT EXISTS` for `CREATE VIEW`. * Add `:select` with function call and alias example to README (PR [#502](https://github.com/seancorfield/honeysql/pull/502) [@markbastian](https://github.com/markbastian)). * Address [#497](https://github.com/seancorfield/honeysql/issues/497) by adding `:alias` special syntax. + * Address [#407](https://github.com/seancorfield/honeysql/issues/407) by adding support for temporal queries (see `FROM` in [SQL Clause Reference](https://cljdoc.org/d/com.github.seancorfield/honeysql/CURRENT/doc/getting-started/sql-clause-reference#from)). * Address [#389](https://github.com/seancorfield/honeysql/issues/389) by adding examples of `[:only :table]` producing `ONLY(table)`. * Attempt to clarify the formatting behavior of the `:values` clause when used to produce column names. * Update `tools.build` to 0.9.5 (and remove `:java-opts` setting from `build/run-task`) diff --git a/doc/clause-reference.md b/doc/clause-reference.md index 8c01e85..f0a617c 100644 --- a/doc/clause-reference.md +++ b/doc/clause-reference.md @@ -522,6 +522,9 @@ use function syntax for this `[:only table]` will produce `ONLY(table)`. This is the ANSI SQL syntax (but PostgreSQL allows the parentheses to be omitted, if you are writing SQL by hand). +Some databases support temporal queries -- see the `:for` clause section +of the `FROM` clause below. + ## select-distinct-on Similar to `:select-distinct` above but the first element @@ -716,8 +719,9 @@ user=> (sql/format {:update :order `:from` accepts a single sequence argument that lists one or more SQL entities. Each entity can either be a -simple table name (keyword or symbol) or a pair of a -table name and an alias: +simple table name (keyword or symbol) or a sequence of a +table name, followed by an optional alias, followed by an +optional temporal clause: ```clojure user=> (sql/format {:select [:username :name] @@ -732,6 +736,25 @@ user=> (sql/format {:select [:u.username :s.name] ["SELECT u.username, s.name FROM user AS u, status AS s WHERE (u.statusid = s.id) AND (u.id = ?)" 9] ``` +A temporal clause starts with `:for`, followed by the time reference +(e.g., `:system-time` or `:business-time`), followed by a temporal qualifier, +one of: +* `:all` +* `:as-of timestamp` +* `:from timestamp1 :to timestamp2` +* `:between timestamp1 :and timestamp2` + +```clojure +user=> (sql/format {:select [:username] + :from [[:user :for :system-time :as-of [:inline "2019-08-01 15:23:00"]]] + :where [:= :id 9]}) +["SELECT username FROM user FOR SYSTEM_TIME AS OF '2019-08-01 15:23:00' WHERE id = ?" 9] +user=> (sql/format {:select [:u.username] + :from [[:user :u :for :system-time :from [:inline "2019-08-01 15:23:00"] :to [:inline "2019-08-01 15:24:00"]]] + :where [:= :u.id 9]}) +["SELECT u.username FROM user AS u FOR SYSTEM_TIME FROM '2019-08-01 15:23:00' TO '2019-08-01 15:24:00' WHERE u.id = ?" 9] +``` + > Note: the actual formatting of a `:from` clause is currently identical to the formatting of a `:select` clause. If you are using inheritance, you can specify `ONLY(table)` as a function diff --git a/src/honey/sql.cljc b/src/honey/sql.cljc index f915026..323bd77 100644 --- a/src/honey/sql.cljc +++ b/src/honey/sql.cljc @@ -344,6 +344,10 @@ (def ^:private ^:dynamic *formatted-column* (atom false)) +(defn- format-fn-name + [x] + (upper-case (str/replace (name x) "-" "_"))) + (defn- format-var [x & [opts]] ;; rather than name/namespace, we want to allow ;; for multiple / in the %fun.call case so that @@ -352,7 +356,7 @@ (cond (= \% (first c)) (let [[f & args] (str/split (subs c 1) #"\.") quoted-args (map #(format-entity (keyword %) opts) args)] - [(str (upper-case (str/replace f "-" "_")) + [(str (format-fn-name f) "(" (str/join ", " quoted-args) ")")]) (= \? (first c)) (let [k (keyword (subs c 1))] @@ -393,64 +397,137 @@ ) (declare format-selects-common) +(declare format-selectable-dsl) + +(defn- bigquery-*-except-replace? + [[maybe-* maybe-except-replace]] + (and (ident? maybe-*) + (or (= "*" (name maybe-*)) + (str/ends-with? (name maybe-*) ".*")) + (ident? maybe-except-replace) + (#{"except" "replace"} (name maybe-except-replace)))) + +(defn- format-bigquery-*-except-replace + "Format BigQuery * except/replace phrases #281." + [star-cols & x] + (let [[sql & params] (format-expr star-cols) + [sql' & params'] + (reduce (fn [[sql & params] [k arg]] + (let [[sql' params'] + (cond (and (ident? k) (= "except" (name k)) arg) + (let [[sqls params] + (format-expr-list arg {:aliased true})] + [(str (sql-kw k) " (" (str/join ", " sqls) ")") + params]) + (and (ident? k) (= "replace" (name k)) arg) + (let [[sql & params] (format-selects-common nil true arg)] + [(str (sql-kw k) " (" sql ")") + params]) + :else + (throw (ex-info "bigquery * only supports except and replace" + {:clause k :arg arg})))] + (-> [(cond->> sql' sql (str sql " "))] + (into params) + (into params')))) + [] + (partition-all 2 x))] + (-> [(str sql " " sql')] + (into params) + (into params')))) + +(defn- split-alias-temporal + "Given a general selectable item, split it into the subject selectable, + an optional alias, and any temporal clauses present." + [[selectable alias-for for-part & more]] + (let [no-alias? (and (= :for (sym->kw alias-for)) for-part)] + [selectable + (if no-alias? + nil + alias-for) + (cond no-alias? + (into [alias-for for-part] more) + (= :for (sym->kw for-part)) + (cons for-part more) + (or for-part (seq more)) + ::too-many!)])) + +(defn- format-temporal + ":for :some-time + + may be: + * :all + * :as-of + * :from :to + * :between :and + + Then generic format here is to alternate between sql-kw and format-expr + as we walk the sequence." + [[for-part the-time & more]] + (let [control {:sql-kw [(fn [x] [(sql-kw x)]) :expr] + :expr [#'format-expr :sql-kw]}] + (loop [sqls [(sql-kw for-part) + (format-fn-name the-time)] + params [] + more more + fmt :sql-kw] + (if (seq more) + (let [[x & more] more + [f fmt] (get control fmt) + [sql' & params'] (f x)] + (recur (conj sqls sql') + (into params params') + more + fmt)) + (into [(str/join " " sqls)] params))))) + +(comment + (format-temporal [:for :some-time :all]) + (format-temporal [:for :business_time :as-of [:inline "2000-12-16"]]) + (format-temporal [:for :business_time :from [:inline "2000-12-16"] :to [:inline "2000-12-17"]]) + (format-temporal [:for :system-time :between [:inline "2000-12-16"] :and [:inline "2000-12-17"]]) + ) + +(defn- format-item-selection + "Format all the possible ways to represent a table/column selection." + [x as] + (if (bigquery-*-except-replace? x) + (format-bigquery-*-except-replace x) + (let [[selectable alias temporal] (split-alias-temporal x) + _ (when (= ::too-many! temporal) + (throw (ex-info "illegal syntax in select expression" + {:symbol selectable :alias alias :unexpected (nnext x)}))) + [sql & params] (if (map? selectable) + (format-dsl selectable {:nested true}) + (format-expr selectable)) + [sql' & params'] (when alias + (if (sequential? alias) + (let [[sqls params] (format-expr-list alias {:aliased true})] + (into [(str/join " " sqls)] params)) + (format-selectable-dsl alias {:aliased true}))) + [sql'' & params''] (when temporal + (format-temporal temporal))] + + (-> [(str sql + (when sql' + (str (if as + (if (and (contains? *dialect* :as) + (not (:as *dialect*))) + " " + " AS ") + " ") + sql')) + (when sql'' + (str " " sql'')))] + (into params) + (into params') + (into params''))))) (defn- format-selectable-dsl [x & [{:keys [as aliased] :as opts}]] (cond (map? x) (format-dsl x {:nested true}) (sequential? x) - (let [s (first x) - a (second x) - pair? (= 2 (count x)) - big? (and (ident? s) (or (= "*" (name s)) (str/ends-with? (name s) ".*")) - (ident? a) (#{"except" "replace"} (name a))) - more? (and (< 2 (count x)) (not big?)) - [sql & params] (if (map? s) - (format-dsl s {:nested true}) - (format-expr s)) - [sql' & params'] (when (or pair? big?) - (cond (sequential? a) - (let [[sqls params] (format-expr-list a {:aliased true})] - (into [(str/join " " sqls)] params)) - big? ; BigQuery support #281 - (reduce (fn [[sql & params] [k arg]] - (let [[sql' params'] - (cond (and (ident? k) (= "except" (name k)) arg) - (let [[sqls params] - (format-expr-list arg {:aliased true})] - [(str (sql-kw k) " (" (str/join ", " sqls) ")") - params]) - (and (ident? k) (= "replace" (name k)) arg) - (let [[sql & params] (format-selects-common nil true arg)] - [(str (sql-kw k) " (" sql ")") - params]) - :else - (throw (ex-info "bigquery * only supports except and replace" - {:clause k :arg arg})))] - (-> [(cond->> sql' sql (str sql " "))] - (into params) - (into params')))) - [] - (partition-all 2 (rest x))) - :else - (format-selectable-dsl a {:aliased true})))] - (-> [(cond pair? - (str sql - (if as - (if (and (contains? *dialect* :as) - (not (:as *dialect*))) - " " - " AS ") - " ") sql') - big? - (str sql " " sql') - more? - (throw (ex-info "illegal syntax in select expression" - {:symbol s :alias a :unexpected (nnext x)})) - :else - sql)] - (into params) - (into params'))) + (format-item-selection x as) (ident? x) (if aliased @@ -2119,4 +2196,11 @@ (sql/format {:select [:*] :from [[[:only :countries]]] :join [[[:only :capitals]] [:= :countries.id :capitals.country_id]]}) + ;; #407 -- temporal clauses: + (sql/format {:select [:username] + :from [[:user :for :system-time :as-of [:inline "2019-08-01 15:23:00"]]] + :where [:= :id 9]}) + (sql/format {:select [:u.username] + :from [[:user :u :for :system-time :from [:inline "2019-08-01 15:23:00"] :to [:inline "2019-08-01 15:24:00"]]] + :where [:= :u.id 9]}) )