├── .editorconfig ├── .gitignore ├── .travis.yml ├── .travis └── trigger-dependant-builds.js ├── LICENSE ├── README.md ├── db_spec ├── pg │ ├── migration.sql │ ├── models.cr │ ├── pg_spec.cr │ └── repository │ │ ├── exec_spec.cr │ │ ├── query_spec.cr │ │ └── scalar_spec.cr ├── repository_spec.cr ├── spec_helper.cr └── sqlite3 │ ├── migration.sql │ ├── models.cr │ ├── repository │ ├── exec_spec.cr │ ├── query_spec.cr │ └── scalar_spec.cr │ └── sqlite3_spec.cr ├── shard.yml ├── spec ├── bulk_query │ ├── delete_spec.cr │ └── insert_spec.cr ├── dummy_converters │ ├── enum.cr │ ├── int32_aray.cr │ ├── json.cr │ └── uuid.cr ├── model │ ├── changes_spec.cr │ ├── class_query_shortcuts_spec.cr │ └── instance_query_shortcuts_spec.cr ├── model_spec.cr ├── models.cr ├── query │ ├── delete_spec.cr │ ├── group_by_spec.cr │ ├── having_spec.cr │ ├── insert_spec.cr │ ├── join_spec.cr │ ├── limit_spec.cr │ ├── offset_spec.cr │ ├── order_by_spec.cr │ ├── select_spec.cr │ ├── update_spec.cr │ └── where_spec.cr ├── query_spec.cr ├── repository │ ├── exec_spec.cr │ ├── logger │ │ ├── dummy_spec.cr │ │ ├── io_spec.cr │ │ └── standard_spec.cr │ ├── query_spec.cr │ └── scalar_spec.cr ├── repository_spec.cr └── spec_helper.cr └── src ├── onyx-sql.cr └── onyx-sql ├── bulk_query.cr ├── bulk_query ├── delete.cr ├── insert.cr ├── returning.cr └── where.cr ├── converters.cr ├── converters ├── pg.cr ├── pg │ ├── any.cr │ ├── enum.cr │ ├── json.cr │ ├── jsonb.cr │ └── uuid.cr ├── sqlite3.cr └── sqlite3 │ ├── any.cr │ ├── enum_int.cr │ ├── enum_text.cr │ ├── json.cr │ ├── uuid_blob.cr │ └── uuid_text.cr ├── ext ├── enumerable │ └── bulk_query.cr └── pg │ └── result_set.cr ├── model.cr ├── model ├── changes.cr ├── class_query_shortcuts.cr ├── enums.cr ├── instance_query_shortcuts.cr ├── mappable.cr └── schema.cr ├── query.cr ├── query ├── delete.cr ├── group_by.cr ├── having.cr ├── insert.cr ├── join.cr ├── limit.cr ├── offset.cr ├── order_by.cr ├── returning.cr ├── select.cr ├── set.cr ├── update.cr ├── where.cr └── wherish.cr ├── repository.cr ├── repository ├── exec.cr ├── logger.cr ├── logger │ ├── dummy.cr │ ├── io.cr │ └── standard.cr ├── query.cr └── scalar.cr └── serializable.cr /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in application that uses them 9 | /shard.lock 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | services: 3 | - postgresql 4 | cache: 5 | directories: 6 | - lib 7 | jobs: 8 | include: 9 | - stage: Tests 10 | name: Crystal spec 11 | script: 12 | - crystal spec 13 | - name: PostgreSQL spec 14 | env: 15 | - POSTGRESQL_URL=postgres://postgres@localhost:5432/test 16 | before_script: 17 | - psql -c 'create database test;' -U postgres 18 | - psql $POSTGRESQL_URL < db_spec/pg/migration.sql 19 | script: 20 | - env POSTGRESQL_URL=$POSTGRESQL_URL crystal spec db_spec/pg 21 | - name: SQLite3 spec 22 | env: $SQLITE3_URL=sqlite3://./test.sqlite3 23 | before_script: 24 | - sqlite3 ./test.sqlite3 < db_spec/sqlite3/migration.sql 25 | script: 26 | - env SQLITE3_URL=$SQLITE3_URL crystal spec db_spec/sqlite3 27 | - stage: Deployment 28 | before_install: 29 | - nvm install 9 30 | - npm install shelljs got 31 | script: 32 | - crystal docs 33 | after_success: 34 | - node .travis/trigger-dependant-builds.js 35 | deploy: 36 | provider: pages 37 | skip_cleanup: true 38 | keep_history: true 39 | github_token: $GITHUB_TOKEN 40 | on: 41 | tags: true 42 | condition: $TRAVIS_TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ 43 | local_dir: docs 44 | addons: 45 | postgresql: "9.5" 46 | -------------------------------------------------------------------------------- /.travis/trigger-dependant-builds.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | "use strict"; 3 | 4 | const shell = require("shelljs"); 5 | const path = require("path"); 6 | const got = require("got"); 7 | 8 | console.log("Fetching Git commit hash..."); 9 | 10 | const gitCommitRet = shell.exec("git rev-parse HEAD", { 11 | cwd: path.join(__dirname, "..") 12 | }); 13 | 14 | if (0 !== gitCommitRet.code) { 15 | console.error("Error getting git commit hash"); 16 | process.exit(-1); 17 | } 18 | 19 | const gitCommitHash = gitCommitRet.stdout.trim(); 20 | 21 | const gitSubjectRet = shell.exec(`git show -s --format=%s ${gitCommitHash}`, { 22 | cwd: path.join(__dirname, "..") 23 | }); 24 | 25 | const gitCommitSubject = gitSubjectRet.stdout.trim(); 26 | 27 | const triggerBuild = (username, repo, branch) => { 28 | console.log(`Triggering ${username}/${repo}@${branch}...`); 29 | 30 | got.post(`https://api.travis-ci.org/repo/${username}%2F${repo}/requests`, { 31 | headers: { 32 | "Content-Type": "application/json", 33 | "Accept": "application/json", 34 | "Travis-API-Version": "3", 35 | "Authorization": `token ${process.env.TRAVIS_API_TOKEN}`, 36 | }, 37 | body: JSON.stringify({ 38 | request: { 39 | message: `onyx-sql@${gitCommitHash.substring(0, 7)} ${gitCommitSubject}`, 40 | branch: branch, 41 | config: { 42 | env: `ONYX_SQL_COMMIT=${gitCommitHash}` 43 | } 44 | }, 45 | }), 46 | }) 47 | .then(() => { 48 | console.log(`Triggered ${username}/${repo}@${branch}`); 49 | }) 50 | .catch((err) => { 51 | console.error(err); 52 | process.exit(-1); 53 | }); 54 | }; 55 | 56 | triggerBuild("vladfaust", "crystalworld", "master"); 57 | triggerBuild("vladfaust", "onyx-todo-json-api", "part-2"); 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Vlad Faust 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Onyx::SQL 4 | 5 | [![Built with Crystal](https://img.shields.io/badge/built%20with-crystal-000000.svg?style=flat-square)](https://crystal-lang.org/) 6 | [![Travis CI build](https://img.shields.io/travis/onyxframework/sql/master.svg?style=flat-square)](https://travis-ci.org/onyxframework/sql) 7 | [![Docs](https://img.shields.io/badge/docs-online-brightgreen.svg?style=flat-square)](https://docs.onyxframework.org/sql) 8 | [![API docs](https://img.shields.io/badge/api_docs-online-brightgreen.svg?style=flat-square)](https://api.onyxframework.org/sql) 9 | [![Latest release](https://img.shields.io/github/release/onyxframework/sql.svg?style=flat-square)](https://github.com/onyxframework/sql/releases) 10 | 11 | A deligtful SQL ORM. 12 | 13 | ## About 👋 14 | 15 | Onyx::SQL is a deligthful database-agnostic SQL ORM for [Crystal language](https://crystal-lang.org/). It features a convenient schema definition DSL, type-safe SQL query builder, clean architecture with Repository and more! 16 | 17 | ## Installation 📥 18 | 19 | Add these lines to your application's `shard.yml`: 20 | 21 | ```yaml 22 | dependencies: 23 | onyx: 24 | github: onyxframework/onyx 25 | version: ~> 0.6.0 26 | onyx-sql: 27 | github: onyxframework/sql 28 | version: ~> 0.9.0 29 | ``` 30 | 31 | This shard follows [Semantic Versioning v2.0.0](http://semver.org/), so check [releases](https://github.com/onyxframework/rest/releases) and change the `version` accordingly. 32 | 33 | > Note that until Crystal is officially released, this shard would be in beta state (`0.*.*`), with every **minor** release considered breaking. For example, `0.1.0` → `0.2.0` is breaking and `0.1.0` → `0.1.1` is not. 34 | 35 | You'd also need to add a database dependency conforming to the [crystal-db](https://github.com/crystal-lang/crystal-db) interface. For example, [pg](https://github.com/will/crystal-pg): 36 | 37 | ```diff 38 | dependencies: 39 | onyx: 40 | github: onyxframework/onyx 41 | version: ~> 0.6.0 42 | onyx-sql: 43 | github: onyxframework/sql 44 | version: ~> 0.9.0 45 | + pg: 46 | + github: will/crystal-pg 47 | + version: ~> 0.18.0 48 | ``` 49 | 50 | ## Usage 💻 51 | 52 | For this PostgreSQL table: 53 | 54 | ```sql 55 | CREATE TABLE users ( 56 | id SERIAL PRIMARY KEY, 57 | name TEXT NOT NULL 58 | created_at TIMESTAMPTZ NOT NULL DEFAULT now() 59 | ); 60 | ``` 61 | 62 | Define the user schema: 63 | 64 | ```crystal 65 | require "onyx/sql" 66 | 67 | class User 68 | include Onyx::SQL::Model 69 | 70 | schema users do 71 | pkey id : Int32 72 | type name : String, not_null: true 73 | type created_at : Time, not_null: true, default: true 74 | end 75 | end 76 | ``` 77 | 78 | Insert a new user instance: 79 | 80 | ```crystal 81 | user = User.new(name: "John") 82 | user = Onyx::SQL.query(user.insert.returning("*")).first 83 | 84 | pp user # => #> 85 | ``` 86 | 87 | Query the user: 88 | 89 | ```crystal 90 | user = Onyx::SQL.query(User.where(id: 1)).first? 91 | ``` 92 | 93 | With another PostgreSQL table: 94 | 95 | ```sql 96 | CREATE TABLE posts ( 97 | id SERIAL PRIMARY KEY, 98 | author_id INT NOT NULL REFERENCES users(id), 99 | content TEXT NOT NULL 100 | created_at TIMESTAMPTZ NOT NULL DEFAULT now() 101 | ); 102 | ``` 103 | 104 | Define the post schema: 105 | 106 | ```crystal 107 | class Post 108 | include Onyx::SQL::Model 109 | 110 | schema posts do 111 | pkey id : Int32 112 | type author : User, not_null: true, key: "author_id" 113 | type content : String, not_null: true 114 | type created_at : Time, not_null: true, default: true 115 | end 116 | end 117 | ``` 118 | 119 | Add the posts reference to the user schema: 120 | 121 | ```diff 122 | class User 123 | include Onyx::SQL::Model 124 | 125 | schema users do 126 | pkey id : Int32 127 | type name : String, not_null: true 128 | type created_at : Time, not_null: true, default: true 129 | + type authored_posts : Array(Post), foreign_key: "author_id" 130 | end 131 | end 132 | ``` 133 | 134 | Create a new post: 135 | 136 | ```crystal 137 | user = User.new(id: 1) 138 | post = Post.new(author: user, content: "Hello, world!") 139 | Onyx::SQL.exec(post.insert) 140 | ``` 141 | 142 | Query all the posts by a user with name "John": 143 | 144 | ```crystal 145 | posts = Onyx::SQL.query(Post 146 | .join(author: true) do |x| 147 | x.select(:id, :name) 148 | x.where(name: "John") 149 | end 150 | ) 151 | 152 | posts.first # => #, @content="Hello, world!"> 153 | ``` 154 | 155 | ## Documentation 📚 156 | 157 | The documentation is available online at [docs.onyxframework.org/sql](https://docs.onyxframework.org/sql). 158 | 159 | ## Community 🍪 160 | 161 | There are multiple places to talk about Onyx: 162 | 163 | * [Gitter](https://gitter.im/onyxframework) 164 | * [Twitter](https://twitter.com/onyxframework) 165 | 166 | ## Support 🕊 167 | 168 | This shard is maintained by me, [Vlad Faust](https://vladfaust.com), a passionate developer with years of programming and product experience. I love creating Open-Source and I want to be able to work full-time on Open-Source projects. 169 | 170 | I will do my best to answer your questions in the free communication channels above, but if you want prioritized support, then please consider becoming my patron. Your issues will be labeled with your patronage status, and if you have a sponsor tier, then you and your team be able to communicate with me privately in [Twist](https://twist.com). There are other perks to consider, so please, don't hesistate to check my Patreon page: 171 | 172 | 173 | 174 | You could also help me a lot if you leave a star to this GitHub repository and spread the word about Crystal and Onyx! 📣 175 | 176 | ## Contributing 177 | 178 | 1. Fork it ( https://github.com/onyxframework/sql/fork ) 179 | 2. Create your feature branch (git checkout -b my-new-feature) 180 | 3. Commit your changes (git commit -am 'feat: some feature') using [Angular style commits](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#commit) 181 | 4. Push to the branch (git push origin my-new-feature) 182 | 5. Create a new Pull Request 183 | 184 | ## Contributors 185 | 186 | - [Vlad Faust](https://github.com/vladfaust) - creator and maintainer 187 | 188 | ## Licensing 189 | 190 | This software is licensed under [MIT License](LICENSE). 191 | 192 | [![Open Source Initiative](https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/Opensource.svg/100px-Opensource.svg.png)](https://opensource.org/licenses/MIT) 193 | -------------------------------------------------------------------------------- /db_spec/pg/migration.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS posts; 2 | DROP TABLE IF EXISTS tags; 3 | DROP TABLE IF EXISTS users; 4 | DROP TYPE IF EXISTS users_role; 5 | DROP TYPE IF EXISTS users_permissions; 6 | DROP EXTENSION IF EXISTS pgcrypto; 7 | CREATE EXTENSION pgcrypto; 8 | 9 | CREATE TYPE users_role AS ENUM ('writer', 'moderator', 'admin'); 10 | CREATE TYPE users_permissions AS ENUM ('create_posts', 'edit_posts'); 11 | 12 | CREATE TABLE users( 13 | uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), 14 | referrer_uuid UUID REFERENCES users (uuid) ON DELETE SET NULL, 15 | activity_status BOOL NOT NULL DEFAULT true, 16 | role users_role NOT NULL DEFAULT 'writer', 17 | permissions users_permissions[] NOT NULL DEFAULT '{create_posts}', 18 | favorite_numbers INT[] NOT NULL DEFAULT '{}', 19 | name VARCHAR(100) NOT NULL, 20 | balance REAL NOT NULL DEFAULT 0, 21 | meta JSON NOT NULL DEFAULT '{}', 22 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 23 | updated_at TIMESTAMPTZ 24 | ); 25 | 26 | CREATE TABLE tags( 27 | id SERIAL PRIMARY KEY, 28 | content TEXT NOT NULL 29 | ); 30 | 31 | CREATE TABLE posts( 32 | id SERIAL PRIMARY KEY, 33 | author_uuid UUID NOT NULL REFERENCES users (uuid), 34 | editor_uuid UUID REFERENCES users (uuid), 35 | tag_ids INT[], 36 | content TEXT NOT NULL, 37 | cover BYTEA, 38 | meta JSONB NOT NULL DEFAULT '{}', 39 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 40 | updated_at TIMESTAMPTZ 41 | ); 42 | -------------------------------------------------------------------------------- /db_spec/pg/models.cr: -------------------------------------------------------------------------------- 1 | require "../../src/onyx-sql/converters/pg" 2 | require "../../src/onyx-sql/converters/pg/uuid" 3 | require "../../src/onyx-sql/converters/pg/json" 4 | require "../../src/onyx-sql/converters/pg/jsonb" 5 | 6 | alias Model = Onyx::SQL::Model 7 | alias Field = Onyx::SQL::Field 8 | 9 | class User 10 | include Model 11 | 12 | enum Role 13 | Writer 14 | Moderator 15 | Admin 16 | end 17 | 18 | enum Permission 19 | CreatePosts 20 | EditPosts 21 | end 22 | 23 | struct Meta 24 | include JSON::Serializable 25 | property foo : String? 26 | 27 | def initialize(@foo = nil) 28 | end 29 | end 30 | 31 | schema users do 32 | pkey uuid : UUID, converter: PG::UUID 33 | 34 | type active : Bool, key: "activity_status", default: true, not_null: true 35 | type role : Role, converter: PG::Enum(Role), default: true, not_null: true 36 | type permissions : Array(Permission), converter: PG::Enum(Permission), default: true, not_null: true 37 | type favorite_numbers : Array(Int32), converter: PG::Any(Int32), default: true, not_null: true 38 | type name : String, not_null: true 39 | type balance : Float32, default: true, not_null: true 40 | type meta : Meta, converter: PG::JSON(User::Meta), default: true, not_null: true 41 | type created_at : Time, default: true, not_null: true 42 | type updated_at : Time 43 | 44 | type referrer : User, key: "referrer_uuid" 45 | type referrals : Array(User), foreign_key: "referrer_uuid" 46 | type authored_posts : Array(Post), foreign_key: "author_uuid" 47 | type edited_posts : Array(Post), foreign_key: "editor_uuid" 48 | end 49 | end 50 | 51 | class Tag 52 | include Model 53 | 54 | schema tags do 55 | pkey id : Int32, converter: PG::Any(Int32) 56 | type content : String, not_null: true 57 | type posts : Array(Post), foreign_key: "tag_ids" 58 | end 59 | end 60 | 61 | class Post 62 | include Model 63 | 64 | record Meta, meta : Hash(String, String)? do 65 | include JSON::Serializable 66 | end 67 | 68 | schema posts do 69 | pkey id : Int32, converter: PG::Any(Int32) 70 | 71 | type content : String, not_null: true 72 | type cover : Bytes 73 | type meta : Meta, converter: PG::JSONB(Meta), default: true 74 | type created_at : Time, default: true, not_null: true 75 | type updated_at : Time 76 | 77 | type author : User, key: "author_uuid", not_null: true 78 | type editor : User, key: "editor_uuid" 79 | type tags : Array(Tag), key: "tag_ids" 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /db_spec/pg/pg_spec.cr: -------------------------------------------------------------------------------- 1 | require "pg" 2 | -------------------------------------------------------------------------------- /db_spec/pg/repository/exec_spec.cr: -------------------------------------------------------------------------------- 1 | require "../pg_spec" 2 | require "../../repository_spec" 3 | 4 | describe "Repository(Postgres)#exec" do 5 | repo = repo(:postgresql) 6 | 7 | describe "with SQL" do 8 | context "without params" do 9 | it do 10 | repo.exec("SELECT 1").should be_truthy 11 | end 12 | end 13 | 14 | context "with single param" do 15 | it do 16 | repo.exec("SELECT ?::int", 1).should be_truthy 17 | end 18 | end 19 | 20 | context "with multiple params" do 21 | it do 22 | repo.exec("SELECT ?::int, ?::text", 1, "foo").should be_truthy 23 | end 24 | end 25 | 26 | context "with single array of params" do 27 | it do 28 | repo.exec("SELECT ?::int[]", "{1,2}").should be_truthy 29 | end 30 | end 31 | 32 | context "with multiple arguments which have an array of params" do 33 | it do 34 | repo.exec("SELECT ?::text, ?::int[]", "foo", "{1,2}").should be_truthy 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /db_spec/pg/repository/query_spec.cr: -------------------------------------------------------------------------------- 1 | require "../pg_spec" 2 | require "../../repository_spec" 3 | require "../models" 4 | 5 | describe "Repository(Postgres)#query" do 6 | repo = repo(:postgresql) 7 | 8 | # This is John 9 | # 10 | 11 | user = uninitialized User 12 | 13 | describe "insert" do 14 | context "with a simple model" do 15 | user = User.new( 16 | name: "John", 17 | active: true, 18 | favorite_numbers: [3, 17] 19 | ) 20 | 21 | user = repo.query(user.insert.returning("*")).first 22 | 23 | it "returns instance" do 24 | user.should be_a(User) 25 | user.favorite_numbers.should eq [3, 17] 26 | end 27 | end 28 | end 29 | 30 | # And this is John's referrer, Jake 31 | # 32 | 33 | referrer = repo.query(User.insert(name: "Jake").returning(User)).first 34 | 35 | describe "update" do 36 | context "with attributes" do 37 | previous_uuid = user.uuid 38 | 39 | # We're updating John's balance and activity status 40 | # 41 | 42 | query = User.query 43 | .update 44 | .set(active: false) 45 | .set(balance: 100.0_f32, updated_at: nil) 46 | .set(favorite_numbers: [11]) 47 | .where(uuid: user.uuid.not_nil!) 48 | .returning(:uuid, :balance) 49 | 50 | user = repo.query(query).first 51 | 52 | it "preloads attributes" do 53 | user.uuid.should eq previous_uuid 54 | user.balance.should eq 100.0 55 | end 56 | end 57 | 58 | context "with direct references" do 59 | # We're setting John's referrer to Jake 60 | # 61 | 62 | query = User.update.set(referrer: referrer).where(uuid: user.uuid.not_nil!).returning(User) 63 | user = repo.query(query).first 64 | 65 | it "preloads references" do 66 | user.referrer.not_nil!.uuid.should be_a(UUID) 67 | end 68 | end 69 | end 70 | 71 | describe "where" do 72 | user = repo.query(User.where(uuid: user.uuid.not_nil!).and_where(balance: 100.0_f32)).first 73 | 74 | it "returns a User instance" do 75 | user.name.should eq "John" 76 | end 77 | 78 | context "with direct non-enumerable join" do 79 | query = User.query 80 | .select(:name, :uuid) 81 | .where(uuid: user.uuid.not_nil!) 82 | .join referrer: true do |q| 83 | q.select("referrer.*") 84 | end 85 | 86 | user = repo.query(query).first 87 | 88 | it "returns a User instance" do 89 | user.name.should eq "John" 90 | end 91 | 92 | it "preloads direct references" do 93 | user.referrer.not_nil!.name.should eq "Jake" 94 | end 95 | end 96 | end 97 | 98 | tag = repo.query(Tag.insert(content: "foo").returning("*")).first 99 | post = uninitialized Post 100 | 101 | describe "insert" do 102 | context "with complex model" do 103 | post = repo.query(Post.query 104 | .insert(author: user, tags: [tag], content: "Blah-blah", cover: "foo".to_slice, meta: Post::Meta.new({"foo" => "bar"})) 105 | .returning(Post) 106 | ).first 107 | 108 | it "returns model instance" do 109 | post.should be_a(Post) 110 | post.meta!.meta.should eq({"foo" => "bar"}) 111 | post.cover!.should eq("foo".to_slice) 112 | end 113 | 114 | it "preloads direct non-enumerable references" do 115 | post.author.not_nil!.uuid.should eq user.uuid 116 | post.author.not_nil!.name.should be_nil 117 | end 118 | 119 | it "preloads direct enumerable references" do 120 | post.tags.not_nil!.size.should eq 1 121 | post.tags.not_nil!.first.id.should eq tag.id 122 | post.tags.not_nil!.first.content.should be_nil 123 | end 124 | end 125 | 126 | context "multiple instances" do 127 | posts = [ 128 | Post.new(author: user, tags: [tag], content: "Foo"), 129 | Post.new(author: user, content: "Bar"), 130 | ] 131 | 132 | posts = repo.query(posts.insert.returning(Post)) 133 | posts.first.content!.should eq "Foo" 134 | end 135 | end 136 | 137 | new_user = repo.query(User.insert(name: "James").returning(User)).first 138 | 139 | describe "update" do 140 | context "with complex reference updates" do 141 | changeset = post.changeset 142 | 143 | changeset.update(tags: [] of Tag, editor: new_user) 144 | changeset.update(created_at: Time.now) 145 | 146 | post = repo.query(post.update(changeset).returning("*")).first 147 | 148 | it "returns model instance" do 149 | post.should be_a(Post) 150 | end 151 | 152 | it "preloads direct non-enumerable references" do 153 | post.editor.not_nil!.uuid.should eq new_user.uuid 154 | end 155 | 156 | it "preloads direct enumerable references" do 157 | post.tags.not_nil!.size.should eq 0 158 | end 159 | end 160 | end 161 | 162 | describe "where" do 163 | context "with foreign non-enumerable join" do 164 | post = repo.query(Post.query 165 | .where(id: post.id.not_nil!) 166 | .and("cardinality(tag_ids) = ?", 0) 167 | 168 | .join author: true do |q| 169 | q.select("author.*") 170 | end 171 | 172 | .join editor: true do |q| 173 | q.select(:uuid) 174 | end 175 | ).first 176 | 177 | it "returns model instance" do 178 | post.should be_a(Post) 179 | end 180 | 181 | it "preloads references" do 182 | post.author.not_nil!.uuid.should eq user.uuid 183 | post.editor.not_nil!.uuid.should eq new_user.uuid 184 | end 185 | end 186 | end 187 | 188 | describe "#delete" do 189 | it do 190 | repo.query(post.delete.returning(:id)).first.id!.should eq post.id 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /db_spec/pg/repository/scalar_spec.cr: -------------------------------------------------------------------------------- 1 | require "../pg_spec" 2 | require "../../repository_spec" 3 | 4 | describe "Repository(Postgres)#scalar" do 5 | repo = repo(:postgresql) 6 | 7 | describe "with SQL" do 8 | context "without params" do 9 | it do 10 | repo.scalar("SELECT 1").as(Int32).should eq 1 11 | end 12 | end 13 | 14 | context "with single param" do 15 | it do 16 | repo.scalar("SELECT ?::int", 1).as(Int32).should eq 1 17 | end 18 | end 19 | 20 | context "with array of params" do 21 | it do 22 | repo.scalar("SELECT ?::int[]", "{1,2}").as(Array(PG::Int32Array)).should eq [1, 2] 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /db_spec/repository_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | enum Database 4 | Postgresql 5 | Sqlite3 6 | end 7 | 8 | def repo(database : Database) 9 | Onyx::SQL::Repository.new(DB.open(ENV["#{database.to_s.upcase}_URL"]), Onyx::SQL::Repository::Logger::Dummy.new) 10 | end 11 | -------------------------------------------------------------------------------- /db_spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/onyx-sql" 3 | -------------------------------------------------------------------------------- /db_spec/sqlite3/migration.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS posts; 2 | DROP TABLE IF EXISTS tags; 3 | DROP TABLE IF EXISTS users; 4 | 5 | CREATE TABLE users ( 6 | referrer_id INT REFERENCES users(rowid) ON DELETE SET NULL, 7 | activity_status BOOLEAN NOT NULL 8 | DEFAULT (1), 9 | role INT NOT NULL 10 | DEFAULT (0), 11 | permissions STRING NOT NULL 12 | DEFAULT ('{create_posts}'), 13 | favorite_numbers STRING NOT NULL 14 | DEFAULT ('{}'), 15 | name STRING NOT NULL, 16 | balance REAL NOT NULL 17 | DEFAULT (0), 18 | meta STRING NOT NULL 19 | DEFAULT ('{}'), 20 | created_at DATETIME NOT NULL 21 | DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), 22 | updated_at DATETIME 23 | ); 24 | 25 | CREATE TABLE tags ( 26 | content TEXT NOT NULL 27 | ); 28 | 29 | CREATE TABLE posts ( 30 | author_id INT REFERENCES users (rowid) 31 | NOT NULL, 32 | editor_id INT REFERENCES users (rowid), 33 | tag_ids STRING, 34 | content TEXT NOT NULL, 35 | created_at DATETIME NOT NULL 36 | DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), 37 | updated_at DATETIME 38 | ); 39 | 40 | -------------------------------------------------------------------------------- /db_spec/sqlite3/models.cr: -------------------------------------------------------------------------------- 1 | require "../../src/onyx-sql/converters/sqlite3" 2 | require "../../src/onyx-sql/converters/sqlite3/json" 3 | 4 | alias Model = Onyx::SQL::Model 5 | alias Field = Onyx::SQL::Field 6 | 7 | class User 8 | include Model 9 | 10 | enum Role 11 | Writer 12 | Moderator 13 | Admin 14 | end 15 | 16 | enum Permission 17 | CreatePosts 18 | EditPosts 19 | end 20 | 21 | struct Meta 22 | include JSON::Serializable 23 | property foo : String? 24 | 25 | def initialize(@foo = nil) 26 | end 27 | end 28 | 29 | schema users do 30 | pkey id : Int32, key: "rowid", converter: SQLite3::Any(Int32) 31 | 32 | type active : Bool, key: "activity_status", default: true, not_null: true 33 | type role : Role, converter: SQLite3::EnumInt(Role), default: true, not_null: true 34 | type permissions : Array(Permission), converter: SQLite3::EnumText(Permission), default: true, not_null: true 35 | type favorite_numbers : Array(Int32), converter: SQLite3::Any(Int32), default: true, not_null: true 36 | type name : String, not_null: true 37 | type balance : Float32, default: true, not_null: true 38 | type meta : Meta, converter: SQLite3::JSON(User::Meta), default: true, not_null: true 39 | type created_at : Time, default: true, not_null: true 40 | type updated_at : Time 41 | 42 | type referrer : User, key: "referrer_id" 43 | type referrals : Array(User), foreign_key: "referrer_id" 44 | type authored_posts : Array(Post), foreign_key: "author_id" 45 | type edited_posts : Array(Post), foreign_key: "editor_id" 46 | end 47 | end 48 | 49 | class Tag 50 | include Model 51 | 52 | schema tags do 53 | pkey id : Int32, converter: SQLite3::Any(Int32), key: "rowid" 54 | type content : String, not_null: true 55 | type posts : Array(Post), foreign_key: "tag_ids" 56 | end 57 | end 58 | 59 | class Post 60 | include Model 61 | 62 | schema posts do 63 | pkey id : Int32, converter: SQLite3::Any(Int32), key: "rowid" 64 | 65 | type content : String, not_null: true 66 | type created_at : Time, default: true, not_null: true 67 | type updated_at : Time 68 | 69 | type author : User, key: "author_id", not_null: true 70 | type editor : User, key: "editor_id" 71 | type tags : Array(Tag), key: "tag_ids" 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /db_spec/sqlite3/repository/exec_spec.cr: -------------------------------------------------------------------------------- 1 | require "../sqlite3_spec" 2 | require "../../repository_spec" 3 | 4 | describe "Repository(Sqlite3)#exec" do 5 | repo = repo(:sqlite3) 6 | 7 | describe "with SQL" do 8 | context "without params" do 9 | it do 10 | repo.exec("SELECT 1").should be_truthy 11 | end 12 | end 13 | 14 | context "with single param" do 15 | it do 16 | repo.exec("SELECT ?", 1).should be_truthy 17 | end 18 | end 19 | 20 | context "with multiple params" do 21 | it do 22 | repo.exec("SELECT ?, ?", 1, "foo").should be_truthy 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /db_spec/sqlite3/repository/query_spec.cr: -------------------------------------------------------------------------------- 1 | require "../sqlite3_spec" 2 | require "../../repository_spec" 3 | require "../models" 4 | 5 | describe "Repository(Sqlite3)#query" do 6 | repo = repo(:sqlite3) 7 | 8 | # This is John 9 | # 10 | 11 | user = uninitialized User 12 | 13 | describe "insert" do 14 | context "with a simple model" do 15 | user = User.new( 16 | name: "John", 17 | active: true, 18 | ) 19 | 20 | cursor = repo.exec(user.insert) 21 | user = repo.query(User.where(id: cursor.last_insert_id.to_i32).select(:id).select(User)).first 22 | 23 | it "returns instance" do 24 | user.should be_a(User) 25 | end 26 | end 27 | end 28 | 29 | # And this is John's referrer, Jake 30 | # 31 | 32 | cursor = repo.exec(User.insert(name: "Jake")) 33 | referrer = repo.query(User.where(id: cursor.last_insert_id.to_i32).select(:id).select(User)).first 34 | 35 | describe "update" do 36 | context "with attributes" do 37 | # We're updating John's balance and activity status 38 | # 39 | 40 | query = User.query 41 | .update 42 | .set(active: false) 43 | .set(balance: 100.0_f32, updated_at: nil) 44 | .where(id: user.id.not_nil!) 45 | 46 | repo.exec(query) 47 | user = repo.query(User.where(id: user.id.not_nil!).select(:id, :balance)).first 48 | 49 | it "preloads attributes" do 50 | user.balance.should eq 100.0 51 | end 52 | end 53 | 54 | context "with direct references" do 55 | # We're setting John's referrer to Jake 56 | # 57 | 58 | query = User.update.set(referrer: referrer).where(id: user.id.not_nil!) 59 | cursor = repo.exec(query) 60 | 61 | it do 62 | cursor.rows_affected.should eq 1 63 | end 64 | end 65 | end 66 | 67 | describe "where" do 68 | user = repo.query(User.where(id: user.id.not_nil!).and_where(balance: 100.0_f32).select(:id).select(User)).first 69 | 70 | it "returns a User instance" do 71 | user.name.should eq "John" 72 | end 73 | 74 | context "with direct non-enumerable join" do 75 | query = User.query 76 | .select(:name, :id) 77 | .where(id: user.id.not_nil!) 78 | .join(referrer: true) do |q| 79 | q.select("referrer.*") 80 | end 81 | 82 | user = repo.query(query).first 83 | 84 | it "returns a User instance" do 85 | user.name.should eq "John" 86 | end 87 | 88 | it "preloads direct references" do 89 | user.referrer.not_nil!.name.should eq "Jake" 90 | end 91 | end 92 | end 93 | 94 | cursor = repo.exec(Tag.insert(content: "foo")) 95 | tag = repo.query(Tag.where(id: cursor.last_insert_id.to_i32).select(:id).select(Tag)).first 96 | post = uninitialized Post 97 | 98 | describe "insert" do 99 | context "with complex model" do 100 | cursor = repo.exec(Post.insert(author: user, tags: [tag], content: "Blah-blah")) 101 | post = repo.query(Post.where(id: cursor.last_insert_id.to_i32).select(:id).select(Post)).first 102 | 103 | it "returns model instance" do 104 | post.should be_a(Post) 105 | end 106 | 107 | it "preloads direct non-enumerable references" do 108 | post.author.not_nil!.id.should eq user.id 109 | post.author.not_nil!.name.should be_nil 110 | end 111 | 112 | it "preloads direct enumerable references" do 113 | post.tags.not_nil!.size.should eq 1 114 | post.tags.not_nil!.first.id.should eq tag.id 115 | post.tags.not_nil!.first.not_nil!.content.should be_nil 116 | end 117 | end 118 | 119 | context "multiple instances" do 120 | posts = [ 121 | Post.new(author: user, tags: [tag], content: "Foo"), 122 | Post.new(author: user, content: "Bar"), 123 | ] 124 | 125 | cursor = repo.exec(posts.insert) 126 | cursor.rows_affected.should eq 2 127 | end 128 | end 129 | 130 | cursor = repo.exec(User.insert(name: "James")) 131 | new_user = repo.query(User.where(id: cursor.last_insert_id.to_i32).select(:id).select(User)).first 132 | 133 | describe "update" do 134 | context "with complex reference updates" do 135 | changeset = post.changeset 136 | 137 | changeset.update(tags: [] of Tag, editor: new_user) 138 | changeset.update(created_at: Time.now) 139 | 140 | cursor = repo.exec(post.update(changeset)) 141 | 142 | it do 143 | cursor.rows_affected.should eq 1 144 | end 145 | end 146 | end 147 | 148 | describe "where" do 149 | context "with foreign non-enumerable join" do 150 | post = repo.query(Post.query 151 | .select(:id) 152 | .select(Post) 153 | .where(id: post.id.not_nil!) 154 | 155 | .join author: true do |q| 156 | q.select("author.rowid") 157 | end 158 | 159 | .join editor: true do |q| 160 | q.select(:id) 161 | end 162 | ).first 163 | 164 | it "returns model instance" do 165 | post.should be_a(Post) 166 | end 167 | 168 | it "preloads references" do 169 | post.author.not_nil!.id.should eq user.id 170 | post.editor.not_nil!.id.should eq new_user.id 171 | end 172 | end 173 | end 174 | 175 | describe "#delete" do 176 | it do 177 | cursor = repo.exec(post.delete.returning(:id)) 178 | cursor.rows_affected.should eq 1 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /db_spec/sqlite3/repository/scalar_spec.cr: -------------------------------------------------------------------------------- 1 | require "../sqlite3_spec" 2 | require "../../repository_spec" 3 | 4 | describe "Repository(Sqlite3)#scalar" do 5 | repo = repo(:sqlite3) 6 | 7 | describe "with SQL" do 8 | context "without params" do 9 | it do 10 | repo.scalar("SELECT 1").as(Int64).should eq 1 11 | end 12 | end 13 | 14 | context "with single param" do 15 | it do 16 | repo.scalar("SELECT ?", 1).as(Int64).should eq 1 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /db_spec/sqlite3/sqlite3_spec.cr: -------------------------------------------------------------------------------- 1 | require "sqlite3" 2 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: onyx-sql 2 | version: 0.9.0 3 | 4 | authors: 5 | - Vlad Faust 6 | 7 | crystal: 0.30.1 8 | 9 | license: MIT 10 | 11 | dependencies: 12 | time_format: 13 | github: vladfaust/time_format.cr 14 | version: ~> 0.1.0 15 | db: 16 | github: crystal-lang/crystal-db 17 | version: ~> 0.6.0 18 | 19 | development_dependencies: 20 | sqlite3: 21 | github: crystal-lang/crystal-sqlite3 22 | version: ~> 0.13.0 23 | pg: 24 | github: will/crystal-pg 25 | version: ~> 0.18.0 26 | -------------------------------------------------------------------------------- /spec/bulk_query/delete_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe "BulkQuery#delete" do 4 | it do 5 | uuid1 = UUID.random 6 | uuid2 = UUID.random 7 | 8 | user1 = User.new(uuid: uuid1) 9 | user2 = User.new(uuid: uuid2) 10 | 11 | q = [user1, user2].delete.returning(:uuid) 12 | 13 | sql, params = q.build 14 | 15 | sql.should eq <<-SQL 16 | DELETE FROM users WHERE uuid IN (?, ?) RETURNING users.uuid 17 | SQL 18 | 19 | params.to_a.should eq [uuid1.to_s, uuid2.to_s] 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/bulk_query/insert_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe "BulkQuery#insert" do 4 | it do 5 | uuid1 = UUID.random 6 | uuid2 = UUID.random 7 | 8 | user1 = User.new(uuid: uuid1, name: "John", active: true, favorite_numbers: [42, 43]) 9 | user2 = User.new(name: "Jake", active: false, referrer: user1) 10 | 11 | q = [user1, user2].insert.returning(User) 12 | 13 | sql, params = q.build 14 | 15 | sql.should eq <<-SQL 16 | INSERT INTO users (uuid, activity_status, favorite_numbers, name, referrer_uuid) VALUES (?, ?, ?, ?, NULL), (DEFAULT, ?, DEFAULT, ?, ?) RETURNING users.* 17 | SQL 18 | 19 | params.to_a.should eq [ 20 | uuid1.to_s, true, "42,43", "John", 21 | false, "Jake", uuid1.to_s, 22 | ] 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/dummy_converters/enum.cr: -------------------------------------------------------------------------------- 1 | module DummyConverters 2 | module Enum(T) 3 | def self.to_db(value : T) 4 | value.to_s 5 | end 6 | 7 | def self.to_db(values : Enumerable(T)) 8 | values.map(&.to_s).join(',') 9 | end 10 | 11 | def self.from_rs(rs : MockDB::ResultSet) 12 | rs.read(String | Nil).try { |s| T.parse(s) } 13 | end 14 | 15 | def self.from_rs_array(rs : MockDB::ResultSet) 16 | rs.read(String | Nil).try &.split(',').map { |s| T.parse(s) } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/dummy_converters/int32_aray.cr: -------------------------------------------------------------------------------- 1 | module DummyConverters 2 | module Int32Array 3 | def self.to_db(value : ::Array(Int32)) 4 | value.map(&.to_s).join(',') 5 | end 6 | 7 | def self.from_rs(rs : MockDB::ResultSet) 8 | rs.read(String | Nil).try &.split(',').map { |s| s.to_i } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy_converters/json.cr: -------------------------------------------------------------------------------- 1 | module DummyConverters 2 | module JSON(T) 3 | def self.to_db(value : T) 4 | value.to_json 5 | end 6 | 7 | def self.from_rs(rs : MockDB::ResultSet) 8 | T.from_json(rs.read(String)) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy_converters/uuid.cr: -------------------------------------------------------------------------------- 1 | module DummyConverters 2 | module UUID 3 | def self.to_db(value : ::UUID) 4 | value.to_s 5 | end 6 | 7 | def self.to_db(values : Enumerable(::UUID)) 8 | values.map(&.to_s).join(',') 9 | end 10 | 11 | def self.from_rs(rs : MockDB::ResultSet) 12 | ::UUID.new(rs.read(String)) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/model/changes_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe Onyx::SQL::Model do 4 | user = User.new(name: "John") 5 | changeset = user.changeset 6 | 7 | describe Onyx::SQL::Model::Changeset do 8 | it "is initially empty" do 9 | changeset.changes.empty?.should be_true 10 | end 11 | 12 | it "tracks changes" do 13 | changeset.update(name: "Bar") 14 | changeset.changes.should eq ({"name" => "Bar"}) 15 | end 16 | end 17 | 18 | describe "#apply" do 19 | it do 20 | user.apply(changeset) 21 | user.name.should eq "Bar" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/model/class_query_shortcuts_spec.cr: -------------------------------------------------------------------------------- 1 | require "../query/**" 2 | 3 | describe Onyx::SQL::Model::ClassQueryShortcuts do 4 | describe ".query" do 5 | it do 6 | User.query.should eq Query(User).new 7 | end 8 | end 9 | 10 | describe ".group_by" do 11 | it do 12 | User.group_by("foo", "bar").should eq Query(User).new.group_by("foo", "bar") 13 | end 14 | end 15 | 16 | describe ".having" do 17 | it do 18 | User.having("foo").having("bar = ?", 42).build.should eq Query(User).new.having("foo").having("bar = ?", 42).build 19 | end 20 | end 21 | 22 | describe ".insert" do 23 | it do 24 | User.insert(name: "John").build.should eq Query(User).new.insert(name: "John").build 25 | end 26 | end 27 | 28 | describe ".limit" do 29 | it do 30 | User.limit(1).should eq Query(User).new.limit(1) 31 | end 32 | end 33 | 34 | describe ".offset" do 35 | it do 36 | User.offset(1).should eq Query(User).new.offset(1) 37 | end 38 | end 39 | 40 | describe ".set" do 41 | it do 42 | User.set(active: true).build.should eq Query(User).new.set(active: true).build 43 | end 44 | end 45 | 46 | describe ".where" do 47 | it do 48 | User.where(active: true).build.should eq Query(User).new.where(active: true).build 49 | end 50 | end 51 | 52 | {% for m in %w(update delete all one) %} 53 | describe {{m}} do 54 | it do 55 | User.{{m.id}}.should eq Query(User).new.{{m.id}} 56 | end 57 | end 58 | {% end %} 59 | 60 | describe ".join" do 61 | context "with table" do 62 | it do 63 | Post.join("users", "author.id = posts.author_id", as: "author").should eq Query(Post).new.join("users", "author.id = posts.author_id", as: "author") 64 | end 65 | end 66 | 67 | context "with reference" do 68 | it do 69 | Post.join(:author).should eq Query(Post).new.join(:author) 70 | end 71 | end 72 | end 73 | 74 | describe ".order_by" do 75 | it do 76 | User.order_by(:uuid, :asc).order_by("foo").should eq Query(User).new.order_by(:uuid, :asc).order_by("foo") 77 | end 78 | end 79 | 80 | describe ".returning" do 81 | it do 82 | User.returning(:name).returning("*").should eq Query(User).new.returning(:name).returning("*") 83 | end 84 | end 85 | 86 | describe ".select" do 87 | it do 88 | User.select(:name).select("*").should eq Query(User).new.select(:name).select("*") 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /spec/model/instance_query_shortcuts_spec.cr: -------------------------------------------------------------------------------- 1 | require "../query/**" 2 | 3 | describe Onyx::SQL::Model do 4 | describe "#insert" do 5 | it do 6 | User.new(name: "John").insert.build.should eq User.insert(name: "John", updated_at: nil, referrer: nil).build 7 | end 8 | end 9 | 10 | describe "#update" do 11 | uuid = UUID.random 12 | user = User.new(uuid: uuid, name: "John", active: true) 13 | changeset = user.changeset 14 | changeset.update(name: "Jake", active: false) 15 | 16 | it do 17 | user.update(changeset).build.should eq User.update.set(active: false, name: "Jake").where(uuid: uuid).build 18 | end 19 | end 20 | 21 | describe "#delete" do 22 | uuid = UUID.random 23 | user = User.new(uuid: uuid, name: "John") 24 | 25 | it do 26 | user.delete.build.should eq User.delete.where(uuid: uuid).build 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/model_spec.cr: -------------------------------------------------------------------------------- 1 | require "./models" 2 | 3 | describe Model do 4 | describe "==" do 5 | it do 6 | uuid = UUID.random 7 | (User.new(uuid: uuid) == User.new(uuid: uuid)).should be_true 8 | end 9 | 10 | it do 11 | (User.new(uuid: UUID.random) == User.new(uuid: UUID.random)).should be_false 12 | end 13 | end 14 | 15 | it "has .table method" do 16 | User.table.should eq "users" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/models.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "./dummy_converters/*" 3 | 4 | require "uuid" 5 | require "json" 6 | 7 | alias Model = Onyx::SQL::Model 8 | alias Field = Onyx::SQL::Field 9 | 10 | @[Model::Options(table: "users", primary_key: @uuid)] 11 | class User 12 | include Model 13 | 14 | enum Role 15 | Writer 16 | Moderator 17 | Admin 18 | end 19 | 20 | enum Permission 21 | CreatePosts 22 | EditPosts 23 | end 24 | 25 | struct Meta 26 | include JSON::Serializable 27 | property foo : String? 28 | 29 | def initialize(@foo = nil) 30 | end 31 | end 32 | 33 | schema users do 34 | pkey uuid : UUID, converter: DummyConverters::UUID 35 | 36 | type active : Bool, key: "activity_status", default: true 37 | type role : Role, converter: DummyConverters::Enum(Role), default: true 38 | type permissions : Array(Permission), converter: DummyConverters::Enum(Permission), default: true 39 | type favorite_numbers : Array(Int32), converter: DummyConverters::Int32Array, default: true 40 | type name : String, not_null: true 41 | type balance : Float32, default: true 42 | type meta : Meta, converter: DummyConverters::JSON(Meta), default: true 43 | type created_at : Time, default: true 44 | type updated_at : Time 45 | 46 | type referrer : User, key: "referrer_uuid" 47 | type referrals : Array(User), foreign_key: "referrer_uuid" 48 | type authored_posts : Array(Post), foreign_key: "author_uuid" 49 | type edited_posts : Array(Post), foreign_key: "editor_uuid" 50 | end 51 | end 52 | 53 | class Tag 54 | include Model 55 | 56 | schema tags do 57 | pkey id : Int32 58 | type content : String 59 | type posts : Array(Post), foreign_key: "tag_ids" 60 | end 61 | end 62 | 63 | class Post 64 | include Model 65 | 66 | schema posts do 67 | pkey id : Int32, converter: DummyConverters::Int32Array 68 | 69 | type content : String 70 | type created_at : Time, default: true 71 | type updated_at : Time 72 | 73 | type author : User, key: "author_uuid" 74 | type editor : User, key: "editor_uuid" 75 | type tags : Array(Tag), key: "tag_ids" 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/query/delete_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe "Query#delete" do 4 | it do 5 | uuid = UUID.random 6 | 7 | q = Query(User).new.delete.where(uuid: uuid) 8 | sql, params = q.build 9 | 10 | sql.should eq <<-SQL 11 | DELETE FROM users WHERE (users.uuid = ?) 12 | SQL 13 | 14 | params.to_a.should eq [uuid.to_s] 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/query/group_by_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe "Query#group_by" do 4 | it do 5 | q = Query(User).new.group_by("foo", "bar") 6 | sql, params = q.build 7 | 8 | sql.should eq <<-SQL 9 | SELECT * FROM users GROUP BY foo, bar 10 | SQL 11 | 12 | params.should be_empty 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/query/having_spec.cr: -------------------------------------------------------------------------------- 1 | require "../query_spec" 2 | 3 | describe "Query#having" do 4 | context "without params" do 5 | it do 6 | q = Query(User).new.having("foo") 7 | 8 | sql, params = q.build 9 | sql.should eq <<-SQL 10 | SELECT * FROM users HAVING (foo) 11 | SQL 12 | 13 | params.should be_empty 14 | end 15 | end 16 | 17 | context "with params" do 18 | it do 19 | q = Query(User).new.having("foo = ? AND bar = ?", 42, 43).having("foo") 20 | 21 | sql, params = q.build 22 | sql.should eq <<-SQL 23 | SELECT * FROM users HAVING (foo = ? AND bar = ?) AND (foo) 24 | SQL 25 | 26 | params.to_a.should eq [42, 43] 27 | end 28 | 29 | it "accepts both splat and enumerable params" do 30 | q1 = Query(User).new.having("foo = ? AND bar = ?", 42, 43) 31 | q2 = Query(User).new.having("foo = ? AND bar = ?", {42, 43}) 32 | q1.build.should eq q2.build 33 | end 34 | end 35 | 36 | describe "shorthands" do 37 | describe "#having_not" do 38 | context "without params" do 39 | it do 40 | Query(User).new.having_not("foo = 'bar'").to_s.should eq <<-SQL 41 | SELECT * FROM users HAVING NOT (foo = 'bar') 42 | SQL 43 | end 44 | end 45 | 46 | context "with params" do 47 | it do 48 | q = Query(User).new.having_not("foo = ?", 42) 49 | 50 | sql, params = q.build 51 | sql.should eq <<-SQL 52 | SELECT * FROM users HAVING NOT (foo = ?) 53 | SQL 54 | 55 | params.to_a.should eq [42] 56 | end 57 | end 58 | end 59 | 60 | describe "manually tested" do 61 | uuid = UUID.random 62 | 63 | it do 64 | q = Query(User).new.having("activity_status IS NOT NULL").and_not("name = ?", "John").or_having("foo") 65 | 66 | sql, params = q.build 67 | sql.should eq <<-SQL 68 | SELECT * FROM users HAVING (activity_status IS NOT NULL) AND NOT (name = ?) OR (foo) 69 | SQL 70 | 71 | params.to_a.should eq ["John"] 72 | end 73 | end 74 | 75 | # It has almost zero benefit for you as a reader, but it allows to check that all methods delegate their arguments as expected. 76 | # 77 | # Methods which are tested: 78 | # 79 | # - `#or_having_not` 80 | # - `#or_having` 81 | # - `#and_having_not` 82 | # - `#and_having` 83 | # 84 | # Each method has two variants (clause with params, single clause) and two situations - when it's called for first time (e.g. `Query.new.and_having`) and when it's called afterwards (e.g. `Query.new.having.and_having`), which results in 16 tests. I decided that it would be simpler to use macros, which however require some skill to understand. 85 | {% for or in [true, false] %} 86 | {% for not in [true, false] %} 87 | describe '#' + {{(or ? "or" : "and")}} + "_having" do 88 | context "when first call" do 89 | context "without params" do 90 | it do 91 | Query(User).new.{{(or ? "or" : "and").id}}_having{{"_not".id if not}}("foo = 'bar'").to_s.should eq <<-SQL 92 | SELECT * FROM users HAVING {{"NOT ".id if not}}(foo = 'bar') 93 | SQL 94 | end 95 | end 96 | 97 | context "with params" do 98 | it do 99 | q = Query(User).new.{{(or ? "or" : "and").id}}_having{{"_not".id if not}}("foo = ?", 42) 100 | 101 | sql, params = q.build 102 | sql.should eq <<-SQL 103 | SELECT * FROM users HAVING {{"NOT ".id if not}}(foo = ?) 104 | SQL 105 | 106 | params.to_a.should eq [42] 107 | end 108 | end 109 | end 110 | 111 | context "when non-first call" do 112 | context "without params" do 113 | it do 114 | Query(User).new.having("first = true").{{(or ? "or" : "and").id}}_having{{"_not".id if not}}("foo = 'bar'").to_s.should eq <<-SQL 115 | SELECT * FROM users HAVING (first = true) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(foo = 'bar') 116 | SQL 117 | end 118 | end 119 | 120 | context "with params" do 121 | it do 122 | q = Query(User).new.having("first = true").{{(or ? "or" : "and").id}}_having{{"_not".id if not}}("foo = ?", 42) 123 | 124 | sql, params = q.build 125 | sql.should eq <<-SQL 126 | SELECT * FROM users HAVING (first = true) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(foo = ?) 127 | SQL 128 | 129 | params.to_a.should eq [42] 130 | end 131 | end 132 | end 133 | end 134 | {% end %} 135 | {% end %} 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/query/insert_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe "Query#insert" do 4 | context "with minimum arguments" do 5 | it do 6 | q = Query(User).new.insert(name: "John") 7 | 8 | sql, params = q.build 9 | 10 | sql.should eq <<-SQL 11 | INSERT INTO users (name) VALUES (?) 12 | SQL 13 | 14 | params.to_a.should eq ["John"] 15 | end 16 | end 17 | 18 | context "with explicit arguments" do 19 | it do 20 | q = Query(User).new.insert(:created_at, "now()") 21 | 22 | sql, params = q.build 23 | 24 | sql.should eq <<-SQL 25 | INSERT INTO users (created_at) VALUES (now()) 26 | SQL 27 | 28 | params.should be_empty 29 | end 30 | end 31 | 32 | context "with many arguments" do 33 | it do 34 | ref_uuid = UUID.random 35 | 36 | q = Query(User).new.insert( 37 | referrer: User.new(uuid: ref_uuid, name: "Jake"), 38 | role: User::Role::Moderator, 39 | permissions: [User::Permission::CreatePosts, User::Permission::EditPosts], 40 | name: "John", 41 | favorite_numbers: [3, 17, 42] 42 | ) 43 | 44 | sql, params = q.build 45 | 46 | sql.should eq <<-SQL 47 | INSERT INTO users (referrer_uuid, role, permissions, name, favorite_numbers) VALUES (?, ?, ?, ?, ?) 48 | SQL 49 | 50 | params.to_a.should eq [ref_uuid.to_s, "Moderator", "CreatePosts,EditPosts", "John", "3,17,42"] 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/query/join_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe "Query#join" do 4 | context "explicit" do 5 | it do 6 | query = Query(User).new.join("some_table", "users.some_keys @> some_table.key", type: :right) 7 | 8 | sql, params = query.build 9 | 10 | sql.should eq <<-SQL 11 | SELECT users.* FROM users RIGHT JOIN some_table ON users.some_keys @> some_table.key 12 | SQL 13 | 14 | params.should be_empty 15 | end 16 | end 17 | 18 | context "with enumerable foreign reference" do 19 | it do 20 | query = Query(User).new.join(:authored_posts) 21 | 22 | sql, params = query.build 23 | 24 | sql.should eq <<-SQL 25 | SELECT users.* FROM users INNER JOIN posts AS authored_posts ON users.uuid = authored_posts.author_uuid 26 | SQL 27 | 28 | params.should be_empty 29 | end 30 | 31 | context "with nested query" do 32 | it do 33 | query = Query(User).new 34 | .join(authored_posts: true) do |q| 35 | q.select(:content) 36 | q.where(content: "foo") 37 | end 38 | 39 | sql, params = query.build 40 | 41 | # Foreign enumerable references' selects are ommited 42 | sql.should eq <<-SQL 43 | SELECT users.* FROM users INNER JOIN posts AS authored_posts ON users.uuid = authored_posts.author_uuid WHERE (authored_posts.content = ?) 44 | SQL 45 | 46 | params.to_a.should eq ["foo"] 47 | end 48 | end 49 | 50 | context "with deep nested query" do 51 | it do 52 | uuid = UUID.random 53 | 54 | query = Query(User).new 55 | .join authored_posts: true, type: Query::JoinType::Right do |q| 56 | q.select(:content) 57 | q.where(content: "foo") 58 | 59 | q.join editor: true, as: "the_editor" do |qq| 60 | qq.select(:name) 61 | qq.where(uuid: uuid) 62 | end 63 | 64 | q.join tags: true do |qq| 65 | qq.where("tags.content LIKE %foo%") 66 | end 67 | end 68 | 69 | sql, params = query.build 70 | 71 | # ditto 72 | sql.should eq <<-SQL 73 | SELECT users.* FROM users RIGHT JOIN posts AS authored_posts ON users.uuid = authored_posts.author_uuid INNER JOIN users AS the_editor ON the_editor.uuid = authored_posts.editor_uuid INNER JOIN tags AS tags ON tags.id IN authored_posts.tag_ids WHERE (authored_posts.content = ?) AND (the_editor.uuid = ?) AND (tags.content LIKE %foo%) 74 | SQL 75 | 76 | params.to_a.should eq ["foo", uuid.to_s] 77 | end 78 | end 79 | 80 | context "with additional arguments" do 81 | it do 82 | query = Query(User).new.join(:authored_posts, type: :right, as: "the_posts") 83 | 84 | query.to_s.should eq <<-SQL 85 | SELECT users.* FROM users RIGHT JOIN posts AS the_posts ON users.uuid = the_posts.author_uuid 86 | SQL 87 | end 88 | end 89 | end 90 | 91 | context "with foreign reference with self referenced as enumerable" do 92 | it do 93 | query = Query(Tag).new.join(:posts) 94 | 95 | query.to_s.should eq <<-SQL 96 | SELECT tags.* FROM tags INNER JOIN posts AS posts ON tags.id IN posts.tag_ids 97 | SQL 98 | end 99 | end 100 | 101 | context "with direct reference" do 102 | it do 103 | query = Query(Post).new.join(:author, type: :left) 104 | 105 | query.to_s.should eq <<-SQL 106 | SELECT posts.* FROM posts LEFT JOIN users AS author ON author.uuid = posts.author_uuid 107 | SQL 108 | end 109 | end 110 | 111 | context "with enumerable direct reference" do 112 | it do 113 | query = Query(Post).new.join(:tags) 114 | 115 | query.to_s.should eq <<-SQL 116 | SELECT posts.* FROM posts INNER JOIN tags AS tags ON tags.id IN posts.tag_ids 117 | SQL 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/query/limit_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe "Query#limit" do 4 | context "with int argument" do 5 | it do 6 | q = Query(User).new.limit(2) 7 | 8 | sql, params = q.build 9 | 10 | sql.should eq <<-SQL 11 | SELECT * FROM users LIMIT 2 12 | SQL 13 | 14 | params.should be_empty 15 | end 16 | end 17 | 18 | context "with nil argument" do 19 | it do 20 | q = Query(User).new.limit(nil) 21 | 22 | sql, params = q.build 23 | 24 | sql.should eq <<-SQL 25 | SELECT * FROM users 26 | SQL 27 | 28 | params.should be_empty 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/query/offset_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe "Query#offset" do 4 | context "with int argument" do 5 | it do 6 | q = Query(User).new.offset(2) 7 | 8 | sql, params = q.build 9 | 10 | sql.should eq <<-SQL 11 | SELECT * FROM users OFFSET 2 12 | SQL 13 | 14 | params.should be_empty 15 | end 16 | end 17 | 18 | context "with nil argument" do 19 | it do 20 | q = Query(User).new.offset(nil) 21 | 22 | sql, params = q.build 23 | 24 | sql.should eq <<-SQL 25 | SELECT * FROM users 26 | SQL 27 | 28 | params.should be_empty 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/query/order_by_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe "Query#order_by" do 4 | context "with attribute argument" do 5 | it do 6 | q = Query(User).new.order_by(:active, :desc) 7 | 8 | sql, params = q.build 9 | 10 | sql.should eq <<-SQL 11 | SELECT * FROM users ORDER BY users.activity_status DESC 12 | SQL 13 | 14 | params.should be_empty 15 | end 16 | end 17 | 18 | context "with string argument" do 19 | it do 20 | q = Query(User).new.order_by("some_column") 21 | 22 | sql, params = q.build 23 | 24 | sql.should eq <<-SQL 25 | SELECT * FROM users ORDER BY some_column 26 | SQL 27 | 28 | params.should be_empty 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/query/select_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe "Query#select" do 4 | context "without call" do 5 | it do 6 | q = Query(User).new 7 | 8 | sql, params = q.build 9 | 10 | sql.should eq <<-SQL 11 | SELECT * FROM users 12 | SQL 13 | 14 | params.should be_empty 15 | end 16 | end 17 | 18 | context "with call" do 19 | it do 20 | q = Query(User).new.select(:active, "foo") 21 | 22 | sql, params = q.build 23 | 24 | sql.should eq <<-SQL 25 | SELECT users.activity_status, foo FROM users 26 | SQL 27 | 28 | params.should be_empty 29 | end 30 | end 31 | 32 | context "with nil call" do 33 | it do 34 | q = Query(User).new.select(nil) 35 | 36 | expect_raises Exception do 37 | q.build 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/query/update_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe "Query#update" do 4 | it do 5 | uuid = UUID.random 6 | ref_uuid = UUID.random 7 | 8 | q = Query(User).new 9 | .update 10 | .set(name: "John") 11 | .set("activity_status = DEFAULT") 12 | .set(referrer: User.new( 13 | uuid: ref_uuid, 14 | name: "Jake" 15 | ), updated_at: nil, favorite_numbers: [0, 1]) 16 | .where(uuid: uuid) 17 | 18 | sql, params = q.build 19 | 20 | sql.should eq <<-SQL 21 | UPDATE users SET name = ?, activity_status = DEFAULT, referrer_uuid = ?, updated_at = ?, favorite_numbers = ? WHERE (users.uuid = ?) 22 | SQL 23 | 24 | params.to_a.should eq ["John", ref_uuid.to_s, nil, "0,1", uuid.to_s] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/query/where_spec.cr: -------------------------------------------------------------------------------- 1 | require "../models" 2 | 3 | describe "Query#where" do 4 | context "with explicit clause" do 5 | context "with params" do 6 | it do 7 | q = Query(User).new.where("foo = ? AND bar = ?", 42, 43) 8 | 9 | sql, params = q.build 10 | 11 | sql.should eq <<-SQL 12 | SELECT * FROM users WHERE (foo = ? AND bar = ?) 13 | SQL 14 | 15 | params.to_a.should eq [42, 43] 16 | end 17 | 18 | it "accepts both splat and enumerable params" do 19 | q1 = Query(User).new.where("foo = ? AND bar = ?", 42, 43) 20 | q2 = Query(User).new.where("foo = ? AND bar = ?", [42, 43]) 21 | q1.build.should eq q2.build 22 | end 23 | end 24 | 25 | context "without params" do 26 | it do 27 | q = Query(User).new.where("foo") 28 | 29 | sql, params = q.build 30 | 31 | sql.should eq <<-SQL 32 | SELECT * FROM users WHERE (foo) 33 | SQL 34 | 35 | params.should be_empty 36 | end 37 | end 38 | end 39 | 40 | context "with fields" do 41 | it do 42 | q = Query(User).new.where(active: true, name: "John") 43 | 44 | sql, params = q.build 45 | 46 | sql.should eq <<-SQL 47 | SELECT * FROM users WHERE (users.activity_status = ? AND users.name = ?) 48 | SQL 49 | 50 | params.to_a.should eq [true, "John"] 51 | end 52 | end 53 | 54 | context "with references" do 55 | uuid = UUID.random 56 | 57 | it do 58 | q = Query(Post).new.where(author: User.new(uuid: uuid, name: "Jake")) 59 | 60 | sql, params = q.build 61 | 62 | sql.should eq <<-SQL 63 | SELECT * FROM posts WHERE (posts.author_uuid = ?) 64 | SQL 65 | 66 | params.to_a.should eq [uuid.to_s] 67 | end 68 | end 69 | 70 | describe "shorthands" do 71 | describe "#where_not" do 72 | context "with explicit clause" do 73 | context "without params" do 74 | it do 75 | Query(User).new.where_not("foo = 'bar'").to_s.should eq <<-SQL 76 | SELECT * FROM users WHERE NOT (foo = 'bar') 77 | SQL 78 | end 79 | end 80 | 81 | context "with params" do 82 | it do 83 | q = Query(User).new.where_not("foo = ?", 42) 84 | 85 | sql, params = q.build 86 | 87 | sql.should eq <<-SQL 88 | SELECT * FROM users WHERE NOT (foo = ?) 89 | SQL 90 | 91 | params.to_a.should eq [42] 92 | end 93 | end 94 | end 95 | 96 | context "with named arguments" do 97 | it do 98 | q = Query(User).new.where_not(active: true, name: "John") 99 | 100 | sql, params = q.build 101 | 102 | sql.should eq <<-SQL 103 | SELECT * FROM users WHERE NOT (users.activity_status = ? AND users.name = ?) 104 | SQL 105 | 106 | params.to_a.should eq [true, "John"] 107 | end 108 | end 109 | end 110 | 111 | describe "manually tested" do 112 | uuid = UUID.random 113 | 114 | it do 115 | q = Query(User).new.where(uuid: uuid).and_where("activity_status IS NOT NULL").and_not("name = ?", "John") 116 | 117 | sql, params = q.build 118 | 119 | sql.should eq <<-SQL 120 | SELECT * FROM users WHERE (users.uuid = ?) AND (activity_status IS NOT NULL) AND NOT (name = ?) 121 | SQL 122 | 123 | params.to_a.should eq [uuid.to_s, "John"] 124 | end 125 | end 126 | 127 | # It has almost zero benefit for you as a reader, but it allows to check that all methods delegate their arguments as expected. 128 | # 129 | # Methods which are tested: 130 | # 131 | # - `#or_where_not` 132 | # - `#or_where` 133 | # - `#and_where_not` 134 | # - `#and_where` 135 | # 136 | # Each method has three variants (clause with params, single clause, named arguments) and two situations - when it's called for first time (e.g. `Query.new.and_where`) and when it's called afterwards (e.g. `Query.new.where.and_where`), which results in 24 tests. I decided that it would be simpler to use macros, which however require some skill to understand. 137 | {% for or in [true, false] %} 138 | {% for not in [true, false] %} 139 | describe '#' + {{(or ? "or" : "and")}} + "_where" do 140 | context "when first call" do 141 | context "with explicit clause" do 142 | context "without params" do 143 | it do 144 | Query(User).new.{{(or ? "or" : "and").id}}_where{{"_not".id if not}}("foo = 'bar'").to_s.should eq <<-SQL 145 | SELECT * FROM users WHERE {{"NOT ".id if not}}(foo = 'bar') 146 | SQL 147 | end 148 | end 149 | 150 | context "with params" do 151 | it do 152 | q = Query(User).new.{{(or ? "or" : "and").id}}_where{{"_not".id if not}}("foo = ?", 42) 153 | 154 | sql, params = q.build 155 | 156 | sql.should eq <<-SQL 157 | SELECT * FROM users WHERE {{"NOT ".id if not}}(foo = ?) 158 | SQL 159 | 160 | params.to_a.should eq [42] 161 | end 162 | end 163 | 164 | context "with named arguments" do 165 | it do 166 | q = Query(User).new.{{(or ? "or" : "and").id}}_where{{"_not".id if not}}(active: true, name: "John") 167 | 168 | sql, params = q.build 169 | 170 | sql.should eq <<-SQL 171 | SELECT * FROM users WHERE {{"NOT ".id if not}}(users.activity_status = ? AND users.name = ?) 172 | SQL 173 | 174 | params.to_a.should eq [true, "John"] 175 | end 176 | end 177 | end 178 | end 179 | 180 | context "when non-first call" do 181 | context "with explicit clause" do 182 | context "without params" do 183 | it do 184 | Query(User).new.where("first = true").{{(or ? "or" : "and").id}}_where{{"_not".id if not}}("foo = 'bar'").to_s.should eq <<-SQL 185 | SELECT * FROM users WHERE (first = true) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(foo = 'bar') 186 | SQL 187 | end 188 | end 189 | 190 | context "with params" do 191 | it do 192 | q = Query(User).new.where("first = true").{{(or ? "or" : "and").id}}_where{{"_not".id if not}}("foo = ?", 42) 193 | 194 | sql, params = q.build 195 | 196 | sql.should eq <<-SQL 197 | SELECT * FROM users WHERE (first = true) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(foo = ?) 198 | SQL 199 | 200 | params.to_a.should eq [42] 201 | end 202 | end 203 | end 204 | 205 | context "with named arguments" do 206 | it do 207 | q = Query(User).new.where("first = true").{{(or ? "or" : "and").id}}_where{{"_not".id if not}}(active: true, name: "John") 208 | 209 | sql, params = q.build 210 | 211 | sql.should eq <<-SQL 212 | SELECT * FROM users WHERE (first = true) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(users.activity_status = ? AND users.name = ?) 213 | SQL 214 | 215 | params.to_a.should eq [true, "John"] 216 | end 217 | end 218 | end 219 | end 220 | {% end %} 221 | {% end %} 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /spec/query_spec.cr: -------------------------------------------------------------------------------- 1 | require "./models" 2 | 3 | alias Query = Onyx::SQL::Query 4 | 5 | describe Query do 6 | {% for joinder in %w(and or) %} 7 | {% for not in [true, false] %} 8 | describe '#' + {{joinder}} + {{not ? "_not" : ""}} do 9 | {% for wherish in %w(where having) %} 10 | context "after " + {{wherish}} do 11 | it "calls " + {{wherish}} do 12 | Query(User).new.{{wherish.id}}("foo").{{joinder.id}}{{"_not".id if not}}("bar").should eq Query(User).new.{{wherish.id}}("foo").{{joinder.id}}_{{wherish.id}}{{"_not".id if not}}("bar") 13 | end 14 | end 15 | {% end %} 16 | end 17 | {% end %} 18 | {% end %} 19 | end 20 | -------------------------------------------------------------------------------- /spec/repository/exec_spec.cr: -------------------------------------------------------------------------------- 1 | require "../repository_spec" 2 | 3 | describe Repository do 4 | db = MockDB.new 5 | repo = Repository.new(db) 6 | 7 | describe "#exec" do 8 | context "with paramsless Query" do 9 | result = repo.exec(Query(User).new.update.set("foo = 42")) 10 | 11 | it "calls DB#exec with valid sql" do 12 | db.latest_exec_sql.should eq <<-SQL 13 | UPDATE users SET foo = 42 14 | SQL 15 | end 16 | 17 | it "does not pass any params to DB#exec" do 18 | db.latest_exec_params.not_nil!.should be_empty 19 | end 20 | end 21 | 22 | context "with params Query" do 23 | result = repo.exec(Query(User).new.update.set(active: true)) 24 | 25 | it "calls DB#exec with valid sql" do 26 | db.latest_exec_sql.should eq <<-SQL 27 | UPDATE users SET activity_status = ? 28 | SQL 29 | end 30 | 31 | it "pass params to DB#exec" do 32 | db.latest_exec_params.should eq [true] 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/repository/logger/dummy_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe Onyx::SQL::Repository::Logger::Dummy do 4 | it do 5 | logger = Onyx::SQL::Repository::Logger::Dummy.new 6 | logger.wrap("foo") { "bar" }.should eq "bar" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/repository/logger/io_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe Onyx::SQL::Repository::Logger::IO do 4 | it do 5 | io = IO::Memory.new 6 | logger = Onyx::SQL::Repository::Logger::IO.new(io, false) 7 | logger.wrap("foo") { "bar" }.should eq "bar" 8 | io.to_s.should match %r{foo\n.+s\n} 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/repository/logger/standard_spec.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | describe Onyx::SQL::Repository::Logger::Standard do 4 | it do 5 | io = IO::Memory.new 6 | standard_logger = ::Logger.new(io, ::Logger::Severity::DEBUG) 7 | logger = Onyx::SQL::Repository::Logger::Standard.new(standard_logger, ::Logger::Severity::INFO, false) 8 | logger.wrap("foo") { "bar" }.should eq "bar" 9 | io.to_s.should match %r{I, \[.+\] INFO -- : foo\nI, \[.+\] INFO -- : .+s\n} 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/repository/query_spec.cr: -------------------------------------------------------------------------------- 1 | require "../repository_spec" 2 | 3 | describe Repository do 4 | db = MockDB.new 5 | repo = Repository.new(db) 6 | 7 | describe "#query" do 8 | context "with Query" do 9 | time = Time.now - 3.days 10 | result = repo.query(Query(User).new.select(:active).where("created_at > ?", time).order_by(:uuid, :desc).one) 11 | 12 | it "calls #query with valid sql" do 13 | db.latest_query_sql.should eq <<-SQL 14 | SELECT users.activity_status FROM users WHERE (created_at > ?) ORDER BY users.uuid DESC LIMIT 1 15 | SQL 16 | end 17 | 18 | it "calls #query with valid params" do 19 | db.latest_query_params.should eq [time] 20 | end 21 | 22 | it "returns model instance" do 23 | result.should be_a(Array(User)) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/repository/scalar_spec.cr: -------------------------------------------------------------------------------- 1 | require "../repository_spec" 2 | 3 | describe Repository do 4 | db = MockDB.new 5 | repo = Repository.new(db) 6 | 7 | describe "#scalar" do 8 | context "with paramsless Query" do 9 | result = repo.scalar(Query(User).new.update.set("foo = 42").returning(:uuid)) 10 | 11 | it "calls DB#scalar with valid sql" do 12 | db.latest_scalar_sql.should eq <<-SQL 13 | UPDATE users SET foo = 42 RETURNING users.uuid 14 | SQL 15 | end 16 | 17 | it "does not pass any params to DB#scalar" do 18 | db.latest_scalar_params.not_nil!.should be_empty 19 | end 20 | end 21 | 22 | context "with params Query" do 23 | result = repo.scalar(Query(User).new.update.set(active: true).returning(:active)) 24 | 25 | it "calls DB#scalar with valid sql" do 26 | db.latest_scalar_sql.should eq <<-SQL 27 | UPDATE users SET activity_status = ? RETURNING users.activity_status 28 | SQL 29 | end 30 | 31 | it "pass params to DB#scalar" do 32 | db.latest_scalar_params.should eq [true] 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/repository_spec.cr: -------------------------------------------------------------------------------- 1 | require "./models" 2 | 3 | alias Repository = Onyx::SQL::Repository 4 | 5 | class MockDB 6 | record ResultSet 7 | 8 | class Driver 9 | end 10 | 11 | getter driver = Driver.new 12 | 13 | def initialize 14 | end 15 | 16 | getter latest_exec_sql : String? = nil 17 | getter latest_exec_params : Enumerable(DB::Any)? = nil 18 | 19 | def exec(sql, *params : DB::Any) 20 | @latest_exec_sql = sql 21 | @latest_exec_params = params.to_a 22 | return DB::ExecResult.new(0, 0) 23 | end 24 | 25 | def exec(sql, params : Enumerable(DB::Any)? = nil) 26 | @latest_exec_sql = sql 27 | @latest_exec_params = params.try &.to_a 28 | return DB::ExecResult.new(0, 0) 29 | end 30 | 31 | getter latest_scalar_sql : String? = nil 32 | getter latest_scalar_params : Enumerable(DB::Any)? = nil 33 | 34 | def scalar(sql : String, *params) 35 | @latest_scalar_sql = sql 36 | @latest_scalar_params = params.to_a 37 | end 38 | 39 | def scalar(sql : String, params : Enumerable(DB::Any)? = nil) 40 | @latest_scalar_sql = sql 41 | @latest_scalar_params = params.try &.to_a 42 | nil.as(DB::Any) 43 | end 44 | 45 | getter latest_query_sql : String? = nil 46 | getter latest_query_params : Enumerable(DB::Any)? = nil 47 | 48 | def query(sql, *params : DB::Any) 49 | @latest_query_sql = sql 50 | @latest_query_params = params.to_a 51 | ResultSet.new 52 | end 53 | 54 | def query(sql, params : Enumerable(DB::Any)? = nil) 55 | @latest_query_sql = sql 56 | @latest_query_params = params.try &.to_a 57 | ResultSet.new 58 | end 59 | end 60 | 61 | module Onyx::SQL 62 | class Repository 63 | def initialize(@db : MockDB, @logger = Onyx::SQL::Repository::Logger::Dummy.new) 64 | end 65 | 66 | protected def db_driver 67 | db.driver 68 | end 69 | end 70 | end 71 | 72 | class User 73 | def self.from_rs(rs : MockDB::ResultSet) 74 | [self.new(name: "Jake")] 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/onyx-sql" 3 | 4 | alias BulkQuery = Onyx::SQL::BulkQuery 5 | -------------------------------------------------------------------------------- /src/onyx-sql.cr: -------------------------------------------------------------------------------- 1 | require "db" 2 | require "./onyx-sql/*" 3 | 4 | # An SQL ORM for Crystal. 5 | module Onyx::SQL 6 | # Use this annotation to mark an Object's instance variable as an SQL field. 7 | # It's not mandatory, though, as including `Serializable` and `Model` has meaningful defaults. 8 | # 9 | # ## `:key` option 10 | # 11 | # The serialization process would rely on a variable name when mapping a database column 12 | # by default. You can alter this behaviour with the `:key` option. For example: 13 | # 14 | # ``` 15 | # class User 16 | # include Onyx::SQL::Model 17 | # @id : Int32? 18 | # end 19 | # 20 | # User.db_column(:id) # => "id" 21 | # User.db_values(id: 42) # => 42 22 | # ``` 23 | # 24 | # By default, the serialization code would look for column named `"id"`. 25 | # But you can change it: 26 | # 27 | # ``` 28 | # class User 29 | # include Onyx::SQL::Model 30 | # 31 | # @[Onyx::SQL::Field(key: "the_id")] 32 | # @id : Int32? 33 | # end 34 | # 35 | # User.db_column(:id) # => "the_id" 36 | # ``` 37 | # 38 | # Now the serialization will map from `"the_id"` column to the `@id` instance variable. 39 | # 40 | # ## `:converter` option 41 | # 42 | # There is a `:converter` option which would define a converter to use for the 43 | # serialization. For example, you have an integer enum column with value `0` stored in 44 | # an SQLite database. In this case, the `Converters::SQLite3::EnumInt` would be helpful: 45 | # 46 | # ``` 47 | # class User 48 | # enum Role 49 | # Writer 50 | # Moderator 51 | # end 52 | # 53 | # @[Onyx::SQL::Field(converter: SQLite3::EnumInt(Role))] 54 | # @role : Role 55 | # end 56 | # 57 | # User.db_values(role: User::Role::Writer) # => 1 58 | # ``` 59 | # 60 | # From now on, the serialization would expect an `INT` column and try to parse 61 | # the `User::Role` enum out from it. 62 | # 63 | # ## `:not_null` option 64 | # 65 | # Set to `true` if a field column is `NOT NULL` in the DB. It is used in `Query` 66 | # builder. For example, it would prohibit doing some queries with `not_null` fields 67 | # with an actual `nil` value: 68 | # 69 | # ``` 70 | # class User 71 | # @[Onyx::SQL::Field(not_null: true)] 72 | # @name : String? 73 | # end 74 | # 75 | # User.update.set(name: nil) # Compilation-time error 76 | # User.insert(name: nil) # Compilation-time error 77 | # ``` 78 | # 79 | # NOTE: `User.new(name: nil).insert` (the instance-level `Model#insert` shortcut) would 80 | # raise `NilAssertionError` in **runtime**, not in compilation-time! So, for increased 81 | # type-safety, consider using class-level `User.insert(name: nil)`, which would raise 82 | # in compilation time instead. 83 | # 84 | # `Model::Changeset` will also get affected by this option: you will not be able to call 85 | # `Model::Changeset#update` with a `nil` value on a `not_null` field. 86 | # 87 | # You will also be unable to **set** the field to a `nil` value when 88 | # using `schema` (see below): 89 | # 90 | # ``` 91 | # class User 92 | # schema users do 93 | # type name : String, not_null: true 94 | # end 95 | # end 96 | # 97 | # user.name = nil # Compilation-time error 98 | # pp user.name # String only, raise `NilAssertionError` if `nil` 99 | # ``` 100 | # 101 | # ## `:default` option 102 | # 103 | # Will mark a field as having `DEFAULT` value in the DB. From now on, a `nil` value 104 | # will be ignored on inserting the model instance: 105 | # 106 | # ``` 107 | # class User 108 | # @name : String? 109 | # 110 | # @[Onyx::SQL::Field(default: true)] 111 | # @created_at : Time? 112 | # end 113 | # 114 | # User.new(name: "John").insert.to_s # INSERT INTO users (name) VALUES (?) 115 | # # But 116 | # User.new(name: "John", created_at: Time.now).to_s # INSERT INTO users (name, created_at) VALUES (?, ?) 117 | # ``` 118 | # 119 | # ## Usage in schema 120 | # 121 | # `Model.schema` DSL macro automatically passes `Model.type` and `Model.pkey` options to a `Field` annotation, 122 | # unless it's a `Model` reference. See `Reference` docs. 123 | # 124 | # ``` 125 | # class User 126 | # include Onyx::SQL::Model 127 | # 128 | # schema users do 129 | # pkey id : Int32, converter: PG::Any(Int32) 130 | # type username : String, key: "the_username", not_null: true 131 | # type age : Int32 132 | # end 133 | # end 134 | # 135 | # # Expands to 136 | # 137 | # @[Onyx::SQL::Model::Options(table: "users", primary_key: @id)] 138 | # class User 139 | # include Onyx::SQL::Model 140 | # 141 | # @[Onyx::SQL::Field(converter: PG::Any(Int32))] 142 | # property! id : Int32 143 | # 144 | # @[Onyx::SQL::Field(key: "the_username", not_null: true)] 145 | # property! username : String 146 | # 147 | # property age : Int32? 148 | # end 149 | # ``` 150 | annotation Field 151 | end 152 | 153 | # Use this annotation to mark an Object's variable as an SQL Reference. 154 | # Both `Model` and bare `Serializable` object can have `Reference` instance variables. 155 | # 156 | # You have to decide what type of reference to use. There are two options -- *direct references* 157 | # and *foreign references*. 158 | # 159 | # ## Direct references 160 | # 161 | # Direct reference should be understood as *this record stores an another record reference 162 | # in this instance variable* and it is determined by the `:key` option. The referenced object 163 | # **must** have `Model::Options` annotation with `:table` and `:primary_key` options. 164 | # Additionaly, a matching accessor must be defined for the primary key (e.g. `User#id` 165 | # in the example below)): 166 | # 167 | # ``` 168 | # @[Onyx::SQL::Model::Options(table: "users", primary_key: @id)] 169 | # class User 170 | # include Onyx::SQL::Model 171 | # property! id : Int32 172 | # end 173 | # 174 | # class Post 175 | # include Onyx::SQL::Model 176 | # 177 | # @[Onyx::SQL::Reference(key: "author_id")] 178 | # property! author : User 179 | # end 180 | # ``` 181 | # 182 | # In this example, a `Post` might have a `User` instance stored in the `@author` variable, 183 | # which is a *direct reference*. When making an SQL query, this instance is implicitly cast to 184 | # a database type -- an `"author_id"` column with `Int32` type: 185 | # 186 | # ``` 187 | # Post.db_column(:author) # => "author_id" 188 | # Post.db_values(author: post.user) # => 42 (the user's ID) 189 | # ``` 190 | # 191 | # If a reference is direct (i.e. has the `:key` option), a referenced instance is initialized 192 | # as soon as a database result set reads that key. For example, if the result set has "author_id" 193 | # key with value `42`, the `Post` instance will be initialized as 194 | # 195 | # ``` 196 | # > 197 | # ``` 198 | # 199 | # And if you want to preload a reference field (or sub-reference), you should use `JOIN`. 200 | # See `Query#join` for more details. 201 | # 202 | # You can make both enumerable and non-enumerable variables references. 203 | # It is impossible to preload enumerable references, though, because the result set is read 204 | # row-by-row. 205 | # 206 | # ## Foreign references 207 | # 208 | # Let's extend the previous example: 209 | # 210 | # ``` 211 | # @[Onyx::SQL::Model::Options(table: "users", primary_key: @id)] 212 | # class User 213 | # include Onyx::SQL::Model 214 | # 215 | # property! id : Int32 216 | # 217 | # @[Onyx::SQL::Reference(foreign_key: "author_id")] 218 | # property! authored_posts : Array(Post) 219 | # end 220 | # 221 | # @[Onyx::SQL::Model::Options(table: "posts", primary_key: @id)] 222 | # class Post 223 | # include Onyx::SQL::Model 224 | # 225 | # property! id : Int32 226 | # 227 | # @[Onyx::SQL::Reference(key: "author_id")] 228 | # property! author : User 229 | # end 230 | # ``` 231 | # 232 | # As you may notice, the `User` class now got the `authored_posts` reference and the `Post` class 233 | # now has the `Model::Options` annotation. A user has a list of all the posts authored by them, 234 | # which is essentialy a *foreign reference*. Basically, the ORM requires that both classes 235 | # have the link defined -- a direct reference in the first and a foreign in the second. But 236 | # don't you worry, it will raise in compilation time and tell you about that. 237 | # 238 | # Foreign references can be joined as well, but it also implies the inability to preload enumerable 239 | # references. However, it works with single foreign references like in this example: 240 | # 241 | # ``` 242 | # class User 243 | # @[Onyx::SQL::Reference(foreign_key: "user_id")] 244 | # property! settings : Settings 245 | # end 246 | # 247 | # class Settings 248 | # property! foo : String 249 | # 250 | # @[Onyx::SQL::Reference(key: "user_id")] 251 | # property! user : User 252 | # end 253 | # 254 | # user = repo.query(User.join(:settings)(&.select(:foo)).where(id: 42)) 255 | # pp user # => > 256 | # ``` 257 | # 258 | # NOTE: You must not use both `:key` and `:foreign_key` options on a single instance variable. 259 | # 260 | # `Reference` annotation also accepts `not_null` option, which is equal to the `Field`s. 261 | # Mark a reference `not_null` if it has a `NOT NULL` clause in the database. 262 | # 263 | # ## Usage in schema 264 | # 265 | # `Model.schema` DSL macro effectively reduces and beautifies the code, 266 | # as the `Reference` annotation is automatically applied if a `Model.type` type is `Model` itself 267 | # and has either `:key` or `:foreign_key` option: 268 | # 269 | # ``` 270 | # class User 271 | # include Onyx::SQL::Model 272 | # 273 | # schema users do 274 | # pkey id : Int32, converter: PG::Any(Int32) 275 | # type authored_posts : Array(Post), foreign_key: "author_id" # This 276 | # end 277 | # end 278 | # 279 | # class Post 280 | # include Onyx::SQL::Model 281 | # 282 | # schema posts do 283 | # pkey id : Int32, converter: PG::Any(Int32) 284 | # type author : User, key: "author_id", not_null: true # And this 285 | # end 286 | # end 287 | # ``` 288 | annotation Reference 289 | end 290 | end 291 | -------------------------------------------------------------------------------- /src/onyx-sql/bulk_query.cr: -------------------------------------------------------------------------------- 1 | require "./ext/enumerable/bulk_query" 2 | require "./bulk_query/*" 3 | 4 | module Onyx::SQL 5 | # Bulk query builder. 6 | # It allows to `#insert` and `#delete` multiple instances in one SQL query. 7 | # Its API is similar to `Query` — you can `#build` it and turn `#to_s`, 8 | # as well as pass it to a `Repository`. 9 | # 10 | # `Enumerable` is monkey-patched with argless `insert` and `update` methods. 11 | # 12 | # ``` 13 | # users = [User.new(name: "Jake"), User.new(name: "John")] 14 | # 15 | # query = BulkQuery.insert(users) 16 | # # Or 17 | # query = users.insert 18 | # 19 | # query.build == {"INSERT INTO users (name) VALUES (?), (?)", ["Jake", "John"]} 20 | # 21 | # users = repo.query(users.insert.returning(:id)) 22 | # ``` 23 | class BulkQuery(T) 24 | # Possible bulk query types. TODO: Add `Update`. 25 | enum Type 26 | Insert 27 | Delete 28 | end 29 | 30 | # Model instances associated with this query. 31 | getter instances : Enumerable(T) 32 | 33 | # Query type. 34 | getter type : Type 35 | 36 | def initialize(@type : Type, @instances) 37 | end 38 | 39 | # Return the SQL representation of this bulk query. Pass `true` to replace `"?"` 40 | # query arguments with `"$n"`, which would work for PostgreSQL. 41 | def to_s(index_params = false) 42 | io = IO::Memory.new 43 | to_s(io, params: nil, index_params: index_params) 44 | io.to_s 45 | end 46 | 47 | # Put the SQL representation of this bulk query into the *io*. 48 | # Pass `true` for *index_params* to replace `"?"` query arguments with `"$n"`, 49 | # which would work for PostgreSQL. 50 | def to_s(io, index_params = false) 51 | to_s(io, params: nil, index_params: index_params) 52 | end 53 | 54 | # Build this bulk query, returning its SQL representation and `Enumerable` 55 | # of DB-ready params. Pass `true` to replace `"?"` query arguments with `"$n"`, 56 | # which would work for PostgreSQL. 57 | def build(index_params = false) : Tuple(String, Enumerable(DB::Any)) 58 | sql = IO::Memory.new 59 | params = Array(DB::Any).new 60 | 61 | to_s(sql, params, index_params) 62 | 63 | return sql.to_s, params 64 | end 65 | 66 | protected def to_s(io, params = nil, index_params = false) 67 | index = index_params ? ParamIndex.new : nil 68 | 69 | case @type 70 | when Type::Insert 71 | append_insert(io, params, index) 72 | append_returning(io, params, index) 73 | when Type::Delete 74 | append_delete(io, params, index) 75 | append_returning(io, params, index) 76 | end 77 | end 78 | 79 | private class ParamIndex 80 | property value = 0 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /src/onyx-sql/bulk_query/delete.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class BulkQuery(T) 3 | # Create a bulk deletion query. `Enumerable` has this method too. 4 | # 5 | # NOTE: Deletion relies on instances' primary key values. The query would **raise** 6 | # `NilAssertionError` upon building if any of the instances has its primary key `nil`. 7 | # 8 | # ``` 9 | # BulkQuery.delete(users) == users.delete 10 | # ``` 11 | def self.delete(instances : Enumerable(T)) 12 | new(:delete, instances) 13 | end 14 | 15 | protected def append_delete(sql, *args) 16 | raise "No instances to delete" if @instances.empty? 17 | 18 | {% begin %} 19 | {% table = T.annotation(SQL::Model::Options)[:table] %} 20 | sql << "DELETE FROM {{table.id}}" 21 | append_where(sql, *args) 22 | {% end %} 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /src/onyx-sql/bulk_query/insert.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class BulkQuery(T) 3 | # Create a bulk insertion query. `Enumerable` has this method too. 4 | # 5 | # The resulting query would have only those columns to insert which have 6 | # a non-nil value in **at least one** instance. 7 | # 8 | # NOTE: Similar to `Model#insert`, this method would **raise** `NilAssertionError` 9 | # if any of `not_null` model variables is actually `nil`. 10 | # 11 | # NOTE: SQLite3 does not support `DEFAULT` keyword as an insertion value. A query 12 | # would raise `near "DEFAULT": syntax error (SQLite3::Exception)` if any of 13 | # the instances has a model variable with `default: true` and actual `nil` value. 14 | # However, if **all** instances has this variable `nil`, the column is not inserted 15 | # at all, therefore no error is raised. 16 | # 17 | # ``` 18 | # BulkQuery.insert(users) == users.insert 19 | # ``` 20 | # 21 | # ``` 22 | # user1 = User.new(name: "Jake", settings: nil) 23 | # user2 = User.new(name: "John", settings: "foo") 24 | # 25 | # # Assuming that user has "settings" with DB `default: true`, 26 | # # this query would raise if using SQLite3 as database 27 | # repo.exec([user1, user2].insert) 28 | # 29 | # # To avoid that, split the insertion queries 30 | # repo.exec(user1.insert) 31 | # repo.exec(user2.insert) 32 | # ``` 33 | def self.insert(instances : Enumerable(T)) 34 | new(:insert, instances) 35 | end 36 | 37 | protected def append_insert(sql, params, params_index) 38 | raise "No instances to insert" if @instances.empty? 39 | 40 | {% begin %} 41 | {% columns = T.instance_vars.reject { |iv| (a = iv.annotation(Reference)) && a[:foreign_key] }.map { |iv| ((a = iv.annotation(Field) || iv.annotation(Reference)) && (k = a[:key]) && k.id.stringify) || iv.name.stringify } %} 42 | 43 | columns = { {{columns.map(&.stringify).join(", ").id}} } 44 | significant_columns = Set(String).new 45 | 46 | values = @instances.map do |instance| 47 | sary = uninitialized Union(DB::Any | Symbol | Nil)[{{columns.size}}] 48 | 49 | {% for ivar, index in T.instance_vars.reject { |iv| (a = iv.annotation(Reference)) && a[:foreign_key] } %} 50 | if !instance.{{ivar.name}}.nil? 51 | sary[{{index}}] = T.db_values({{ivar.name}}: instance.{{ivar.name}}!)[0] 52 | significant_columns.add({{columns[index]}}) 53 | else 54 | {% if ann = ivar.annotation(Field) || ivar.annotation(Reference) %} 55 | {% if ann[:default] %} 56 | sary[{{index}}] = :default 57 | {% elsif ann[:not_null] %} 58 | raise NilAssertionError.new("{{T}}@{{ivar.name}} must not be nil on insert") 59 | {% else %} 60 | sary[{{index}}] = nil 61 | {% end %} 62 | {% else %} 63 | sary[{{index}}] = nil 64 | {% end %} 65 | end 66 | {% end %} 67 | 68 | sary 69 | end 70 | 71 | sql << "INSERT INTO {{T.annotation(SQL::Model::Options)[:table].id}} (" 72 | sql << significant_columns.join(", ") << ") VALUES " 73 | sql << values.join(", ") do |v| 74 | '(' + v.map_with_index do |value, index| 75 | if significant_columns.includes?(columns[index]) 76 | case value 77 | when Symbol then "DEFAULT" 78 | when nil then "NULL" 79 | else 80 | if params 81 | params << value 82 | end 83 | 84 | params_index ? "$#{params_index.value += 1}" : '?' 85 | end 86 | end 87 | end.reject(&.nil?).join(", ") + ')' 88 | end 89 | {% end %} 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /src/onyx-sql/bulk_query/returning.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class BulkQuery(T) 3 | # Add `RETURNING` clause by either model field or reference or 4 | # explicit Char or String. 5 | # 6 | # NOTE: All `RETURNING` clauses are removed on `Repository#exec(query)` call. 7 | # NOTE: SQLite does **not** support `RETURNING` clause. 8 | # 9 | # ``` 10 | # q = users.insert.returning(:id, :name) 11 | # q.build # => {"INSERT INTO users ... RETURNING id, name"} 12 | # 13 | # q = users.delete.returning("foo") 14 | # q.build # => {"DELETE FROM users ... RETURNING foo"} 15 | # ``` 16 | def returning(values : Enumerable(T::Field | T::Reference | Char | String)) 17 | values.each do |value| 18 | {% begin %} 19 | {% table = T.annotation(Model::Options)[:table] %} 20 | 21 | if value.is_a?(T::Field) 22 | case value 23 | {% for ivar in T.instance_vars.reject(&.annotation(Reference)) %} 24 | when .{{ivar.name}}? 25 | column = T.db_column({{ivar.name.symbolize}}) 26 | ensure_returning << "{{table.id}}.#{column}" 27 | {% end %} 28 | else 29 | raise "BUG: #{value} didn't match any of #{T} instance variables" 30 | end 31 | elsif value.is_a?(T::Reference) 32 | case value 33 | {% for ivar in T.instance_vars.select(&.annotation(Reference)).reject(&.annotation(Reference)[:foreign_key]) %} 34 | when .{{ivar.name}}? 35 | column = T.db_column({{ivar.name.symbolize}}) 36 | ensure_returning << "{{table.id}}.#{column}" 37 | {% end %} 38 | else 39 | raise "BUG: #{value} didn't match any of #{T} instance variables" 40 | end 41 | else 42 | ensure_returning << value.to_s 43 | end 44 | {% end %} 45 | end 46 | 47 | self 48 | end 49 | 50 | # ditto 51 | def returning(*values : T::Field | T::Reference | Char | String) 52 | returning(values) 53 | end 54 | 55 | # Add `RETURNING` asterisk clause for the whole `T` table. 56 | # 57 | # NOTE: All `RETURNING` clauses are removed on `Repository#exec(query)` call. 58 | # NOTE: SQLite does **not** support `RETURNING` clause. 59 | # 60 | # ``` 61 | # posts.insert.returning(Post) # => "... RETURNING posts.*" 62 | # ``` 63 | def returning(klass : T.class) 64 | ensure_returning << {{T.annotation(Model::Options)[:table].id.stringify}} + ".*" 65 | 66 | self 67 | end 68 | 69 | # Add `RETURNING` asterisk clause for the whole `T` table and optionally *values*. 70 | # 71 | # NOTE: All `RETURNING` clauses are removed on `Repository#exec(query)` call. 72 | # NOTE: SQLite does **not** support `RETURNING` clause. 73 | # 74 | # ``` 75 | # posts.returning(Post, :id) # => "... RETURNING posts.*, posts.id" 76 | # ``` 77 | def returning(klass : T.class, *values : T::Field | T::Reference | Char | String) 78 | ensure_returning << {{T.annotation(Model::Options)[:table].id.stringify}} + ".*" 79 | 80 | unless values.empty? 81 | returning(values) 82 | end 83 | 84 | self 85 | end 86 | 87 | @returning : Deque(String)? = nil 88 | protected property returning 89 | 90 | protected def get_returning 91 | @returning 92 | end 93 | 94 | protected def ensure_returning 95 | @returning ||= Deque(String).new 96 | end 97 | 98 | protected def append_returning(sql, *args) 99 | return if @returning.nil? || ensure_returning.empty? 100 | 101 | sql << " RETURNING " 102 | 103 | first = true 104 | ensure_returning.each do |value| 105 | sql << ", " unless first; first = false 106 | sql << value 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /src/onyx-sql/bulk_query/where.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class BulkQuery(T) 3 | protected def append_where(sql, params, params_index) 4 | {% begin %} 5 | {% 6 | options = T.annotation(Model::Options) 7 | raise "Onyx::SQL::Model::Options annotation must be defined for #{T}" unless options 8 | 9 | pk = options[:primary_key] 10 | raise "Onyx::SQL::Model::Options annotation is missing `primary_key` option for #{T}" unless pk 11 | 12 | pk_ivar = T.instance_vars.find { |iv| "@#{iv.name}".id == pk.id } 13 | raise "Cannot find primary key field #{pk} for #{T}" unless pk_ivar 14 | %} 15 | 16 | sql << " WHERE " << T.db_column({{pk_ivar.name.symbolize}}) 17 | sql << " IN (" << @instances.join(", ") do 18 | params_index ? "$#{params_index.value += 1}" : '?' 19 | end << ')' 20 | 21 | if params 22 | params.concat(@instances.map do |i| 23 | T.db_values({{pk_ivar.name}}: i.{{pk_ivar.name}}!)[0] 24 | end) 25 | end 26 | {% end %} 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /src/onyx-sql/converters.cr: -------------------------------------------------------------------------------- 1 | # A collection of modules to convert between arbitrary Crystal types and [`DB::Any`](http://crystal-lang.github.io/crystal-db/api/latest/DB/Any.html). 2 | module Onyx::SQL::Converters 3 | end 4 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/pg.cr: -------------------------------------------------------------------------------- 1 | require "pg" 2 | 3 | # A collection of modules to convert to and from `PG` database values. 4 | # Depends on . 5 | module Onyx::SQL::Converters::PG 6 | end 7 | 8 | require "./pg/enum" 9 | require "./pg/any" 10 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/pg/any.cr: -------------------------------------------------------------------------------- 1 | require "../pg" 2 | require "pg/pq/param" 3 | 4 | # Converts between the PostgreSQL values (`Nil`, `Bytes`, `Number`, `Bool`, `String`, `Array`, 5 | # `Time` and also [`PG::Geo`](https://github.com/will/crystal-pg/blob/master/src/pg/geo.cr)) 6 | # and native Crystal types. It automatically handles enumerables. 7 | # See `Field` to read about of how to apply converters. 8 | # 9 | # NOTE: Although PostgreSQL natively supports arrays, some databases don't (for example, SQLite). 10 | # That's why you have to explicitly declare a converter on a variable which is (or may be) an array. 11 | # 12 | # ```sql 13 | # CREATE TABLE users ( 14 | # id INTEGER, 15 | # favorite_movies TEXT[] 16 | # ); 17 | # ``` 18 | # 19 | # ``` 20 | # require "onyx-sql/converters/pg" 21 | # 22 | # class User 23 | # include Onyx::SQL::Model 24 | # 25 | # schema do 26 | # type id : Int32, converter: PG::Any(Int32) 27 | # type favorite_movies : Array(String), converter: PG::Any(String) 28 | # end 29 | # end 30 | # 31 | # Onyx::SQL::Converters::PG::Any(Int32).new.to_db([42, 43]) # => Bytes 32 | # ``` 33 | module Onyx::SQL::Converters::PG::Any(T) 34 | protected def self.to_db(*, x : U) : DB::Any forall U 35 | {% unless [Nil, Bytes, Number, Bool, String, Array, Time, ::PG::Geo::Point, ::PG::Geo::Line, ::PG::Geo::Circle, ::PG::Geo::LineSegment, ::PG::Geo::Box, ::PG::Geo::Path, ::PG::Geo::Polygon].any? { |t| U <= t } %} 36 | {% raise "Type #{U} is not supported by #{@type}. Consider using another converter" %} 37 | {% end %} 38 | 39 | param = PQ::Param.encode(x) 40 | 41 | case param.format 42 | when 0_i16 then String.new(param.slice) 43 | when 1_i16 then param.slice 44 | else 45 | "BUG: Unknown PQ::Param format #{param.format}" 46 | end 47 | end 48 | 49 | def self.to_db(value : T) : DB::Any 50 | to_db(x: value) 51 | end 52 | 53 | def self.to_db(values : Enumerable(T)) : DB::Any 54 | to_db(x: values.to_a) 55 | end 56 | 57 | def self.from_rs(rs : DB::ResultSet) : T? 58 | rs.read(T | Nil) 59 | end 60 | 61 | def self.from_rs_array(rs : DB::ResultSet) : Array(T)? 62 | rs.read(Array(T) | Nil) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/pg/enum.cr: -------------------------------------------------------------------------------- 1 | require "../pg" 2 | require "../../ext/pg/result_set" 3 | 4 | # Converts between the PostgreSQL `ENUM` type and Crystal `Enum`s. 5 | # See `Field` to read about of how to apply converters. 6 | # 7 | # ```sql 8 | # CREATE TYPE users_role AS ENUM ('writer', 'moderator', 'admin'); 9 | # CREATE TYPE users_permissions AS ENUM ('create_posts', 'edit_posts'); 10 | # 11 | # CREATE TABLE users ( 12 | # role users_role NOT NULL DEFAULT 'writer', 13 | # permissions users_permissions[] NOT NULL DEFAULT '{create_posts}' 14 | # ); 15 | # ``` 16 | # 17 | # ``` 18 | # require "onyx-sql/converters/pg" 19 | # 20 | # class User 21 | # include Onyx::SQL::Model 22 | # 23 | # enum Role 24 | # Writer 25 | # Moderator 26 | # Admin 27 | # end 28 | # 29 | # enum Permission 30 | # CreatePosts 31 | # EditPosts 32 | # end 33 | # 34 | # schema do 35 | # type role : Role, converter: PG::Enum(User::Role) 36 | # type permissions : Array(Permission), converter: PG::Enum(User::Permission) 37 | # end 38 | # end 39 | # ``` 40 | module Onyx::SQL::Converters::PG::Enum(T) 41 | def self.to_db(value : T) : DB::Any 42 | value.to_s.underscore 43 | end 44 | 45 | def self.to_db(values : Enumerable(T)) : DB::Any 46 | values.map { |v| to_db(v) }.join(',').try { |x| "{#{x}}" } 47 | end 48 | 49 | def self.from_rs(rs : DB::ResultSet) : T? 50 | rs.read(Bytes | Nil).try { |bytes| T.parse(String.new(bytes)) } 51 | end 52 | 53 | def self.from_rs_array(rs : DB::ResultSet) : Array(T)? 54 | rs.read_raw.try do |bytes| 55 | String.new(bytes).delete("^a-z_\n").split("\n").map do |s| 56 | T.parse(s) 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/pg/json.cr: -------------------------------------------------------------------------------- 1 | require "../pg" 2 | require "../../ext/pg/result_set" 3 | require "json" 4 | 5 | # Converts between the PostgreSQL's native `"JSON"` column type and Crystal objects with 6 | # `#to_json` and `.from_json` methods (e.g. `JSON::Serializable` or `Hash`). 7 | # See `Field` to read about of how to apply converters. 8 | # 9 | # ```sql 10 | # CREATE TABLE users ( 11 | # meta JSON NOT NULL DEFAULT "{}" 12 | # ); 13 | # ``` 14 | # 15 | # ``` 16 | # require "onyx-sql/converters/pg/json" 17 | # 18 | # class User 19 | # include Onyx::SQL::Model 20 | # 21 | # struct Meta 22 | # include JSON::Serilalizable 23 | # property foo : String 24 | # end 25 | # 26 | # schema do 27 | # type meta : Meta, converter: PG::JSON(Meta) 28 | # end 29 | # end 30 | # ``` 31 | module Onyx::SQL::Converters::PG::JSON(T) 32 | def self.to_db(value : T) : DB::Any 33 | value.to_json 34 | end 35 | 36 | def self.from_rs(rs : DB::ResultSet) : T? 37 | bytes = rs.read_raw 38 | bytes.try do |bytes| 39 | T.from_json(String.new(bytes)) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/pg/jsonb.cr: -------------------------------------------------------------------------------- 1 | require "../pg" 2 | require "../../ext/pg/result_set" 3 | require "json" 4 | 5 | # Converts between the PostgreSQL's `"JSONB"` type and Crystal objects. 6 | # It works the same way as the `JSON` converter does, but with `"JSONB"` type. 7 | # 8 | # OPTIMIZE: Refactor to extend the `JSON` module. Currently impossible due to https://github.com/crystal-lang/crystal/issues/7167. 9 | module Onyx::SQL::Converters::PG::JSONB(T) 10 | def self.to_db(value : T) : DB::Any 11 | value.to_json 12 | end 13 | 14 | def self.from_rs(rs : DB::ResultSet) : T? 15 | bytes = rs.read_raw 16 | bytes.try do |bytes| 17 | T.from_json(String.new(bytes[1, bytes.bytesize - 1])) 18 | end 19 | end 20 | 21 | def self.from_rs_array(rs) : T? 22 | from_rs(rs) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/pg/uuid.cr: -------------------------------------------------------------------------------- 1 | require "../pg" 2 | require "uuid" 3 | 4 | # Converts between the PostgreSQL's `UUID` type and Crystal's `UUID`. 5 | # See `Field` to read about of how to apply converters. 6 | # 7 | # ```sql 8 | # CREATE EXTENSION pgcrypto; 9 | # CREATE TABLE users ( 10 | # uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), 11 | # ); 12 | # ``` 13 | # 14 | # ``` 15 | # require "onyx-sql/converters/pg/uuid" 16 | # 17 | # class User 18 | # include Onyx::SQL::Model 19 | # 20 | # schema do 21 | # pkey uuid : UUID, converter: PG::UUID 22 | # end 23 | # end 24 | # ``` 25 | module Onyx::SQL::Converters::PG::UUID 26 | def self.to_db(value : ::UUID) : DB::Any 27 | value.to_s 28 | end 29 | 30 | def self.to_db(values : Enumerable(::UUID)) : DB::Any 31 | values.map { |v| to_db(v) }.join(',').try { |x| "{#{x}}" } 32 | end 33 | 34 | def self.from_rs(rs) : ::UUID? 35 | rs.read(String | Nil).try { |s| ::UUID.new(s) } 36 | end 37 | 38 | def self.from_rs_array(rs) : ::Array(::UUID)? 39 | rs.read(::Array(String) | Nil).try &.map { |s| ::UUID.new(s) } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/sqlite3.cr: -------------------------------------------------------------------------------- 1 | require "sqlite3" 2 | 3 | # A collection of modules to convert to and from `SQLite3` database values. 4 | # Depends on . 5 | module Onyx::SQL::Converters::SQLite3 6 | end 7 | 8 | require "./sqlite3/any" 9 | require "./sqlite3/enum_int" 10 | require "./sqlite3/enum_text" 11 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/sqlite3/any.cr: -------------------------------------------------------------------------------- 1 | require "../sqlite3" 2 | 3 | # Converts between the SQLite3 types (`NULL`, `INTEGER`, `REAL`, `TEXT` and `BLOB`) 4 | # and native Crystal types. 5 | # See `Field` to read about of how to apply converters. 6 | # 7 | # SQLite3 does **not** natively support neither arrays nor `Time` nor `Bool`. This converters 8 | # implements the following workarounds: 9 | # 10 | # * `Time` is stored as string in the following format: `"%F %H:%M:%S.%L"`. 11 | # For example, `"2019-02-13 22:58:28.942"`. 12 | # * `Bool`s are stored as `"1"` and `"0"` 13 | # * Enumerable types are stored as `"{x,y}"` strings. For example, `[42, 43]` will be stored as 14 | # `"{42,43"`, `[Time]` as `"{2019-02-13 14:17:59.228}"` and empty arrays as `"{}"` 15 | # 16 | # As this converter is two-way, the implications above apply to values set by the developer as well. 17 | # For example, if a bool value is set to `"true"` by default in the database, 18 | # the converter would raise upon parsing. 19 | # 20 | # ```sql 21 | # CREATE TABLE users ( 22 | # id INTEGER, 23 | # favorite_movies STRING NOT NULL DEFAULT '{}' 24 | # ); 25 | # ``` 26 | # 27 | # ``` 28 | # require "onyx-sql/converters/sqlite3" 29 | # 30 | # class User 31 | # include Onyx::SQL::Model 32 | # 33 | # schema do 34 | # type id : Int32, converter: SQLite3::Any(Int32) 35 | # type favorite_movies : Array(String), converter: SQLite3::Any(String) 36 | # end 37 | # end 38 | # 39 | # Onyx::SQL::Converters::SQLite3::Any(Int32).new.to_db(42) # => 42 40 | # Onyx::SQL::Converters::SQLite3::Any(Int32).new.to_db([42, 43]) # => "{42, 43}" 41 | # Onyx::SQL::Converters::SQLite3::Any(Int32).new.to_db(true) # => "{1}" 42 | # ``` 43 | module Onyx::SQL::Converters::SQLite3::Any(T) 44 | protected def self.check 45 | {% unless T <= DB::Any && !T.union? %} 46 | {% raise "Type #{T} is not supported by #{@type}. Consider using another converter" %} 47 | {% end %} 48 | end 49 | 50 | def self.to_db(value : T) : DB::Any 51 | check 52 | value 53 | end 54 | 55 | def self.to_db(values : Array(T)) : DB::Any 56 | check 57 | 58 | values.join(',') do |value| 59 | {% if T <= Number || T <= String %} 60 | value.to_s 61 | {% elsif T <= Bool %} 62 | value ? '1' : '0' 63 | {% elsif T <= Time %} 64 | value.in(::SQLite3::TIME_ZONE).to_s(::SQLite3::DATE_FORMAT) 65 | {% elsif T <= Nil %} 66 | "" 67 | {% elsif T <= Bytes %} 68 | {% raise "Must not use Bytes type with #{@type}. Consider using another specialized converter" %} 69 | {% end %} 70 | end.try { |v| "{#{v}}" } 71 | end 72 | 73 | def self.from_rs(rs : DB::ResultSet) : T? 74 | check 75 | rs.read(T | Nil) 76 | end 77 | 78 | def self.from_rs_array(rs : DB::ResultSet) : ::Array(T)? 79 | check 80 | 81 | rs.read(String | Nil).try do |string| 82 | sub = string[1..-2] rescue "" 83 | 84 | if sub.empty? 85 | return ::Array(T).new 86 | else 87 | sub.split(',').map do |s| 88 | {% if T <= Int32 %} 89 | s.to_i32 90 | {% elsif T <= Int64 %} 91 | s.to_i64 92 | {% elsif T <= Float32 %} 93 | s.to_f32 94 | {% elsif T <= Float64 %} 95 | s.to_f64 96 | {% elsif T <= Nil %} 97 | s == "" ? nil : raise "Unexpected non-empty SQLite3 array entry '#{s}'" 98 | {% elsif T <= String %} 99 | s 100 | {% elsif T <= Bool %} 101 | case s 102 | when '1' then true 103 | when '0' then false 104 | else 105 | raise "Unexpected non-bit SQLite3 array entry '#{s}'" 106 | end 107 | {% elsif T <= Bytes %} 108 | {% raise "Must not use Bytes type with #{@type}. Consider using another specialized converter" %} 109 | {% end %} 110 | end 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/sqlite3/enum_int.cr: -------------------------------------------------------------------------------- 1 | require "../sqlite3" 2 | 3 | # Converts between the SQLite3 `INTEGER` (or `TEXT` for arrays) type and Crystal `Enum`s. 4 | # See `Field` to read about of how to apply converters. 5 | # 6 | # Comparison of `Converters::SQLite3::EnumInt` and `Converters::SQLite3::EnumText`: 7 | # 8 | # ```text 9 | # | | EnumInt | EnumText | 10 | # | --------------------- | ------- | -------- | 11 | # | Bytes needed to store | Less | More | 12 | # | Depends on enum index | Yes | No | 13 | # ``` 14 | # 15 | # In a nutshell, if you modify a `Enum` values in the further cycles of application development, 16 | # it will be harder to migrate changes in the database if you use `EnumInt` converter, but it takes less bytes. 17 | # 18 | # ```sql 19 | # CREATE TABLE users ( 20 | # role INTEGER NOT NULL DEFAULT 0, 21 | # roles TEXT NOT NULL DEFAULT '{0}' 22 | # ); 23 | # ``` 24 | # 25 | # ``` 26 | # require "onyx-sql/converters/sqlite3" 27 | # 28 | # class User 29 | # include Onyx::SQL::Model 30 | # 31 | # enum Role 32 | # Writer 33 | # Moderator 34 | # Admin 35 | # end 36 | # 37 | # schema do 38 | # type role : Role, converter: SQLite3::EnumInt(User::Role) 39 | # type roles : Array(Role), converter: SQLite3::EnumInt(User::Role) 40 | # end 41 | # end 42 | # ``` 43 | module Onyx::SQL::Converters::SQLite3::EnumInt(T) 44 | def self.to_db(value : T) 45 | value.to_i 46 | end 47 | 48 | def self.to_db(values : Enumerable(T)) 49 | Any(Int32).to_db(values.map(&.to_i)) 50 | end 51 | 52 | def self.from_rs(rs) : T? 53 | rs.read(Int32 | Nil).try { |i| T.new(i) } 54 | end 55 | 56 | def self.from_rs_array(rs) : ::Array(T)? 57 | Any(Int32).from_rs_array(rs).try &.map { |i| T.new(i) } 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/sqlite3/enum_text.cr: -------------------------------------------------------------------------------- 1 | require "../sqlite3" 2 | 3 | # Converts between the SQLite3 `TEXT` type and Crystal `Enum`s. 4 | # See `Field` to read about of how to apply converters. 5 | # 6 | # See the comparison of enum converters at `Converters::SQLite3::EnumInt`. 7 | # 8 | # ```sql 9 | # CREATE TABLE users ( 10 | # permission TEXT NOT NULL DEFAULT 'create_posts', 11 | # permissions TEXT NOT NULL DEFAULT '{create_posts,edit_posts}' 12 | # ); 13 | # ``` 14 | # 15 | # ``` 16 | # require "onyx-sql/converters/sqlite3" 17 | # 18 | # class User 19 | # include Onyx::SQL::Model 20 | # 21 | # enum Permission 22 | # CreatePosts 23 | # EditPosts 24 | # end 25 | # 26 | # schema do 27 | # type permission : Permission, converter: SQLite3::EnumText(User::Permission))] 28 | # type permissions : Array(Permission), converter: SQLite3::EnumText(User::Permission) 29 | # end 30 | # end 31 | # ``` 32 | module Onyx::SQL::Converters::SQLite3::EnumText(T) 33 | def self.to_db(value : T) 34 | value.to_s 35 | end 36 | 37 | def self.to_db(values : Enumerable(T)) 38 | Any(String).to_db(values.map(&.to_s)) 39 | end 40 | 41 | def self.from_rs(rs) : T? 42 | rs.read(String | Nil).try { |s| T.parse(s) } 43 | end 44 | 45 | def self.from_rs_array(rs) : ::Array(T)? 46 | Any(String).from_rs_array(rs).try &.map { |s| T.parse(s) } 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/sqlite3/json.cr: -------------------------------------------------------------------------------- 1 | require "../sqlite3" 2 | require "json" 3 | 4 | # Converts between the SQLite3 `TEXT` type and Crystal objects with 5 | # `#to_json` and `.from_json` methods (e.g. `JSON::Serializable`). 6 | # See `Field` to read about of how to apply converters. 7 | # 8 | # ```sql 9 | # CREATE TABLE users ( 10 | # meta TEXT NOT NULL DEFAULT '{"foo":"bar"}' 11 | # ); 12 | # ``` 13 | # 14 | # ``` 15 | # require "onyx-sql/converters/sqlite3/json" 16 | # 17 | # class User 18 | # include Onyx::SQL::Model 19 | # 20 | # struct Meta 21 | # include JSON::Serilalizable 22 | # property foo : String 23 | # end 24 | # 25 | # schema do 26 | # type meta : Meta, converter: SQLite3::JSON(Meta) 27 | # end 28 | # end 29 | # ``` 30 | module Onyx::SQL::Converters::SQLite3::JSON(T) 31 | def self.to_db(value : T) 32 | value.to_json 33 | end 34 | 35 | def self.to_db(values : Enumerable(T)) 36 | {% raise "Not implemented" %} 37 | end 38 | 39 | def self.from_rs(rs) : T? 40 | rs.read(String | Nil).try { |json| T.from_json(json) } 41 | end 42 | 43 | def self.from_rs_array(rs) : ::Array(T)? 44 | {% raise "Not implemented" %} 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/sqlite3/uuid_blob.cr: -------------------------------------------------------------------------------- 1 | require "../sqlite3" 2 | require "uuid" 3 | 4 | # Converts between the SQLite3 `BLOB` type and Crystal's `UUID`. 5 | # See `Field` to read about of how to apply converters. 6 | # 7 | # Comparison of `Converters::SQLite3::UUIDBlob` and `Converters::SQLite3::UUIDText`: 8 | # 9 | # ```text 10 | # | | UUIDBlob | UUIDText | 11 | # | --------------------- | -------- | -------- | 12 | # | Bytes needed to store | 16 | ~32 | 13 | # | Comparable | No | Yes | 14 | # | Can store array | No | Yes | 15 | # ``` 16 | # 17 | # When you use `BLOB` type for storing uuids, you cannot compare them with `WHERE uuid = ?` 18 | # (making it useless as primary keys) and cannot store an array of uuids in a single column, 19 | # but it occupies less bytes. 20 | # 21 | # ```sql 22 | # CREATE TABLE users ( 23 | # uuid BLOB 24 | # ); 25 | # ``` 26 | # 27 | # ``` 28 | # require "onyx-sql/converters/sqlite3/uuid_blob" 29 | # 30 | # class User 31 | # include Onyx::SQL::Model 32 | # 33 | # schema do 34 | # type uuid : UUID = UUID.random, converter: SQLite3::UUIDBlob 35 | # end 36 | # end 37 | # ``` 38 | module Onyx::SQL::Converters::SQLite3::UUIDBlob 39 | def self.to_db(value : ::UUID) : DB::Any 40 | value.to_slice 41 | end 42 | 43 | def self.to_db(values : Enumerable(::UUID)) : DB::Any 44 | {% raise "Not implemented" %} 45 | end 46 | 47 | def self.from_rs(rs) : ::UUID? 48 | rs.read(Bytes | Nil).try { |b| ::UUID.new(b) } 49 | end 50 | 51 | def self.from_rs_array(rs) : ::Array(::UUID)? 52 | {% raise "Not implemented" %} 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /src/onyx-sql/converters/sqlite3/uuid_text.cr: -------------------------------------------------------------------------------- 1 | require "../sqlite3" 2 | require "uuid" 3 | 4 | # Converts between the SQLite3 `BLOB` type and Crystal's `UUID`. 5 | # See `Field` to read about of how to apply converters. 6 | # 7 | # See the comparison of uuid converters at `Converters::SQLite3::UUIDBlob`. 8 | # 9 | # ```sql 10 | # CREATE TABLE users ( 11 | # uuid TEXT, 12 | # uuids TEXT 13 | # ); 14 | # ``` 15 | # 16 | # ``` 17 | # require "onyx-sql/converters/sqlite3/uuid_text" 18 | # 19 | # class User 20 | # include Onyx::SQL::Model 21 | # 22 | # schema do 23 | # pkey uuid : UUID = UUID.random, converter: SQLite3::UUIDText 24 | # type uuids : Array(UUID), converter: SQLite3::UUIDText 25 | # end 26 | # end 27 | # ``` 28 | module Onyx::SQL::Converters::SQLite3::UUIDText 29 | def self.to_db(value : ::UUID) : DB::Any 30 | value.to_s 31 | end 32 | 33 | def self.to_db(values : Enumerable(::UUID)) : DB::Any 34 | Any(String).to_db(values.map(&.to_s)) 35 | end 36 | 37 | def self.from_rs(rs) : ::UUID? 38 | rs.read(String | Nil).try { |s| ::UUID.new(s) } 39 | end 40 | 41 | def self.from_rs_array(rs) : ::Array(::UUID)? 42 | Any(String).from_rs_array(rs).try &.map { |s| ::UUID.new(s) } 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /src/onyx-sql/ext/enumerable/bulk_query.cr: -------------------------------------------------------------------------------- 1 | module Enumerable(T) 2 | def insert 3 | Onyx::SQL::BulkQuery(T).insert(self) 4 | end 5 | 6 | def delete 7 | Onyx::SQL::BulkQuery(T).delete(self) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /src/onyx-sql/ext/pg/result_set.cr: -------------------------------------------------------------------------------- 1 | require "pg" 2 | 3 | class PG::ResultSet < DB::ResultSet 4 | def read_raw : Bytes | Nil 5 | col_bytesize = conn.read_i32 6 | 7 | if col_bytesize == -1 8 | @column_index += 1 9 | return nil 10 | end 11 | 12 | sized_io = IO::Sized.new(conn.soc, col_bytesize) 13 | 14 | begin 15 | slice = Bytes.new(col_bytesize) 16 | sized_io.read_fully(slice) 17 | ensure 18 | conn.soc.skip(sized_io.read_remaining) if sized_io.read_remaining > 0 19 | @column_index += 1 20 | end 21 | 22 | slice 23 | rescue IO::Error 24 | raise DB::ConnectionLost.new(statement.connection) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /src/onyx-sql/model.cr: -------------------------------------------------------------------------------- 1 | require "./model/*" 2 | require "./serializable" 3 | require "./converters" 4 | 5 | # Model (also *record*) is a business unit. 6 | # Models usually have fields and relations with other models. 7 | # The `Model` module allows to represent an SQL model as a plain Crystal object. 8 | # 9 | # ```sql 10 | # CREATE TABLE users ( 11 | # id SERIAL PRIMARY KEY, 12 | # username TEXT NOT NULL 13 | # ); 14 | # 15 | # CREATE TABLE posts ( 16 | # id SERIAL PRIMARY KEY, 17 | # content TEXT NOT NULL, 18 | # cover TEXT, 19 | # author_id INT NOT NULL REFERENCES users (id), 20 | # ); 21 | # ``` 22 | # 23 | # ``` 24 | # class User 25 | # include Onyx::SQL::Model 26 | # 27 | # schema users do 28 | # pkey id : Int32 29 | # type username : String, not_null: true 30 | # type authored_posts : Array(Post), foreign_key: "author_id" 31 | # end 32 | # end 33 | # 34 | # class Post 35 | # include Onyx::SQL::Model 36 | # 37 | # schema posts do 38 | # pkey id : Int32 39 | # type content : String, not_null: true 40 | # type cover : String 41 | # type author : User, key: "author_id", not_null: true 42 | # end 43 | # end 44 | # ``` 45 | # 46 | # In this example, `User` and `Post` are models. `User` has primary key `id`, field `username` and 47 | # foreign enumerable reference `authored_posts`. `Post` also has primary key `id`, 48 | # non-nilable field `content` and nilable field `cover`, and direct reference `author`. 49 | # It's pretty simple and straightforward mapping. 50 | # Read more about references in `Serializable` docs. 51 | # 52 | # ## Serialization 53 | # 54 | # `Model` module includes `Serializable`, which enables deserializing models from a `DB::ResultSet`, 55 | # effectively allowing this: 56 | # 57 | # ``` 58 | # db = DB.open(ENV["DATABASE_URL"]) 59 | # users = User.from_rs(db.query("SELECT * FROM users")) 60 | # ``` 61 | # 62 | # But it's more convenient to use `Repository` to interact with the database: 63 | # 64 | # ``` 65 | # repo = Onyx::SQL::Repository.new(db) 66 | # users = repo.query(User, "SELECT * FROM users") 67 | # ``` 68 | # 69 | # That's not much less code, but the repo, for example, handles query arguments 70 | # (`?` -> `$1` for PostrgreSQL queries) and also logs the requests. 71 | # The real power of repository is handling `Query` arguments: 72 | # 73 | # ``` 74 | # user = repo.query(User.where(id: 42)).first 75 | # ``` 76 | # 77 | # ## Schema 78 | # 79 | # Onyx::SQL is based on Crystal annotations to keep composition and simplify the underlying code. 80 | # But since annotations are quite low-level, they are masked under the convenient `.schema` DSL. 81 | # It's a good idea to understand what the `.schema` macro generates, but it's not mandatory 82 | # for most of developers. 83 | module Onyx::SQL::Model 84 | include Converters 85 | 86 | # Return this model database table. 87 | # It must be defined with `Options` annotation: 88 | # 89 | # ``` 90 | # @[Onyx::SQL::Model::Options(table: "users")] 91 | # class User 92 | # end 93 | # 94 | # pp User.table # => "users" 95 | # ``` 96 | # 97 | # This method is defined upon the module inclusion. 98 | def self.table 99 | end 100 | 101 | macro included 102 | include Onyx::SQL::Serializable 103 | include Onyx::SQL::Model::Mappable(self) 104 | extend Onyx::SQL::Model::ClassQueryShortcuts(self) 105 | 106 | macro finished 107 | def self.table 108 | # When using `schema` macro, the annotation is placed on the 109 | # second macro run only, therefore need to wait until `finished` 110 | {% verbatim do %} 111 | {% raise "A model must have table defined with `Onyx::SQL::Model::Options` annotation" unless (ann = @type.annotation(Onyx::SQL::Model::Options)) && ann[:table] %} 112 | {{@type.annotation(Onyx::SQL::Model::Options)[:table].id.stringify}} 113 | {% end %} 114 | end 115 | end 116 | 117 | {% verbatim do %} 118 | # Initialize an instance of `self`. It accepts an arbitrary amount of arguments, 119 | # but they must match the variable names, raising in compile-time instead: 120 | # 121 | # ``` 122 | # User.new(id: 42, username: "John") # => 123 | # User.new(foo: "bar") # Compilation-time error 124 | # ``` 125 | def initialize(**values : **T) : self forall T 126 | {% for ivar in @type.instance_vars %} 127 | {% 128 | a = 42 # BUG: Dummy assignment, otherwise the compiler crashes 129 | 130 | unless ivar.type.nilable? 131 | raise "#{@type}@#{ivar.name} must be nilable, as it's an Onyx::SQL::Model variable" 132 | end 133 | 134 | unless ivar.type.union_types.size == 2 135 | raise "Only T | Nil unions can be an Onyx::SQL::Model's variables (got #{ivar.type} type for #{@type}@#{ivar.name})" 136 | end 137 | 138 | type = ivar.type.union_types.find { |t| t != Nil } 139 | 140 | if type <= Enumerable 141 | if (type.type_vars.size != 1 || type.type_vars.first.union?) 142 | raise "If an Onyx::SQL::Model variable is a Enumerable, it must have a single non-union type var (got #{type} type for #{@type}@#{ivar.name})" 143 | end 144 | end 145 | %} 146 | {% end %} 147 | 148 | values.each do |key, value| 149 | {% begin %} 150 | case key 151 | {% for key, value in T %} 152 | {% found = false %} 153 | 154 | {% for ivar in @type.instance_vars %} 155 | {% if ivar.name == key %} 156 | {% raise "Invalid type #{value} for #{@type}@#{ivar.name} (expected #{ivar.type})" unless value <= ivar.type %} 157 | 158 | when {{ivar.name.symbolize}} 159 | @{{ivar.name}} = value.as({{value}}) 160 | 161 | {% found = true %} 162 | {% end %} 163 | {% end %} 164 | 165 | {% raise "Cannot find instance variable by key #{key} in #{@type}" unless found %} 166 | {% end %} 167 | else 168 | raise "BUG: Runtime key mismatch" 169 | end 170 | {% end %} 171 | end 172 | 173 | self 174 | end 175 | {% end %} 176 | end 177 | 178 | # Compare `self` against *other* model of the same type by their primary keys. 179 | # Returns `false` if the `self` primary key is `nil`. 180 | def ==(other : self) 181 | {% begin %} 182 | {% 183 | options = @type.annotation(Onyx::SQL::Model::Options) 184 | raise "Onyx::SQL::Model::Options annotation must be defined for #{@type}" unless options 185 | 186 | pk = options[:primary_key] 187 | raise "#{@type} must have Onyx::SQL::Model::Options annotation with :primary_key option" unless pk 188 | 189 | pk_rivar = @type.instance_vars.find { |riv| "@#{riv.name}".id == pk.id } 190 | raise "Cannot find primary key field #{pk} in #{@type}" unless pk_rivar 191 | %} 192 | 193 | unless primary_key.nil? 194 | primary_key == other.{{pk_rivar.name}} 195 | end 196 | {% end %} 197 | end 198 | 199 | # This annotation specifies options for a `Model`. It has two mandatory options itself: 200 | # 201 | # * `:table` -- the table name in the DB, e.g. "users" 202 | # * `:primary_key` -- the primary key **variable**, for example: 203 | # 204 | # ``` 205 | # @[Onyx::SQL::Options(table: "users", primary_key: @id)] 206 | # class User 207 | # include Onyx::SQL::Model 208 | # @id : Int32? 209 | # end 210 | # ``` 211 | # 212 | # The `Model.schema` macro defines the `Options` annotation for you: 213 | # 214 | # ``` 215 | # class User 216 | # include Onyx::SQL::Model 217 | # 218 | # schema users do # "users" is going to be the :table option 219 | # pkey id : Int32 # @id is the :primary_key 220 | # end 221 | # end 222 | # ``` 223 | # 224 | # TODO: Handle different `:primary_key` variants: 225 | # 226 | # ``` 227 | # @[Options(primary_key: {@a, @b})] # Composite 228 | # 229 | # @[Options(primary_key: {x: @a, y: @b})] # With different getters (and composite) 230 | # class User 231 | # def x 232 | # @a 233 | # end 234 | # end 235 | # ``` 236 | annotation Options 237 | end 238 | 239 | def_hash primary_key 240 | 241 | protected def primary_key 242 | {% begin %} 243 | {% 244 | options = @type.annotation(Onyx::SQL::Model::Options) 245 | raise "Onyx::SQL::Model::Options annotation must be defined for #{@type}" unless options 246 | 247 | pk = options[:primary_key] 248 | raise "#{@type} must have Onyx::SQL::Model::Options annotation with :primary_key option" unless pk 249 | 250 | pk_rivar = @type.instance_vars.find { |riv| "@#{riv.name}".id == pk.id } 251 | raise "Cannot find primary key field #{pk} in #{@type}" unless pk_rivar 252 | %} 253 | 254 | @{{pk_rivar}} 255 | {% end %} 256 | end 257 | end 258 | -------------------------------------------------------------------------------- /src/onyx-sql/model/changes.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL::Model 2 | # A changeset for a model. Used to track changes made to it. 3 | # To update a value in a changeset, call `#update`. This will **not** affect the original model. 4 | # 5 | # It is instantiated via `Model#changeset`: 6 | # 7 | # ``` 8 | # user = User.new(name: "John") 9 | # changeset = user.changeset 10 | # changeset.update(name: "Jake") 11 | # pp changeset.changes # => {"name" => "Jake"} 12 | # ``` 13 | # 14 | # It is handy to use a changeset with `Model#update` method. 15 | # Note that `Model#update` may raise `NoChanges` error 16 | # if the changeset is empty. 17 | class Changeset(T, U) 18 | getter values : Hash(String, U) 19 | getter initial_values : Hash(String, U) 20 | 21 | protected def initialize(@initial_values : Hash(String, U)) 22 | @values = @initial_values.dup 23 | end 24 | 25 | # Update a changeset value by `T` instance varialbe. 26 | # This will **not** affect the original model. 27 | def update(**values : **V) : Nil forall V 28 | values.each do |key, value| 29 | {% begin %} 30 | case key 31 | {% for key, value in V %} 32 | {% found = false %} 33 | 34 | {% for ivar in T.instance_vars %} 35 | {% if ivar.name == key %} 36 | {% if ann = ivar.annotation(Field) || ivar.annotation(Reference) %} 37 | {% 38 | raise "On Changeset#update: #{key} is nilable in compilation time (#{value}), but @#{ivar.name} has `not_null` option set to `true`. Consider calling `.not_nil!` on the value" if ann[:not_null] && value.nilable? 39 | 40 | raise "On Changeset#update: #{T}@#{key} is a foreign reference, therefore it cannot be updated" if (ann = ivar.annotation(Reference)) && ann[:foreign_key] 41 | %} 42 | {% end %} 43 | 44 | {% found = true %} 45 | 46 | when {{ivar.name.symbolize}} 47 | @values[{{ivar.name.stringify}}] = value.as({{value}}) 48 | {% end %} 49 | {% end %} 50 | 51 | {% raise "Cannot find instance variable by key :#{key} in #{T}" unless found %} 52 | {% end %} 53 | else 54 | raise "BUG: Unmatched :#{key} in runtime" 55 | end 56 | {% end %} 57 | end 58 | end 59 | 60 | # Return changes on this changeset. 61 | def changes : Hash(String, U) 62 | hash = Hash(String, U).new 63 | 64 | values.each do |key, value| 65 | if value.responds_to?(:primary_key) 66 | if !initial_values.has_key?(key) || (value.primary_key && initial_values[key] != value) 67 | hash[key] = value 68 | end 69 | else 70 | if !initial_values.has_key?(key) || initial_values[key] != value 71 | hash[key] = value 72 | end 73 | end 74 | end 75 | 76 | hash 77 | end 78 | 79 | # Return `true` if there are no changes on this changeset. 80 | def empty? 81 | changes.empty? 82 | end 83 | 84 | # Return `#changes` on this changeset or raise `NoChanges` if it's empty. 85 | def changes! 86 | actual_changes = changes 87 | raise NoChanges.new if actual_changes.empty? 88 | actual_changes 89 | end 90 | 91 | # Raised when there are no actual changes on a changeset on `Changeset#changes!` call. 92 | class NoChanges < Exception 93 | end 94 | end 95 | 96 | # Create a new changeset for this instance with snapshot of actual values. 97 | # It is then likely to be passed to the `#update` method. 98 | # 99 | # ``` 100 | # user = User.new(id: 42, name: "John") 101 | # changeset = user.changeset 102 | # pp changeset.initial_values # => {"id" => 42, "name" => "John"} 103 | # pp changeset.values # => {"id" => 42, "name" => "John"} 104 | # 105 | # changeset.update(name: "Jake") 106 | # pp changeset.values # => {"id" => 42, "name" => "Jake"} 107 | # pp changeset.empty? # => false 108 | # pp changeset.changes # => {"name" => "Jake"} 109 | # 110 | # user.update(changeset) == User.update.set(name: "Jake").where(id: 42) 111 | # ``` 112 | def changeset 113 | {% begin %} 114 | hash = Hash(String, Union( 115 | {% for ivar in @type.instance_vars %} 116 | {{ivar.type}}, 117 | {% end %} 118 | )).new 119 | 120 | {% for ivar in @type.instance_vars %} 121 | unless @{{ivar.name}}.nil? 122 | hash[{{ivar.name.stringify}}] = @{{ivar.name}} 123 | end 124 | {% end %} 125 | 126 | Changeset(self, Union( 127 | {% for ivar in @type.instance_vars %} 128 | {{ivar.type}}, 129 | {% end %} 130 | )).new(hash) 131 | {% end %} 132 | end 133 | 134 | # Apply a *changeset*, merging self values with the changeset's. 135 | def apply(changeset : Changeset(self, U)) : self forall U 136 | changeset.changes.each do |key, value| 137 | {% begin %} 138 | case key 139 | {% for ivar in @type.instance_vars %} 140 | when {{ivar.stringify}} 141 | @{{ivar}} = value.as({{ivar.type}}) 142 | {% end %} 143 | else 144 | raise "BUG: Unknown key :#{key} for Changeset(#{self})" 145 | end 146 | {% end %} 147 | end 148 | 149 | self 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /src/onyx-sql/model/class_query_shortcuts.cr: -------------------------------------------------------------------------------- 1 | require "../query" 2 | 3 | # This module is **extended** by an object whenever it includes the `Model` module. 4 | # It brings shortucts to a matching query initialization: 5 | # 6 | # ``` 7 | # class User 8 | # include Onyx::SQL::Model 9 | # end 10 | # 11 | # User.query == Query(User).new 12 | # ``` 13 | module Onyx::SQL::Model::ClassQueryShortcuts(T) 14 | # Create a new `Query(T)`. 15 | def query : Query 16 | Query(T).new 17 | end 18 | 19 | {% for method in %w( 20 | delete 21 | group_by 22 | insert 23 | limit 24 | offset 25 | set 26 | update 27 | where 28 | having 29 | all 30 | one 31 | ) %} 32 | # Create a new `Query(self)` and call `Query#{{method.id}}` on it. 33 | def {{method.id}}(*args, **nargs) : Query(T) 34 | query.{{method.id}}(*args, **nargs) 35 | end 36 | {% end %} 37 | 38 | # Create a new `Query(self)` and call `Query#order_by` on it. 39 | def order_by(value : T::Field | String, order : Query::Order? = nil) : Query(T) 40 | query.order_by(value, order) 41 | end 42 | 43 | # Create a new `Query(self)` and call `Query#returning` on it. 44 | def returning(values : Enumerable(T::Field | T::Reference | Char | String)) 45 | query.returning(values) 46 | end 47 | 48 | # ditto 49 | def returning(*values : T::Field | T::Reference | Char | String) 50 | returning(values) 51 | end 52 | 53 | # ditto 54 | def returning(klass : T.class, *values : T::Field | T::Reference | Char | String) 55 | query.returning(klass, *values) 56 | end 57 | 58 | # Create a new `Query(self)` and call `Query#select` on it. 59 | def select(values : Enumerable(T::Field | T::Reference | Char | String)) 60 | query.select(values) 61 | end 62 | 63 | # ditto 64 | def select(*values : T::Field | T::Reference | Char | String) 65 | self.select(values) 66 | end 67 | 68 | # ditto 69 | def select(klass : T.class, *values : T::Field | T::Reference | Char | String) 70 | query.select(klass, *values) 71 | end 72 | 73 | # Create a new `Query(self)` and call `Query#join` on it. 74 | def join(table : String, on : String, as _as : String? = nil, type : Onyx::SQL::Query::JoinType = :inner) : Query(T) 75 | query.join(table, on, _as, type) 76 | end 77 | 78 | # ditto 79 | def join(reference : T::Reference, on : String? = nil, as _as : String = reference.to_s.underscore, type : Onyx::SQL::Query::JoinType = :inner) : Query(T) 80 | query.join(reference, on, _as, type) 81 | end 82 | 83 | # ditto 84 | def join(reference : T::Reference, klass, *, on : String? = nil, as _as : String = reference.to_s.underscore, type : JoinType = :inner, &block) 85 | query.join(reference, klass, on: on, as: _as, type: type, &block) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /src/onyx-sql/model/enums.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL::Model 2 | macro included 3 | macro finished 4 | {% verbatim do %} 5 | {% 6 | getters = @type.methods.select do |_def| 7 | _def.body.is_a?(InstanceVar) 8 | end 9 | 10 | fields = getters.select do |g| 11 | if g.return_type.is_a?(Union) 12 | type = g.return_type.types.find { |t| !t.is_a?(Nil) } 13 | 14 | if type.resolve < Enumerable 15 | !(type.resolve.type_vars.first < Onyx::SQL::Model) 16 | else 17 | !(type.resolve < Onyx::SQL::Model) 18 | end 19 | end 20 | end 21 | 22 | references = getters.select do |g| 23 | if g.return_type.is_a?(Union) 24 | type = g.return_type.types.find { |t| !t.is_a?(Nil) } 25 | 26 | if type.resolve < Enumerable 27 | type.resolve.type_vars.first < Onyx::SQL::Model 28 | else 29 | type.resolve < Onyx::SQL::Model 30 | end 31 | end 32 | end 33 | %} 34 | 35 | {% if fields.size > 0 %} 36 | enum Field 37 | {% for field in fields %} 38 | {{field.body.name[1..-1].camelcase}} 39 | {% end %} 40 | end 41 | {% else %} 42 | enum Field 43 | Nop 44 | end 45 | {% end %} 46 | 47 | {% if references.size > 0 %} 48 | enum Reference 49 | {% for reference in references %} 50 | {{reference.body.name[1..-1].camelcase}} 51 | {% end %} 52 | end 53 | {% else %} 54 | enum Reference 55 | Nop 56 | end 57 | {% end %} 58 | {% end %} 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /src/onyx-sql/model/instance_query_shortcuts.cr: -------------------------------------------------------------------------------- 1 | require "./changes" 2 | require "../query" 3 | 4 | module Onyx::SQL::Model 5 | # A shortcut method to genereate an insert `Query` pre-filled with actual `self` values. 6 | # See `Query#insert`. 7 | # 8 | # NOTE: Will raise `NilAssertionError` in runtime if a field has `not_null: true` option and 9 | # is actually `nil`. Conisder using `ClassQueryShortcuts#insert` instead. 10 | # 11 | # ``` 12 | # user = User.new(id: 42, name: "John") 13 | # user.insert == Query(User).new.insert(id: 42, name: "John") 14 | # ``` 15 | def insert : Query 16 | query = Query(self).new 17 | 18 | {% for ivar in @type.instance_vars %} 19 | # Skip foreign references. Because they are foreign, you know 20 | {% unless ((a = ivar.annotation(Reference)) && !a[:key]) %} 21 | {% ann = ivar.annotation(Field) || ivar.annotation(Reference) %} 22 | 23 | {% if (ann && ann[:default]) || @type.annotation(Model::Options)[:primary_key].id == "@#{ivar.name}".id %} 24 | unless @{{ivar.name}}.nil? 25 | query.insert({{ivar.name}}: @{{ivar.name}}.not_nil!) 26 | end 27 | {% elsif ann && ann[:not_null] %} 28 | if @{{ivar.name}}.nil? 29 | raise NilAssertionError.new("{{@type}}@{{ivar.name}} must not be nil on {{@type}}#insert") 30 | else 31 | query.insert({{ivar.name}}: @{{ivar.name}}.not_nil!) 32 | end 33 | {% else %} 34 | query.insert({{ivar.name}}: @{{ivar.name}}) 35 | {% end %} 36 | {% end %} 37 | {% end %} 38 | 39 | query 40 | end 41 | 42 | # A shortcut method to genereate an update `Query` with *changeset* values. 43 | # See `Query#update` and `Query#set`. 44 | # 45 | # ``` 46 | # user = User.new(id: 42, name: "John") 47 | # changeset = user.changeset 48 | # changeset.update(name: "Jake") 49 | # user.update(changeset) == Query(User).new.update.set(name: "Jake").where(id: 42) 50 | # ``` 51 | def update(changeset : Changeset(self, U)) : Query forall U 52 | query = Query(self).new.update 53 | 54 | {% begin %} 55 | changeset.changes!.each do |key, value| 56 | case key 57 | {% for ivar in @type.instance_vars %} 58 | {% unless (a = ivar.annotation(Reference)) && a[:foreign_key] %} 59 | when {{ivar.name.stringify}} 60 | {% if (a = ivar.annotation(Field) || ivar.annotation(Reference)) && a[:not_null] %} 61 | if value.nil? 62 | raise NilAssertionError.new("{{@type}}@{{ivar.name}} must not be nil on {{@type}}#update") 63 | else 64 | query.set({{ivar.name}}: value.as({{ivar.type}}).not_nil!) 65 | end 66 | {% else %} 67 | query.set({{ivar.name}}: value.as({{ivar.type}})) 68 | {% end %} 69 | {% end %} 70 | {% end %} 71 | else 72 | raise "BUG: Unrecognized Changeset({{@type}}) key :#{key}" 73 | end 74 | end 75 | {% end %} 76 | 77 | where_self(query) 78 | end 79 | 80 | # A shortcut method to genereate a delete `Query`. 81 | # See `Query#delete`. 82 | # 83 | # ``` 84 | # user = User.new(id: 42) 85 | # user.delete == Query(User).new.delete.where(id: 42) 86 | # ``` 87 | def delete : Query 88 | query = Query(self).new.delete 89 | where_self(query) 90 | end 91 | 92 | protected def where_self(query : Query) 93 | {% begin %} 94 | {% 95 | options = @type.annotation(Model::Options) 96 | raise "Onyx::SQL::Model::Options annotation must be defined for #{@type}" unless options 97 | 98 | pk = options[:primary_key] 99 | raise "Onyx::SQL::Model::Options annotation is missing :primary_key option for #{@type}" unless pk 100 | 101 | pk_ivar = @type.instance_vars.find { |iv| "@#{iv.name}".id == pk.id } 102 | raise "Cannot find primary key field #{pk} for #{@type}" unless pk_ivar 103 | %} 104 | 105 | query.where({{pk_ivar.name}}: @{{pk_ivar.name}}.not_nil!) 106 | {% end %} 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /src/onyx-sql/model/mappable.cr: -------------------------------------------------------------------------------- 1 | # This module allows to map a `Model` **to** the database. 2 | module Onyx::SQL::Model::Mappable(T) 3 | # Return a `Tuple` of DB-ready values. It respects `Field` and `Reference` annotations, 4 | # also working with `Converter`s. 5 | # 6 | # It ignores `not_null` option. It will call `.not_nil!` on enumerable references' 7 | # primary keys, thus can raise `NilAssertionError`. 8 | # 9 | # ``` 10 | # User.db_values(id: user.id) # => {42} 11 | # User.db_values(foo: "bar") # => Compilation-time error: unknown User instance variable foo 12 | # Post.db_values(author: user) # => May raise NilAssertionError if `user.id` is `nil` 13 | # ``` 14 | def self.db_values(**values : **U) : Tuple forall U 15 | end 16 | 17 | # Return a instance *variable* SQL column name. 18 | # 19 | # ``` 20 | # User.db_column(:id) # "id" 21 | # User.db_column(:unknown) # Compilation-time error 22 | # ``` 23 | def self.db_column(variable : T::Field | T::Reference) : String 24 | end 25 | 26 | macro included 27 | def self.db_values(**values : **U) : Tuple forall U 28 | {% verbatim do %} 29 | {% begin %} 30 | return { 31 | {% for key, value in U %} 32 | {% found = false %} 33 | 34 | {% for ivar in @type.instance_vars %} 35 | {% if ann = ivar.annotation(Onyx::SQL::Reference) %} 36 | {% if key == ivar.name %} 37 | {% 38 | found = true 39 | 40 | type = ivar.type.union_types.find { |t| t != Nil } 41 | enumerable = false 42 | 43 | if type <= Enumerable 44 | enumerable = true 45 | type = type.type_vars.first 46 | end 47 | 48 | options = type.annotation(Onyx::SQL::Model::Options) 49 | raise "Onyx::SQL::Model::Options annotation must be defined for #{type}" unless options 50 | 51 | pk = options[:primary_key] 52 | raise "#{type} must have Onyx::SQL::Model::Options annotation with :primary_key option" unless pk 53 | 54 | pk_rivar = type.instance_vars.find { |riv| "@#{riv.name}".id == pk.id } 55 | raise "Cannot find primary key field #{pk} in #{type}" unless pk_rivar 56 | 57 | pk_type = pk_rivar.type.union_types.find { |t| t != Nil } 58 | converter = (a = pk_rivar.annotation(Onyx::SQL::Field)) && a[:converter] 59 | %} 60 | 61 | {% if enumerable %} 62 | {% val = "values[#{key.symbolize}].try &.map(&.#{pk_rivar.name}.not_nil!)".id %} 63 | {% else %} 64 | {% val = "values[#{key.symbolize}].try &.#{pk_rivar.name}".id %} 65 | {% end %} 66 | 67 | {% if converter %} 68 | {{val}}.try { |v| {{converter}}.to_db(v).as(DB::Any) }, 69 | {% elsif pk_type <= DB::Any %} 70 | {% if enumerable %} 71 | {% raise "Cannot implicitly map enumerable reference #{@type}@#{ivar.name} to DB::Any. Consider applying a converter with `#to_db(Array(#{pk_type}))` method to #{type}@#{pk_rivar.name} to make it work" %} 72 | {% else %} 73 | {{val}}.as(DB::Any), 74 | {% end %} 75 | {% else %} 76 | {% raise "Cannot implicitly map reference #{@type}@#{ivar.name} to DB::Any. Consider applying a converter with `#to_db(#{pk_type})` method to #{type}@#{pk_rivar.name} to make it work" %} 77 | {% end %} 78 | {% end %} 79 | {% else %} 80 | {% if key == ivar.name %} 81 | {% 82 | found = true 83 | type = ivar.type.union_types.find { |t| t != Nil } 84 | converter = (a = ivar.annotation(Onyx::SQL::Field)) && a[:converter] 85 | %} 86 | 87 | {% if converter %} 88 | (values[{{key.symbolize}}].try do |val| 89 | {{converter}}.to_db(val).as(DB::Any) 90 | end), 91 | {% elsif type <= DB::Any %} 92 | values[{{key.symbolize}}].as(DB::Any), 93 | {% else %} 94 | {% raise "Cannot implicitly map #{@type}@#{ivar.name} to DB::Any. Consider applying a converter with `#to_db(#{type})` method to #{@type}@#{ivar.name} to make it work" %} 95 | {% end %} 96 | {% end %} 97 | {% end %} 98 | {% end %} 99 | 100 | {% raise "Cannot find an instance variable named @#{key} in #{@type}" unless found %} 101 | {% end %} 102 | } 103 | {% end %} 104 | {% end %} 105 | end 106 | 107 | def self.db_column(variable : T::Field | T::Reference) : String 108 | {% verbatim do %} 109 | {% begin %} 110 | if variable.is_a?(T::Field) 111 | case variable 112 | {% for ivar in @type.instance_vars.reject(&.annotation(Onyx::SQL::Reference)) %} 113 | when .{{ivar.name}}? 114 | {% key = ((a = ivar.annotation(Onyx::SQL::Field)) && a[:key]) || ivar.name %} 115 | return {{key.id.stringify}} 116 | {% end %} 117 | else 118 | raise "BUG: #{variable} is unmatched" 119 | end 120 | else 121 | case variable 122 | {% for ivar in @type.instance_vars.select(&.annotation(Onyx::SQL::Reference)) %} 123 | {% if ivar.annotation(Onyx::SQL::Reference)[:key] %} 124 | when .{{ivar.name}}? 125 | return {{ivar.annotation(Onyx::SQL::Reference)[:key].id.stringify}} 126 | {% else %} 127 | when .{{ivar.name}}? 128 | raise "Cannot map foreign {{@type}} reference @{{ivar.name}} to a DB column" 129 | {% end %} 130 | {% end %} 131 | else 132 | raise "BUG: #{variable} is unmatched" 133 | end 134 | end 135 | {% end %} 136 | {% end %} 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /src/onyx-sql/model/schema.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL::Model 2 | # `.schema` is a convenient DSL to avoid dealing with cumbersome (but extremely powerful) 3 | # annotations directly. Consider this code: 4 | # 5 | # ``` 6 | # @[Onyx::SQL::Model::Options(table: "users", primary_key: @id)] 7 | # class User 8 | # include Onyx::SQL::Model 9 | # 10 | # property! id : Int32? 11 | # 12 | # @[Onyx::SQL::Field(not_null: true)] 13 | # property! username : String 14 | # 15 | # @[Onyx::SQL::Reference(foreign_key: "author_id")] 16 | # property! authored_posts : Array(Post)? 17 | # end 18 | # 19 | # @[Onyx::SQL::Model::Options(table: "posts", primary_key: @id)] 20 | # class Post 21 | # include Onyx::SQL::Model 22 | # 23 | # @[Onyx::SQL::Field(converter: PG::Any(Int32))] 24 | # property! id : Int32? 25 | # 26 | # @[Onyx::SQL::Field(not_null: true)] 27 | # property! content : String? 28 | # 29 | # property cover : String? 30 | # 31 | # @[Onyx::SQL::Reference(key: "author_id", not_null: true)] 32 | # property! author : User? 33 | # end 34 | # ``` 35 | # 36 | # With the DSL, it could be simplifed to: 37 | # 38 | # ``` 39 | # class User 40 | # schema users do 41 | # pkey id : Int32 42 | # type username : String, not_null: true 43 | # type authored_posts : Array(Post), foreign_key: "author_id" 44 | # end 45 | # end 46 | # 47 | # class Post 48 | # schema posts do 49 | # pkey id : Int32 50 | # type content : String, not_null: true 51 | # type cover : String 52 | # type author : User, key: "author_id", not_null: true 53 | # end 54 | # end 55 | # ``` 56 | # 57 | # This macro has a single mandatory argument *table*, which is, obviously, the model's table name. 58 | # The schema currently **requires** a `.pkey` variable. 59 | # 60 | # TODO: Make the primary key optional. 61 | macro schema(table, &block) 62 | {{yield.id}} 63 | define_options({{table}}) 64 | end 65 | 66 | # Declare a model field or reference. **Must** be called within `.schema` block. 67 | # Expands `type` to either nilable `property` or raise-on-nil `property!`, depending on 68 | # the `not_null` option. The latter would raise in runtime if accessed or tried to 69 | # be set with the `nil` value. 70 | # 71 | # ``` 72 | # class User 73 | # include Onyx::SQL::Model 74 | # 75 | # schema users do 76 | # pkey id : Int32 77 | # type name : String, not_null: true 78 | # type age : Int32 79 | # end 80 | # end 81 | # 82 | # user = User.new 83 | # 84 | # user.id # => nil 85 | # user.name # => nil 86 | # user.age # => nil 87 | # 88 | # user.insert # Will raise in runtime, because name is `nil` 89 | # User.insert(name: user.name) # Safer alternative, would raise in compilation time instead 90 | # 91 | # user = User.new(name: "John", age: 18) 92 | # user.name = nil # Would raise in compilation time, cannot set to `nil` 93 | # user.age = nil # OK 94 | # ``` 95 | macro type(declaration, **options) 96 | property {{declaration.var}} : {{declaration.type}} | Nil 97 | 98 | def {{declaration.var}}! 99 | raise NilAssertionError.new("{{@type}}@{{declaration.var}} is expected to not be nil") if @{{declaration.var}}.nil? 100 | @{{declaration.var}}.not_nil! 101 | end 102 | 103 | macro finished 104 | {% unless options.empty? %} 105 | \{% 106 | type = {{declaration.type}} 107 | 108 | if type.union? 109 | if type.union_types.size != 2 110 | raise "Only T | Nil unions can be an Onyx::SQL::Model's variables (got #{type} type for #{@type}@#{declaration.var})" 111 | end 112 | 113 | type = type.union_types.find { |t| t != Nil } 114 | end 115 | 116 | if type <= Enumerable 117 | if !(type <= Array) || type.type_vars.size != 1 118 | raise "Cannot use #{type} as an Onyx::SQL instance variable for #{@type}. Only Array(T) is supported for Enumerable" 119 | end 120 | 121 | type = type.type_vars.first 122 | end 123 | %} 124 | 125 | \{% if type < Onyx::SQL::Model %} 126 | \{{"@[Onyx::SQL::Reference(key: #{{{options[:key]}}}, foreign_key: #{{{options[:foreign_key]}}}, not_null: #{{{options[:not_null]}}})]".id}} 127 | \{% else %} 128 | \{{"@[Onyx::SQL::Field(key: #{{{options[:key]}}}, default: #{{{options[:default]}}}, converter: #{{{options[:converter]}}}, not_null: #{{{options[:not_null]}}})]".id}} 129 | \{% end %} 130 | 131 | @{{declaration.var}} : {{declaration.type}} | Nil{{" = #{declaration.value}".id if declaration.value}} 132 | {% end %} 133 | end 134 | end 135 | 136 | # Declare a model primary key, **must** be called within `.schema` block. 137 | # It is equal to `.type`, but also passes `not_null: true` and 138 | # defines the `:primary_key` option for the `Options` annotation. 139 | # It's currently mandatory to have a primary key in a model, which may change in the future. 140 | # 141 | # ``` 142 | # class User 143 | # schema users do 144 | # pkey id : Int32 145 | # end 146 | # end 147 | # 148 | # # Expands to 149 | # 150 | # @[Onyx::SQL::Model::Options(primary_key: @id)] 151 | # class User 152 | # @[Onyx::SQL::Field(not_null: true)] 153 | # property! id : Int32 154 | # end 155 | macro pkey(declaration, **options) 156 | private ONYX_SQL_MODEL_SCHEMA_PK = {{"@#{declaration.var}".id}} 157 | type({{declaration}}, not_null: true, default: true, {{**options}}) 158 | end 159 | 160 | private macro define_options(table) 161 | {% raise "Primary key is not defined in #{@type} schema. Use `pkey` macro for this" unless ONYX_SQL_MODEL_SCHEMA_PK %} 162 | 163 | @[Onyx::SQL::Model::Options(table: {{table}}, primary_key: {{ONYX_SQL_MODEL_SCHEMA_PK}})] 164 | class ::{{@type}} 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /src/onyx-sql/query/delete.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # Mark this query as a `DELETE` one. It's recommended to call `#where` as well: 4 | # 5 | # ``` 6 | # query = User.delete.where(id: 17) 7 | # query.build # => {"DELETE FROM users WHERE id = ?", {17}} 8 | # ``` 9 | # 10 | # `Model`s have a handy `Model#delete` shortcut: 11 | # 12 | # ``` 13 | # user.delete == User.delete.where(id: user.id) 14 | # ``` 15 | def delete 16 | @type = :delete 17 | self 18 | end 19 | 20 | protected def append_delete(sql, *args) 21 | {% begin %} 22 | {% table = T.annotation(SQL::Model::Options)[:table] %} 23 | sql << "DELETE FROM " << (@alias || {{table.id.stringify}}) 24 | {% end %} 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/onyx-sql/query/group_by.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # Add `GROUP_BY` clause. 4 | def group_by(values : Enumerable(String)) 5 | values.each do |value| 6 | ensure_group_by << value 7 | end 8 | 9 | self 10 | end 11 | 12 | # ditto 13 | def group_by(*values : String) 14 | group_by(values) 15 | end 16 | 17 | @group_by : Deque(String)? = nil 18 | 19 | protected def get_group_by 20 | @group_by 21 | end 22 | 23 | protected def ensure_group_by 24 | @group_by ||= Deque(String).new 25 | end 26 | 27 | protected def append_group_by(sql, *args) 28 | return if @group_by.nil? || ensure_group_by.empty? 29 | 30 | sql << " GROUP BY " 31 | 32 | first = true 33 | ensure_group_by.each do |value| 34 | sql << ", " unless first; first = false 35 | sql << value 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /src/onyx-sql/query/having.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # This method will raise in compilation-time, 4 | # because having a `HAVING` query with a `Model`'s attributes makes no sense. 5 | def having(or : Bool = false, not : Bool = false, **values : **U) : self forall U 6 | {% begin %} 7 | {% pretty_values = "" %} 8 | 9 | {% for key, value, index in U %} 10 | {% pretty_values = pretty_values + "#{key}: #{value}" %} 11 | {% pretty_values = pretty_values + ", " if index < (U.size - 1) %} 12 | {% end %} 13 | 14 | {% raise "Cannot call `Query(#{T})#having(#{pretty_values.id})` because `HAVING` clause with direct `#{T}` fields or references makes no sense. Use the string clause version instead" %} 15 | {% end %} 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/onyx-sql/query/insert.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # Mark this query as `INSERT` one and insert the arguments. It's a **type-safe** method. 4 | # However, it will call `.not_nil!` on references' primary keys, thus it can raise 5 | # `NilAssertionError` in runtime: 6 | # 7 | # ``` 8 | # Post.insert(content: "foo", author: user) # Will raise NilAssertionError in runtime if `user.id` is `nil` 9 | # ``` 10 | # 11 | # TODO: Consider inserting explicit reference keys instead, e.g. 12 | # `Post.insert(author_id: user.id.not_nil!)` (when `Model.db_values` allows to). 13 | # 14 | # ## Example: 15 | # 16 | # ``` 17 | # query = User.insert(name: "John", age: 18) 18 | # query.build # => {"INSERT INTO users (name, age) VALUES (?, ?)", {"John", 18}} 19 | # ``` 20 | # 21 | # `Model`s have a handy `Model#insert` shortcut. But it is less type-safe (regarding to 22 | # `not_null` variables, see in `Model#insert` docs): 23 | # 24 | # ``` 25 | # user.insert == User.insert(id: user.id, name: user.name.not_nil!) 26 | # ``` 27 | def insert(**values : **U) : self forall U 28 | values.each do |key, value| 29 | {% begin %} 30 | case key 31 | {% for key, value in U %} 32 | {% 33 | ivar = T.instance_vars.find(&.name.== key) 34 | 35 | not_null = (a = ivar.annotation(Field) || ivar.annotation(Reference)) && a[:not_null] 36 | raise "On Query(#{T})#insert: #{key} is nilable in compilation time (`#{value}`), but #{T}@#{ivar.name} has `not_null` option set to `true`. Consider calling `.not_nil!` on the value" if not_null && value.nilable? 37 | 38 | db_default = (a = ivar.annotation(Field)) && a[:default] 39 | is_pk = "@#{ivar.name}".id == T.annotation(Model::Options)[:primary_key].id 40 | %} 41 | 42 | {% raise "Cannot find an instance variable named @#{key} in #{T}" unless ivar %} 43 | 44 | when {{key.symbolize}} 45 | if !value.nil? || !({{db_default}} || {{is_pk}}) 46 | ensure_insert << Insert.new( 47 | T.db_column({{ivar.name.symbolize}}), 48 | Box(DB::Any).new(T.db_values({{ivar.name}}: value.as({{value}}))[0]).as(Void*) 49 | ) 50 | end 51 | {% end %} 52 | else 53 | raise "BUG: Runtime case didn't match anything" 54 | end 55 | {% end %} 56 | end 57 | 58 | @type = :insert 59 | self 60 | end 61 | 62 | def insert(name : T::Field | T::Reference | String, value : String) 63 | if name.is_a?(T::Field) || name.is_a?(T::Reference) 64 | ensure_insert << Insert.new(T.db_column(name), value) 65 | else 66 | ensure_insert << Insert.new(name, value) 67 | end 68 | 69 | @type = :insert 70 | self 71 | end 72 | 73 | private struct Insert 74 | getter column, value 75 | 76 | def initialize(@column : String, @value : Void* | String) 77 | end 78 | end 79 | 80 | @insert : Deque(Insert)? = nil 81 | 82 | protected def get_insert 83 | @insert 84 | end 85 | 86 | protected def ensure_insert 87 | @insert ||= Deque(Insert).new 88 | end 89 | 90 | protected def append_insert(sql, params, params_index) 91 | raise "BUG: Empty @insert" if ensure_insert.empty? 92 | 93 | {% begin %} 94 | {% table = T.annotation(Model::Options)[:table] %} 95 | sql << "INSERT INTO " << (@alias || {{table.id.stringify}}) << " (" 96 | {% end %} 97 | 98 | values_sql = IO::Memory.new 99 | 100 | first = true 101 | ensure_insert.each do |insert| 102 | unless first 103 | sql << ", " 104 | values_sql << ", " 105 | end; first = false 106 | 107 | sql << insert.column 108 | 109 | if value = insert.value.as?(Void*) 110 | values_sql << (params_index ? "$#{params_index.value += 1}" : "?") 111 | params.not_nil!.push(Box(DB::Any).unbox(value)) if params 112 | else 113 | values_sql << insert.value.as(String) 114 | end 115 | end 116 | 117 | sql << ") VALUES (" << values_sql << ")" 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /src/onyx-sql/query/join.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # The SQL join type. 4 | enum JoinType 5 | Inner 6 | Left 7 | Right 8 | Full 9 | end 10 | 11 | # Add explicit `JOIN` clause. 12 | # If the query hasn't had any `#select` calls before, then `#select(T)` is called on it. 13 | # 14 | # ``` 15 | # query.join("a", on: "a.id = b.id", as: "c", :right) # => RIGHT JOIN a ON a.id = b.id AS c 16 | # ``` 17 | def join(table : String, on : String, as _as : String? = nil, type : JoinType = :inner) 18 | ensure_join << Join.new( 19 | table: table, 20 | on: on, 21 | as: _as, 22 | type: type 23 | ) 24 | 25 | if @select.nil? 26 | self.select(T) 27 | end 28 | 29 | self 30 | end 31 | 32 | # Add `JOIN` clause by a model reference. 33 | # Yields another `Query` instance which has the reference's type. 34 | # It then merges the yielded query with the main query. 35 | # 36 | # ``` 37 | # class User 38 | # include Onyx::SQL::Model 39 | # 40 | # schema users do 41 | # pkey id : Int32 42 | # type username : String 43 | # type authored_posts : Array(Post), foreign_key: "author_id" 44 | # end 45 | # end 46 | # 47 | # class Post 48 | # include Onyx::SQL::Model 49 | # 50 | # schema posts do 51 | # pkey id : Int32 52 | # type body : String 53 | # type author : User, key: "author_id" 54 | # end 55 | # end 56 | # 57 | # query = Post 58 | # .join(author: true) do |q| 59 | # pp typeof(q) # => Query(User) 60 | # q.select(:username) # :username is looked up in User in compilation time 61 | # q.where(id: 42) 62 | # end 63 | # 64 | # query.build # => {"SELECT posts.*, author.username FROM posts INNER JOIN users ON posts.author_id = author.id AS author WHERE author.id = ?", {42}} 65 | # ``` 66 | # 67 | # In fact, the resulting SQL slightly differs from the example above: 68 | # 69 | # ```text 70 | # SELECT posts.*, '' AS _author, author.username, '' AS _author FROM posts ... 71 | # ``` 72 | # 73 | # The `"AS _author"` thing is a *marker*, which is used to preload references on 74 | # `Serializable.from_rs` call: 75 | # 76 | # ``` 77 | # posts = repo.query(query) 78 | # pp posts # => [>, >, ...] 79 | # ``` 80 | # 81 | # Read more about preloading references in `Serializable` docs. 82 | # 83 | # If parent query hasn't had any `#select` calls before, then `#select(T)` is called on it. 84 | # 85 | # NOTE: The syntax is about to be improved from `join(author: true)` to `join(:author)`. 86 | # See the relevant [forum topic](https://forum.crystal-lang.org/t/symbols/391). 87 | def join(*, on : String? = nil, as _as : String? = nil, type : JoinType = :inner, **values : **U, &block) : self forall U 88 | {% begin %} 89 | {% 90 | raise "Can only join a single reference" if U.keys.size != 1 91 | key = U.keys.first 92 | 93 | ivar = T.instance_vars.find { |iv| iv.name == key } 94 | raise "Cannot find instance variable @#{key} in #{T}" unless ivar 95 | 96 | ann = ivar.annotation(Reference) 97 | raise "Instance variable @#{key} of #{T} must have Onyx::SQL::Reference annotation" unless ann 98 | %} 99 | 100 | join({{ivar.name.symbolize}}, on: on, as: _as || {{ivar.name.stringify}}, type: type) 101 | 102 | {% 103 | type = ivar.type.union_types.find { |t| t != Nil } 104 | append_select = true 105 | 106 | if type <= Enumerable 107 | # Do not append select on foreign enumerable references, 108 | # because that would result in multiple rows 109 | append_select = false unless ann[:key] 110 | 111 | type = type.type_vars.first 112 | end 113 | %} 114 | 115 | subquery = Query({{type}}).new(_as || {{ivar.name.stringify}}) 116 | yield subquery 117 | 118 | {% if append_select %} 119 | if sub_select = subquery.get_select 120 | self.select("'' AS _{{ivar.name}}") 121 | ensure_select.concat(sub_select) 122 | self.select("'' AS _{{ivar.name}}") 123 | end 124 | 125 | ensure_order_by.concat(subquery.get_order_by.not_nil!) if subquery.get_order_by 126 | {% end %} 127 | 128 | ensure_join.concat(subquery.get_join.not_nil!) if subquery.get_join 129 | ensure_where.concat(subquery.get_where.not_nil!) if subquery.get_where 130 | {% end %} 131 | 132 | self 133 | end 134 | 135 | # Add `JOIN` clause by a model *reference* without yielding a sub-query. 136 | # If the query hasn't had any `#select` calls before, then `#select(T)` is called on it. 137 | # 138 | # ``` 139 | # query = Post.join(:author).where(id: 17) 140 | # query.build # => {"SELECT posts.* FROM posts INNER JOIN users AS author ON posts.author_id = author.id WHERE posts.id = ?", {17}} 141 | # ``` 142 | # 143 | # Note that in this case there are no markers, so a post's `@author` reference would 144 | # **not** have `@username` variable filled. However, the reference itself would 145 | # still present, as a post row itself contains the `"author_id"` column, which would 146 | # be put into a post's `@author` instance upon calling `Serializable.from_rs`: 147 | # 148 | # ``` 149 | # post = repo.query(Post.select(Post, "author.username").join(:author)).first 150 | # # SELECT posts.*, author.username FROM posts ... 151 | # pp post # => > 152 | # ``` 153 | def join(reference : T::Reference, on : String? = nil, as _as : String = reference.to_s.underscore, type : JoinType = :inner) 154 | {% begin %} 155 | {% 156 | table = T.annotation(Model::Options)[:table].id 157 | pkey = T.instance_vars.find do |ivar| 158 | "@#{ivar.name}".id == T.annotation(Model::Options)[:primary_key].id 159 | end 160 | %} 161 | 162 | case reference 163 | {% for ivar in T.instance_vars.select(&.annotation(Reference)) %} 164 | {% 165 | type = ivar.type.union_types.find { |t| t != Nil } 166 | enumerable = false 167 | 168 | if type <= Enumerable 169 | enumerable = true 170 | type = type.type_vars.first 171 | end 172 | 173 | roptions = type.annotation(Model::Options) 174 | raise "Onyx::SQL::Model::Options annotation must be defined for #{type}" unless roptions 175 | 176 | rtable = roptions[:table].id 177 | raise "Onyx::SQL::Model::Options annotation is missing `table` option for #{type}" unless rtable 178 | %} 179 | 180 | when .{{ivar.name}}? 181 | {% if key = ivar.annotation(Reference)[:key] %} 182 | {% 183 | rpk = roptions[:primary_key] 184 | raise "Onyx::SQL::Model::Options annotation is missing `primary_key` option for #{type}" unless rpk 185 | 186 | rpk = rpk.name.stringify.split('@')[1].id 187 | on_op = (enumerable ? "IN".id : "=".id) 188 | %} 189 | 190 | ensure_join << Join.new( 191 | table: {{rtable.stringify}}, 192 | on: on || "#{_as}.#{ {{type}}.db_column({{rpk.symbolize}}) } {{on_op}} #{@alias || {{table.stringify}}}.{{key.id}}", 193 | as: _as, 194 | type: type 195 | ) 196 | {% elsif foreign_key = ivar.annotation(Reference)[:foreign_key] %} 197 | {% 198 | rivar = type.instance_vars.find do |rivar| 199 | (a = rivar.annotation(Reference)) && (a[:key].id == foreign_key.id) 200 | end 201 | raise "Cannot find matching reference for #{T}@#{ivar.name} in #{type}" unless rivar 202 | 203 | rtype = rivar.type.union_types.find { |t| t != Nil } 204 | key = rivar.annotation(Reference)[:key].id 205 | on_op = rtype <= Enumerable ? "IN".id : "=".id 206 | %} 207 | 208 | ensure_join << Join.new( 209 | table: {{rtable.stringify}}, 210 | on: on || "#{@alias || {{table.stringify}}}.#{T.db_column({{pkey.name.symbolize}})} {{on_op}} #{_as}.{{key}}", 211 | as: _as, 212 | type: type 213 | ) 214 | {% else %} 215 | {% raise "Neither `key` nor `foreign_key` option is set for reference @#{ivar.name} of #{T}" %} 216 | {% end %} 217 | {% end %} 218 | else 219 | raise "BUG: Cannot find reference #{reference} in #{T}::Reference enum" 220 | end 221 | {% end %} 222 | 223 | if @select.nil? 224 | self.select(T) 225 | end 226 | 227 | self 228 | end 229 | 230 | # TODO: Make this work 231 | # {% for x in %w(inner left right full) %} 232 | # def {{x.id}}_join(**values, &block) 233 | # join(**values, type: {{x.id.symbolize}}, &block) 234 | # end 235 | # {% end %} 236 | 237 | private struct Join 238 | getter table, on, _as, type 239 | 240 | def initialize(@table : String, @on : String, as @_as : String?, @type : JoinType) 241 | end 242 | end 243 | 244 | @join : Deque(Join)? = nil 245 | 246 | protected def get_join 247 | @join 248 | end 249 | 250 | protected def ensure_join 251 | @join ||= Deque(Join).new 252 | end 253 | 254 | protected def append_join(sql, *args) 255 | return unless @join 256 | 257 | ensure_join.each do |join| 258 | clause = case join.type 259 | when .inner? then " INNER JOIN " 260 | when .left? then " LEFT JOIN " 261 | when .right? then " RIGHT JOIN " 262 | when .full? then " FULL JOIN " 263 | end 264 | 265 | sql << clause << join.table 266 | 267 | if (_as = join._as) && !_as.empty? 268 | sql << " AS " << join._as 269 | end 270 | 271 | unless join.on.empty? 272 | sql << " ON " << join.on 273 | end 274 | end 275 | end 276 | end 277 | end 278 | -------------------------------------------------------------------------------- /src/onyx-sql/query/limit.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # Add `LIMIT` clause. `nil` argument cancels it. 4 | def limit(@limit : Int32?) 5 | self 6 | end 7 | 8 | @limit : Int32? = nil 9 | 10 | protected def get_limit 11 | @limit 12 | end 13 | 14 | protected def append_limit(sql, *args) 15 | return unless @limit 16 | sql << " LIMIT " << @limit 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /src/onyx-sql/query/offset.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # Add `OFFSET` clause. `nil` argument cancels it. 4 | def offset(@offset : Int32?) 5 | self 6 | end 7 | 8 | @offset : Int32? = nil 9 | 10 | protected def get_offset 11 | @offset 12 | end 13 | 14 | protected def append_offset(sql, *args) 15 | return unless @offset 16 | sql << " OFFSET " << @offset 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /src/onyx-sql/query/order_by.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # The `ORDER BY` clause order. 4 | enum Order 5 | Desc 6 | Asc 7 | end 8 | 9 | # Add `ORDER BY` clause by either a model field or explicit `String` *value*. 10 | # 11 | # ``` 12 | # q = User.all.order_by(:id, :desc) 13 | # q.build # => {"SELECT users.* FROM users ORDER BY id DESC"} 14 | # 15 | # q = User.all.order_by("foo(bar)") 16 | # q.build # => {"SELECT users.* FROM users ORDER BY foo(bar)"} 17 | # ``` 18 | def order_by(value : T::Field | String, order : Order? = nil) 19 | if value.is_a?(T::Field) 20 | {% begin %} 21 | case value 22 | {% for ivar in T.instance_vars.reject { |iv| iv.annotation(Reference) } %} 23 | when .{{ivar.name}}? 24 | ensure_order_by.add(OrderBy.new( 25 | "#{@alias || {{T.annotation(Model::Options)[:table].id.stringify}}}.#{T.db_column({{ivar.name.symbolize}})}", 26 | order 27 | )) 28 | {% end %} 29 | else 30 | raise "BUG: #{value} didn't match any of #{T} instance variables" 31 | end 32 | {% end %} 33 | else 34 | ensure_order_by.add(OrderBy.new(value, order)) 35 | end 36 | 37 | self 38 | end 39 | 40 | private struct OrderBy 41 | getter column, order 42 | 43 | def initialize(@column : String, @order : Order? = nil) 44 | end 45 | end 46 | 47 | @order_by : ::Set(OrderBy)? = nil 48 | 49 | protected def get_order_by 50 | @order_by 51 | end 52 | 53 | protected def ensure_order_by 54 | @order_by ||= ::Set(OrderBy).new 55 | end 56 | 57 | protected def append_order_by(sql, *args) 58 | return if @order_by.nil? || ensure_order_by.empty? 59 | 60 | sql << " ORDER BY " 61 | 62 | first = true 63 | ensure_order_by.each do |order_by| 64 | sql << ", " unless first; first = false 65 | sql << order_by.column 66 | 67 | if order = order_by.order 68 | sql << " " << order.to_s.upcase 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /src/onyx-sql/query/returning.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # Add `RETURNING` clause by either model field or reference or explicit Char or String. 4 | # 5 | # NOTE: All `RETURNING` clauses are removed on `Repository#exec(query)` call. 6 | # NOTE: SQLite does **not** support `RETURNING` clause. 7 | # 8 | # ``` 9 | # q = user.insert.returning(:id, :name) 10 | # q.build # => {"INSERT INTO users ... RETURNING id, name"} 11 | # 12 | # q = user.insert.returning("foo") 13 | # q.build # => {"INSERT INTO users ... RETURNING foo"} 14 | # ``` 15 | def returning(values : Enumerable(T::Field | T::Reference | Char | String)) 16 | values.each do |value| 17 | {% begin %} 18 | {% table = T.annotation(Model::Options)[:table] %} 19 | 20 | if value.is_a?(T::Field) 21 | case value 22 | {% for ivar in T.instance_vars.reject(&.annotation(Reference)) %} 23 | when .{{ivar.name}}? 24 | column = T.db_column({{ivar.name.symbolize}}) 25 | 26 | ensure_returning << if @alias 27 | "#{@alias}.#{column}" 28 | else 29 | "{{table.id}}.#{column}" 30 | end 31 | {% end %} 32 | else 33 | raise "BUG: #{value} didn't match any of #{T} instance variables" 34 | end 35 | elsif value.is_a?(T::Reference) 36 | case value 37 | {% for ivar in T.instance_vars.select(&.annotation(Reference)).reject(&.annotation(Reference)[:foreign_key]) %} 38 | when .{{ivar.name}}? 39 | column = T.db_column({{ivar.name.symbolize}}) 40 | 41 | ensure_returning << if @alias 42 | "#{@alias}.#{column}" 43 | else 44 | "{{table.id}}.#{column}" 45 | end 46 | {% end %} 47 | else 48 | raise "BUG: #{value} didn't match any of #{T} instance variables" 49 | end 50 | else 51 | ensure_returning << value.to_s 52 | end 53 | {% end %} 54 | end 55 | 56 | self 57 | end 58 | 59 | # ditto 60 | def returning(*values : T::Field | T::Reference | Char | String) 61 | returning(values) 62 | end 63 | 64 | # Add `RETURNING` asterisk clause for the whole `T` table. 65 | # 66 | # NOTE: All `RETURNING` clauses are removed on `Repository#exec(query)` call. 67 | # NOTE: SQLite does **not** support `RETURNING` clause. 68 | # 69 | # ``` 70 | # Post.returning(Post) # => RETURNING posts.* 71 | # ``` 72 | def returning(klass : T.class) 73 | ensure_returning << if @alias 74 | "#{@alias}.*" 75 | else 76 | {{T.annotation(Model::Options)[:table].id.stringify}} + ".*" 77 | end 78 | 79 | self 80 | end 81 | 82 | # Add `RETURNING` asterisk clause for the whole `T` table and optionally *values*. 83 | # 84 | # NOTE: All `RETURNING` clauses are removed on `Repository#exec(query)` call. 85 | # NOTE: SQLite does **not** support `RETURNING` clause. 86 | # 87 | # ``` 88 | # Post.returning(Post, :id) # => RETURNING posts.*, posts.id 89 | # ``` 90 | def returning(klass : T.class, *values : T::Field | T::Reference | Char | String) 91 | ensure_returning << if @alias 92 | "#{@alias}.*" 93 | else 94 | {{T.annotation(Model::Options)[:table].id.stringify}} + ".*" 95 | end 96 | 97 | unless values.empty? 98 | returning(values) 99 | end 100 | 101 | self 102 | end 103 | 104 | @returning : Deque(String)? = nil 105 | protected property returning 106 | 107 | protected def get_returning 108 | @returning 109 | end 110 | 111 | protected def ensure_returning 112 | @returning ||= Deque(String).new 113 | end 114 | 115 | protected def append_returning(sql, *args) 116 | return if @returning.nil? || ensure_returning.empty? 117 | 118 | sql << " RETURNING " 119 | 120 | first = true 121 | ensure_returning.each do |value| 122 | sql << ", " unless first; first = false 123 | sql << value 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /src/onyx-sql/query/select.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # Add `SELECT` clause by either model field or reference or explicit Char or String. 4 | # 5 | # If no `#select` is called on this query, then it would select everything (`"*"`). 6 | # Otherwise it would select those columns only which were specified explicitly. 7 | # 8 | # ``` 9 | # q = User.all 10 | # q.build # => {"SELECT * FROM users"} 11 | # 12 | # q = User.select(:id, :name) 13 | # q.build # => {"SELECT users.id, users.name FROM users"} 14 | # 15 | # q = User.select("foo") 16 | # q.build # => {"SELECT foo FROM users"} 17 | # 18 | # # Note that in this case the author reference would not be 19 | # # actually preloaded, because the resulting query is missing markers 20 | # q = Post.join(:author) 21 | # q.build # => {"SELECT * FROM posts JOIN users ..."} 22 | # 23 | # # That's better 24 | # q = Post.select(Post).join(author: true) { |x| x.select(:name) } 25 | # q.build # => {"SELECT posts.*, '' AS _author, author.name ..."} 26 | # ``` 27 | def select(values : Enumerable(T::Field | T::Reference | Char | String)) 28 | values.each do |value| 29 | {% begin %} 30 | {% table = T.annotation(Model::Options)[:table] %} 31 | 32 | if value.is_a?(T::Field) 33 | case value 34 | {% for ivar in T.instance_vars.reject(&.annotation(Reference)) %} 35 | when .{{ivar.name}}? 36 | column = T.db_column({{ivar.name.symbolize}}) 37 | 38 | ensure_select << if @alias 39 | "#{@alias}.#{column}" 40 | else 41 | "{{table.id}}.#{column}" 42 | end 43 | {% end %} 44 | else 45 | raise "BUG: #{value} didn't match any of #{T} instance variables" 46 | end 47 | elsif value.is_a?(T::Reference) 48 | case value 49 | {% for ivar in T.instance_vars.select(&.annotation(Reference)).reject(&.annotation(Reference)[:foreign_key]) %} 50 | when .{{ivar.name}}? 51 | column = T.db_column({{ivar.name.symbolize}}) 52 | 53 | ensure_select << if @alias 54 | "#{@alias}.#{column}" 55 | else 56 | "{{table.id}}.#{column}" 57 | end 58 | {% end %} 59 | else 60 | raise "BUG: #{value} didn't match any of #{T} instance variables" 61 | end 62 | else 63 | ensure_select << value.to_s 64 | end 65 | {% end %} 66 | end 67 | 68 | @type = :select 69 | self 70 | end 71 | 72 | # ditto 73 | def select(*values : T::Field | T::Reference | Char | String) 74 | self.select(values) 75 | end 76 | 77 | # Add `SELECT` asterisk clause for the whole `T` table. 78 | # 79 | # ``` 80 | # Post.select(Post, :id) # => SELECT posts.*, posts.id 81 | # ``` 82 | def select(klass : T.class) 83 | ensure_select << if @alias 84 | "#{@alias}.*" 85 | else 86 | {{T.annotation(Model::Options)[:table].stringify}} + ".*" 87 | end 88 | 89 | @type = :select 90 | self 91 | end 92 | 93 | # Add `SELECT` asterisk clause for the whole `T` table and *values*. 94 | # 95 | # ``` 96 | # Post.select(Post, :id) # => SELECT posts.*, posts.id 97 | # ``` 98 | def select(klass : T.class, *values : T::Field | T::Reference | Char | String) 99 | ensure_select << if @alias 100 | "#{@alias}.*" 101 | else 102 | {{T.annotation(Model::Options)[:table].stringify}} + ".*" 103 | end 104 | 105 | unless values.empty? 106 | self.select(values) 107 | end 108 | 109 | @type = :select 110 | self 111 | end 112 | 113 | # Reset current query selects. This will lead to empty select clause for this partucular 114 | # query. It's useful when on joining. However, it would raise in runtime if the resulting 115 | # query has no actual selects. 116 | # 117 | # ``` 118 | # Post.select(nil).build # "Cannot build a query with empty SELECT clause" runtime error 119 | # 120 | # q = Post.select(nil).join(author: true) { |x| x.select(:username) } 121 | # q.build # => {"SELECT author.username FROM posts JOIN user AS author ..."} 122 | # ``` 123 | # 124 | # NOTE: If you call it *after* join, then it will erase everything: 125 | # 126 | # ```crystal 127 | # Post.join(author: true) { |x| x.select(:username) }.select(nil).build 128 | # # Cannot build a query with empty SELECT clause 129 | # ``` 130 | def select(nil_value : Nil) 131 | @select = Deque(String).new 132 | self 133 | end 134 | 135 | @select : Deque(String)? = nil 136 | 137 | protected def get_select 138 | @select 139 | end 140 | 141 | protected def ensure_select 142 | @select = Deque(String).new if @select.nil? 143 | @select.not_nil! 144 | end 145 | 146 | protected def append_select(sql, *args) 147 | if selects = @select 148 | raise "Cannot build a query with empty SELECT clause" if selects.empty? 149 | 150 | sql << "SELECT " 151 | 152 | first = true 153 | selects.each do |s| 154 | sql << ", " unless first; first = false 155 | sql << s 156 | end 157 | else 158 | sql << "SELECT *" 159 | end 160 | 161 | sql << " FROM " << (@alias || {{T.annotation(Model::Options)[:table].id.stringify}}) 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /src/onyx-sql/query/set.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # Add explicit `SET` *clause* and mark this query as `UPDATE` one. 4 | # 5 | # ``` 6 | # query.set("salary = salary * 2") 7 | # ``` 8 | def set(clause : String) 9 | ensure_set << Set.new(clause) 10 | 11 | @type = :update 12 | self 13 | end 14 | 15 | # Add explicit `SET` *clause* with *params* and mark this query as `UPDATE` one. 16 | # 17 | # ``` 18 | # query.set("salary = salary * ?", 2) 19 | # ``` 20 | def set(clause : String, *params : DB::Any) 21 | ensure_set << Set.new( 22 | clause: clause, 23 | explicit: false, 24 | values: params, 25 | ) 26 | 27 | @type = :update 28 | self 29 | end 30 | 31 | # Add `SET` clauses from *values* and mark this query as `UPDATE` one. 32 | # It's a **type-safe** method. 33 | # However, it will call `.not_nil!` on references' primary keys, thus it can raise 34 | # `NilAssertionError` in runtime: 35 | # 36 | # ``` 37 | # Post.update.set(author: user) # Will raise NilAssertionError in runtime if `user.id` is `nil` 38 | # ``` 39 | # 40 | # TODO: Consider updating explicit reference keys instead, e.g. 41 | # `Post.update.set(author_id: user.id.not_nil!)` (when `Model.db_values` allows to). 42 | # 43 | # ## Example: 44 | # 45 | # ``` 46 | # query = User.update.set(name: "Jake", age: 17) 47 | # query.build # => {"UPDATE users SET name = ?, age = ?", {"Jake", 17}} 48 | # ``` 49 | def set(**values : **U) : self forall U 50 | values.each do |key, value| 51 | {% begin %} 52 | case key 53 | {% for key, value in U %} 54 | {% 55 | ivar = T.instance_vars.find(&.name.== key) 56 | raise "Cannot find an instance variable named @#{key} in #{T}" unless ivar 57 | 58 | not_null = (a = ivar.annotation(Field) || ivar.annotation(Reference)) && a[:not_null] 59 | 60 | raise "On Query(#{T})#set: #{key} is nilable in compilation time (`#{value}`), but #{T}@#{ivar.name} has `not_null` option set to `true`. Consider calling `.not_nil!` on the value" if not_null && value.nilable? 61 | 62 | if (a = ivar.annotation(Reference)) && a[:foreign_key] 63 | raise <<-TEXT 64 | \e[41m Onyx::SQL compilation error \e[0m — Must not update \e[33m\e[1mUser@foo\e[0m in a query, because it's a foreign reference 65 | TEXT 66 | end 67 | %} 68 | 69 | when {{key.symbolize}} 70 | ensure_set << Set.new( 71 | clause: T.db_column({{ivar.name.symbolize}}), 72 | explicit: true, 73 | values: T.db_values({{ivar.name}}: value.as({{value}})).to_a 74 | ) 75 | {% end %} 76 | else 77 | raise "BUG: Runtime case didn't match anything" 78 | end 79 | {% end %} 80 | end 81 | 82 | @type = :update 83 | self 84 | end 85 | 86 | @set : Deque(Set)? = nil 87 | 88 | protected def get_set 89 | @set 90 | end 91 | 92 | private struct Set 93 | getter clause, values 94 | getter? explicit 95 | 96 | def initialize(@clause : String, @explicit : Bool = false, @values : Enumerable(DB::Any)? = nil) 97 | end 98 | end 99 | 100 | protected def ensure_set 101 | @set ||= Deque(Set).new 102 | end 103 | 104 | protected def append_set(sql, params, params_index) 105 | raise "No values to SET in the UPDATE query" if ensure_set.empty? 106 | 107 | sql << " SET " 108 | 109 | first = true 110 | ensure_set.each do |set| 111 | sql << ", " unless first; first = false 112 | sql << set.clause 113 | 114 | if set.explicit? 115 | values = set.values 116 | raise "BUG: Nil values for explicit set" unless values 117 | raise "BUG: Explicit set values size != 1 (got #{values.size} instead)" unless values.size == 1 118 | 119 | sql << " = " << (params_index ? "$#{params_index.value += 1}" : '?') 120 | params.try &.push(values.first) 121 | else 122 | set.values.try &.each do |value| 123 | params.try &.push(value) 124 | end 125 | end 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /src/onyx-sql/query/update.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # Mark this query as `UPDATE` one. This is for convenience only, and the `#build` would 4 | # raise if there are no `SET` clauses in the query. 5 | # 6 | # ``` 7 | # User.update.build # Runtime error: No values to SET in the UPDATE query 8 | # User.update.set(name: "Jane") # OK 9 | # ``` 10 | # 11 | # `Model`s have a handy `Model#update` shortcut: 12 | # 13 | # ``` 14 | # changeset = user.changeset 15 | # changeset.update(name: "Jake") 16 | # user.update(changeset) == User.update.set(name: "Jake").where(id: user.id) 17 | # ``` 18 | def update 19 | @type = :update 20 | self 21 | end 22 | 23 | protected def append_update(sql, *args) 24 | {% begin %} 25 | {% table = T.annotation(SQL::Model::Options)[:table] %} 26 | sql << "UPDATE " << (@alias || {{table.id.stringify}}) 27 | {% end %} 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /src/onyx-sql/query/where.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | # Add `WHERE` clause with *values*. All clauses in a single call are concatenated with `AND`. 4 | # 5 | # It's a **type-safe** method. However, it will call `.not_nil!` on references' 6 | # primary keys, thus it can raise `NilAssertionError` in runtime: 7 | # 8 | # ``` 9 | # Post.where(author: user) # Will raise NilAssertionError in runtime if `user.id` is `nil` 10 | # ``` 11 | # 12 | # Consider using explicit reference keys instead in this case, e.g. 13 | # 14 | # ``` 15 | # Post.where(author_id: user.id.not_nil!) 16 | # ``` 17 | # 18 | # ## Example: 19 | # 20 | # ``` 21 | # query = User.where(name: "John", age: 18) 22 | # query.build # => {"SELECT ... FROM users WHERE (name = ? AND age = ?)", {"John", 18}} 23 | # ``` 24 | def where(or : Bool = false, not : Bool = false, **values : **U) : self forall U 25 | {% begin %} 26 | internal_clauses = uninitialized String[{{U.size}}] 27 | internal_params = Deque(DB::Any).new 28 | 29 | {% table = T.annotation(Model::Options)[:table].id %} 30 | 31 | values.each_with_index do |key, value, index| 32 | case key 33 | {% for key, value in U %} 34 | {% found = false %} 35 | 36 | {% for ivar in T.instance_vars %} 37 | # If the ivar is a reference 38 | {% if ann = ivar.annotation(Reference) %} 39 | {% if key == ivar.name || key == ann[:key].id %} 40 | {% 41 | type = ivar.type.union_types.find { |t| t != Nil } 42 | pk = type.annotation(Model::Options)[:primary_key] 43 | %} 44 | 45 | # Iterate through reference instance vars to find the primary key var 46 | # 47 | 48 | {% pk_rivar = nil %} 49 | 50 | {% for rivar in type.instance_vars %} 51 | {% if "@#{rivar.name}".id == pk.name.id %} 52 | {% pk_rivar = rivar %} 53 | {% end %} 54 | {% end %} 55 | 56 | {% raise "`primary_key: #{pk}` option didn't match any of `#{type}` instance variables" unless pk_rivar %} 57 | 58 | {% raise "On Query(#{T})#where: #{key} is nilable in compilation time (`#{value}`), but #{T}@#{ivar.name} has `not_null` option set to `true`. Consider calling `.not_nil!` on the value" if ann[:not_null] && value.nilable? %} 59 | 60 | {% reference_sql_key = ivar.annotation(Reference)[:key].id %} 61 | 62 | # If the key is a reference key (e.g. `#where(author_id: 42)`) 63 | {% if key.id == ann[:key].id %} 64 | {% raise "Invalid compile-time type `#{value}` for argument `#{key.symbolize}` in `Query(#{T})#where` call. Expected: `#{pk_rivar.type}`" unless value <= pk_rivar.type %} 65 | 66 | {% found = true %} 67 | 68 | when {{key.symbolize}} 69 | if value.nil? 70 | internal_clauses[index] = (@alias || {{table.stringify}}) + ".{{reference_sql_key}} IS NULL" 71 | else 72 | internal_clauses[index] = (@alias || {{table.stringify}}) + ".{{reference_sql_key}} = ?" 73 | internal_params << {{type}}.db_values({{pk_rivar.name}}: value.as({{value}}))[0] 74 | end 75 | 76 | # If the key is a reference name (e.g. `#where(author: user)`) 77 | {% elsif key.id == ivar.name %} 78 | {% raise "Invalid compile-time type `#{value}` for argument `#{key.symbolize}` in `Query(#{T})#where` call. Expected: `#{ivar.type}`" unless value <= ivar.type %} 79 | 80 | {% found = true %} 81 | 82 | when {{key.symbolize}} 83 | if value.nil? 84 | internal_clauses[index] = (@alias || {{table.stringify}}) + ".{{reference_sql_key}} IS NULL" 85 | else 86 | internal_clauses[index] = (@alias || {{table.stringify}}) + ".{{reference_sql_key}} = ?" 87 | internal_params << {{type}}.db_values({{pk_rivar.name}}: value.as({{value}}).{{pk_rivar.name}})[0] 88 | end 89 | {% end %} 90 | {% elsif key == ann[:foreign_key].id %} 91 | {% raise "Cannot call `Query(#{T})#where` with foreign reference key argument `#{key.symbolize}`" %} 92 | {% end %} 93 | 94 | # If the ivar is not a reference, but a field 95 | {% elsif key.id == ivar.name %} 96 | {% not_null = (a = ivar.annotation(Field)) && a[:not_null] %} 97 | 98 | {% raise "On Query#where: argument `#{key}` is nilable in compilation time (`#{value}`), but #{T}@#{ivar.name} has `not_null` option set to `true`. Consider calling `.not_nil!` on the `#{key}` argument" if not_null && value.nilable? %} 99 | 100 | {% raise "Invalid compile-time type `#{value}` for argument `#{key.symbolize}` on `Query(#{T})#where` call. Expected: `#{ivar.type}`" unless value <= ivar.type %} 101 | 102 | {% found = true %} 103 | 104 | {% field_sql_key = ((a = ivar.annotation(Field)) && (k = a[:key]) && k.id) || ivar.name %} 105 | 106 | when {{key.symbolize}} 107 | if value.nil? 108 | internal_clauses[index] = (@alias || {{table.stringify}}) + ".{{field_sql_key}} IS NULL" 109 | else 110 | internal_clauses[index] = (@alias || {{table.stringify}}) + ".{{field_sql_key}} = ?" 111 | internal_params << T.db_values({{ivar.name}}: value.as({{value}}))[0] 112 | end 113 | {% end %} 114 | {% end %} 115 | 116 | {% raise "Class `#{T}` has neither field nor reference with key `#{key.symbolize}` eligible for `Query(#{T})#where` call" unless found %} 117 | {% end %} 118 | end 119 | end 120 | 121 | ensure_where << Where.new( 122 | clause: internal_clauses.join(" AND "), 123 | params: internal_params, 124 | or: or, 125 | not: not 126 | ) 127 | 128 | @latest_wherish_clause = :where 129 | {% end %} 130 | 131 | self 132 | end 133 | 134 | # Add `NOT` clause with *values* to `WHERE`. 135 | # 136 | # ``` 137 | # query.where_not(id: 42) # => "WHERE (...) AND NOT (id = ?)" 138 | # ``` 139 | def where_not(**values) 140 | where(**values, not: true) 141 | end 142 | 143 | {% for or in [true, false] %} 144 | {% for not in [true, false] %} 145 | # Add `{{or ? "OR".id : "AND".id}}{{" NOT".id if not}}` clause with *values* to `WHERE`. 146 | # 147 | # ``` 148 | # {{(or ? "or" : "and").id}}_where{{"_not".id if not}}(id: 42) # => "WHERE (...) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(id = ?)" 149 | # ``` 150 | def {{(or ? "or" : "and").id}}_where{{"_not".id if not}}(**values : **U) forall U 151 | where(**values, or: {{or}}, not: {{not}}) 152 | end 153 | 154 | # This method will raise in compilation-time, 155 | # because having a `HAVING` query with a `Model`'s attributes makes no sense. 156 | def {{(or ? "or" : "and").id}}_having{{"_not".id if not}}(**values : **U) forall U 157 | \{% begin %} 158 | \{% pretty_values = "" %} 159 | 160 | \{% for key, value, index in U %} 161 | \{% pretty_values = pretty_values + "#{key}: #{value}" %} 162 | \{% pretty_values = pretty_values + ", " if index < (U.size - 1) %} 163 | \{% end %} 164 | 165 | \{% raise "Cannot call `Query(#{T})##{ {{(or ? "or" : "and")}} }_having#{ {{not ? "_not" : ""}} }(#{pretty_values.id})` because `HAVING` clause with direct `#{T}` fields or references makes no sense. Use the string clause version instead" %} 166 | \{% end %} 167 | end 168 | {% end %} 169 | {% end %} 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /src/onyx-sql/query/wherish.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Query(T) 3 | {% for wherish in %w(where having) %} 4 | # Add `{{wherish.upcase.id}}` *clause* with *params*. 5 | # 6 | # ``` 7 | # query = User.{{wherish.id}}("foo = ?", "bar") 8 | # query.build # => {"{{wherish.upcase.id}} (foo = ?)", {"bar"}} 9 | # ``` 10 | # 11 | # Multiple calls concatenate clauses with `AND`: 12 | # 13 | # ``` 14 | # query = User.{{wherish.id}}("foo = ?", "bar").{{wherish.id}}("baz = ?", 42) 15 | # query.build # => {"{{wherish.upcase.id}} (foo = ?) AND (baz = ?)", {"bar", 42}} 16 | # ``` 17 | def {{wherish.id}}(clause : String, params : Enumerable(DB::Any), or : Bool = false, not : Bool = false) 18 | ensure_{{wherish.id}} << {{wherish.camelcase.id}}.new( 19 | clause: clause, 20 | params: params.to_a.map(&.as(DB::Any)), 21 | or: or, 22 | not: not 23 | ) 24 | 25 | @latest_wherish_clause = :{{wherish.id}} 26 | 27 | self 28 | end 29 | 30 | # ditto 31 | def {{wherish.id}}(clause : String, *params : DB::Any, or : Bool = false, not : Bool = false) 32 | {{wherish.id}}(clause, params.to_a, or, not) 33 | end 34 | 35 | # Add `{{wherish.upcase.id}}` *clause* without params. 36 | # 37 | # ``` 38 | # query = User.{{wherish.id}}("foo IS NULL") 39 | # query.build # => {"{{wherish.upcase.id}} (foo IS NULL)", {}} 40 | # ``` 41 | # 42 | # Multiple calls concatenate clauses with `AND`: 43 | # 44 | # ``` 45 | # query = User.{{wherish.id}}("foo IS NULL").{{wherish.id}}("bar IS NOT NULL") 46 | # query.build # => {"{{wherish.upcase.id}} (foo IS NULL) AND (bar IS NOT NULL)", {}} 47 | # ``` 48 | def {{wherish.id}}(clause : String, or : Bool = false, not : Bool = false) 49 | ensure_{{wherish.id}} << {{wherish.camelcase.id}}.new( 50 | clause: clause, 51 | params: nil, 52 | or: or, 53 | not: not 54 | ) 55 | 56 | @latest_wherish_clause = :{{wherish.id}} 57 | 58 | self 59 | end 60 | 61 | # Add `NOT` *clause* with *params* to `{{wherish.upcase.id}}`. 62 | # 63 | # ``` 64 | # query.{{wherish.id}}_not("foo = ?", "bar") # => "{{wherish.upcase.id}} (...) AND NOT (foo = ?)" 65 | # ``` 66 | def {{wherish.id}}_not(clause, *params) 67 | {{wherish.id}}(clause, *params, not: true) 68 | end 69 | 70 | # Add `NOT` *clause* to `{{wherish.upcase.id}}`. 71 | # 72 | # ``` 73 | # query.{{wherish.id}}_not("foo IS NULL") # => "{{wherish.upcase.id}} (...) AND NOT (foo IS NULL)" 74 | # ``` 75 | def {{wherish.id}}_not(clause) 76 | {{wherish.id}}(clause, not: true) 77 | end 78 | 79 | {% for or in [true, false] %} 80 | {% for not in [true, false] %} 81 | # Add `{{or ? "OR".id : "AND".id}}{{" NOT".id if not}}` *clause* with *params* to `{{wherish.upcase.id}}`. 82 | # 83 | # ``` 84 | # query.{{(or ? "or" : "and").id}}_{{wherish.id}}{{"_not".id if not}}("foo = ?", "bar") # => "{{wherish.upcase.id}} (...) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(foo = ?)" 85 | # ``` 86 | def {{(or ? "or" : "and").id}}_{{wherish.id}}{{"_not".id if not}}(clause : String, *params) 87 | {{wherish.id}}(clause, *params, or: {{or}}, not: {{not}}) 88 | end 89 | 90 | # Add `{{or ? "OR".id : "AND".id}}{{" NOT".id if not}}` *clause* to `{{wherish.upcase.id}}`. 91 | # 92 | # ``` 93 | # query.{{(or ? "or" : "and").id}}_{{wherish.id}}{{"_not".id if not}}("foo IS NULL") # => "{{wherish.upcase.id}} (...) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(foo IS NULL)" 94 | # ``` 95 | def {{(or ? "or" : "and").id}}_{{wherish.id}}{{"_not".id if not}}(clause : String) 96 | {{wherish.id}}(clause, or: {{or}}, not: {{not}}) 97 | end 98 | {% end %} 99 | {% end %} 100 | 101 | private struct {{wherish.camelcase.id}} 102 | getter clause, params, or, not 103 | 104 | def initialize( 105 | @clause : String, 106 | @params : Enumerable(DB::Any)?, 107 | @or : Bool, 108 | @not : Bool 109 | ) 110 | end 111 | end 112 | 113 | @{{wherish.id}} : Deque({{wherish.camelcase.id}}) | Nil = nil 114 | 115 | protected def get_{{wherish.id}} 116 | @{{wherish.id}} 117 | end 118 | 119 | protected def ensure_{{wherish.id}} 120 | @{{wherish.id}} = Deque({{wherish.camelcase.id}}).new if @{{wherish.id}}.nil? 121 | @{{wherish.id}}.not_nil! 122 | end 123 | 124 | protected def append_{{wherish.id}}(sql, params, params_index) 125 | if {{wherish.id}} = @{{wherish.id}} 126 | sql << " {{wherish.upcase.id}} " 127 | first_clause = true 128 | 129 | {{wherish.id}}.each do |clause| 130 | if pi = params_index 131 | formatted_clause = clause.clause.gsub("?") { "$#{pi.value += 1}" } 132 | else 133 | formatted_clause = clause.clause 134 | end 135 | 136 | sql << (clause.or ? " OR " : " AND ") unless first_clause 137 | sql << "NOT " if clause.not 138 | sql << "(" << formatted_clause << ")" 139 | 140 | first_clause = false 141 | 142 | unless params.nil? || clause.params.nil? 143 | clause.params.not_nil!.each do |param| 144 | params.not_nil! << param 145 | end 146 | end 147 | end 148 | end 149 | end 150 | {% end %} 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /src/onyx-sql/repository.cr: -------------------------------------------------------------------------------- 1 | require "./repository/*" 2 | 3 | module Onyx::SQL 4 | # A gateway between `Serializable` and DB. Its main features are logging, 5 | # expanding `Onyx::SQL::Query` instances and serializing from resulting `DB::ResultSet`. 6 | # 7 | # ``` 8 | # db = DB.open(ENV["DATABASE_URL"]) 9 | # repo = Onyx::SQL::Repository.new(db) 10 | # 11 | # repo.scalar("SELECT 1").as(Int32) 12 | # # [sql] SELECT 1 13 | # # 593μs 14 | # 15 | # repo.scalar("SELECT ?", 1).as(Int32) 16 | # # ditto 17 | # 18 | # repo.query("SELECT * FROM users") # Returns raw `DB::ResultSet` 19 | # repo.query(User, "SELECT * FROM users") # Returns `Array(User)` 20 | # repo.query(User.all) # Returns `Array(User)` as well 21 | # # [sql] SELECT users.* FROM users 22 | # # 442μs 23 | # ``` 24 | class Repository 25 | # A `::DB::Database | ::DB::Connection` instance for this repository. 26 | property db 27 | 28 | # A `Repository::Logger` instance for this repository. 29 | property logger 30 | 31 | # Initialize the repository. 32 | def initialize(@db : ::DB::Database | ::DB::Connection, @logger : Logger = Logger::Standard.new) 33 | end 34 | 35 | protected def postgresql? 36 | {% if Object.all_subclasses.any? { |sc| sc.stringify == "PG::Driver" } %} 37 | return db.is_a?(PG::Driver) 38 | {% end %} 39 | 40 | return false 41 | end 42 | 43 | # Prepare query for initialization. 44 | # 45 | # If the `#db` driver is `PG::Driver`, replace all `?` with `$1`, `$2` etc. Otherwise return *sql_query* untouched. 46 | def prepare_query(sql_query : String) 47 | {% begin %} 48 | case db_driver 49 | {% if Object.all_subclasses.any? { |sc| sc.stringify == "PG::Driver" } %} 50 | when PG::Driver 51 | counter = 0 52 | sql_query.gsub("?") { '$' + (counter += 1).to_s } 53 | {% end %} 54 | else sql_query 55 | end 56 | {% end %} 57 | end 58 | 59 | # Return `#db` driver name, e.g. `"postgresql"` for `PG::Driver`. 60 | def driver 61 | {% begin %} 62 | case db_driver 63 | {% if Object.all_subclasses.any? { |sc| sc.stringify == "PG::Driver" } %} 64 | when PG::Driver then "postgresql" 65 | {% end %} 66 | {% if Object.all_subclasses.any? { |sc| sc.stringify == "SQLite3::Driver" } %} 67 | when SQLite3::Driver then "sqlite3" 68 | {% end %} 69 | else "sql" 70 | end 71 | {% end %} 72 | end 73 | 74 | protected def db_driver 75 | if db.is_a?(::DB::Database) 76 | db.as(::DB::Database).driver 77 | else 78 | db.as(::DB::Connection).context.as(::DB::Database).driver 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /src/onyx-sql/repository/exec.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Repository 3 | # Call `db.exec(sql, params)`. 4 | def exec(sql : String, params : Enumerable(DB::Any)? = nil) : DB::ExecResult 5 | sql = prepare_query(sql) 6 | 7 | @logger.wrap("[#{driver}] #{sql}") do 8 | if params 9 | db.exec(sql, params.to_a) 10 | else 11 | db.exec(sql) 12 | end 13 | end 14 | end 15 | 16 | # Call `db.exec(sql, *params)`. 17 | def exec(sql : String, *params : DB::Any) : DB::ExecResult 18 | exec(sql, params) 19 | end 20 | 21 | # Call `db.exec(*query.build)`. It removes any `Query#returning` clauses to avoid DB hanging. 22 | def exec(query : Query) : DB::ExecResult 23 | raise ArgumentError.new("Must not call 'Repository#exec' with SELECT Query. Consider using 'Repository#scalar' or 'Repository#query' instead") if query.type.select? 24 | 25 | # Removes `RETURNING` clause, so the DB doesn't hang! 🍬 26 | query.returning = nil 27 | 28 | exec(*query.build(postgresql?)) 29 | end 30 | 31 | # ditto 32 | def exec(query : BulkQuery) : DB::ExecResult 33 | # Removes `RETURNING` clause, so the DB doesn't hang! 🍬 34 | query.returning = nil 35 | 36 | exec(*query.build(postgresql?)) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /src/onyx-sql/repository/logger.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Repository 3 | # Logs anything with time elapsed (presumably requests from `Repository`). 4 | abstract class Logger 5 | abstract def wrap(data_to_log : String, &block) 6 | end 7 | end 8 | end 9 | 10 | require "./logger/*" 11 | -------------------------------------------------------------------------------- /src/onyx-sql/repository/logger/dummy.cr: -------------------------------------------------------------------------------- 1 | require "../logger" 2 | 3 | # Does not log anything. 4 | class Onyx::SQL::Repository::Logger::Dummy < Onyx::SQL::Repository::Logger 5 | # Does nothing except yielding the *block*. 6 | def wrap(data_to_log : String, &block) 7 | yield 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /src/onyx-sql/repository/logger/io.cr: -------------------------------------------------------------------------------- 1 | require "time_format" 2 | require "colorize" 3 | 4 | require "../logger" 5 | 6 | # Logs anything followed by time elapsed by the block run into the specified `IO`. 7 | # 8 | # ``` 9 | # logger = Onyx::SQL::Repository::Logger::IO.new(STDOUT) 10 | # 11 | # result = logger.wrap("SELECT * FROM users") do 12 | # db.query("SELECT * FROM users") 13 | # end 14 | # 15 | # # => SELECT * FROM users 16 | # # => 501μs 17 | # ``` 18 | class Onyx::SQL::Repository::Logger::IO < Onyx::SQL::Repository::Logger 19 | def initialize(@io : ::IO, @colors = true) 20 | end 21 | 22 | # Wrap a block, logging elapsed time and returning the result. 23 | def wrap(data_to_log : String, &block) 24 | log(data_to_log) 25 | started_at = Time.monotonic 26 | 27 | result = yield 28 | 29 | log_elapsed(TimeFormat.auto(Time.monotonic - started_at)) 30 | result 31 | end 32 | 33 | protected def log(data_to_log) 34 | @io << (@colors ? data_to_log.colorize(:blue).to_s : data_to_log) + "\n" 35 | end 36 | 37 | protected def log_elapsed(time) 38 | @io << (@colors ? time.colorize(:magenta).to_s : time) + "\n" 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /src/onyx-sql/repository/logger/standard.cr: -------------------------------------------------------------------------------- 1 | require "logger" 2 | require "colorize" 3 | require "time_format" 4 | 5 | require "../logger" 6 | 7 | # Logs anything followed by elapsed time by the block into a standard `Logger`. 8 | # 9 | # ``` 10 | # logger = Logger.new(STDOUT, Logger::Severity::INFO) 11 | # repo_logger = Onyx::SQL::Repository::Logger::Standard.new(logger) 12 | # 13 | # result = repo_logger.wrap("SELECT * FROM users") do 14 | # db.query("SELECT * FROM users") 15 | # end 16 | # 17 | # # [21:54:51:068] INFO > SELECT * FROM users 18 | # # [21:54:51:068] INFO > 501μs 19 | # ``` 20 | class Onyx::SQL::Repository::Logger::Standard < Onyx::SQL::Repository::Logger 21 | def initialize( 22 | @logger : ::Logger = ::Logger.new(STDOUT, ::Logger::Severity::INFO), 23 | @log_level : ::Logger::Severity = ::Logger::Severity::INFO, 24 | @colors = true 25 | ) 26 | end 27 | 28 | # Wrap a block, logging elapsed time at *log_level* and returning the result. 29 | def wrap(data_to_log : String, &block) 30 | log(data_to_log) 31 | started_at = Time.monotonic 32 | 33 | result = yield 34 | 35 | log_elapsed(TimeFormat.auto(Time.monotonic - started_at)) 36 | 37 | result 38 | end 39 | 40 | protected def log(data_to_log) 41 | @logger.log(@log_level, @colors ? data_to_log.colorize(:blue) : data_to_log) 42 | end 43 | 44 | protected def log_elapsed(time) 45 | @logger.log(@log_level, @colors ? time.colorize(:magenta) : time) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /src/onyx-sql/repository/query.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Repository 3 | # Call `db.query(sql, params)`. 4 | def query(sql : String, params : Enumerable(DB::Any)? = nil) 5 | sql = prepare_query(sql) 6 | 7 | @logger.wrap("[#{driver}] #{sql}") do 8 | if params 9 | db.query(sql, params.to_a) 10 | else 11 | db.query(sql) 12 | end 13 | end 14 | end 15 | 16 | # Call `db.query(sql, *params)`. 17 | def query(sql : String, *params : DB::Any) 18 | query(sql, params) 19 | end 20 | 21 | # Call `db.query(sql, params)` and map the result to `Array(T)`. 22 | def query(klass : T.class, sql : String, params : Enumerable(DB::Any)? = nil) : Array(T) forall T 23 | rs = query(sql, params) 24 | klass.from_rs(rs) 25 | end 26 | 27 | # Call `db.query(sql, *params)` and map the result to `Array(T)`. 28 | def query(klass : T.class, sql : String, *params : DB::Any) : Array(T) forall T 29 | query(klass, sql, params) 30 | end 31 | 32 | # Call `db.query(*query.build)` and map the result to `Array(T)`. 33 | def query(query : Query(T)) : Array(T) forall T 34 | query(T, *query.build(postgresql?)) 35 | end 36 | 37 | # ditto 38 | def query(query : BulkQuery(T)) : Array(T) forall T 39 | query(T, *query.build(postgresql?)) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /src/onyx-sql/repository/scalar.cr: -------------------------------------------------------------------------------- 1 | module Onyx::SQL 2 | class Repository 3 | # Call `db.scalar(sql, params)`. 4 | def scalar(sql : String, params : Enumerable(DB::Any)? = nil) 5 | sql = prepare_query(sql) 6 | 7 | @logger.wrap("[#{driver}] #{sql}") do 8 | if params 9 | db.scalar(sql, params.to_a) 10 | else 11 | db.scalar(sql) 12 | end 13 | end 14 | end 15 | 16 | # Call `db.scalar(sql, *params)`. 17 | def scalar(sql : String, *params : DB::Any) 18 | scalar(sql, params) 19 | end 20 | 21 | # Call `db.scalar(*query.build)`. 22 | def scalar(query : Query) 23 | scalar(*query.build(postgresql?)) 24 | end 25 | 26 | # ditto 27 | def scalar(query : BulkQuery) 28 | scalar(*query.build(postgresql?)) 29 | end 30 | end 31 | end 32 | --------------------------------------------------------------------------------