diff --git a/doc/clause-reference.md b/doc/clause-reference.md index 3b9bc5d..a9a1f0e 100644 --- a/doc/clause-reference.md +++ b/doc/clause-reference.md @@ -84,7 +84,7 @@ user=> (sql/format {:create-table :fruit [[:id :int [:not nil]] [:name [:varchar 32] [:not nil]] [:cost :float :null]]}) -;; \n has been replaced by an actual newline here for clarity: +;; reformatted for clarity: ["CREATE TABLE fruit ( id INT NOT NULL, name VARCHAR(32) NOT NULL, diff --git a/doc/special-syntax.md b/doc/special-syntax.md index 88e1a24..f75a6cf 100644 --- a/doc/special-syntax.md +++ b/doc/special-syntax.md @@ -206,7 +206,7 @@ Otherwise, these render as regular function calls: ```clojure [:foreign-key :a] ;=> FOREIGN KEY(a) -[:primary-key :x :y] ;=> PRIMARY KEY(x,y) +[:primary-key :x :y] ;=> PRIMARY KEY(x, y) ``` ## constraint, default, references @@ -233,7 +233,7 @@ These behave like the group above except that if the first argument is `nil`, it is omitted: ```clojure -[:index :foo :bar :quux] ;=> INDEX foo(bar,quux) -[:index nil :bar :quux] ;=> INDEX(bar,quux) +[:index :foo :bar :quux] ;=> INDEX foo(bar, quux) +[:index nil :bar :quux] ;=> INDEX(bar, quux) [:unique :a :b] ;=> UNIQUE a(b) ``` diff --git a/src/honey/sql.cljc b/src/honey/sql.cljc index a378d38..a6d223d 100644 --- a/src/honey/sql.cljc +++ b/src/honey/sql.cljc @@ -513,9 +513,9 @@ (cons id (map upper-case spec))))) (defn- format-table-columns [k xs] - [(str "(\n " - (str/join ",\n " (map #'format-single-column xs)) - "\n)")]) + [(str "(" + (str/join ", " (map #'format-single-column xs)) + ")")]) (defn- format-add-item [k spec] [(str (sql-kw k) " " (format-single-column spec))]) @@ -682,14 +682,14 @@ (defn- function-0 [k xs] [(str (sql-kw k) (when (seq xs) - (str "(" (str/join "," (map #'format-simple-expr xs)) ")")))]) + (str "(" (str/join ", " (map #'format-simple-expr xs)) ")")))]) (defn- function-1 [k xs] [(str (sql-kw k) (when (seq xs) (str " " (format-simple-expr (first xs)) (when-let [args (next xs)] - (str "(" (str/join "," (map #'format-simple-expr args)) ")")))))]) + (str "(" (str/join ", " (map #'format-simple-expr args)) ")")))))]) (defn- function-1-opt [k xs] [(str (sql-kw k) @@ -697,7 +697,7 @@ (str (when-let [e (first xs)] (str " " (format-simple-expr e))) (when-let [args (next xs)] - (str "(" (str/join "," (map #'format-simple-expr args)) ")")))))]) + (str "(" (str/join ", " (map #'format-simple-expr args)) ")")))))]) (def ^:private special-syntax (atom @@ -1007,6 +1007,7 @@ (format-expr :id) (format-expr 1) (format {:select [:a [:b :c] [[:d :e]] [[:f :g] :h]]}) + (format {:select [[[:d :e]] :a [:b :c]]}) (format-on-expr :where [:= :id 1]) (format-dsl {:select [:*] :from [:table] :where [:= :id 1]}) (format {:select [:t.*] :from [[:table :t]] :where [:= :id 1]} {}) diff --git a/src/honey/sql/helpers.cljc b/src/honey/sql/helpers.cljc index dd727a9..100382c 100644 --- a/src/honey/sql/helpers.cljc +++ b/src/honey/sql/helpers.cljc @@ -52,7 +52,15 @@ (defn drop-index [& args] (generic-1 :drop-index args)) (defn rename-table [& args] (generic :alter-table args)) (defn create-table [& args] (generic :create-table args)) -(defn with-columns [& args] (generic :with-columns args)) +(defn with-columns [& args] + ;; special case so (with-columns [[:col-1 :definition] [:col-2 :definition]]) + ;; also works in addition to (with-columns [:col-1 :definition] [:col-2 :definition]) + (cond (and (= 1 (count args)) (sequential? (first args)) (sequential? (ffirst args))) + (generic-1 :with-columns args) + (and (= 2 (count args)) (sequential? (second args)) (sequential? (fnext args))) + (generic-1 :with-columns args) + :else + (generic :with-columns args))) (defn create-view [& args] (generic-1 :create-view args)) (defn drop-table [& args] (generic :drop-table args)) (defn nest [& args] (generic :nest args)) @@ -105,7 +113,10 @@ ;; to make this easy to use in a select, wrap it so it becomes a function: (defn over [& args] [(into [:over] args)]) +;; helper to ease compatibility with former nilenso/honeysql-postgres code: +(defn upsert [data & clauses] (default-merge data clauses)) + #?(:clj (assert (= (clojure.core/set (conj @@#'h/base-clause-order - :composite :over)) + :composite :over :upsert)) (clojure.core/set (map keyword (keys (ns-publics *ns*))))))) diff --git a/test/honey/sql/helpers_test.cljc b/test/honey/sql/helpers_test.cljc index 34dc372..a5c251c 100644 --- a/test/honey/sql/helpers_test.cljc +++ b/test/honey/sql/helpers_test.cljc @@ -337,7 +337,7 @@ (is (= (sql/format {:create-table :films :with-columns [[:id :int :unsigned :auto-increment] [:name [:varchar 50] [:not nil]]]}) - ["CREATE TABLE films (\n id INT UNSIGNED AUTO_INCREMENT,\n name VARCHAR(50) NOT NULL\n)"])) + ["CREATE TABLE films (id INT UNSIGNED AUTO_INCREMENT, name VARCHAR(50) NOT NULL)"])) (is (= (sql/format (-> (create-view :metro) (select :*) (from :cities) @@ -347,22 +347,22 @@ (with-columns [:id :int :unsigned :auto-increment] [:name [:varchar 50] [:not nil]]))) - ["CREATE TABLE films (\n id INT UNSIGNED AUTO_INCREMENT,\n name VARCHAR(50) NOT NULL\n)"])) + ["CREATE TABLE films (id INT UNSIGNED AUTO_INCREMENT, name VARCHAR(50) NOT NULL)"])) (is (= (sql/format (-> (create-table :films :if-not-exists) (with-columns [:id :int :unsigned :auto-increment] [:name [:varchar 50] [:not nil]]))) - ["CREATE TABLE IF NOT EXISTS films (\n id INT UNSIGNED AUTO_INCREMENT,\n name VARCHAR(50) NOT NULL\n)"])) + ["CREATE TABLE IF NOT EXISTS films (id INT UNSIGNED AUTO_INCREMENT, name VARCHAR(50) NOT NULL)"])) (is (= (sql/format (-> {:create-table :films :with-columns [[:id :int :unsigned :auto-increment] [:name [:varchar 50] [:not nil]]]})) - ["CREATE TABLE films (\n id INT UNSIGNED AUTO_INCREMENT,\n name VARCHAR(50) NOT NULL\n)"])) + ["CREATE TABLE films (id INT UNSIGNED AUTO_INCREMENT, name VARCHAR(50) NOT NULL)"])) (is (= (sql/format (-> {:create-table [:films :if-not-exists] :with-columns [[:id :int :unsigned :auto-increment] [:name [:varchar 50] [:not nil]]]})) - ["CREATE TABLE IF NOT EXISTS films (\n id INT UNSIGNED AUTO_INCREMENT,\n name VARCHAR(50) NOT NULL\n)"])) + ["CREATE TABLE IF NOT EXISTS films (id INT UNSIGNED AUTO_INCREMENT, name VARCHAR(50) NOT NULL)"])) (is (= (sql/format {:drop-table :foo}) ["DROP TABLE foo"])) (is (= (sql/format {:drop-table [:if-exists :foo]}) diff --git a/test/honey/sql/postgres_test.cljc b/test/honey/sql/postgres_test.cljc new file mode 100644 index 0000000..c720eb8 --- /dev/null +++ b/test/honey/sql/postgres_test.cljc @@ -0,0 +1,304 @@ +;; copied from https://github.com/nilenso/honeysql-postgres +;; on 2021-02-13 to verify the completeness of support for +;; those features within HoneySQL v2 + +;; where there are differences, the original code is kept +;; with #_ and the modified code follows it (aside from +;; the ns form which has numerous changes to both match +;; the structure of HoneySQL v2 and to work with cljs) + +(ns honey.sql.postgres-test + (:refer-clojure :exclude [update partition-by set]) + (:require #?(:clj [clojure.test :refer [deftest is testing]] + :cljs [cljs.test :refer-macros [deftest is testing]]) + ;; pull in all the PostgreSQL helpers that the nilenso + ;; library provided (as well as the regular HoneySQL ones): + [honey.sql.helpers :as sqlh :refer + [upsert on-conflict do-nothing on-constraint + returning do-update-set + ;; not needed because do-update-set can do this directly + #_do-update-set! + alter-table rename-column drop-column + add-column partition-by + ;; not needed because insert-into can do this directly + #_insert-into-as + create-table rename-table drop-table + window create-view over with-columns + ;; temporarily disable until these are also implemented: + #_#_create-extension drop-extension + ;; already part of HoneySQL + insert-into values where select columns + from order-by update set]] + [honey.sql :as sql])) + +(deftest upsert-test + (testing "upsert sql generation for postgresql" + (is (= ["INSERT INTO distributors (did, dname) VALUES (?, ?), (?, ?) ON CONFLICT (did) DO UPDATE SET dname = EXCLUDED.dname RETURNING *" 5 "Gizmo Transglobal" 6 "Associated Computing, Inc"] + (-> (insert-into :distributors) + (values [{:did 5 :dname "Gizmo Transglobal"} + {:did 6 :dname "Associated Computing, Inc"}]) + (upsert (-> (on-conflict :did) + (do-update-set :dname))) + (returning :*) + sql/format))) + (is (= ["INSERT INTO distributors (did, dname) VALUES (?, ?) ON CONFLICT (did) DO NOTHING" 7 "Redline GmbH"] + (-> (insert-into :distributors) + (values [{:did 7 :dname "Redline GmbH"}]) + (upsert (-> (on-conflict :did) + do-nothing)) + sql/format))) + (is (= ["INSERT INTO distributors (did, dname) VALUES (?, ?) ON CONFLICT ON CONSTRAINT distributors_pkey DO NOTHING" 9 "Antwerp Design"] + (-> (insert-into :distributors) + (values [{:did 9 :dname "Antwerp Design"}]) + (upsert (-> #_(on-conflict-constraint :distributors_pkey) + (on-conflict (on-constraint :distributors_pkey)) + do-nothing)) + sql/format))) + (is (= ["INSERT INTO distributors (did, dname) VALUES (?, ?), (?, ?) ON CONFLICT (did) DO UPDATE SET dname = EXCLUDED.dname" 10 "Pinp Design" 11 "Foo Bar Works"] + (sql/format {:insert-into :distributors + :values [{:did 10 :dname "Pinp Design"} + {:did 11 :dname "Foo Bar Works"}] + :upsert {:on-conflict [:did] + :do-update-set [:dname]}}))) + (is (= ["INSERT INTO distributors (did, dname) VALUES (?, ?) ON CONFLICT (did) DO UPDATE SET dname = ?" 23 "Foo Distributors" " (formerly " ")"] + (-> (insert-into :distributors) + (values [{:did 23 :dname "Foo Distributors"}]) + (on-conflict :did) + #_(do-update-set! [:dname "EXCLUDED.dname || ' (formerly ' || d.dname || ')'"]) + (do-update-set {:dname [:|| :EXCLUDED.dname " (formerly " :d.dname ")"]}) + sql/format))) + (is (= ["INSERT INTO distributors (did, dname) (SELECT ?, ?) ON CONFLICT ON CONSTRAINT distributors_pkey DO NOTHING" 1 "whatever"] + (-> (insert-into :distributors) + (columns :did :dname) + (select 1 "whatever") + #_(query-values (select 1 "whatever")) + (upsert (-> (on-conflict (on-constraint :distributors_pkey)) + do-nothing)) + sql/format))))) + +(deftest upsert-where-test + (is (= ["INSERT INTO user (phone, name) VALUES (?, ?) ON CONFLICT (phone) WHERE phone IS NOT NULL DO UPDATE SET phone = EXCLUDED.phone, name = EXCLUDED.name WHERE user.active = FALSE" "5555555" "John"] + (sql/format + {:insert-into :user + :values [{:phone "5555555" :name "John"}] + :upsert {:on-conflict [:phone] + :where [:<> :phone nil] + :do-update-set {:fields [:phone :name] + :where [:= :user.active false]}}})))) + +(deftest returning-test + (testing "returning clause in sql generation for postgresql" + (is (= ["DELETE FROM distributors WHERE did > 10 RETURNING *"] + (sql/format {:delete-from :distributors + :where [:> :did :10] + :returning [:*]}))) + (is (= ["UPDATE distributors SET dname = ? WHERE did = 2 RETURNING did dname" "Foo Bar Designs"] + (-> (update :distributors) + (set {:dname "Foo Bar Designs"}) + (where [:= :did :2]) + (returning [:did :dname]) + sql/format))))) + +(deftest create-view-test + (testing "creating a view from a table" + (is (= ["CREATE VIEW metro AS SELECT * FROM cities WHERE metroflag = ?" "Y"] + (-> (create-view :metro) + (select :*) + (from :cities) + (where [:= :metroflag "Y"]) + sql/format))))) + +(deftest drop-table-test + (testing "drop table sql generation for a single table" + (is (= ["DROP TABLE cities"] + (sql/format (drop-table :cities))))) + (testing "drop table sql generation for multiple tables" + (is (= ["DROP TABLE cities, towns, vilages"] + (sql/format (drop-table :cities :towns :vilages)))))) + +(deftest create-table-test + (testing "create table with two columns" + (is (= ["CREATE TABLE cities (city VARCHAR(80) PRIMARY KEY, location POINT)"] + (-> (create-table :cities) + (with-columns [[:city [:varchar 80] [:primary-key]] + [:location :point]]) + sql/format)))) + (testing "create table with foreign key reference" + (is (= ["CREATE TABLE weather (city VARCHAR(80) REFERENCES CITIES(CITY), temp_lo INT, temp_hi INT, prcp REAL, date DATE)"] + (-> (create-table :weather) + (with-columns [[:city [:varchar :80] [:references :cities :city]] + [:temp_lo :int] + [:temp_hi :int] + [:prcp :real] + [:date :date]]) + sql/format)))) + (testing "creating table with table level constraint" + (is (= ["CREATE TABLE films (code CHAR(5), title VARCHAR(40), did INTEGER, date_prod DATE, kind VARCHAR(10), CONSTRAINT code_title PRIMARY KEY(CODE, TITLE))"] + (-> (create-table :films) + (with-columns [[:code [:char 5]] + [:title [:varchar 40]] + [:did :integer] + [:date_prod :date] + [:kind [:varchar 10]] + [[:constraint :code_title] [:primary-key :code :title]]]) + sql/format)))) + (testing "creating table with column level constraint" + (is (= ["CREATE TABLE films (code CHAR(5) CONSTRAINT firstkey PRIMARY KEY, title VARCHAR(40) NOT NULL, did INTEGER NOT NULL, date_prod DATE, kind VARCHAR(10))"] + (-> (create-table :films) + (with-columns [[:code [:char 5] [:constraint :firstkey] [:primary-key]] + [:title [:varchar 40] [:not nil]] + [:did :integer [:not nil]] + [:date_prod :date] + [:kind [:varchar 10]]]) + sql/format)))) + (testing "creating table with columns with default values" + (is (= ["CREATE TABLE distributors (did INTEGER PRIMARY KEY DEFAULT NEXTVAL('SERIAL'), name VARCHAR(40) NOT NULL)"] + (-> (create-table :distributors) + (with-columns [[:did :integer [:primary-key] [:default [:nextval "serial"]]] + [:name [:varchar 40] [:not nil]]]) + sql/format)))) + (testing "creating table with column checks" + (is (= ["CREATE TABLE products (product_no INTEGER, name TEXT, price NUMERIC CHECK(PRICE > 0), discounted_price NUMERIC, CHECK((discounted_price > 0) AND (price > discounted_price)))"] + (-> (create-table :products) + (with-columns [[:product_no :integer] + [:name :text] + [:price :numeric [:check [:> :price 0]]] + [:discounted_price :numeric] + [[:check [:and [:> :discounted_price 0] [:> :price :discounted_price]]]]]) + sql/format))))) + +(deftest over-test + (testing "window function over on select statemt" + (is (= ["SELECT id, AVG(salary) OVER (PARTITION BY department ORDER BY designation) AS Average, max(salary) OVER w AS MaxSalary FROM employee WINDOW w AS (PARTITION BY department)"] + (-> (select :id) + (over + [[:avg :salary] (-> (partition-by :department) (order-by [:designation])) :Average] + [[:max :salary] :w :MaxSalary]) + (from :employee) + (window :w (partition-by :department)) + sql/format))))) + +(deftest alter-table-test + (testing "alter table add column generates the required sql" + (is (= ["ALTER TABLE employees ADD COLUMN address text"] + (-> (alter-table :employees) + (add-column :address :text) + sql/format)))) + (testing "alter table drop column generates the required sql" + (is (= ["ALTER TABLE employees DROP COLUMN address"] + (-> (alter-table :employees) + (drop-column :address) + sql/format)))) + (testing "alter table rename column generates the requred sql" + (is (= ["ALTER TABLE employees RENAME COLUMN address TO homeaddress"] + (-> (alter-table :employees) + (rename-column :address :homeaddress) + sql/format)))) + (testing "alter table rename table generates the required sql" + (is (= ["ALTER TABLE employees RENAME TO managers"] + (-> (alter-table :employees) + (rename-table :managers) + sql/format))))) + +(deftest insert-into-with-alias + (testing "insert into with alias" + (is (= ["INSERT INTO distributors AS d (did, dname) VALUES (?, ?), (?, ?) ON CONFLICT (did) DO UPDATE SET dname = EXCLUDED.dname WHERE d.zipcode <> ? RETURNING d.*" 5 "Gizmo Transglobal" 6 "Associated Computing, Inc" "21201"] + (-> #_(insert-into-as :distributors :d) + (insert-into :distributors :d) + (values [{:did 5 :dname "Gizmo Transglobal"} + {:did 6 :dname "Associated Computing, Inc"}]) + (upsert (-> (on-conflict :did) + (do-update-set :dname) + (where [:<> :d.zipcode "21201"]))) + (returning :d.*) + sql/format))))) + +(deftest create-table-if-not-exists + (testing "create a table if not exists" + (is (= ["CREATE TABLE IF NOT EXISTS tablename"] + (-> (create-table :tablename :if-not-exists) + sql/format))))) + +(deftest drop-table-if-exists + (testing "drop a table if it exists" + (is (= ["DROP TABLE IF EXISTS t1, t2, t3"] + (-> (drop-table :if-exists :t1 :t2 :t3) + sql/format))))) + +(deftest select-where-ilike + (testing "select from table with ILIKE operator" + (is (= ["SELECT * FROM products WHERE name ILIKE ?" "%name%"] + (-> (select :*) + (from :products) + (where [:ilike :name "%name%"]) + sql/format))))) + +(deftest select-where-not-ilike + (testing "select from table with NOT ILIKE operator" + (is (= ["SELECT * FROM products WHERE name NOT ILIKE ?" "%name%"] + (-> (select :*) + (from :products) + (where [:not-ilike :name "%name%"]) + sql/format))))) + +(deftest values-except-select + (testing "select which values are not not present in a table" + (is (= ["VALUES (?), (?), (?) EXCEPT SELECT id FROM images" 4 5 6] + (sql/format + {:except + [{:values [[4] [5] [6]]} + {:select [:id] :from [:images]}]}))))) + +(deftest select-except-select + (testing "select which rows are not present in another table" + (is (= ["SELECT ip EXCEPT SELECT ip FROM ip_location"] + (sql/format + {:except + [{:select [:ip]} + {:select [:ip] :from [:ip_location]}]}))))) + +(deftest values-except-all-select + (testing "select which values are not not present in a table" + (is (= ["VALUES (?), (?), (?) EXCEPT ALL SELECT id FROM images" 4 5 6] + (sql/format + {:except-all + [{:values [[4] [5] [6]]} + {:select [:id] :from [:images]}]}))))) + +(deftest select-except-all-select + (testing "select which rows are not present in another table" + (is (= ["SELECT ip EXCEPT ALL SELECT ip FROM ip_location"] + (sql/format + {:except-all + [{:select [:ip]} + {:select [:ip] :from [:ip_location]}]}))))) + +(deftest select-distinct-on + (testing "select distinct on" + (is (= ["SELECT DISTINCT ON(\"a\", \"b\") \"c\" FROM \"products\""] + (-> (select [[:distinct-on :a :b]] :c) + (from :products) + (sql/format {:quoted true})) + #_(-> (select :c) + (from :products) + (modifiers :distinct-on :a :b) + (sql/format :quoting :ansi)))))) + +#_(deftest create-extension-test + (testing "create extension" + (is (= ["CREATE EXTENSION \"uuid-ossp\""] + (-> (create-extension :uuid-ossp) + (sql/format :allow-dashed-names? true + :quoting :ansi))))) + (testing "create extension if not exists" + (is (= ["CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\""] + (-> (create-extension :uuid-ossp :if-not-exists? true) + (sql/format :allow-dashed-names? true + :quoting :ansi)))))) + +#_(deftest drop-extension-test + (testing "create extension" + (is (= ["DROP EXTENSION \"uuid-ossp\""] + (-> (drop-extension :uuid-ossp) + (sql/format :allow-dashed-names? true + :quoting :ansi))))))