;; copyright (c) 2019-2024 Sean Corfield, all rights reserved

(ns next.jdbc.sql
  "Some utility functions that make common operations easier by
  providing some syntactic sugar over `execute!`/`execute-one!`.

  This is intended to provide a minimal level of parity with
  `clojure.java.jdbc` (`insert!`, `insert-multi!`, `query`, `find-by-keys`,
  `get-by-id`, `update!`, and `delete!`).

  For anything more complex, use a library like HoneySQL
  https://github.com/seancorfield/honeysql to generate SQL + parameters.

  The following options are supported:
  * `:table-fn` -- specify a function used to convert table names (strings)
      to SQL entity names -- see the `next.jdbc.quoted` namespace for the
      most common quoting strategy functions,
  * `:column-fn` -- specify a function used to convert column names (strings)
      to SQL entity names -- see the `next.jdbc.quoted` namespace for the
      most common quoting strategy functions.

  In addition, `find-by-keys` supports `:order-by` to add an `ORDER BY`
  clause to the generated SQL."
  (:require [clojure.string :as str]
            [next.jdbc :refer [execute! execute-batch! execute-one!]]
            [next.jdbc.sql.builder
             :refer [for-delete for-insert for-insert-multi for-query
                     for-update]]))

(set! *warn-on-reflection* true)

(defn insert!
  "Syntactic sugar over `execute-one!` to make inserting hash maps easier.

  Given a connectable object, a table name, and a data hash map, inserts the
  data as a single row in the database and attempts to return a map of generated
  keys."
  ([connectable table key-map]
   (insert! connectable table key-map {}))
  ([connectable table key-map opts]
   (let [opts (merge (:options connectable) opts)]
     (execute-one! connectable
                   (for-insert table key-map opts)
                   (merge {:return-keys true} opts)))))

(defn insert-multi!
  "Syntactic sugar over `execute!` or `execute-batch!` to make inserting
  columns/rows easier.

  Given a connectable object, a table name, a sequence of column names, and
  a vector of rows of data (vectors of column values), inserts the data as
  multiple rows in the database and attempts to return a vector of maps of
  generated keys.

  Given a connectable object, a table name, a sequence of hash maps of data,
  which all have the same set of keys, inserts the data as multiple rows in
  the database and attempts to return a vector of maps of generated keys.

  If called with `:batch` true will call `execute-batch!` - see its documentation
  for situations in which the generated keys may or may not be returned as well as
  additional options that can be passed.

  Note: without `:batch` this expands to a single SQL statement with placeholders for
  every value being inserted -- for large sets of rows, this may exceed the limits
  on SQL string size and/or number of parameters for your JDBC driver or your
  database!"
  {:arglists '([connectable table hash-maps]
               [connectable table hash-maps opts]
               [connectable table cols rows]
               [connectable table cols rows opts])}
  ([connectable table hash-maps]
   (insert-multi! connectable table hash-maps {}))
  ([connectable table hash-maps-or-cols opts-or-rows]
   (if (map? (first hash-maps-or-cols))
     (let [cols  (keys (first hash-maps-or-cols))
           ->row (fn ->row [m]
                   (map #(get m %) cols))]
       (when-not (apply = (map (comp set keys) hash-maps-or-cols))
         (throw (IllegalArgumentException.
                 "insert-multi! hash maps must all have the same keys")))
       (insert-multi! connectable table cols (map ->row hash-maps-or-cols) opts-or-rows))
     (if (map? opts-or-rows)
       (insert-multi! connectable table hash-maps-or-cols [] opts-or-rows)
       (insert-multi! connectable table hash-maps-or-cols opts-or-rows {}))))
  ([connectable table cols rows opts]
   (if (seq rows)
     (let [opts   (merge (:options connectable) opts)
           batch? (:batch opts)]
       (if batch?
         (let [[sql & param-groups] (for-insert-multi table cols rows opts)]
              (execute-batch! connectable sql param-groups
                     (merge {:return-keys true :return-generated-keys true} opts)))
         (execute! connectable
                   (for-insert-multi table cols rows opts)
                   (merge {:return-keys true} opts))))
     [])))

(defn query
  "Syntactic sugar over `execute!` to provide a query alias.

  Given a connectable object, and a vector of SQL and its parameters,
  returns a vector of hash maps of rows that match."
  ([connectable sql-params]
   (query connectable sql-params {}))
  ([connectable sql-params opts]
   (let [opts (merge (:options connectable) opts)]
     (execute! connectable sql-params opts))))

(defn find-by-keys
  "Syntactic sugar over `execute!` to make certain common queries easier.

  Given a connectable object, a table name, and either a hash map of
  columns and values to search on or a vector of a SQL where clause and
  parameters, returns a vector of hash maps of rows that match.

  If `:all` is passed instead of a hash map or vector -- the query will
  select all rows in the table, subject to any pagination options below.

  If `:columns` is passed, only that specified subset of columns will be
  returned in each row (otherwise all columns are selected).

  If the `:order-by` option is present, add an `ORDER BY` clause. `:order-by`
  should be a vector of column names or pairs of column name / direction,
  which can be `:asc` or `:desc`.

  If the `:top` option is present, the SQL Server `SELECT TOP ?` syntax
  is used and the value of the option is inserted as an additional parameter.

  If the `:limit` option is present, the MySQL `LIMIT ? OFFSET ?` syntax
  is used (using the `:offset` option if present, else `OFFSET ?` is omitted).
  PostgreSQL also supports this syntax.

  If the `:offset` option is present (without `:limit`), the standard
  `OFFSET ? ROWS FETCH NEXT ? ROWS ONLY` syntax is used (using the `:fetch`
  option if present, else `FETCH...` is omitted)."
  ([connectable table key-map]
   (find-by-keys connectable table key-map {}))
  ([connectable table key-map opts]
   (let [opts (merge (:options connectable) opts)]
     (execute! connectable (for-query table key-map opts) opts))))

(defn aggregate-by-keys
  "A wrapper over `find-by-keys` that additionally takes an aggregate SQL
  expression (a string), and returns just a single result: the value of that
  of that aggregate for the matching rows.

  Accepts all the same options as `find-by-keys` except `:columns` since that
  is used internally by this wrapper to pass the aggregate expression in."
  ([connectable table aggregate key-map]
   (aggregate-by-keys connectable table aggregate key-map {}))
  ([connectable table aggregate key-map opts]
   (let [opts         (merge (:options connectable) opts)
         _
         (when-not (string? aggregate)
           (throw (IllegalArgumentException.
                   "aggregate-by-keys requires a string aggregate expression")))
         _
         (when (:columns opts)
           (throw (IllegalArgumentException.
                   "aggregate-by-keys does not support the :columns option")))

         ;; this should be unique enough as an alias to never clash with
         ;; a real column name in anyone's tables -- in addition it is
         ;; stable for a given aggregate expression so it should allow
         ;; for query caching in the JDBC driver:
         ;; (we use abs to avoid negative hash codes which would produce
         ;; a hyphen in the alias name which is not valid in SQL identifiers)
         total-name   (str "next_jdbc_aggregate_"
                           (Math/abs (.hashCode ^String aggregate)))
         total-column (keyword total-name)
         ;; because some databases return uppercase column names:
         total-col-u  (keyword (str/upper-case total-name))]
     (-> (find-by-keys connectable table key-map
                       (assoc opts :columns [[aggregate total-column]]))
         (first)
         (as-> row (or (get row total-column) (get row total-col-u)))))))

(defn get-by-id
  "Syntactic sugar over `execute-one!` to make certain common queries easier.

  Given a connectable object, a table name, and a primary key value, returns
  a hash map of the first row that matches.

  By default, the primary key is assumed to be `id` but that can be overridden
  in the five-argument call.

  As with `find-by-keys`, you can specify `:columns` to return just a
  subset of the columns in the returned row.

  Technically, this also supports `:order-by`, `:top`, `:limit`, `:offset`,
  and `:fetch` -- like `find-by-keys` -- but they don't make as much sense
  here since only one row is ever returned."
  ([connectable table pk]
   (get-by-id connectable table pk :id {}))
  ([connectable table pk opts]
   (get-by-id connectable table pk :id opts))
  ([connectable table pk pk-name opts]
   (let [opts (merge (:options connectable) opts)]
     (execute-one! connectable (for-query table {pk-name pk} opts) opts))))

(defn update!
  "Syntactic sugar over `execute-one!` to make certain common updates easier.

  Given a connectable object, a table name, a hash map of columns and values
  to set, and either a hash map of columns and values to search on or a vector
  of a SQL where clause and parameters, perform an update on the table."
  ([connectable table key-map where-params]
   (update! connectable table key-map where-params {}))
  ([connectable table key-map where-params opts]
   (let [opts (merge (:options connectable) opts)]
     (execute-one! connectable
                   (for-update table key-map where-params opts)
                   opts))))

(defn delete!
  "Syntactic sugar over `execute-one!` to make certain common deletes easier.

  Given a connectable object, a table name, and either a hash map of columns
  and values to search on or a vector of a SQL where clause and parameters,
  perform a delete on the table."
  ([connectable table where-params]
   (delete! connectable table where-params {}))
  ([connectable table where-params opts]
   (let [opts (merge (:options connectable) opts)]
     (execute-one! connectable (for-delete table where-params opts) opts))))
