querykit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +179 -0
- data/lib/querykit/adapters/adapter.rb +28 -0
- data/lib/querykit/adapters/mysql_adapter.rb +44 -0
- data/lib/querykit/adapters/postgresql_adapter.rb +50 -0
- data/lib/querykit/adapters/sqlite_adapter.rb +43 -0
- data/lib/querykit/case_builder.rb +102 -0
- data/lib/querykit/configuration.rb +160 -0
- data/lib/querykit/connection.rb +211 -0
- data/lib/querykit/delete_query.rb +54 -0
- data/lib/querykit/extensions/case_when.rb +30 -0
- data/lib/querykit/insert_query.rb +58 -0
- data/lib/querykit/query.rb +473 -0
- data/lib/querykit/repository.rb +182 -0
- data/lib/querykit/update_query.rb +59 -0
- data/lib/querykit/version.rb +5 -0
- data/lib/querykit.rb +110 -0
- data/sig/adapters/adapter.rbs +14 -0
- data/sig/adapters/mysql_adapter.rbs +15 -0
- data/sig/adapters/postgresql_adapter.rbs +15 -0
- data/sig/adapters/sqlite_adapter.rbs +15 -0
- data/sig/case_builder.rbs +23 -0
- data/sig/configuration.rbs +22 -0
- data/sig/connection.rbs +36 -0
- data/sig/delete_query.rbs +22 -0
- data/sig/extensions/case_when.rbs +10 -0
- data/sig/insert_query.rbs +19 -0
- data/sig/query.rbs +83 -0
- data/sig/querykit.rbs +25 -0
- data/sig/repository.rbs +46 -0
- data/sig/update_query.rbs +25 -0
- metadata +120 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: feea6ca48b1ea6f3c73857cce133cc97e557893a483f2eb8938e836530a27a97
|
|
4
|
+
data.tar.gz: 82d4ea4e022afea3078fde535396f48b6d3abd7934c1e25abb2035e632662450
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6cae19bde22463189de0f810a2646f8f2f2fe6269bfe0119fe2bb83ea1e7578d77872df929126fea43f8bbd5f79c01690d2d372521ce969fdeb229b590b5a377
|
|
7
|
+
data.tar.gz: 0173d4cef8f6e875e414858dd5d2d11ada2e09cfd697f48cc5440e2c346e8a7a55d46c17557c937842e5b7db2513bf48e53ebd8e0fee28587e0ee731353ad64c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0]
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Initial release of Quby
|
|
7
|
+
- Fluent query builder for SELECT, INSERT, UPDATE, DELETE
|
|
8
|
+
- WHERE conditions with operators: =, >, <, >=, <=, !=, LIKE
|
|
9
|
+
- WHERE variants: where_in, where_not_in, where_null, where_not_null, where_between, where_raw
|
|
10
|
+
- JOIN support: INNER, LEFT, RIGHT
|
|
11
|
+
- ORDER BY, GROUP BY, HAVING clauses
|
|
12
|
+
- LIMIT, OFFSET, and pagination helpers
|
|
13
|
+
- Database adapters for SQLite3, PostgreSQL, MySQL
|
|
14
|
+
- Transaction support
|
|
15
|
+
- Raw SQL execution
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Kiebor81
|
|
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 all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# QueryKit
|
|
2
|
+
|
|
3
|
+
A fluent, intuitive query builder and micro-ORM for Ruby inspired by .NET''s SqlKata. Perfect for projects where Active Record feels like overkill.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Zero dependencies** (except database drivers)
|
|
8
|
+
- **Fluent, chainable API** inspired by SqlKata
|
|
9
|
+
- **Multiple database adapters** (SQLite3, PostgreSQL, MySQL)
|
|
10
|
+
- **Comprehensive WHERE clauses** (operators, IN, NULL, BETWEEN, EXISTS, raw SQL)
|
|
11
|
+
- **JOIN support** (INNER, LEFT, RIGHT, CROSS)
|
|
12
|
+
- **Aggregate shortcuts** (count, avg, sum, min, max)
|
|
13
|
+
- **UNION/UNION ALL** for combining queries
|
|
14
|
+
- **Optional model mapping** (Dapper-style)
|
|
15
|
+
- **Optional repository pattern** (C#-style)
|
|
16
|
+
- **Transaction support**
|
|
17
|
+
- **Raw SQL when you need it**
|
|
18
|
+
- **SQL injection protection** via parameterized queries
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
|
|
24
|
+
gem install querykit
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then your preferred database driver.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# SQLite
|
|
32
|
+
gem install sqlite3
|
|
33
|
+
|
|
34
|
+
# PostgreSQL
|
|
35
|
+
gem install pg
|
|
36
|
+
|
|
37
|
+
# MySQL
|
|
38
|
+
gem install mysql2
|
|
39
|
+
```
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
See [`demo.rb`](examples/demo.rb) for more extensive examples.
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
require_relative 'querykit'
|
|
46
|
+
|
|
47
|
+
# Configure once
|
|
48
|
+
QueryKit.setup(:sqlite, database: 'app.db')
|
|
49
|
+
|
|
50
|
+
# Query builder
|
|
51
|
+
users = QueryKit.connection.get(
|
|
52
|
+
QueryKit.connection.query('users')
|
|
53
|
+
.where('age', '>', 18)
|
|
54
|
+
.order_by('name')
|
|
55
|
+
.limit(10)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Repository pattern (query scoping)
|
|
59
|
+
class UserRepository < QueryKit::Repository
|
|
60
|
+
table ''users''
|
|
61
|
+
model User
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
repo = UserRepository.new
|
|
65
|
+
user = repo.find(1)
|
|
66
|
+
users = repo.where('age', '>', 18)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Documentation
|
|
70
|
+
|
|
71
|
+
- [Getting Started](docs/getting-started.md) - Setup and basic usage
|
|
72
|
+
- [Query Builder](docs/query-builder.md) - SELECT, INSERT, UPDATE, DELETE
|
|
73
|
+
- [Advanced Features](docs/advanced-features.md) - Model mapping, repositories, transactions
|
|
74
|
+
- [API Reference](docs/api-reference.md) - Complete API documentation
|
|
75
|
+
- [Security Best Practices](docs/security.md) - SQL injection protection and safe usage
|
|
76
|
+
- [Concurrency & Thread Safety](docs/concurrency.md) - Multi-threaded usage and connection management
|
|
77
|
+
- [CASE WHEN Extension](docs/extensions/case-when.md) - Optional fluent CASE expressions
|
|
78
|
+
|
|
79
|
+
**Full documentation site:** https://kiebor81.github.io/querykit
|
|
80
|
+
|
|
81
|
+
**API documentation (YARD):** Generate locally with `rake doc`
|
|
82
|
+
|
|
83
|
+
## Why QueryKit?
|
|
84
|
+
|
|
85
|
+
**vs Active Record:** Much lighter, no DSL, no magic. Just build queries and execute them.
|
|
86
|
+
|
|
87
|
+
**vs Sequel:** Simpler API, fewer features by design. If you need a full ORM, use Sequel.
|
|
88
|
+
|
|
89
|
+
**vs Raw SQL:** Type-safe, composable queries with protection against SQL injection.
|
|
90
|
+
|
|
91
|
+
## Security
|
|
92
|
+
|
|
93
|
+
QueryKit uses **parameterized queries by default**, protecting against SQL injection when used correctly:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# SAFE - Values are automatically parameterized
|
|
97
|
+
db.query('users').where('email', user_input)
|
|
98
|
+
|
|
99
|
+
# UNSAFE - Never interpolate user input
|
|
100
|
+
db.raw("SELECT * FROM users WHERE email = '#{user_input}'")
|
|
101
|
+
|
|
102
|
+
# SAFE - Use placeholders with raw SQL
|
|
103
|
+
db.raw('SELECT * FROM users WHERE email = ?', user_input)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**See [Security Best Practices](docs/security.md) for detailed guidance.**
|
|
107
|
+
|
|
108
|
+
## Philosophy
|
|
109
|
+
|
|
110
|
+
- **Minimal dependencies** - Only database drivers required
|
|
111
|
+
- **Simple and explicit** - No hidden magic or metaprogramming
|
|
112
|
+
- **Composable** - Build queries piece by piece
|
|
113
|
+
- **Flexible** - Use what you need, ignore what you don't
|
|
114
|
+
- **Extensible** - Opt-in extensions for advanced features
|
|
115
|
+
|
|
116
|
+
## Extensions
|
|
117
|
+
|
|
118
|
+
QueryKit supports optional extensions that add advanced features without bloating the core:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
require 'querykit/extensions/case_when'
|
|
122
|
+
|
|
123
|
+
# Load extensions at startup
|
|
124
|
+
QueryKit.use_extensions(QueryKit::CaseWhenExtension)
|
|
125
|
+
|
|
126
|
+
# Or load multiple extensions
|
|
127
|
+
QueryKit.use_extensions([Extension1, Extension2])
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Available extensions:
|
|
131
|
+
- **CASE WHEN** - Fluent CASE expression builder ([docs](docs/extensions/case-when.md))
|
|
132
|
+
|
|
133
|
+
Extensions use Ruby's `prepend` to cleanly override methods without monkey-patching.
|
|
134
|
+
|
|
135
|
+
## What QueryKit Doesn't Do
|
|
136
|
+
|
|
137
|
+
These features are intentionally excluded to maintain simplicity:
|
|
138
|
+
|
|
139
|
+
- **Migrations** - Use a dedicated migration tool
|
|
140
|
+
- **Associations** - Write explicit JOINs instead
|
|
141
|
+
- **Validations** - Handle in your business logic layer
|
|
142
|
+
- **Callbacks** - Keep side effects explicit
|
|
143
|
+
- **Soft Deletes** - Implement as a WHERE filter in repositories
|
|
144
|
+
- **Eager Loading** - Use JOINs or accept N+1 queries
|
|
145
|
+
|
|
146
|
+
For advanced SQL features, use raw SQL:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
# Common Table Expressions (CTEs)
|
|
150
|
+
db.raw(<<~SQL, user_id)
|
|
151
|
+
WITH ranked_orders AS (
|
|
152
|
+
SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) as rn
|
|
153
|
+
FROM orders
|
|
154
|
+
)
|
|
155
|
+
SELECT * FROM ranked_orders WHERE rn = 1 AND user_id = ?
|
|
156
|
+
SQL
|
|
157
|
+
|
|
158
|
+
# Window Functions
|
|
159
|
+
db.raw('SELECT *, AVG(salary) OVER (PARTITION BY department) as dept_avg FROM employees')
|
|
160
|
+
|
|
161
|
+
# Upsert (SQLite)
|
|
162
|
+
db.raw('INSERT INTO users (id, name) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name', id, name)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Contributing
|
|
166
|
+
|
|
167
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
168
|
+
|
|
169
|
+
This project adheres to the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
|
|
170
|
+
|
|
171
|
+
## Acknowledgments
|
|
172
|
+
|
|
173
|
+
Inspired by [SqlKata](https://sqlkata.com/) (.NET) and [Arel](https://github.com/rails/arel) (Ruby).
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
This is a personal project, but suggestions and bug reports are welcome via issues.
|
|
178
|
+
|
|
179
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryKit
|
|
4
|
+
module Adapters
|
|
5
|
+
# Abstract adapter base class
|
|
6
|
+
class Adapter
|
|
7
|
+
def execute(sql, bindings = [])
|
|
8
|
+
raise NotImplementedError, "#{self.class} must implement #execute"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def begin_transaction
|
|
12
|
+
raise NotImplementedError, "#{self.class} must implement #begin_transaction"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def commit
|
|
16
|
+
raise NotImplementedError, "#{self.class} must implement #commit"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def rollback
|
|
20
|
+
raise NotImplementedError, "#{self.class} must implement #rollback"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def close
|
|
24
|
+
# Optional: Override if adapter needs cleanup
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'adapter'
|
|
4
|
+
|
|
5
|
+
module QueryKit
|
|
6
|
+
module Adapters
|
|
7
|
+
class MySQLAdapter < Adapter
|
|
8
|
+
def initialize(config)
|
|
9
|
+
require 'mysql2'
|
|
10
|
+
@client = Mysql2::Client.new(config)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def execute(sql, bindings = [])
|
|
14
|
+
stmt = @client.prepare(sql)
|
|
15
|
+
@last_result = stmt.execute(*bindings)
|
|
16
|
+
@last_result.map { |row| row }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def last_insert_id
|
|
20
|
+
@client.last_id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def affected_rows
|
|
24
|
+
@last_result ? @last_result.count : 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def begin_transaction
|
|
28
|
+
@client.query("START TRANSACTION")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def commit
|
|
32
|
+
@client.query("COMMIT")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rollback
|
|
36
|
+
@client.query("ROLLBACK")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def close
|
|
40
|
+
@client.close
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'adapter'
|
|
4
|
+
|
|
5
|
+
module QueryKit
|
|
6
|
+
module Adapters
|
|
7
|
+
class PostgreSQLAdapter < Adapter
|
|
8
|
+
def initialize(config)
|
|
9
|
+
require 'pg'
|
|
10
|
+
@conn = PG.connect(config)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def execute(sql, bindings = [])
|
|
14
|
+
# Convert ? placeholders to $1, $2, etc.
|
|
15
|
+
placeholder_count = 0
|
|
16
|
+
sql = sql.gsub('?') { |_| "$#{placeholder_count += 1}" }
|
|
17
|
+
|
|
18
|
+
@last_result = @conn.exec_params(sql, bindings)
|
|
19
|
+
@last_result.map { |row| row }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def last_insert_id
|
|
23
|
+
result = @conn.exec("SELECT lastval()")
|
|
24
|
+
result[0]['lastval'].to_i
|
|
25
|
+
rescue PG::Error
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def affected_rows
|
|
30
|
+
@last_result ? @last_result.cmd_tuples : 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def begin_transaction
|
|
34
|
+
@conn.exec("BEGIN")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def commit
|
|
38
|
+
@conn.exec("COMMIT")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def rollback
|
|
42
|
+
@conn.exec("ROLLBACK")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def close
|
|
46
|
+
@conn.close
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'adapter'
|
|
4
|
+
|
|
5
|
+
module QueryKit
|
|
6
|
+
module Adapters
|
|
7
|
+
class SQLiteAdapter < Adapter
|
|
8
|
+
def initialize(database_path)
|
|
9
|
+
require 'sqlite3'
|
|
10
|
+
@db = SQLite3::Database.new(database_path)
|
|
11
|
+
@db.results_as_hash = true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def execute(sql, bindings = [])
|
|
15
|
+
@db.execute(sql, bindings)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def last_insert_id
|
|
19
|
+
@db.last_insert_row_id
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def affected_rows
|
|
23
|
+
@db.changes
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def begin_transaction
|
|
27
|
+
@db.execute("BEGIN TRANSACTION")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def commit
|
|
31
|
+
@db.execute("COMMIT")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def rollback
|
|
35
|
+
@db.execute("ROLLBACK")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def close
|
|
39
|
+
@db.close
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryKit
|
|
4
|
+
# Builder for CASE WHEN expressions
|
|
5
|
+
# Used internally by Query when select_case is called
|
|
6
|
+
class CaseBuilder
|
|
7
|
+
attr_reader :column, :whens, :else_value, :alias_name
|
|
8
|
+
|
|
9
|
+
def initialize(column = nil)
|
|
10
|
+
@column = column
|
|
11
|
+
@whens = []
|
|
12
|
+
@else_value = nil
|
|
13
|
+
@alias_name = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Add a WHEN condition
|
|
17
|
+
# @param condition [String, Array] Column name or [column, operator, value]
|
|
18
|
+
# @param operator [String, Object] Operator or value if condition is column name
|
|
19
|
+
# @param value [Object] Value to compare (only if operator provided)
|
|
20
|
+
def when(condition, operator = nil, value = nil)
|
|
21
|
+
if @column && operator.nil?
|
|
22
|
+
# Simple CASE with column: when('value') -> WHEN ? (comparing against @column)
|
|
23
|
+
@whens << { value: condition, then: nil }
|
|
24
|
+
elsif value.nil? && !operator.nil?
|
|
25
|
+
# when('age', 18) -> WHEN age = 18
|
|
26
|
+
@whens << { column: condition, operator: '=', value: operator, then: nil }
|
|
27
|
+
elsif !value.nil?
|
|
28
|
+
# when('age', '>', 18) -> WHEN age > 18
|
|
29
|
+
@whens << { column: condition, operator: operator, value: value, then: nil }
|
|
30
|
+
else
|
|
31
|
+
# when('age > 18') -> WHEN age > 18 (raw condition)
|
|
32
|
+
@whens << { raw: condition, then: nil }
|
|
33
|
+
end
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Set the THEN value for the last WHEN
|
|
38
|
+
def then(value)
|
|
39
|
+
raise 'No WHEN clause to add THEN to' if @whens.empty?
|
|
40
|
+
@whens.last[:then] = value
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Set the ELSE value
|
|
45
|
+
def else(value)
|
|
46
|
+
@else_value = value
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Set the alias for the CASE expression
|
|
51
|
+
def as(alias_name)
|
|
52
|
+
@alias_name = alias_name
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build the CASE expression
|
|
57
|
+
def to_sql
|
|
58
|
+
raise 'CASE expression must have at least one WHEN clause' if @whens.empty?
|
|
59
|
+
|
|
60
|
+
sql = 'CASE'
|
|
61
|
+
sql += " #{@column}" if @column
|
|
62
|
+
|
|
63
|
+
@whens.each do |w|
|
|
64
|
+
if w[:raw]
|
|
65
|
+
sql += " WHEN #{w[:raw]} THEN ?"
|
|
66
|
+
elsif @column
|
|
67
|
+
# Simple CASE: CASE column WHEN value THEN result
|
|
68
|
+
sql += " WHEN ? THEN ?"
|
|
69
|
+
else
|
|
70
|
+
# Searched CASE: CASE WHEN column op value THEN result
|
|
71
|
+
sql += " WHEN #{w[:column]} #{w[:operator]} ? THEN ?"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
sql += ' ELSE ?' if @else_value
|
|
76
|
+
sql += ' END'
|
|
77
|
+
sql += " AS #{@alias_name}" if @alias_name
|
|
78
|
+
|
|
79
|
+
sql
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Get bindings for the CASE expression
|
|
83
|
+
def bindings
|
|
84
|
+
bindings = []
|
|
85
|
+
|
|
86
|
+
@whens.each do |w|
|
|
87
|
+
if @column && !w[:raw]
|
|
88
|
+
# Simple CASE: need value in WHEN clause
|
|
89
|
+
bindings << w[:value]
|
|
90
|
+
elsif !w[:raw]
|
|
91
|
+
# Searched CASE: value goes in condition
|
|
92
|
+
bindings << w[:value]
|
|
93
|
+
end
|
|
94
|
+
bindings << w[:then]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
bindings << @else_value if @else_value
|
|
98
|
+
|
|
99
|
+
bindings
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mutex_m'
|
|
4
|
+
|
|
5
|
+
module QueryKit
|
|
6
|
+
# Configuration class for global QueryKit settings
|
|
7
|
+
#
|
|
8
|
+
# @note This class is used internally by the global configuration methods.
|
|
9
|
+
# Most users should use {QueryKit.setup} or {QueryKit.configure} instead.
|
|
10
|
+
class Configuration
|
|
11
|
+
attr_accessor :adapter, :connection_options
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@adapter = nil
|
|
15
|
+
@connection_options = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Configure with adapter and options
|
|
19
|
+
#
|
|
20
|
+
# @param adapter [Symbol] the database adapter type
|
|
21
|
+
# @param options [Hash] connection options
|
|
22
|
+
# @return [void]
|
|
23
|
+
def setup(adapter, options = {})
|
|
24
|
+
@adapter = adapter
|
|
25
|
+
@connection_options = options
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Check if configuration is set
|
|
29
|
+
#
|
|
30
|
+
# @return [Boolean] true if adapter is configured
|
|
31
|
+
def configured?
|
|
32
|
+
!@adapter.nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Validate configuration
|
|
36
|
+
#
|
|
37
|
+
# @raise [ConfigurationError] if not configured
|
|
38
|
+
# @return [void]
|
|
39
|
+
def validate!
|
|
40
|
+
raise ConfigurationError, "QueryKit not configured. Call QueryKit.configure first." unless configured?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Error raised when attempting to use QueryKit without configuration
|
|
45
|
+
class ConfigurationError < StandardError; end
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
# Get the global configuration instance
|
|
49
|
+
#
|
|
50
|
+
# @return [Configuration] the global configuration object
|
|
51
|
+
def configuration
|
|
52
|
+
@configuration ||= Configuration.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Configure QueryKit globally
|
|
56
|
+
#
|
|
57
|
+
# @yield [Configuration] the configuration object
|
|
58
|
+
# @return [void]
|
|
59
|
+
#
|
|
60
|
+
# @example
|
|
61
|
+
# QueryKit.configure do |config|
|
|
62
|
+
# config.adapter = :sqlite
|
|
63
|
+
# config.connection_options = { database: 'db/app.db' }
|
|
64
|
+
# end
|
|
65
|
+
def configure
|
|
66
|
+
yield(configuration) if block_given?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Setup with parameters (alternative to configure block)
|
|
70
|
+
#
|
|
71
|
+
# @param adapter [Symbol] the database adapter type (:sqlite, :postgresql, :mysql)
|
|
72
|
+
# @param options [Hash] connection options specific to the adapter
|
|
73
|
+
# @return [void]
|
|
74
|
+
#
|
|
75
|
+
# @example
|
|
76
|
+
# QueryKit.setup(:sqlite, database: 'db/app.db')
|
|
77
|
+
# Setup with parameters (alternative to configure block)
|
|
78
|
+
#
|
|
79
|
+
# @param adapter [Symbol] the database adapter type (:sqlite, :postgresql, :mysql)
|
|
80
|
+
# @param options [Hash] connection options specific to the adapter
|
|
81
|
+
# @return [void]
|
|
82
|
+
#
|
|
83
|
+
# @example
|
|
84
|
+
# QueryKit.setup(:sqlite, database: 'db/app.db')
|
|
85
|
+
def setup(adapter, options = {})
|
|
86
|
+
configuration.setup(adapter, options)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Get a connection using global configuration
|
|
90
|
+
#
|
|
91
|
+
# Creates a singleton connection that is reused across calls.
|
|
92
|
+
# The connection is lazily initialized on first access.
|
|
93
|
+
#
|
|
94
|
+
# @return [QueryKit::Connection] the global connection instance
|
|
95
|
+
# @raise [ConfigurationError] if QueryKit has not been configured
|
|
96
|
+
#
|
|
97
|
+
# @note Thread-safety: The connection singleton creation is thread-safe,
|
|
98
|
+
# but individual database operations depend on the underlying adapter's
|
|
99
|
+
# thread-safety. SQLite connections should not be shared across threads.
|
|
100
|
+
# For multi-threaded applications, create separate connections per thread
|
|
101
|
+
# using {QueryKit.connect} instead of using the global connection.
|
|
102
|
+
#
|
|
103
|
+
# @example Single-threaded usage (safe)
|
|
104
|
+
# QueryKit.setup(:sqlite, database: 'app.db')
|
|
105
|
+
# db = QueryKit.connection
|
|
106
|
+
# users = db.get(db.query('users'))
|
|
107
|
+
#
|
|
108
|
+
# @example Multi-threaded usage (use separate connections)
|
|
109
|
+
# threads = 10.times.map do
|
|
110
|
+
# Thread.new do
|
|
111
|
+
# # Create a new connection per thread
|
|
112
|
+
# db = QueryKit.connect(:sqlite, database: 'app.db')
|
|
113
|
+
# users = db.get(db.query('users'))
|
|
114
|
+
# end
|
|
115
|
+
# end
|
|
116
|
+
# threads.each(&:join)
|
|
117
|
+
#
|
|
118
|
+
# @see QueryKit.connect for creating separate connection instances
|
|
119
|
+
def connection
|
|
120
|
+
configuration.validate!
|
|
121
|
+
|
|
122
|
+
# Thread-safe singleton initialization
|
|
123
|
+
@connection_mutex ||= Mutex.new
|
|
124
|
+
@connection_mutex.synchronize do
|
|
125
|
+
@connection ||= begin
|
|
126
|
+
# Normalize connection options for SQLite (expects string path)
|
|
127
|
+
config = if configuration.adapter == :sqlite
|
|
128
|
+
configuration.connection_options[:database] || configuration.connection_options
|
|
129
|
+
else
|
|
130
|
+
configuration.connection_options
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
connect(configuration.adapter, config)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Reset configuration (useful for testing)
|
|
139
|
+
#
|
|
140
|
+
# Clears the global configuration and connection singleton.
|
|
141
|
+
#
|
|
142
|
+
# @return [void]
|
|
143
|
+
#
|
|
144
|
+
# @note This method is primarily intended for testing. In production,
|
|
145
|
+
# you typically configure once at application startup.
|
|
146
|
+
#
|
|
147
|
+
# @example
|
|
148
|
+
# QueryKit.setup(:sqlite, database: 'test.db')
|
|
149
|
+
# # ... tests ...
|
|
150
|
+
# QueryKit.reset!
|
|
151
|
+
# QueryKit.setup(:sqlite, database: 'other.db')
|
|
152
|
+
def reset!
|
|
153
|
+
@connection_mutex&.synchronize do
|
|
154
|
+
@connection = nil
|
|
155
|
+
end
|
|
156
|
+
@configuration = nil
|
|
157
|
+
@connection_mutex = nil
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|