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 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