├── .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 | [](https://crystal-lang.org/)
6 | [](https://travis-ci.org/onyxframework/sql)
7 | [](https://docs.onyxframework.org/sql)
8 | [](https://api.onyxframework.org/sql)
9 | [](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 | [](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 |
--------------------------------------------------------------------------------