├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── doc ├── records.md └── specs.md ├── include └── mekao.hrl ├── rebar.lock ├── src ├── mekao.app.src ├── mekao.erl └── mekao_utils.erl └── test └── mekao_tests.erl /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.dump 3 | *.plt 4 | ebin/ 5 | deps/ 6 | test/*.beam 7 | _build/ 8 | rebar3.crashdump 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: erlang 3 | otp_release: 4 | - 19.0 5 | - R16B03-1 6 | branches: 7 | only: 8 | - master 9 | - devel 10 | install: 11 | - wget https://s3.amazonaws.com/rebar3/rebar3 -O rebar3 12 | - chmod u+x rebar3 13 | script: ./rebar3 do dialyzer,eunit 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2016 Daniil Churikov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mekao 2 | This library will help you to construct SQL queries. It have no other 3 | facilities: no pools, no caching, no RDBMS specific code. 4 | Main assumption is that records are used to represent DB data. 5 | 6 | [![Build Status](https://secure.travis-ci.org/ddosia/mekao.png?branch=master)](http://travis-ci.org/ddosia/mekao) 7 | 8 | 9 | # ToC 10 | 1. [Thesis](#thesis) 11 | 2. [Basic usage](#usage) 12 | 3. [Install](#install) 13 | 4. [Records](#records) 14 | 5. [Selectors](#selectors) 15 | 6. [Specs](doc/specs.md) 16 | 17 | 18 | # Thesis 19 | SQL is complex language. There are variety of weird cases when you will be 20 | not satisfied with query, generated by any tool. Sophisticated tools aimed 21 | to hide this complexity and eventually became as complex as SQL itself and even 22 | more: they substitute well defined SQL by vague DSL, add caching layers, do 23 | fancy type conversions. 24 | 25 | Goals of this library: 26 | * to cover most basic cases; 27 | * to give you an ability to adjust generated query; 28 | * to be embeddable into other libraries; 29 | * to be vendor agnostic(generated SQL is good for any RDBMS, but could be tuned to 30 | match yours). 31 | 32 | 33 | # Usage 34 | Suppose that we have table `books` in our SQL db: 35 | 36 | | Column | Type | Attributes | 37 | |-----------|-----------|-----------------------------| 38 | | id | int | Primary key, read only | 39 | | isbn | varchar | | 40 | | title | varchar | | 41 | | author | varchar | | 42 | | created | timestamp | Read-only | 43 | 44 | To begin to use *mekao* you'll need couple of things: 45 | * make a record with same fields as in SQL table you are interested in; 46 | * describe table in terms of *mekao*; 47 | * write some general settings for all queries (like how exactly LIMIT queries 48 | are constructed for your RDBMS dialect, or how placeholders are looks like for 49 | you DB driver). 50 | 51 | ### make a record 52 | ```erlang 53 | -record(book, {id, isbn, title, author, created}). 54 | ``` 55 | 56 | 57 | ### describe a table 58 | ```erlang 59 | -include_lib("mekao/include/mekao.hrl"). 60 | 61 | -define(TABLE_BOOKS, #mekao_table{ 62 | name = <<"books">>, 63 | columns = [ 64 | #mekao_column{name = <<"id">>, type = int, key = true, ro = true}, 65 | #mekao_column{name = <<"isbn">>, type = varchar}, 66 | #mekao_column{name = <<"title">>, type = varchar}, 67 | #mekao_column{name = <<"author">>, type = varchar}, 68 | #mekao_column{name = <<"created">>, type = datetime, ro = true} 69 | ] 70 | }). 71 | ``` 72 | Pay attention: each field in `#mekao_table{}` must be at the same position as 73 | corresponding field in `#book{}` (like `title` column have 3rd position in both 74 | records). 75 | 76 | 77 | ### write general settings 78 | ```erlang 79 | -define(S, #mekao_settings{ 80 | %% our placeholders will look like: 81 | %% ... WHERE id = $1 AND isbn = $2 82 | placeholder = fun (_, Pos, _) -> [$$ | integer_to_list(Pos)] end 83 | }). 84 | ``` 85 | 86 | 87 | ### glimpse of usage 88 | 89 | ```erlang 90 | fetch_book(SelectBook) -> 91 | {ok, #mekao_query{ 92 | body = Q, types = Types, values = Vals 93 | }} = mekao:select(SelectBook, ?TABLE_BOOKS, ?S), 94 | {iolist_to_binary(Q), Types, Vals}. 95 | 96 | update_book(SetBook, WhereBook) -> 97 | {ok, #mekao_query{ 98 | body = Q, types = Types, values = Vals 99 | }} = mekao:update(SetBook, WhereBook, ?TABLE_BOOKS, ?S), 100 | {iolist_to_binary(Q), Types, Vals}. 101 | 102 | %%... snip ... 103 | 104 | {<<"SELECT id, isbn, title, author, created FROM books WHERE id = $1">>, 105 | [int], [1] 106 | } = fetch_book(#book{id = 1, _ = '$skip'}), 107 | 108 | {<<"SELECT id, isbn, title, author, created FROM books" 109 | " WHERE author LIKE $1">>, [varchar], [<<"%Joe%">>] 110 | } = fetch_book( 111 | #book{author = {'$predicate', like, <<"%Joe%">>}, _ = '$skip'} 112 | ), 113 | 114 | {<<"UPDATE books SET author = $1 WHERE id IS NULL">>, 115 | [varchar], [<<"Joe">>] 116 | } = update_book( 117 | #book{author = <<"Joe">>, _ = '$skip'}, %% SET clause 118 | #book{id = undefined, _ = '$skip'} %% WHERE clause 119 | ), 120 | 121 | %%... snip ... 122 | ``` 123 | 124 | You definitely noticed `'$skip'` atom. When you construct record 125 | like this, every other field will have `'$skip'` as a value: 126 | ```erlang 127 | 1> #book{id = 1, _ = '$skip'}. 128 | #book{id = 1, isbn = '$skip', title = '$skip', 129 | author = '$skip', created = '$skip'} 130 | ``` 131 | This instructs *mekao* that you don't want to include other fields in query. 132 | 133 | You may wonder about `iolist_to_binary/1` trick. All queries generated by 134 | *mekao* have a type `iodata()`. This means there could be mixed strings, 135 | binaries, chars, nested lists of strings and so on. Some drivers do accept 136 | `iodata()`, others do not. This made in the sake of performance, it is up to 137 | application to convert this to any acceptable form. 138 | 139 | Placeholders `$1` and `$2` were generated with help of user-defined 140 | `#mekao_settings.placeholder` function. 141 | 142 | If you want to extend resulted query use `mekao:prepare_*` set of queries 143 | instead. 144 | 145 | For more examples please see [test/mekao_tests.erl](test/mekao_tests.erl). 146 | 147 | # Install 148 | Add this to `rebar.config` 149 | ```erlang 150 | {deps, [ 151 | {mekao, {git, "git://github.com/ddosia/mekao.git", {branch, "v0"}}} 152 | ]}. 153 | 154 | ``` 155 | 156 | Alternatively use [hex](https://hex.pm/packages/mekao). 157 | 158 | Project follows [SemVer](http://semver.org) versioning conventions. Backward 159 | incompatible changes will result in a new branch, named after *MAJOR* version, 160 | i.e. *v0*, *v1*, *v2* and so on. Make sure that your project depends 161 | on particular branch and not on master. 162 | 163 | 164 | # Records 165 | * [#mekao_settings{}](doc/records.md#mekao_settings) 166 | * [#mekao_table{}](doc/records.md#mekao_table) 167 | * [#mekao_column{}](doc/records.md#mekao_column) 168 | 169 | 170 | # Selectors 171 | Selectors is a way to adjust `WHERE` clause. When you pass record to *mekao* 172 | each field may contain regular value, or special predicate term. 173 | 174 | | SQL | predicate | 175 | | --------- | --------------------------------------------- | 176 | | `=` | `{'$predicate', '=', term()}` | 177 | | `<>` | `{'$predicate', '<>', term()}` | 178 | | `>` | `{'$predicate', '>', term()}` | 179 | | `>=` | `{'$predicate', '>=', term()}` | 180 | | `<` | `{'$predicate', '<', term()}` | 181 | | `<=` | `{'$predicate', '<=', term()}` | 182 | | `LIKE` | `{'$predicate', like, term()}` | 183 | | `BETWEEN` | `{'$predicate', 'between', term(), term()}` | 184 | | `IN` | `{'$predicate', in, [term(), ...]}` | 185 | | `NOT` | `{'$predicate', not, predicate()}` | 186 | 187 | Example: 188 | ```erlang 189 | DT1 = {{2013, 1, 1}, {0, 0, 0}}, 190 | DT2 = {{2014, 1, 1}, {0, 0, 0}}, 191 | 192 | mekao:select( 193 | #book{ 194 | created = {'$predicate', between, DT1, DT2}, 195 | _ = '$skip' 196 | }, ?TABLE_BOOKS, ?S 197 | ). 198 | %% SELECT id, isbn, title, author, created FROM books 199 | %% WHERE created BETWEEN $1 AND $2 200 | ``` 201 | 202 | see `mekao:selector()` type spec. 203 | -------------------------------------------------------------------------------- /doc/records.md: -------------------------------------------------------------------------------- 1 | # ToC 2 | 1. [#mekao_settings{}](#mekao_settings) 3 | 2. [#mekao_table{}](#mekao_table) 4 | 3. [#mekao_column{}](#mekao_column) 5 | 6 | 7 | ## #mekao_settings{} 8 | This record contains general setting. 9 | ```erlang 10 | -record(mekao_settings, { 11 | placeholder :: fun( ( mekao:column() 12 | , Num :: non_neg_integer() 13 | , Val :: term() 14 | ) -> iodata()), 15 | 16 | limit :: undefined 17 | | fun( ( mekao:'query'(#mekao_select{}) 18 | , RowCount :: non_neg_integer() 19 | , Offset :: non_neg_integer() 20 | ) -> mekao:'query'(#mekao_select{})), 21 | 22 | returning :: undefined 23 | | fun(( insert | update | delete, mekao:table()) -> iodata()), 24 | 25 | is_null = fun mekao_utils:is_null/1 :: fun((Value :: term()) -> boolean()) 26 | }). 27 | ``` 28 | 29 | ### placeholder 30 | Produce WHERE clause placeholders. 31 | 32 | Example: 33 | ```erlang 34 | #mekao_settings{ 35 | placeholder = fun (_, Pos, _) -> [$$ | integer_to_list(Pos)] end 36 | }. %% <<"... WHERE field1 = $1, field2 = $2...">> 37 | ``` 38 | See [#mekao_column{}](#mekao_column) 39 | 40 | ### limit 41 | If defined will be used in select functions to limit result. 42 | 43 | Example: 44 | ```erlang 45 | #mekao_settings{ 46 | limit = fun 47 | (Q, RowCount, Offset) -> 48 | #mekao_query{ 49 | body = #mekao_select{where = Where} = QBody, 50 | types = Types, values = Vals, next_ph_num = Num 51 | } = Q, 52 | Q#mekao_query{ 53 | body = QBody#mekao_select{ 54 | where = {<<"">>, Where, [ 55 | <<" LIMIT ">>, mk_ph(Num), 56 | <<" OFFSET ">>, mk_ph(Num + 1) 57 | ]} 58 | }, 59 | types = Types ++ [int, int], 60 | values = Vals ++ [RowCount, Offset], 61 | next_ph_num = Num + 2 62 | } 63 | end 64 | }. 65 | ``` 66 | 67 | ### returning 68 | Produce RETURNING clause for INSERT, UPDATE or DELETE queries. 69 | 70 | Example: 71 | ```erlang 72 | #mekao_settings{ 73 | returning = fun (_, _) -> <<"RETURNING id">> end 74 | }. %% <<"INSERT INTO... RETURNING id">> 75 | ``` 76 | 77 | ### is_null 78 | Check value and return true if it `IS NULL`, false otherwise. Is used to 79 | compose WHERE clause and produce `WHERE column IS NULL` instead of 80 | `WHERE column = NULL`. 81 | 82 | Example: 83 | ```erlang 84 | #mekao_settings{ 85 | is_null = 86 | fun (undefined) -> true 87 | (null) -> true 88 | (_) -> false 89 | end 90 | }. 91 | ``` 92 | 93 | ## #mekao_table{} 94 | This record describes SQL table. 95 | ```erlang 96 | -record(mekao_table, { 97 | name :: iodata(), 98 | columns = [] :: [ mekao:column() 99 | % entity record's field on the same pos is out of 100 | % interest 101 | | '$skip' ], 102 | %% order by column position or by arbitrary expression 103 | order_by = [] :: [ non_neg_integer() % record's field pos 104 | | iodata() % arbitrary expression 105 | | { non_neg_integer() | iodata() 106 | , { asc | desc | default 107 | , nulls_first | nulls_last | default} 108 | } 109 | ] 110 | }). 111 | ``` 112 | 113 | ### name 114 | Table name 115 | 116 | Example: 117 | ```erlang 118 | #mekao_table{ 119 | name = <<"books">> 120 | }. 121 | ``` 122 | 123 | ### columns 124 | List of table columns. Order matters. Each column position must be the same 125 | as a corresponding record's field. 126 | 127 | Example: 128 | ```erlang 129 | -record(book, {id, isbn, not_db_field}). 130 | 131 | #mekao_table{ 132 | columns = [ 133 | #mekao_column{name = <<"id">>}, %% first position, same as #book.id 134 | #mekao_column{name = <<"isbn">>} %% second position, same as #book.isbn 135 | '$skip' 136 | ] 137 | }. 138 | ``` 139 | The last column `#book.not_db_field` illustrating possibility of having some 140 | record's fields, which are not represented by any particular SQL table's 141 | column, but serves just for internal usage. 142 | 143 | See [#mekao_column{}](#mekao_column) 144 | 145 | ### order_by 146 | List of positions and/or arbitrary expressions to sort result of select queries. 147 | 148 | Example: 149 | ```erlang 150 | -record(book, {id, isbn}). 151 | 152 | #mekao_table{ 153 | order_by = [#book.isbn, <<"isbn">>] 154 | }. 155 | %% SELECT id, isbn FROM ... ORDER BY 2, isbn 156 | %% although you could see that `isbn` have `2` position in SELECT statement, 157 | %% #mekao_table.order_by expects number `3` (`#book.isbn = 3`). 158 | ``` 159 | 160 | ## #mekao_column{} 161 | This record is intended to describe particular column. 162 | ```erlang 163 | -record(mekao_column, { 164 | name :: iodata(), %% sql column name 165 | type :: term(), %% sql datatype, acceptable by underlying 166 | %% driver 167 | key = false :: boolean(), %% primary key part 168 | ro = false :: boolean(), %% readonly 169 | transform :: undefined 170 | | fun ((Val :: term()) -> NewVal :: term()) 171 | }). 172 | ``` 173 | 174 | ### name 175 | Column name as in SQL table. 176 | 177 | Example: 178 | ```erlang 179 | #mekao_column{ 180 | name = <<"author">> 181 | }. 182 | ``` 183 | 184 | ### type 185 | Column type. Could be used later by underlying db driver. 186 | 187 | Example: 188 | ```erlang 189 | #mekao_column{ 190 | type = datetime 191 | }. 192 | ``` 193 | 194 | ### key 195 | True if this is a part of primary key, false otherwise. 196 | 197 | Have special meaning for some operations suffixed whit `_pk` 198 | (i.e. `mekao:select_pk/3`). 199 | 200 | ### ro 201 | True if this field is read only. 202 | 203 | Read only fields will be skipped during inserts and updates. 204 | 205 | ### transform 206 | Sometimes values should be grained before we feed it to db driver. 207 | 208 | Example: 209 | ```erlang 210 | #mekao_column{ 211 | name = <<"mamal_type">> 212 | type = int, 213 | transform = 214 | fun (human) -> 0; 215 | (dog) -> 1; 216 | (cat) -> 2; 217 | (whale) -> 65535 218 | end 219 | }. 220 | ``` 221 | -------------------------------------------------------------------------------- /doc/specs.md: -------------------------------------------------------------------------------- 1 | # Specs 2 | 3 | This document tries to describe how to use specs with *mekao*. 4 | 5 | Several times I heard similar question regarded `ets:select/2`: I have a record 6 | but *dialyzer* complains when I put atom `'_'` as a field's value, what should I 7 | do? The same problem applicable to *mekao* usage — some of the values have 8 | special meaning (i.e. `'$skip'` or `{'$predicate, ...'}`). 9 | 10 | Typical record may look like this: 11 | 12 | ```erlang 13 | -record(book, { 14 | id :: pos_integer(), 15 | isbn :: binary(), 16 | title :: binary(), 17 | author :: binary(), 18 | created :: non_neg_integer() 19 | }). 20 | ``` 21 | When we want to select a records by it's author we could write something like 22 | this: 23 | 24 | ```erlang 25 | mekao:select(#book{author = <<"Joe">>, _ = '$skip'}, Table, Settings). 26 | ``` 27 | That is direct violation of the spec, because non of the fields of the `#book{}` 28 | were allowed to put the `'$skip'` atom as a value. 29 | 30 | One of the possible solutions may be just to add this and other special terms 31 | to the spec like this: 32 | 33 | ```erlang 34 | -record(book, { 35 | id :: '$skip' | pos_integer(), 36 | isbn :: '$skip' | binary(), 37 | title :: '$skip' | binary(), 38 | author :: '$skip' | binary(), 39 | created :: '$skip' | non_neg_integer() 40 | }). 41 | ``` 42 | But I suggest not to do this and here is why. I found that regular code uses 43 | special values only when in need to communicate with DB, in other cases it tend 44 | to work with ordinary record values. And each such communication returns 45 | ordinary record too. So I use to divide specs into 3 groups like this: 46 | 47 | ```erlang 48 | %% record without the specs 49 | -record(book, {id, isbn, title, author, created}). 50 | 51 | %% parametrized typespec for the #book{} 52 | -type book(E) :: #book{ 53 | id :: E | pos_integer(), 54 | isbn :: E | binary(), 55 | title :: E | binary(), 56 | author :: E | binary(), 57 | created :: E | non_neg_integer() 58 | }. 59 | 60 | %% typespec for regular usage 61 | -type book() :: book(none()). 62 | 63 | %% typespec for inserts and updates(set clause) 64 | -type book_inserter() :: book('$skip'). 65 | 66 | %% typespec for selects, deletes and updates(where clause) 67 | -type book_selector() :: #book{ 68 | id :: '$skip' | mekao:predicate(pos_integer()), 69 | isbn :: '$skip' | mekao:predicate(binary()), 70 | title :: '$skip' | mekao:predicate(binary()), 71 | author :: '$skip' | mekao:predicate(binary()), 72 | created :: '$skip' | mekao:predicate(non_neg_integer()) 73 | }. 74 | ``` 75 | 76 | From now on you can safely create record instance with any value, but when you 77 | will try to pass it around, function with corresponding spec will warn you about 78 | violation. 79 | 80 | If this seems like overkill to you feel free to contact me and discuss any other 81 | ideas. 82 | -------------------------------------------------------------------------------- /include/mekao.hrl: -------------------------------------------------------------------------------- 1 | -record(mekao_query, { 2 | body, 3 | types = [] :: [term()], 4 | values = [] :: [term()], 5 | next_ph_num = 1 :: non_neg_integer() 6 | }). 7 | 8 | -record(mekao_select, { 9 | columns :: mekao:iotriple(), 10 | table :: mekao:iotriple(), 11 | where :: mekao:iotriple(), 12 | order_by :: mekao:iotriple() 13 | }). 14 | 15 | -record(mekao_insert, { 16 | table :: mekao:iotriple(), 17 | columns :: mekao:iotriple(), 18 | values :: mekao:iotriple(), 19 | returning :: mekao:iotriple() 20 | }). 21 | 22 | -record(mekao_update, { 23 | table :: mekao:iotriple(), 24 | set :: mekao:iotriple(), 25 | where :: mekao:iotriple(), 26 | returning :: mekao:iotriple() 27 | }). 28 | 29 | -record(mekao_delete, { 30 | table :: mekao:iotriple(), 31 | where :: mekao:iotriple(), 32 | returning :: mekao:iotriple() 33 | }). 34 | 35 | -record(mekao_column, { 36 | name :: iodata(), %% sql column name 37 | type :: term(), %% sql datatype, acceptable by underlying 38 | %% driver 39 | key = false :: boolean(), %% primary key part 40 | ro = false :: boolean(), %% readonly 41 | transform :: undefined 42 | | fun ((Val :: term()) -> NewVal :: term()) 43 | }). 44 | 45 | -record(mekao_table, { 46 | name :: iodata(), 47 | columns = [] :: [ mekao:column() 48 | % entity record's field on the same pos is out of 49 | % interest 50 | | '$skip' ], 51 | %% order by column position or by arbitrary expression 52 | order_by = [] :: [ non_neg_integer() % record's field pos 53 | | iodata() % arbitrary expression 54 | | { non_neg_integer() | iodata() 55 | , { asc | desc | default 56 | , nulls_first | nulls_last | default} 57 | } 58 | ] 59 | }). 60 | 61 | -record(mekao_settings, { 62 | placeholder :: fun( ( mekao:column() 63 | , Num :: non_neg_integer() 64 | , Val :: term() 65 | ) -> iodata()), 66 | 67 | limit :: undefined 68 | | fun( ( mekao:'query'(#mekao_select{}) 69 | , RowCount :: non_neg_integer() 70 | , Offset :: non_neg_integer() 71 | ) -> mekao:'query'(#mekao_select{})), 72 | 73 | returning :: undefined 74 | | fun(( insert | update | delete, mekao:table()) -> iodata()), 75 | 76 | is_null = fun mekao_utils:is_null/1 :: fun((Value :: term()) -> boolean()) 77 | }). 78 | -------------------------------------------------------------------------------- /rebar.lock: -------------------------------------------------------------------------------- 1 | []. 2 | -------------------------------------------------------------------------------- /src/mekao.app.src: -------------------------------------------------------------------------------- 1 | {application, mekao, 2 | [ 3 | {description, "Simple SQL builder"}, 4 | {vsn, "0.5.0"}, 5 | {maintainers, ["Daniil Churikov "]}, 6 | {licenses, ["MIT"]}, 7 | {links, [{"Github", "https://github.com/ddosia/mekao"}]}, 8 | {registered, []}, 9 | {modules, []}, 10 | {env, []}, 11 | {applications, [ kernel, stdlib ]} 12 | ]}. 13 | -------------------------------------------------------------------------------- /src/mekao.erl: -------------------------------------------------------------------------------- 1 | -module(mekao). 2 | 3 | %% API 4 | -export([ 5 | select_pk/3, select/3, select/4, 6 | exists_pk/3, exists/3, 7 | count/3, 8 | insert/3, insert_all/3, 9 | update_pk/3, 10 | update_pk_diff/4, 11 | update/4, 12 | delete_pk/3, 13 | delete/3, 14 | 15 | prepare_select/3, prepare_select/4, 16 | prepare_exists/3, prepare_count/3, 17 | prepare_insert/3, prepare_insert_all/3, 18 | prepare_update/4, 19 | prepare_delete/3, 20 | build/1 21 | ]). 22 | 23 | -include("mekao.hrl"). 24 | 25 | -type iotriple() :: iodata() | {iodata(), iodata(), iodata()}. 26 | 27 | -type table() :: #mekao_table{}. 28 | -type column() :: #mekao_column{}. 29 | -type s() :: #mekao_settings{}. 30 | 31 | -type predicate(Val) :: Val 32 | | { '$predicate', between, Val, Val } 33 | | { '$predicate', in, [Val, ...] } 34 | | { '$predicate' 35 | , '=' | '<>' | '>' | '>=' | '<' | '<=' | like 36 | , Val 37 | } 38 | | { '$predicate', 'not', predicate(Val) }. 39 | 40 | -type select_opt() :: {limit, { RowCount :: non_neg_integer() 41 | , Offset :: non_neg_integer()}}. 42 | 43 | %% generic query 44 | -type 'query'(Body) :: #mekao_query{body :: Body}. 45 | 46 | %% prepared query 47 | -type p_query() :: 'query'( #mekao_insert{} 48 | | #mekao_select{} 49 | | #mekao_update{} 50 | | #mekao_delete{} 51 | ). 52 | %% built query 53 | -type b_query() :: 'query'(iolist()). 54 | 55 | -export_type([ 56 | iotriple/0, 57 | table/0, column/0, s/0, 58 | predicate/1, 59 | 'query'/1, p_query/0, b_query/0 60 | ]). 61 | 62 | 63 | %% not much sense in terms of analysis, just in doc purposes 64 | -type entity() :: tuple() | list(term() | '$skip'). 65 | -type selector() :: tuple() | list(predicate(term()) | '$skip'). 66 | 67 | %% =================================================================== 68 | %% API functions 69 | %% =================================================================== 70 | 71 | -spec insert( entity(), table(), s() 72 | ) -> {ok, b_query()} | {error, empty_insert}. 73 | %% @doc Inserts entity, omits columns with `$skip' value. 74 | insert(E, Table, S) -> 75 | Q = prepare_insert( 76 | skip(fun skip_ro_or_skip/2, Table#mekao_table.columns, e2l(E)), 77 | Table, S 78 | ), 79 | 80 | if Q#mekao_query.values /= [] -> 81 | {ok, build(Q)}; 82 | true -> 83 | {error, empty_insert} 84 | end. 85 | 86 | 87 | -spec insert_all([entity(), ...], table(), s()) -> {ok, b_query()}. 88 | %% @doc Inserts entities, places `DEFAULT' keyword when column with `$skip' 89 | %% value occurs. 90 | insert_all(Es = [_ | _], Table, S) -> 91 | Q = prepare_insert_all( 92 | [skip(fun skip_ro_or_skip/2, Table#mekao_table.columns, e2l(E)) 93 | || E <- Es], 94 | Table, S 95 | ), 96 | {ok, build(Q)}. 97 | 98 | 99 | -spec select_pk(selector(), table(), s()) -> {ok, b_query()} 100 | | {error, pk_miss}. 101 | %% @doc Reads entity by it's primary key. 102 | select_pk(E, Table, S) -> 103 | Q = prepare_select( 104 | skip(fun skip_not_pk/2, Table#mekao_table.columns, e2l(E)), 105 | Table, S 106 | ), 107 | if (Q#mekao_query.body)#mekao_select.where /= [] -> 108 | {ok, build(Q)}; 109 | true -> 110 | {error, pk_miss} 111 | end. 112 | 113 | 114 | -spec select(selector(), table(), s()) -> {ok, b_query()}. 115 | %% @doc Selects several entities, omits columns with `$skip' value. 116 | select(E, Table, S) -> 117 | select(E, [], Table, S). 118 | 119 | -spec select(selector(), [select_opt()], table(), s()) -> {ok, b_query()}. 120 | select(E, Opts, Table, S) -> 121 | {ok, build(prepare_select(E, Opts, Table, S))}. 122 | 123 | 124 | -spec exists_pk(selector(), table(), s()) -> {ok, b_query()} 125 | | {error, pk_miss}. 126 | %% @doc Same as `exists/3' but checks only primary key in `EXISTS' clause. 127 | exists_pk(E, Table, S) -> 128 | Q = prepare_exists( 129 | skip(fun skip_not_pk/2, Table#mekao_table.columns, e2l(E)), 130 | Table, S 131 | ), 132 | if (Q#mekao_query.body)#mekao_select.where /= [] -> 133 | {ok, build(Q)}; 134 | true -> 135 | {error, pk_miss} 136 | end. 137 | 138 | 139 | -spec exists(selector(), table(), s()) -> {ok, b_query()}. 140 | %% @doc Selects one column called `exists' with value `1' or `0', depends on 141 | %% `EXISTS' clause return. 142 | exists(E, Table, S) -> 143 | {ok, build(prepare_exists(E, Table, S))}. 144 | 145 | 146 | -spec count(selector(), table(), s()) -> {ok, b_query()}. 147 | %% @doc Selects `COUNT(*)' with `WHERE' clause based on `selector()'. 148 | count(E, Table, S) -> 149 | {ok, build(prepare_count(E, Table, S))}. 150 | 151 | 152 | -spec update_pk(selector(), table(), s()) -> {ok, b_query()} 153 | | {error, pk_miss} 154 | | {error, empty_update}. 155 | %% @doc Updates entity by it's primary key, omits columns with `$skip' value. 156 | update_pk(E, Table = #mekao_table{columns = MekaoCols}, S) -> 157 | 158 | SetSkipFun = 159 | fun(#mekao_column{ro = RO, key = Key}, V) -> 160 | RO orelse V == '$skip' orelse Key 161 | end, 162 | 163 | Vals = e2l(E), 164 | Q = prepare_update( 165 | skip(SetSkipFun, MekaoCols, Vals), 166 | skip(fun skip_not_pk/2, MekaoCols, Vals), 167 | Table, S 168 | ), 169 | if (Q#mekao_query.body)#mekao_update.set == [] -> 170 | {error, empty_update}; 171 | (Q#mekao_query.body)#mekao_update.where == [] -> 172 | {error, pk_miss}; 173 | true -> 174 | {ok, build(Q)} 175 | end. 176 | 177 | 178 | -spec update_pk_diff( Old :: entity(), New :: entity(), table(), s() 179 | ) -> {ok, b_query()} 180 | | {error, pk_miss} 181 | | {error, empty_update}. 182 | %% @doc Updates only changed fields by primary key. This is possible to update 183 | %% PK as well if it is not `ro = true'. 184 | update_pk_diff(E1, E2, Table = #mekao_table{columns = MekaoCols}, S) -> 185 | Vals1 = e2l(E1), 186 | Vals2 = e2l(E2), 187 | DiffVals = mekao_utils:map2( 188 | fun 189 | (V, V) -> '$skip'; 190 | (_, V2) -> V2 191 | end, Vals1, Vals2 192 | ), 193 | 194 | Q = prepare_update( 195 | skip(fun skip_ro_or_skip/2, MekaoCols, DiffVals), 196 | skip(fun skip_not_pk/2, MekaoCols, Vals1), 197 | Table, S 198 | ), 199 | 200 | if (Q#mekao_query.body)#mekao_update.set == [] -> 201 | {error, empty_update}; 202 | (Q#mekao_query.body)#mekao_update.where == [] -> 203 | {error, pk_miss}; 204 | true -> 205 | {ok, build(Q)} 206 | end. 207 | 208 | 209 | -spec update(entity(), selector(), table(), s()) -> {ok, b_query()} 210 | | {error, empty_update}. 211 | %% @doc Updates entities, composes WHERE clause from `Selector' 212 | %% non `$skip' fields. This is possible to update PK as well if it 213 | %% is not `ro = true'. 214 | update(E, Selector, Table = #mekao_table{columns = MekaoCols}, S) -> 215 | Q = prepare_update( 216 | skip(fun skip_ro_or_skip/2, MekaoCols, e2l(E)), 217 | Selector, Table, S 218 | ), 219 | if (Q#mekao_query.body)#mekao_update.set == [] -> 220 | {error, empty_update}; 221 | true -> 222 | {ok, build(Q)} 223 | end. 224 | 225 | 226 | -spec delete_pk(selector(), table(), s()) -> {ok, b_query()} | {error, pk_miss}. 227 | %% @doc Deletes entity by primary key. 228 | delete_pk(E, Table, S) -> 229 | Q = prepare_delete( 230 | skip(fun skip_not_pk/2, Table#mekao_table.columns, e2l(E)), 231 | Table, S 232 | ), 233 | if (Q#mekao_query.body)#mekao_delete.where /= [] -> 234 | {ok, build(Q)}; 235 | true -> 236 | {error, pk_miss} 237 | end. 238 | 239 | 240 | -spec delete(selector(), table(), s()) -> {ok, b_query()}. 241 | %% @doc Deletes entities, composes WHERE clause from `Selector' 242 | %% non `$skip' fields. 243 | delete(Selector, Table, S) -> 244 | {ok, build(prepare_delete(Selector, Table, S))}. 245 | 246 | 247 | -spec prepare_insert(entity(), table(), s()) -> p_query(). 248 | prepare_insert(E, Table, S) -> 249 | {NextNum, {Cols, PHs, Types, Vals}} = 250 | qdata(1, e2l(E), Table#mekao_table.columns, S), 251 | Q = #mekao_insert{ 252 | table = Table#mekao_table.name, 253 | columns = mekao_utils:intersperse(Cols, <<", ">>), 254 | values = [<<"(">>, mekao_utils:intersperse(PHs, <<", ">>), <<")">>], 255 | returning = returning(insert, Table, S) 256 | }, 257 | #mekao_query{ 258 | body = Q, 259 | types = Types, 260 | values = Vals, 261 | next_ph_num = NextNum 262 | }. 263 | 264 | 265 | -spec prepare_insert_all([entity(), ...], table(), s()) -> p_query(). 266 | prepare_insert_all(Es = [_ | _], Table, S) -> 267 | MekaoCols = Table#mekao_table.columns, 268 | {ResNum, {RevPHs, RevTypes, RevVals}} = 269 | lists:foldl( 270 | fun(E, {AccNum, {AccPHs, AccTypes, AccVals}}) -> 271 | {NextNum, {PHs, Types, Vals}} = 272 | qdata_insert(AccNum, e2l(E), MekaoCols, S), 273 | {NextNum, { 274 | [PHs | AccPHs], 275 | lists:reverse(Types) ++ AccTypes, 276 | lists:reverse(Vals) ++ AccVals 277 | }} 278 | end, {1, {[], [], []}}, Es 279 | ), 280 | 281 | Q = #mekao_insert{ 282 | table = Table#mekao_table.name, 283 | columns = intersperse_sel_cols(MekaoCols), 284 | values = 285 | mekao_utils:intersperse( 286 | lists:reverse(RevPHs), <<", ">>, 287 | fun(PHs) -> 288 | [<<"(">>, mekao_utils:intersperse(PHs, <<", ">>), <<")">>] 289 | end 290 | ), 291 | returning = returning(insert, Table, S) 292 | }, 293 | #mekao_query{ 294 | body = Q, 295 | types = lists:reverse(RevTypes), 296 | values = lists:reverse(RevVals), 297 | next_ph_num = ResNum 298 | }. 299 | 300 | 301 | -spec prepare_select(selector(), table(), s()) -> p_query(). 302 | prepare_select(E, Table, S) -> 303 | prepare_select(E, [], Table, S). 304 | 305 | 306 | -spec prepare_select(selector(), [select_opt()], table(), s()) -> p_query(). 307 | prepare_select(E, Opts, Table, S) -> 308 | #mekao_table{ 309 | columns = MekaoCols, 310 | order_by = OrderBy 311 | } = Table, 312 | 313 | {NextNum, QData} = qdata(1, e2l(E), MekaoCols, S), 314 | {Where, {Types, Vals}} = where(QData, S), 315 | 316 | Q = #mekao_select{ 317 | table = Table#mekao_table.name, 318 | columns = intersperse_sel_cols(MekaoCols), 319 | where = Where, 320 | order_by = order_by(OrderBy) 321 | }, 322 | limit( 323 | #mekao_query{ 324 | body = Q, 325 | types = Types, 326 | values = Vals, 327 | next_ph_num = NextNum 328 | }, Opts, S 329 | ). 330 | 331 | 332 | -spec prepare_exists(selector(), table(), s()) -> p_query(). 333 | prepare_exists(E, Table, S) -> 334 | PrepQ = #mekao_query{ 335 | body = PrepQBody = #mekao_select{order_by = OrderBy} 336 | } = prepare_select(e2l(E), Table, S), 337 | PrepQ#mekao_query{ 338 | body = PrepQBody#mekao_select{ 339 | columns = { 340 | <<"SELECT COUNT(*) AS exists" 341 | " FROM (SELECT 1) AS t WHERE EXISTS(">>, 342 | <<"1">>, <<"">> 343 | }, 344 | order_by = {<<"">>, OrderBy, <<")">>} 345 | } 346 | }. 347 | 348 | 349 | -spec prepare_count(selector(), table(), s()) -> p_query(). 350 | prepare_count(E, Table, S) -> 351 | PrepQ = #mekao_query{body = PrepQBody} = 352 | prepare_select(e2l(E), Table, S), 353 | PrepQ#mekao_query{ 354 | body = PrepQBody#mekao_select{ 355 | columns = <<"COUNT(*) as count">> 356 | } 357 | }. 358 | 359 | -spec prepare_update(entity(), selector(), table(), s()) -> p_query(). 360 | prepare_update(SetE, WhereE, Table = #mekao_table{columns = MekaoCols}, S) -> 361 | {SetNextNum, {SetCols, SetPHs, SetTypes, SetVals}} = 362 | qdata(1, e2l(SetE), MekaoCols, S), 363 | 364 | {WhereNextNum, WhereQData} = 365 | qdata(SetNextNum, e2l(WhereE), MekaoCols, S), 366 | 367 | {Where, {WhereTypes, WhereVals}} = 368 | where(WhereQData, S), 369 | 370 | Set = mekao_utils:intersperse2( 371 | fun (C, PH) -> [C, <<" = ">>, PH] end, 372 | <<", ">>, SetCols, SetPHs 373 | ), 374 | 375 | Q = #mekao_update{ 376 | table = Table#mekao_table.name, 377 | set = Set, 378 | where = Where, 379 | returning = returning(update, Table, S) 380 | }, 381 | #mekao_query{ 382 | body = Q, 383 | types = SetTypes ++ WhereTypes, 384 | values = SetVals ++ WhereVals, 385 | next_ph_num = WhereNextNum 386 | }. 387 | 388 | 389 | -spec prepare_delete(selector(), table(), s()) -> p_query(). 390 | prepare_delete(E, Table, S) -> 391 | {NextNum, QData} = 392 | qdata(1, e2l(E), Table#mekao_table.columns, S), 393 | {Where, {Types, Vals}} 394 | = where(QData, S), 395 | 396 | Q = #mekao_delete{ 397 | table = Table#mekao_table.name, 398 | where = Where, 399 | returning = returning(delete, Table, S) 400 | }, 401 | #mekao_query{ 402 | body = Q, 403 | types = Types, 404 | values = Vals, 405 | next_ph_num = NextNum 406 | }. 407 | 408 | 409 | -spec build(p_query()) -> b_query(). 410 | build(Q = #mekao_query{body = Select}) when is_record(Select, mekao_select) -> 411 | #mekao_select{ 412 | columns = Columns, 413 | table = Table, 414 | where = Where, 415 | order_by = OrderBy 416 | } = Select, 417 | 418 | Q#mekao_query{ 419 | body = [ 420 | untriplify(Columns, fun (C) -> [<<"SELECT ">>, C] end), 421 | <<" ">>, 422 | untriplify(Table, fun(C) -> [<<"FROM ">>, C] end), 423 | untriplify(Where, fun build_where/1), 424 | untriplify(OrderBy, fun build_order_by/1) 425 | ] 426 | }; 427 | 428 | build(Q = #mekao_query{body = Insert}) when is_record(Insert, mekao_insert) -> 429 | #mekao_insert{ 430 | table = Table, 431 | columns = Columns, 432 | values = Values, 433 | returning = Return 434 | } = Insert, 435 | 436 | Q#mekao_query{ 437 | body = [ 438 | <<"INSERT ">>, untriplify(Table, fun(C) -> [<<"INTO ">>, C] end), 439 | <<" ">>, 440 | untriplify(Columns, fun (Cs) -> [<<"(">>, Cs, <<")">>] end), 441 | <<" ">>, 442 | untriplify(Values, fun (Vs) -> [<<"VALUES ">>, Vs] end), 443 | untriplify(Return, fun build_return/1) 444 | ] 445 | }; 446 | 447 | build(Q = #mekao_query{body = Update}) when is_record(Update, mekao_update) -> 448 | #mekao_update{ 449 | table = Table, 450 | set = Set, 451 | where = Where, 452 | returning = Return 453 | } = Update, 454 | Q#mekao_query{ 455 | body = [ 456 | untriplify(Table, fun (C) -> [<<"UPDATE ">>, C] end), 457 | <<" ">>, 458 | untriplify(Set, fun (C) -> [<<"SET ">>, C] end), 459 | untriplify(Where, fun build_where/1), 460 | untriplify(Return, fun build_return/1) 461 | ] 462 | }; 463 | 464 | build(Q = #mekao_query{body = Delete}) when is_record(Delete, mekao_delete) -> 465 | #mekao_delete{ 466 | table = Table, 467 | where = Where, 468 | returning = Return 469 | } = Delete, 470 | Q#mekao_query{ 471 | body = [ 472 | <<"DELETE ">>, 473 | untriplify(Table, fun(C) -> [<<"FROM ">>, C] end), 474 | untriplify(Where, fun build_where/1), 475 | untriplify(Return, fun build_return/1) 476 | ] 477 | }. 478 | 479 | %%%=================================================================== 480 | %%% Internal functions 481 | %%%=================================================================== 482 | 483 | %% @doc entity to list 484 | e2l(Vals) when is_list(Vals) -> 485 | Vals; 486 | e2l(E) when is_tuple(E) -> 487 | [_EntityName | Vals] = tuple_to_list(E), 488 | Vals. 489 | 490 | 491 | skip(SkipFun, Cols, Vals) -> 492 | mekao_utils:map2( 493 | fun ('$skip', V) -> V; 494 | (C, V) -> 495 | Skip = SkipFun(C, V), 496 | if Skip -> '$skip'; 497 | true -> V 498 | end 499 | end, Cols, Vals 500 | ). 501 | 502 | 503 | qdata(Num, [], [], _) -> 504 | {Num, {[], [], [], []}}; 505 | 506 | qdata(Num, ['$skip' | Vals], [_Col | Cols], S) -> 507 | qdata(Num, Vals, Cols, S); 508 | 509 | qdata(Num, [_Pred | Vals], ['$skip' | Cols], S) -> 510 | qdata(Num, Vals, Cols, S); 511 | 512 | qdata(Num, [Pred | Vals], [#mekao_column{name = CName} = Col | Cols], S) -> 513 | {NextNum, NewPH, NewT, NewV} = qdata_predicate(Num, Pred, Col, S), 514 | 515 | {ResNum, {ResCols, ResPHs, ResTypes, ResVals}} = qdata( 516 | NextNum, Vals, Cols, S 517 | ), 518 | 519 | {ResNum, {[CName | ResCols], [NewPH | ResPHs], [NewT | ResTypes], 520 | [NewV | ResVals]}}. 521 | 522 | 523 | qdata_insert(Num, [], [], _) -> 524 | {Num, {[], [], []}}; 525 | 526 | qdata_insert(Num, [_Pred | Vals], ['$skip' | Cols], S) -> 527 | qdata_insert(Num, Vals, Cols, S); 528 | 529 | qdata_insert(Num, ['$skip' | Vals], [_Col | Cols], S) -> 530 | {ResNum, {ResPHs, ResTypes, ResVals}} = qdata_insert( 531 | Num, Vals, Cols, S 532 | ), 533 | {ResNum, {[<<"DEFAULT">> | ResPHs], ResTypes, ResVals}}; 534 | 535 | qdata_insert(Num, [Pred | Vals], [Col | Cols], S) -> 536 | {NextNum, NewPH, NewT, NewV} = qdata_plain_predicate(Num, Pred, Col, S), 537 | 538 | {ResNum, {ResPHs, ResTypes, ResVals}} = qdata_insert( 539 | NextNum, Vals, Cols, S 540 | ), 541 | 542 | {ResNum, {[NewPH | ResPHs], [NewT | ResTypes], [NewV | ResVals]}}. 543 | 544 | 545 | qdata_predicate(Num, {'$predicate', 'not', Pred}, Col, S) -> 546 | {NextNum, NewPH, NewT, NewV} = qdata_predicate(Num, Pred, Col, S), 547 | {NextNum, NewPH, NewT, {'$predicate', 'not', NewV}}; 548 | 549 | qdata_predicate(Num, {'$predicate', in, InVals}, Col, S) -> 550 | #mekao_settings{placeholder = PHFun} = S, 551 | #mekao_column{type = T, transform = TrFun} = Col, 552 | 553 | %% intentional `error:badmatch' to prevent empty `... IN ()' 554 | true = is_list(InVals) andalso InVals /= [], 555 | {NewNum, RevPHs, RevTypes, RevVals} = 556 | lists:foldl( 557 | fun(InV, {InNum, InPHs, InTypes, InTransVals}) -> 558 | TransV = transform(TrFun, InV), 559 | PH = PHFun(Col, InNum, TransV), 560 | {InNum + 1, [PH | InPHs], [T | InTypes], 561 | [TransV | InTransVals]} 562 | end, {Num, [], [], []}, InVals 563 | ), 564 | {NewNum, lists:reverse(RevPHs), lists:reverse(RevTypes), 565 | {'$predicate', in, lists:reverse(RevVals)}}; 566 | 567 | qdata_predicate(Num, {'$predicate', 'between', V1, V2}, Col, S) -> 568 | #mekao_settings{placeholder = PHFun} = S, 569 | #mekao_column{type = T, transform = TrFun} = Col, 570 | 571 | TransV1 = transform(TrFun, V1), 572 | TransV2 = transform(TrFun, V2), 573 | PH1 = PHFun(Col, Num, TransV1), 574 | PH2 = PHFun(Col, Num + 1, TransV2), 575 | {Num + 2, {PH1, PH2}, T, 576 | {'$predicate', 'between', TransV1, TransV2} 577 | }; 578 | 579 | qdata_predicate(Num, {'$predicate', Op, V}, Col, S) -> 580 | #mekao_settings{placeholder = PHFun} = S, 581 | #mekao_column{type = T, transform = TrFun} = Col, 582 | 583 | TransV = transform(TrFun, V), 584 | PH = PHFun(Col, Num, TransV), 585 | {Num + 1, PH, T, {'$predicate', Op, TransV}}; 586 | 587 | qdata_predicate(Num, V, Col, S) -> 588 | qdata_plain_predicate(Num, V, Col, S). 589 | 590 | 591 | qdata_plain_predicate(Num, V, Col, S) -> 592 | #mekao_settings{placeholder = PHFun} = S, 593 | #mekao_column{type = T, transform = TrFun} = Col, 594 | 595 | TransV = transform(TrFun, V), 596 | PH = PHFun(Col, Num, TransV), 597 | {Num + 1, PH, T, TransV}. 598 | 599 | 600 | -spec returning(insert | update | delete, table(), s()) -> iolist(). 601 | returning(_QType, _Table, #mekao_settings{returning = undefined}) -> 602 | []; 603 | returning(QType, Table, #mekao_settings{returning = RetFun}) -> 604 | RetFun(QType, Table). 605 | 606 | 607 | where({[], [], [], []}, _S) -> 608 | {[], {[], []}}; 609 | 610 | where({[C], [PH], [T], [V]}, S) -> 611 | {W, {NewTs, NewVs}} = predicate({C, PH, T, V}, S), 612 | {[W], {NewTs, NewVs}}; 613 | 614 | where({[C | Cs], [PH | PHs], [T | Types], [V | Vals]}, S) -> 615 | {W, {NewTs, NewVs}} = predicate({C, PH, T, V}, S), 616 | {Ws, {ResTs, ResVs}} = where({Cs, PHs, Types, Vals}, S), 617 | {[W, <<" AND ">> | Ws], 618 | {NewTs ++ ResTs, NewVs ++ ResVs}}. 619 | 620 | 621 | limit( PSelect, Opts, #mekao_settings{limit = undefined}) -> 622 | %% intentional error:badmatch in case if `limit' was specified but 623 | %% `#mekao_settings.limit' was not 624 | undefined = proplists:get_value(limit, Opts), 625 | PSelect; 626 | limit( PSelect, Opts, #mekao_settings{limit = LimitFun} 627 | ) when is_function(LimitFun, 3) -> 628 | case proplists:get_value(limit, Opts) of 629 | undefined -> 630 | PSelect; 631 | {RowCount, Offset} -> 632 | #mekao_query{} = LimitFun(PSelect, RowCount, Offset) 633 | end. 634 | 635 | 636 | %% TODO: add ANY, ALL handling 637 | predicate({C, PH, T, {'$predicate', Op, V}}, S) when Op == '='; Op == '<>' -> 638 | IsNull = (S#mekao_settings.is_null)(V), 639 | if not IsNull -> 640 | {[C, op_to_bin(Op), PH], {[T], [V]}}; 641 | Op == '=' -> 642 | {[C, <<" IS NULL">>], {[], []}}; 643 | Op == '<>' -> 644 | {[C, <<" IS NOT NULL">>], {[], []}} 645 | end; 646 | 647 | predicate({C, PH, T, {'$predicate', 'not', Pred}}, _S) -> 648 | {W, Rest} = predicate({C, PH, T, Pred}, _S), 649 | {[<<"NOT (">>, W, <<")">>], Rest}; 650 | 651 | predicate({C, {PH1, PH2}, T, {'$predicate', between, V1, V2}}, _S) -> 652 | {[C, <<" BETWEEN ">>, PH1, <<" AND ">>, PH2], {[T, T], [V1, V2]}}; 653 | 654 | predicate({C, PH, T, {'$predicate', like, V}}, _S) -> 655 | {[C, <<" LIKE ">>, PH], {[T], [V]}}; 656 | 657 | predicate( {C, PHs, Ts, {'$predicate', in, Vals}}, _S 658 | ) when is_list(Vals), Vals /= [] -> 659 | {[C, <<" IN (">>, 660 | mekao_utils:intersperse(PHs, <<", ">>), 661 | <<")">> 662 | ], {Ts, Vals}}; 663 | 664 | predicate({C, PH, T, {'$predicate', OP, V}}, _S) -> 665 | {[C, op_to_bin(OP), PH], {[T], [V]}}; 666 | 667 | predicate({C, PH, T, V}, S) -> 668 | predicate({C, PH, T, {'$predicate', '=', V}}, S). 669 | 670 | 671 | op_to_bin('=') -> <<" = ">>; 672 | op_to_bin('<>') -> <<" <> ">>; 673 | op_to_bin('>') -> <<" > ">>; 674 | op_to_bin('>=') -> <<" >= ">>; 675 | op_to_bin('<') -> <<" < ">>; 676 | op_to_bin('<=') -> <<" <= ">>. 677 | 678 | 679 | order_by([]) -> 680 | []; 681 | order_by([O]) -> 682 | [order_by_1(O)]; 683 | order_by([O | OrderBy]) -> 684 | [order_by_1(O), <<", ">> | order_by(OrderBy)]. 685 | 686 | order_by_1(E) when not is_tuple(E) -> 687 | order_by_1({E, {default, default}}); 688 | 689 | order_by_1({Pos, Opts}) when is_integer(Pos) -> 690 | order_by_1({integer_to_list(Pos - 1), Opts}); 691 | 692 | order_by_1({Expr, Opts}) when is_list(Expr); is_binary(Expr) -> 693 | [Expr, order_by_opts(Opts)]. 694 | 695 | order_by_opts({Ordering, Nulls}) -> 696 | O = case Ordering of 697 | default -> 698 | <<"">>; 699 | asc -> 700 | <<" ASC">>; 701 | desc -> 702 | <<" DESC">> 703 | end, 704 | case Nulls of 705 | default -> 706 | O; 707 | nulls_first -> 708 | <>; 709 | nulls_last -> 710 | <> 711 | end. 712 | 713 | 714 | build_return([]) -> 715 | <<>>; 716 | build_return(Return) -> 717 | [<<" ">> | Return]. 718 | 719 | build_where([]) -> 720 | <<>>; 721 | build_where(Where) -> 722 | [<<" WHERE ">> | Where]. 723 | 724 | 725 | build_order_by([]) -> 726 | <<>>; 727 | build_order_by(OrderBy) -> 728 | [<<" ORDER BY ">>, OrderBy]. 729 | 730 | 731 | transform(undefined, V) -> 732 | V; 733 | transform(TrFun, V) when is_function(TrFun, 1) -> 734 | TrFun(V). 735 | 736 | untriplify({C1, C2, C3}, F) when is_function(F) -> 737 | [C1, F(C2), C3]; 738 | untriplify(C, F) -> 739 | F(C). 740 | 741 | 742 | skip_not_pk(#mekao_column{key = Key}, _) -> not Key. 743 | skip_ro_or_skip(#mekao_column{ro = RO}, V) -> RO orelse V == '$skip'. 744 | 745 | intersperse_sel_cols(MekaoCols) -> 746 | mekao_utils:intersperse( 747 | [Col#mekao_column.name || Col <- MekaoCols, Col /= '$skip'], 748 | <<", ">> 749 | ). 750 | -------------------------------------------------------------------------------- /src/mekao_utils.erl: -------------------------------------------------------------------------------- 1 | %% @private 2 | -module(mekao_utils). 3 | 4 | %% API 5 | -export([ 6 | identity/1, 7 | is_null/1, 8 | intersperse/2, intersperse/3, 9 | intersperse2/4, 10 | 11 | map2/3, map3/4 12 | ]). 13 | 14 | -include("mekao.hrl"). 15 | 16 | %% =================================================================== 17 | %% API functions 18 | %% =================================================================== 19 | 20 | -spec map2( fun( ( V1 :: term(), V2 :: term()) -> ResV :: term() ) 21 | , L1 :: list(), L2 :: list()) -> list(). 22 | 23 | map2(_Fun, [], []) -> 24 | []; 25 | 26 | map2(Fun, [V1 | L1], [V2 | L2]) -> 27 | [Fun(V1, V2) | map2(Fun, L1, L2)]. 28 | 29 | 30 | -spec map3( fun( ( V1 :: term(), V2 :: term(), V3 :: term()) -> ResV :: term() ) 31 | , L1 :: list(), L2 :: list(), L3 :: list()) -> list(). 32 | 33 | map3(_Fun, [], [], []) -> 34 | []; 35 | 36 | map3(Fun, [V1 | L1], [V2 | L2], [V3 | L3]) -> 37 | [Fun(V1, V2, V3) | map3(Fun, L1, L2, L3)]. 38 | 39 | 40 | -spec is_null(term()) -> boolean(). 41 | is_null(V) -> 42 | V == undefined. 43 | 44 | -spec identity(term()) -> term(). 45 | identity(X) -> X. 46 | 47 | intersperse(List, Sep) -> 48 | intersperse(List, Sep, fun identity/1). 49 | 50 | -spec intersperse( List :: list() 51 | , Separator :: term() 52 | , ValFun :: fun((term()) -> term()) 53 | ) -> list(). 54 | intersperse([], _, _) -> 55 | []; 56 | intersperse([Item], _, Fun) -> 57 | [Fun(Item)]; 58 | intersperse([Item | Items], Sep, Fun) -> 59 | [Fun(Item), Sep | intersperse(Items, Sep, Fun)]. 60 | 61 | 62 | intersperse2(_Fun, _Sep, [], []) -> 63 | []; 64 | intersperse2(Fun, _Sep, [I1], [I2]) -> 65 | [Fun(I1, I2)]; 66 | intersperse2(Fun, Sep, [I1 | I1s], [I2 | I2s]) -> 67 | [Fun(I1, I2), Sep | intersperse2(Fun, Sep, I1s, I2s)]. 68 | 69 | 70 | %%%=================================================================== 71 | %%% Internal functions 72 | %%%=================================================================== 73 | 74 | %%%=================================================================== 75 | %%% Tests 76 | %%%=================================================================== 77 | 78 | -ifdef(TEST). 79 | -include_lib("eunit/include/eunit.hrl"). 80 | 81 | map2_test_() -> 82 | F = fun (A, B) -> A + B end, 83 | [ 84 | ?_assertMatch([], map2(F, [], [])), 85 | ?_assertMatch([3], map2(F, [1], [2])), 86 | ?_assertMatch([10, 10, 10], map2(F, [1, 2, 3], [9, 8, 7])), 87 | ?_assertException(error, function_clause, map2(F, [1], [])), 88 | ?_assertException(error, function_clause, map2(F, [], [1])) 89 | ]. 90 | 91 | map3_test_() -> 92 | F = fun (A, B, C) -> A + B + C end, 93 | [ 94 | ?_assertMatch([], map3(F, [], [], [])), 95 | ?_assertMatch([6], map3(F, [1], [2], [3])), 96 | ?_assertMatch([15, 15, 15], map3(F, [1, 2, 3], [9, 8, 7], [5, 5, 5])), 97 | ?_assertException(error, function_clause, map3(F, [1], [], [])), 98 | ?_assertException(error, function_clause, map3(F, [], [1], [])), 99 | ?_assertException(error, function_clause, map3(F, [], [], [1])) 100 | ]. 101 | 102 | intersperse_test_() -> 103 | L = [a, b, c], 104 | ValFun = fun (a) -> 1; (b) -> 2; (c) -> 3 end, 105 | 106 | [ 107 | ?_assert([1, x, 2, x, 3] == intersperse(L, x, ValFun)), 108 | ?_assert([1] == intersperse([a], x, ValFun)), 109 | ?_assert([] == intersperse([], x, ValFun)) 110 | ]. 111 | 112 | intersperse2_test_() -> 113 | ValFun = fun (V1, V2) -> {V1, V2} end, 114 | 115 | [ 116 | ?_assert( 117 | intersperse2(ValFun, x, [a, b, c, d], [d, c, b, a]) 118 | == [{a, d}, x, {b, c}, x, {c, b}, x, {d, a}] 119 | ), 120 | ?_assert(intersperse2(ValFun, x, [a], [b]) == [{a, b}]), 121 | ?_assert(intersperse2(ValFun, x, [], []) == []) 122 | ]. 123 | 124 | -endif. 125 | -------------------------------------------------------------------------------- /test/mekao_tests.erl: -------------------------------------------------------------------------------- 1 | -module(mekao_tests). 2 | 3 | -record(book, {id, isbn, title, author, created, not_db_field}). 4 | 5 | -include("mekao.hrl"). 6 | -include_lib("eunit/include/eunit.hrl"). 7 | 8 | -define(TABLE_BOOKS, #mekao_table{ 9 | name = <<"books">>, 10 | columns = [ 11 | #mekao_column{name = <<"id">>, type = int, key = true, ro = true}, 12 | #mekao_column{name = <<"isbn">>, type = varchar}, 13 | #mekao_column{name = <<"title">>, type = varchar}, 14 | #mekao_column{name = <<"author">>, type = varchar}, 15 | #mekao_column{name = <<"created">>, type = timestamp, ro = true}, 16 | '$skip' %% skipping #book.not_db_field 17 | ] 18 | }). 19 | 20 | -define(S, #mekao_settings{ 21 | placeholder = fun (_, Pos, _) -> mk_ph(Pos) end 22 | }). 23 | 24 | %%%=================================================================== 25 | %%% Tests 26 | %%%=================================================================== 27 | 28 | is_null_test_() -> 29 | Null = erlang:make_ref(), 30 | S = ?S#mekao_settings{is_null = fun(V) -> V == Null end}, 31 | [ 32 | %% TODO: extend this to test UPDATE and DELETE 33 | ?_assertMatch( 34 | #mekao_query{ 35 | body = <<"SELECT id, isbn, title, author, created FROM books", 36 | " WHERE author IS NULL">>, 37 | types = [], 38 | values = [] 39 | }, 40 | mk_call( 41 | select, #book{author = Null, _ = '$skip'}, ?TABLE_BOOKS, S 42 | ) 43 | ), 44 | ?_assertMatch( 45 | #mekao_query{ 46 | body = <<"SELECT id, isbn, title, author, created FROM books", 47 | " WHERE author IS NOT NULL">>, 48 | types = [], 49 | values = [] 50 | }, 51 | mk_call( 52 | select, #book{author = {'$predicate', '<>', Null}, _ = '$skip'}, 53 | ?TABLE_BOOKS, S 54 | ) 55 | ) 56 | ]. 57 | 58 | 59 | empty_where_test_() -> [ 60 | %% TODO: extend this to test UPDATE and DELETE 61 | ?_assertMatch( 62 | #mekao_query{ 63 | body = <<"SELECT id, isbn, title, author, created FROM books">>, 64 | types = [], 65 | values = [] 66 | }, 67 | mk_call(select, #book{_ = '$skip'}) 68 | ) 69 | ]. 70 | 71 | 72 | skip_test_() -> 73 | %% TODO: extend this to test UPDATE and DELETE 74 | #book{author = Author, title = Title} = book(undefined), [ 75 | ?_assertMatch( 76 | #mekao_query{ 77 | body = <<"SELECT id, isbn, title, author, created FROM books", 78 | " WHERE title = $1 AND author = $2">>, 79 | types = [varchar, varchar], 80 | values = [Title, Author] 81 | }, 82 | mk_call( 83 | select, #book{author = Author, title = Title, _ = '$skip'} 84 | ) 85 | ), 86 | ?_assertMatch( 87 | #mekao_query{ 88 | body = <<"INSERT INTO books (title, author) VALUES ($1, $2)">>, 89 | types = [varchar, varchar], 90 | values = [Title, Author] 91 | }, 92 | mk_call( 93 | insert, #book{author = Author, title = Title, _ = '$skip'} 94 | ) 95 | ) 96 | ]. 97 | 98 | 99 | returning_test_() -> 100 | RetFun = 101 | fun 102 | (T, #mekao_table{columns = Cols}) when T == insert; T == update -> 103 | CNames = mekao_utils:intersperse( 104 | [CName || #mekao_column{name = CName} <- Cols], 105 | <<", ">> 106 | ), 107 | [<<"RETURNING ">>, CNames]; 108 | (delete, #mekao_table{columns = Cols}) -> 109 | CNames = mekao_utils:intersperse( 110 | [CName || #mekao_column{name = CName, key = true} <- Cols], 111 | <<", ">> 112 | ), 113 | [<<"RETURNING ">>, CNames] 114 | end, 115 | #book{isbn = Isbn, title = Title, author = Author} = book(1), 116 | [ 117 | ?_assertMatch( 118 | #mekao_query{ 119 | body = <<"INSERT INTO books (isbn, title, author)", 120 | " VALUES ($1, $2, $3)" 121 | " RETURNING id, isbn, title, author, created">>, 122 | types = [varchar, varchar, varchar], 123 | values = [Isbn, Title, Author] 124 | }, 125 | mk_call(insert, book(1), ?TABLE_BOOKS, ?S#mekao_settings{ 126 | returning = RetFun 127 | }) 128 | ), 129 | ?_assertMatch( 130 | #mekao_query{ 131 | body = <<"UPDATE books SET isbn = $1, title = $2, author = $3", 132 | " WHERE id = $4", 133 | " RETURNING id, isbn, title, author, created">>, 134 | types = [varchar, varchar, varchar, int], 135 | values = [Isbn, Title, Author, 1] 136 | }, 137 | mk_call(update_pk, book(1), ?TABLE_BOOKS, ?S#mekao_settings{ 138 | returning = RetFun 139 | }) 140 | ), 141 | ?_assertMatch( 142 | #mekao_query{ 143 | body = <<"DELETE FROM books WHERE id = $1 RETURNING id">>, 144 | types = [int], 145 | values = [1] 146 | }, 147 | mk_call(delete_pk, book(1), ?TABLE_BOOKS, ?S#mekao_settings{ 148 | returning = RetFun 149 | }) 150 | ) 151 | ]. 152 | 153 | 154 | limit_test() -> 155 | RowCount0 = 10, 156 | Offset0 = 40, 157 | S = ?S#mekao_settings{ 158 | limit = fun 159 | (Q, RowCount, Offset) -> 160 | #mekao_query{ 161 | body = #mekao_select{where = Where} = QBody, 162 | types = Types, values = Vals, next_ph_num = Num 163 | } = Q, 164 | Q#mekao_query{ 165 | body = QBody#mekao_select{ 166 | where = {<<"">>, Where, [ 167 | <<" LIMIT ">>, mk_ph(Num), 168 | <<" OFFSET ">>, mk_ph(Num + 1) 169 | ]} 170 | }, 171 | types = Types ++ [int, int], 172 | values = Vals ++ [RowCount, Offset], 173 | next_ph_num = Num + 2 174 | } 175 | end 176 | }, 177 | ?assertMatch( 178 | #mekao_query{ 179 | body = <<"SELECT id, isbn, title, author, created FROM books", 180 | " LIMIT $1 OFFSET $2">>, 181 | types = [int, int], 182 | values = [RowCount0, Offset0], 183 | next_ph_num = 3 184 | }, 185 | mk_call( 186 | select, 187 | #book{_ = '$skip' }, 188 | [{limit, {RowCount0, Offset0}}], 189 | ?TABLE_BOOKS, S 190 | ) 191 | ), 192 | ?assertMatch( 193 | #mekao_query{ 194 | body = <<"SELECT id, isbn, title, author, created FROM books", 195 | " WHERE title LIKE $1 LIMIT $2 OFFSET $3">>, 196 | types = [varchar, int, int], 197 | values = [<<"%Erlang%">>, RowCount0, Offset0], 198 | next_ph_num = 4 199 | }, 200 | mk_call( 201 | select, 202 | #book{ 203 | title = {'$predicate', like, <<"%Erlang%">>}, 204 | _ = '$skip' 205 | }, 206 | [{limit, {RowCount0, Offset0}}], 207 | ?TABLE_BOOKS, S 208 | ) 209 | ), 210 | ?assertMatch( 211 | #mekao_query{ 212 | body = <<"SELECT id, isbn, title, author, created FROM books">>, 213 | types = [], values = [], next_ph_num = 1 214 | }, 215 | mk_call(select, #book{_ = '$skip'}, [], ?TABLE_BOOKS, S) 216 | ), 217 | ?assertException( 218 | error, {badmatch, {RowCount0, Offset0}}, 219 | mk_call( 220 | select, 221 | #book{_ = '$skip'}, 222 | [{limit, {RowCount0, Offset0}}], 223 | ?TABLE_BOOKS, ?S 224 | ) 225 | ). 226 | 227 | 228 | column_type_test() -> 229 | Table0 = #mekao_table{columns = Cols} = ?TABLE_BOOKS, 230 | 231 | Table = Table0#mekao_table{ 232 | columns = Cols ++ [ 233 | #mekao_column{ 234 | name = <<"type">>, type = int, 235 | transform = fun (undefined) -> 0; 236 | (hardcover) -> 1; 237 | (paperback) -> 2 238 | end 239 | } 240 | ] 241 | }, 242 | Book = #book{isbn = Isbn, title = Title, author = Author} = book(1), 243 | ?assertMatch( 244 | #mekao_query{ 245 | body = <<"INSERT INTO books (isbn, title, author, type)", 246 | " VALUES ($1, $2, $3, $4)">>, 247 | types = [varchar, varchar, varchar, int], 248 | values = [Isbn, Title, Author, 2] 249 | }, 250 | mk_call(insert, erlang:append_element(Book, paperback), Table, ?S) 251 | ). 252 | 253 | placeholder_test() -> 254 | S = ?S#mekao_settings{ 255 | placeholder = 256 | fun 257 | (#mekao_column{type = int}, _Pos, Val) -> 258 | integer_to_list(Val); 259 | (#mekao_column{type = varchar}, _Pos, Val) -> 260 | [$', Val, $'] 261 | end 262 | }, 263 | 264 | Table = #mekao_table{columns = Cols} = ?TABLE_BOOKS, 265 | AuthorCol = lists:keyfind(<<"author">>, #mekao_column.name, Cols), 266 | NewAuthorCol = AuthorCol#mekao_column{ 267 | transform = 268 | fun 269 | (undefined) -> <<"">>; 270 | (Val) -> Val 271 | end 272 | }, 273 | NewCols = lists:keyreplace( 274 | <<"author">>, #mekao_column.name, Cols, NewAuthorCol 275 | ), 276 | 277 | Book = #book{isbn = Isbn, title = Title} = book(1), 278 | 279 | QBody = <<"UPDATE books SET isbn = '", Isbn/binary, "',", 280 | " title = '", Title/binary, "', author = '' WHERE id = 1">>, 281 | 282 | ?assertMatch( 283 | #mekao_query{ 284 | body = QBody, 285 | types = [varchar, varchar, varchar, int], 286 | values = [Isbn, Title, <<"">>, 1] 287 | }, 288 | mk_call( 289 | update_pk, Book#book{author = undefined}, 290 | Table#mekao_table{columns = NewCols}, S 291 | ) 292 | ). 293 | 294 | 295 | predicate_test_() -> 296 | Ops = ['=', '<>', '>', '>=', '<', '<='], 297 | DT = {{2013, 1, 1}, {0, 0, 0}}, 298 | lists:map( 299 | fun(Op) -> 300 | OpBin = atom_to_binary(Op, utf8), 301 | QBody = <<"SELECT id, isbn, title, author, created FROM books", 302 | " WHERE created ", OpBin/binary," $1">>, 303 | ?_assertMatch( 304 | #mekao_query{ 305 | body = QBody, types = [timestamp], values = [DT] 306 | }, 307 | mk_call( 308 | select, #book{created = {'$predicate', Op, DT}, _ = '$skip'} 309 | ) 310 | ) 311 | end, Ops 312 | ). 313 | 314 | 315 | like_test() -> 316 | Title = <<"%Erlang%">>, 317 | ?assertMatch( 318 | #mekao_query{ 319 | body = <<"SELECT id, isbn, title, author, created FROM books", 320 | " WHERE title LIKE $1">>, 321 | types = [varchar], 322 | values = [Title] 323 | }, 324 | mk_call( 325 | select, #book{title = {'$predicate', like, Title}, _ = '$skip'} 326 | ) 327 | ). 328 | 329 | 330 | between_test() -> 331 | NowSecs = calendar:datetime_to_gregorian_seconds( 332 | calendar:now_to_datetime(os:timestamp()) 333 | ), 334 | DT1 = calendar:gregorian_seconds_to_datetime(NowSecs - 24 * 60 * 60), 335 | DT2 = calendar:gregorian_seconds_to_datetime(NowSecs), 336 | 337 | ?assertMatch( 338 | #mekao_query{ 339 | body = <<"SELECT id, isbn, title, author, created FROM books", 340 | " WHERE created BETWEEN $1 AND $2">>, 341 | types = [timestamp, timestamp], 342 | values = [DT1, DT2], 343 | next_ph_num = 3 344 | }, 345 | mk_call( 346 | select, #book{ 347 | created = {'$predicate', between, DT1, DT2}, 348 | _ = '$skip' 349 | } 350 | ) 351 | ). 352 | 353 | in_test() -> 354 | ?assertMatch( 355 | #mekao_query{ 356 | body = <<"SELECT id, isbn, title, author, created FROM books", 357 | " WHERE id IN ($1, $2, $3, $4)">>, 358 | types = [int, int, int, int], 359 | values = [1, 2, 3, 4] 360 | }, 361 | mk_call( 362 | select, #book{id = {'$predicate', in, [1, 2, 3, 4]}, _ = '$skip'} 363 | ) 364 | ), 365 | {ok, UpdateQ = #mekao_query{body = UpdateQBody}} 366 | = mekao:update( 367 | #book{title = NewTitle = <<"NewTitle">>, _ = '$skip'}, 368 | #book{id = {'$predicate', in, [1, 2, 3, 4]}, _ = '$skip'}, 369 | ?TABLE_BOOKS, ?S 370 | ), 371 | ?assertMatch( 372 | #mekao_query{ 373 | body = <<"UPDATE books SET title = $1" 374 | " WHERE id IN ($2, $3, $4, $5)">>, 375 | types = [varchar, int, int, int, int], 376 | values = [NewTitle, 1, 2, 3, 4] 377 | }, 378 | UpdateQ#mekao_query{ 379 | body = iolist_to_binary(UpdateQBody) 380 | } 381 | ), 382 | ?assertMatch( 383 | #mekao_query{ 384 | body = <<"DELETE FROM books WHERE id IN ($1, $2, $3, $4)">>, 385 | types = [int, int, int, int], 386 | values = [1, 2, 3, 4] 387 | }, 388 | mk_call( 389 | delete, 390 | #book{id = {'$predicate', in, [1, 2, 3, 4]}, _ = '$skip'} 391 | ) 392 | ), 393 | ?assertException( 394 | error, {badmatch, false}, 395 | mk_call(delete, #book{id = {'$predicate', in, []}, _ = '$skip'}) 396 | ). 397 | 398 | 399 | not_test() -> 400 | Title = <<"Erlang">>, 401 | ?assertMatch( 402 | #mekao_query{ 403 | body = <<"DELETE FROM books WHERE NOT (title = $1)">>, 404 | types = [varchar], 405 | values = [Title] 406 | }, 407 | mk_call( 408 | delete, 409 | #book{title = {'$predicate', 'not', Title}, _ = '$skip'} 410 | ) 411 | ), 412 | ?assertMatch( 413 | #mekao_query{ 414 | body = <<"SELECT id, isbn, title, author, created FROM books" 415 | " WHERE NOT (title IS NULL)">>, 416 | types = [], 417 | values = [] 418 | }, 419 | mk_call( 420 | select, 421 | #book{title = {'$predicate', 'not', undefined}, _ = '$skip'}, 422 | ?TABLE_BOOKS, 423 | ?S#mekao_settings{is_null = fun(V) -> V == undefined end} 424 | ) 425 | ), 426 | ?assertMatch( 427 | #mekao_query{ 428 | body = <<"DELETE FROM books WHERE NOT (title LIKE $1)">>, 429 | types = [varchar], 430 | values = [Title] 431 | }, 432 | mk_call( 433 | delete, 434 | #book{ 435 | title = {'$predicate', 'not', 436 | {'$predicate', like, Title} 437 | }, _ = '$skip' 438 | } 439 | ) 440 | ), 441 | ?assertMatch( 442 | #mekao_query{ 443 | body = <<"DELETE FROM books WHERE NOT (NOT (title = $1))">>, 444 | types = [varchar], 445 | values = [Title] 446 | }, 447 | mk_call( 448 | delete, 449 | #book{ 450 | title = {'$predicate', 'not', 451 | {'$predicate', 'not', Title} 452 | }, _ = '$skip' 453 | } 454 | ) 455 | ). 456 | 457 | 458 | prepare_select_test() -> 459 | #book{author = Author} = book(1), 460 | Author2 = <<"Francesco Cesarini">>, 461 | 462 | Q = #mekao_query{ 463 | body = QBody = #mekao_select{where = Where}, 464 | types = Types, 465 | values = Vals, 466 | next_ph_num = Num 467 | } = mekao:prepare_select( 468 | #book{author = Author, _ = '$skip'}, ?TABLE_BOOKS, ?S 469 | ), 470 | 471 | NewQ = #mekao_query{body = NewQBody} = 472 | mekao:build(Q#mekao_query{ 473 | body = QBody#mekao_select{ 474 | where = [Where, <<" OR author = ">>, mk_ph(Num)] 475 | }, 476 | types = Types ++ [int], 477 | values = Vals ++ [Author2], 478 | next_ph_num = Num + 1 479 | }), 480 | 481 | ?assertMatch( 482 | #mekao_query{ 483 | body = <<"SELECT id, isbn, title, author, created FROM books", 484 | " WHERE author = $1 OR author = $2">>, 485 | types = [varchar, int], 486 | values = [Author, Author2], 487 | next_ph_num = 3 488 | }, 489 | NewQ#mekao_query{body = iolist_to_binary(NewQBody)} 490 | ). 491 | 492 | 493 | exists_test() -> 494 | #book{id = Id, isbn = Isbn, author = Author} = book(1), 495 | ?assertMatch( 496 | #mekao_query{ 497 | body = << 498 | "SELECT COUNT(*) AS exists FROM (SELECT 1) AS t WHERE EXISTS(" 499 | "SELECT 1 FROM books WHERE isbn = $1 AND author = $2" 500 | ")" 501 | >>, types = [varchar, varchar], values = [Isbn, Author] 502 | }, 503 | mk_call( 504 | exists, 505 | #book{isbn = Isbn, author = Author, _ = '$skip'} 506 | ) 507 | ), 508 | ?assertMatch( 509 | #mekao_query{ 510 | body = << 511 | "SELECT COUNT(*) AS exists FROM (SELECT 1) AS t WHERE EXISTS(" 512 | "SELECT 1 FROM books WHERE id IS NULL" 513 | ")" 514 | >>, types = [], values = [] 515 | }, 516 | mk_call( 517 | exists_pk, 518 | #book{id = undefined, _ = '$skip'} 519 | ) 520 | ), 521 | ?assertMatch( 522 | #mekao_query{ 523 | body = << 524 | "SELECT COUNT(*) AS exists FROM (SELECT 1) AS t WHERE EXISTS(" 525 | "SELECT 1 FROM books WHERE id = $1" 526 | ")" 527 | >>, types = [int], values = [Id] 528 | }, 529 | mk_call( 530 | exists_pk, 531 | #book{id = Id, author = <<"Doesn't matter">>, _ = '$skip'} 532 | ) 533 | ), 534 | ?assertMatch( 535 | {error, pk_miss}, 536 | mekao:exists_pk(book('$skip'), ?TABLE_BOOKS, ?S) 537 | ). 538 | 539 | 540 | count_test() -> 541 | #book{isbn = Isbn, author = Author} = book(1), 542 | ?assertMatch( 543 | #mekao_query{ 544 | body = << 545 | "SELECT COUNT(*) as count FROM books" 546 | " WHERE isbn = $1 AND author = $2" 547 | >>, types = [varchar, varchar], values = [Isbn, Author] 548 | }, 549 | mk_call( 550 | count, 551 | #book{isbn = Isbn, author = Author, _ = '$skip'} 552 | ) 553 | ). 554 | 555 | 556 | prepare_select_triple_test() -> 557 | T = ?TABLE_BOOKS#mekao_table{order_by = [#book.title]}, 558 | #book{author = Author} = book(1), 559 | 560 | Q = #mekao_query{ 561 | body = QBody = #mekao_select{ 562 | columns = Cols, 563 | table = Table, 564 | where = Where, 565 | order_by = OrderBy 566 | } 567 | } = mekao:prepare_select( 568 | #book{author = Author, _ = '$skip'}, T, ?S 569 | ), 570 | 571 | NewQ = #mekao_query{body = NewQBody} = 572 | mekao:build(Q#mekao_query{ 573 | body = QBody#mekao_select{ 574 | columns = {<<"|1|">>, Cols, <<"|2|">>}, 575 | table = {<<"|3|">>, Table, <<"|4|">>}, 576 | where = {<<"|5|">>, Where, <<"|6|">>}, 577 | order_by = {<<"|7|">>, OrderBy, <<"|8|">>} 578 | } 579 | }), 580 | ?assertMatch( 581 | #mekao_query{ 582 | body = <<"|1|SELECT id, isbn, title, author, created|2|", 583 | " |3|FROM books|4||5| WHERE author = $1|6||7|", 584 | " ORDER BY 3|8|">>, 585 | types = [varchar], 586 | values = [Author] 587 | }, 588 | NewQ#mekao_query{body = iolist_to_binary(NewQBody)} 589 | ). 590 | 591 | 592 | prepare_insert_triple_test() -> 593 | Q = #mekao_query{ 594 | body = QBody = #mekao_insert{ 595 | table = Table, 596 | columns = Columns, 597 | values = Values 598 | } 599 | } = mekao:prepare_insert(book(1), ?TABLE_BOOKS, ?S), 600 | 601 | NewQ = #mekao_query{body = NewQBody} = 602 | mekao:build(Q#mekao_query{ 603 | body = QBody#mekao_insert{ 604 | table = {<<"|1|">>, Table, <<"|2|">>}, 605 | columns = {<<"|3|">>, Columns, <<"|4|">>}, 606 | values = {<<"|5|">>, Values, <<"|6|">>}, 607 | returning = {<<"|7|">>, <<"RETURNING *">>, <<"|8|">>} 608 | } 609 | }), 610 | ?assertMatch( 611 | #mekao_query{ 612 | body = <<"INSERT |1|INTO books|2|", 613 | " |3|(id, isbn, title, author, created)|4|", 614 | " |5|VALUES ($1, $2, $3, $4, $5)|6|", 615 | "|7| RETURNING *|8|">> 616 | }, 617 | NewQ#mekao_query{body = iolist_to_binary(NewQBody)} 618 | ). 619 | 620 | 621 | prepare_update_triple_test() -> 622 | #book{id = Id, isbn = Isbn} = book(1), 623 | Q = #mekao_query{ 624 | body = QBody = #mekao_update{ 625 | table = Table, 626 | set = Set, 627 | where = Where 628 | } 629 | } = mekao:prepare_update( 630 | #book{isbn = Isbn, _ = '$skip'}, 631 | #book{id = Id, _ = '$skip'}, 632 | ?TABLE_BOOKS, ?S 633 | ), 634 | 635 | NewQ = #mekao_query{body = NewQBody} = 636 | mekao:build(Q#mekao_query{ 637 | body = QBody#mekao_update{ 638 | table = {<<"|1|">>, Table, <<"|2|">>}, 639 | set = {<<"|3|">>, Set, <<"|4|">>}, 640 | where = {<<"|5|">>, Where, <<"|6|">>}, 641 | returning = {<<"|7|">>, <<"RETURNING *">>, <<"|8|">>} 642 | } 643 | }), 644 | ?assertMatch( 645 | #mekao_query{ 646 | body = <<"|1|UPDATE books|2| |3|SET isbn = $1|4|", 647 | "|5| WHERE id = $2|6||7| RETURNING *|8|">>, 648 | types = [varchar, int], 649 | values = [Isbn, Id] 650 | }, 651 | NewQ#mekao_query{body = iolist_to_binary(NewQBody)} 652 | ). 653 | 654 | 655 | prepare_delete_triple_test() -> 656 | Q = #mekao_query{ 657 | body = QBody = #mekao_delete{ 658 | table = Table, 659 | where = Where 660 | } 661 | } = mekao:prepare_delete(#book{id = 1, _ = '$skip'}, ?TABLE_BOOKS, ?S), 662 | 663 | NewQ = #mekao_query{body = NewQBody} = 664 | mekao:build(Q#mekao_query{ 665 | body = QBody#mekao_delete{ 666 | table = {<<"|1|">>, Table, <<"|2|">>}, 667 | where = {<<"|3|">>, Where, <<"|4|">>}, 668 | returning = {<<"|5|">>, <<"RETURNING *">>, <<"|6|">>} 669 | } 670 | }), 671 | ?assertMatch( 672 | #mekao_query{ 673 | body = <<"DELETE |1|FROM books|2||3| WHERE id = $1|4||5|", 674 | " RETURNING *|6|">>, 675 | types = [int], 676 | values = [1] 677 | }, 678 | NewQ#mekao_query{body = iolist_to_binary(NewQBody)} 679 | ). 680 | 681 | 682 | select_pk_test() -> 683 | ?assertMatch( 684 | #mekao_query{ 685 | body = <<"SELECT id, isbn, title, author, created FROM books", 686 | " WHERE id IS NULL">>, 687 | types = [], values = [] 688 | }, 689 | mk_call(select_pk, book(undefined)) 690 | ), 691 | ?assertMatch( 692 | #mekao_query{ 693 | body = <<"SELECT id, isbn, title, author, created FROM books", 694 | " WHERE id = $1">>, 695 | types = [int], values = [1] 696 | }, 697 | mk_call(select_pk, book(1)) 698 | ). 699 | 700 | 701 | order_by_test() -> 702 | T = ?TABLE_BOOKS#mekao_table{ 703 | order_by = [ 704 | #book.id, 705 | {#book.isbn, {asc, default}}, 706 | {#book.title, {desc, default}}, 707 | {#book.author, {asc, nulls_first}}, 708 | {#book.created, {desc, nulls_last}}, 709 | "EXTRACT(DAY FROM created)" 710 | ] 711 | }, 712 | ?assertMatch( 713 | #mekao_query{ 714 | body = <<"SELECT id, isbn, title, author, created FROM books", 715 | " ORDER BY 1, 2 ASC, 3 DESC, 4 ASC NULLS FIRST," 716 | " 5 DESC NULLS LAST, EXTRACT(DAY FROM created)">> 717 | }, 718 | mk_call( 719 | select, #book{_ = '$skip'}, T, ?S 720 | ) 721 | ). 722 | 723 | 724 | insert_test() -> 725 | Book = #book{isbn = Isbn, title = Title, author = Author} = book(1), 726 | ?assertMatch( 727 | #mekao_query{ 728 | body = <<"INSERT INTO books (isbn, title, author)", 729 | " VALUES ($1, $2, $3)">>, 730 | types = [varchar, varchar, varchar], 731 | values = [Isbn, Title, Author]}, 732 | mk_call(insert, Book) 733 | ). 734 | 735 | insert_all_test() -> 736 | #book{isbn = Isbn, title = Title, author = Author} = book(1), 737 | R = mk_call( 738 | insert_all, [ 739 | #book{isbn = Isbn, _ = '$skip'}, 740 | #book{title = Title, _ = '$skip'}, 741 | #book{author = Author, _ = '$skip'} 742 | ] 743 | ), 744 | ?assertMatch( 745 | #mekao_query{ 746 | body = <<"INSERT INTO books (id, isbn, title, author, created)", 747 | " VALUES " 748 | "(DEFAULT, $1, DEFAULT, DEFAULT, DEFAULT), " 749 | "(DEFAULT, DEFAULT, $2, DEFAULT, DEFAULT), " 750 | "(DEFAULT, DEFAULT, DEFAULT, $3, DEFAULT)" 751 | >>, 752 | types = [varchar, varchar, varchar], 753 | values = [Isbn, Title, Author] 754 | }, 755 | R 756 | ). 757 | 758 | 759 | update_test() -> 760 | #book{isbn = Isbn, title = Title} = book(1), 761 | {ok, UpdateQ = #mekao_query{body = UpdateQBody}} 762 | = mekao:update( 763 | #book{title = Title, _ = '$skip'}, #book{isbn = Isbn, _ = '$skip'}, 764 | ?TABLE_BOOKS, ?S 765 | ), 766 | 767 | ?assertMatch( 768 | {error, empty_update}, 769 | mekao:update( 770 | #book{_ = '$skip'}, #book{isbn = Isbn, _ = '$skip'}, 771 | ?TABLE_BOOKS, ?S 772 | ) 773 | ), 774 | ?assertMatch( 775 | #mekao_query{ 776 | body = <<"UPDATE books SET title = $1 WHERE isbn = $2">>, 777 | types = [varchar, varchar], 778 | values = [Title, Isbn] 779 | }, 780 | UpdateQ#mekao_query{ 781 | body = iolist_to_binary(UpdateQBody) 782 | } 783 | ). 784 | 785 | 786 | update_pk_test() -> 787 | Book = #book{isbn = Isbn, title = Title, author = Author} = book(1), 788 | ?assertMatch( 789 | #mekao_query{ 790 | body = <<"UPDATE books SET isbn = $1, title = $2, author = $3", 791 | " WHERE id IS NULL">>, 792 | types = [varchar, varchar, varchar], 793 | values = [Isbn, Title, Author] 794 | }, 795 | mk_call(update_pk, Book#book{id = undefined}) 796 | ), 797 | ?assertMatch( 798 | #mekao_query{ 799 | body = <<"UPDATE books SET isbn = $1, title = $2, author = $3", 800 | " WHERE id = $4">>, 801 | types = [varchar, varchar, varchar, int], 802 | values = [Isbn, Title, Author, 1] 803 | }, 804 | mk_call(update_pk, Book) 805 | ). 806 | 807 | 808 | update_pk_diff_test() -> 809 | Book = #book{title = Title} = book(1), 810 | 811 | {ok, UpdatePKDiffQ = #mekao_query{body = UpdatePKDiffQBody}} 812 | = mekao:update_pk_diff( 813 | Book#book{title = <<"Unknown">>}, Book, ?TABLE_BOOKS, ?S 814 | ), 815 | 816 | ?assertMatch( 817 | #mekao_query{ 818 | body = <<"UPDATE books SET title = $1 WHERE id = $2">>, 819 | types = [varchar, int], 820 | values = [Title, 1] 821 | }, 822 | UpdatePKDiffQ#mekao_query{ 823 | body = iolist_to_binary(UpdatePKDiffQBody) 824 | } 825 | ). 826 | 827 | 828 | %% @doc When key is not read only, it must be changeable. 829 | update_key_changed_test() -> 830 | TBooks = #mekao_table{columns = Cols} = ?TABLE_BOOKS, 831 | IdCol = #mekao_column{key = true, ro = true} 832 | = lists:keyfind(<<"id">>, #mekao_column.name, Cols), 833 | NewCols = lists:keystore( 834 | <<"id">>, #mekao_column.name, Cols, IdCol#mekao_column{ro = false} 835 | ), 836 | NewTBooks = TBooks#mekao_table{columns = NewCols}, 837 | 838 | Title = <<"NewTitle">>, 839 | 840 | {ok, Q1 = #mekao_query{body = QBody1}} 841 | = mekao:update( 842 | #book{id = 2, title = Title, _ = '$skip'}, 843 | #book{id = 1, _ = '$skip'}, 844 | NewTBooks, 845 | ?S 846 | ), 847 | 848 | 849 | {ok, Q2 = #mekao_query{body = QBody2}} 850 | = mekao:update_pk( 851 | #book{id = 1, title = Title, _ = '$skip'}, 852 | NewTBooks, 853 | ?S 854 | ), 855 | 856 | Book = book(1), 857 | {ok, Q3 = #mekao_query{body = QBody3}} 858 | = mekao:update_pk_diff( 859 | Book, 860 | Book#book{id = 2, title = Title}, 861 | NewTBooks, 862 | ?S 863 | ), 864 | 865 | ?assertMatch( 866 | #mekao_query{ 867 | body = <<"UPDATE books SET id = $1, title = $2 WHERE id = $3">>, 868 | types = [int, varchar, int], 869 | values = [2, Title, 1] 870 | }, 871 | Q1#mekao_query{ 872 | body = iolist_to_binary(QBody1) 873 | } 874 | ), 875 | ?assertMatch( 876 | #mekao_query{ 877 | body = <<"UPDATE books SET title = $1 WHERE id = $2">>, 878 | types = [varchar, int], 879 | values = [Title, 1] 880 | }, 881 | Q2#mekao_query{ 882 | body = iolist_to_binary(QBody2) 883 | } 884 | ), 885 | ?assertMatch( 886 | #mekao_query{ 887 | body = <<"UPDATE books SET id = $1, title = $2 WHERE id = $3">>, 888 | types = [int, varchar, int], 889 | values = [2, Title, 1] 890 | }, 891 | Q3#mekao_query{ 892 | body = iolist_to_binary(QBody3) 893 | } 894 | ). 895 | 896 | 897 | delete_pk_test() -> 898 | ?assertMatch( 899 | #mekao_query{ 900 | body = <<"DELETE FROM books WHERE id IS NULL">>, 901 | types = [], values = [] 902 | }, 903 | mk_call(delete_pk, book(undefined)) 904 | ), 905 | ?assertMatch( 906 | #mekao_query{ 907 | body = <<"DELETE FROM books WHERE id = $1">>, 908 | types = [int], values = [1] 909 | }, 910 | mk_call(delete_pk, book(1)) 911 | ). 912 | 913 | delete_test() -> 914 | #book{author = Author} = book(1), 915 | ?assertMatch( 916 | #mekao_query{ 917 | body = <<"DELETE FROM books WHERE author = $1">>, 918 | types = [varchar], 919 | values = [Author] 920 | }, 921 | mk_call(delete, #book{author = Author, _ = '$skip'}) 922 | ). 923 | 924 | error_test_() -> [ 925 | ?_assertMatch( 926 | {error, empty_insert}, 927 | mekao:insert(#book{_ = '$skip'}, ?TABLE_BOOKS, ?S) 928 | ), 929 | ?_assertMatch( 930 | {error, pk_miss}, 931 | mekao:select_pk(book('$skip'), ?TABLE_BOOKS, ?S) 932 | ), 933 | ?_assertMatch( 934 | {error, pk_miss}, 935 | mekao:update_pk(book('$skip'), ?TABLE_BOOKS, ?S) 936 | ), 937 | ?_assertMatch( 938 | {error, empty_update}, 939 | mekao:update_pk(#book{id = 1,_ = '$skip'}, ?TABLE_BOOKS, ?S) 940 | ), 941 | ?_assertMatch( 942 | {error, empty_update}, 943 | mekao:update_pk_diff(book(1), book(1), ?TABLE_BOOKS, ?S) 944 | ), 945 | ?_assertMatch( 946 | {error, pk_miss}, 947 | mekao:update_pk_diff( 948 | (book('$skip'))#book{isbn = <<"Unknown">>}, book('$skip'), 949 | ?TABLE_BOOKS, ?S 950 | ) 951 | ), 952 | ?_assertMatch( 953 | {error, pk_miss}, 954 | mekao:delete_pk(book('$skip'), ?TABLE_BOOKS, ?S) 955 | ) 956 | ]. 957 | 958 | 959 | %%%=================================================================== 960 | %%% Internal functions 961 | %%%=================================================================== 962 | 963 | book(Id) -> 964 | #book{ 965 | id = Id, 966 | isbn = <<"978-1593274351">>, 967 | title = <<"Learn You Some Erlang for Great Good!: A Beginner's Guide">>, 968 | author = <<"Fred Hebert">>, 969 | created = calendar:now_to_datetime(os:timestamp()) 970 | }. 971 | 972 | mk_call(CallName, E) -> 973 | mk_call(CallName, E, ?TABLE_BOOKS). 974 | 975 | mk_call(CallName, E, Table) -> 976 | mk_call(CallName, E, Table, ?S). 977 | 978 | mk_call(CallName, E, Table, S) -> 979 | {ok, Q = #mekao_query{body = B}} = mekao:CallName(E, Table, S), 980 | Q#mekao_query{body = iolist_to_binary(B)}. 981 | 982 | mk_call(CallName, E, Opts, Table, S) -> 983 | {ok, Q = #mekao_query{body = B}} = mekao:CallName(E, Opts, Table, S), 984 | Q#mekao_query{body = iolist_to_binary(B)}. 985 | 986 | mk_ph(N) when is_integer(N) -> 987 | [$$ | integer_to_list(N)]. 988 | --------------------------------------------------------------------------------