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.
@@ -0,0 +1,473 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryKit
4
+ # Query builder for constructing SQL SELECT statements.
5
+ #
6
+ # Provides a fluent, chainable API for building complex SQL queries
7
+ # with automatic parameter binding for security.
8
+ #
9
+ # @example Basic query
10
+ # query = Query.new('users')
11
+ # .select('id', 'name', 'email')
12
+ # .where('age', '>', 18)
13
+ # .order_by('name')
14
+ # .limit(10)
15
+ #
16
+ # @example Complex query with joins
17
+ # query = Query.new('users')
18
+ # .join('posts', 'users.id', 'posts.user_id')
19
+ # .where('users.active', true)
20
+ # .where('posts.published', true)
21
+ # .select('users.*', 'COUNT(posts.id) as post_count')
22
+ # .group_by('users.id')
23
+ class Query
24
+ # @return [String, nil] the table name for this query
25
+ attr_reader :table
26
+
27
+ # @return [Array<Hash>] the WHERE conditions
28
+ attr_reader :wheres
29
+
30
+ # @return [Array<String>] the columns to select
31
+ attr_reader :selects
32
+
33
+ # @return [Array<Hash>] the JOIN clauses
34
+ attr_reader :joins
35
+
36
+ # @return [Array<String>] the ORDER BY clauses
37
+ attr_reader :orders
38
+
39
+ # @return [Array<String>] the GROUP BY columns
40
+ attr_reader :groups
41
+
42
+ # @return [Integer, nil] the LIMIT value
43
+ attr_reader :limit_value
44
+
45
+ # @return [Integer, nil] the OFFSET value
46
+ attr_reader :offset_value
47
+
48
+ # @return [Array] the parameter bindings for safe query execution
49
+ attr_accessor :bindings
50
+
51
+ # Initialize a new Query instance.
52
+ #
53
+ # @param table [String, nil] the table name to query
54
+ #
55
+ # @example
56
+ # query = Query.new('users')
57
+ # query = Query.new # table can be set later with from()
58
+ def initialize(table = nil)
59
+ @table = table
60
+ @selects = []
61
+ @wheres = []
62
+ @joins = []
63
+ @orders = []
64
+ @groups = []
65
+ @havings = []
66
+ @limit_value = nil
67
+ @offset_value = nil
68
+ @bindings = []
69
+ @distinct = false
70
+ @unions = []
71
+ end
72
+
73
+ # Set the table name for this query.
74
+ #
75
+ # @param table [String] the table name
76
+ # @return [Query] self for method chaining
77
+ #
78
+ # @example
79
+ # query.from('users')
80
+ def from(table)
81
+ @table = table
82
+ self
83
+ end
84
+
85
+ # Specify columns to select.
86
+ #
87
+ # @param columns [Array<String>] column names to select. Defaults to '*' if none provided.
88
+ # @return [Query] self for method chaining
89
+ #
90
+ # @example Select specific columns
91
+ # query.select('id', 'name', 'email')
92
+ #
93
+ # @example Select with aliases
94
+ # query.select('users.id', 'users.name as user_name')
95
+ #
96
+ # @example Select all columns (default)
97
+ # query.select # equivalent to SELECT *
98
+ def select(*columns)
99
+ columns = ['*'] if columns.empty?
100
+ @selects.concat(columns.flatten)
101
+ self
102
+ end
103
+
104
+ # Set the query to return distinct results.
105
+ #
106
+ # @return [Query] self for method chaining
107
+ #
108
+ # @example
109
+ # query.select('country').distinct
110
+ def distinct
111
+ @distinct = true
112
+ self
113
+ end
114
+
115
+ # Add a WHERE condition to the query.
116
+ #
117
+ # Supports multiple calling patterns for flexibility.
118
+ # Values are automatically parameterized for SQL injection protection.
119
+ #
120
+ # @overload where(column, value)
121
+ # @param column [String] the column name
122
+ # @param value [Object] the value to compare (assumes = operator)
123
+ # @example
124
+ # query.where('status', 'active')
125
+ #
126
+ # @overload where(column, operator, value)
127
+ # @param column [String] the column name
128
+ # @param operator [String] comparison operator (=, >, <, >=, <=, !=, LIKE)
129
+ # @param value [Object] the value to compare
130
+ # @example
131
+ # query.where('age', '>', 18)
132
+ # query.where('name', 'LIKE', 'John%')
133
+ #
134
+ # @overload where(hash)
135
+ # @param hash [Hash] column-value pairs (all use = operator)
136
+ # @example
137
+ # query.where(status: 'active', country: 'USA')
138
+ #
139
+ # @return [Query] self for method chaining
140
+ def where(column, operator = nil, value = nil)
141
+ # Handle different argument patterns
142
+ if column.is_a?(Hash)
143
+ column.each { |k, v| where(k, '=', v) }
144
+ return self
145
+ end
146
+
147
+ if value.nil? && !operator.nil?
148
+ value = operator
149
+ operator = '='
150
+ end
151
+
152
+ @wheres << { type: 'basic', column: column, operator: operator, value: value, boolean: 'AND' }
153
+ @bindings << value unless value.nil?
154
+ self
155
+ end
156
+
157
+ # Add an OR WHERE condition to the query.
158
+ #
159
+ # @param column [String] the column name
160
+ # @param operator [String, Object] the operator or value (if value is nil)
161
+ # @param value [Object, nil] the value to compare
162
+ #
163
+ # @return [Query] self for method chaining
164
+ #
165
+ # @example
166
+ # query.where('status', 'active').or_where('priority', 'high')
167
+ def or_where(column, operator = nil, value = nil)
168
+ if value.nil? && !operator.nil?
169
+ value = operator
170
+ operator = '='
171
+ end
172
+
173
+ @wheres << { type: 'basic', column: column, operator: operator, value: value, boolean: 'OR' }
174
+ @bindings << value unless value.nil?
175
+ self
176
+ end
177
+
178
+ # WHERE IN clause
179
+ def where_in(column, values)
180
+ @wheres << { type: 'in', column: column, values: values, boolean: 'AND' }
181
+ @bindings.concat(values)
182
+ self
183
+ end
184
+
185
+ # WHERE NOT IN clause
186
+ def where_not_in(column, values)
187
+ @wheres << { type: 'not_in', column: column, values: values, boolean: 'AND' }
188
+ @bindings.concat(values)
189
+ self
190
+ end
191
+
192
+ # WHERE IS NULL / IS NOT NULL
193
+ def where_null(column)
194
+ @wheres << { type: 'null', column: column, boolean: 'AND' }
195
+ self
196
+ end
197
+
198
+ # WHERE IS NOT NULL
199
+ def where_not_null(column)
200
+ @wheres << { type: 'not_null', column: column, boolean: 'AND' }
201
+ self
202
+ end
203
+
204
+ # WHERE BETWEEN
205
+ def where_between(column, min, max)
206
+ @wheres << { type: 'between', column: column, min: min, max: max, boolean: 'AND' }
207
+ @bindings << min << max
208
+ self
209
+ end
210
+
211
+ # Raw WHERE clause
212
+ def where_raw(sql, *bindings)
213
+ @wheres << { type: 'raw', sql: sql, boolean: 'AND' }
214
+ @bindings.concat(bindings)
215
+ self
216
+ end
217
+
218
+ # WHERE EXISTS
219
+ def where_exists(subquery)
220
+ sql = subquery.is_a?(String) ? subquery : subquery.to_sql
221
+ @wheres << { type: 'exists', sql: sql, boolean: 'AND' }
222
+ @bindings.concat(subquery.bindings) if subquery.respond_to?(:bindings)
223
+ self
224
+ end
225
+
226
+ # WHERE NOT EXISTS
227
+ def where_not_exists(subquery)
228
+ sql = subquery.is_a?(String) ? subquery : subquery.to_sql
229
+ @wheres << { type: 'not_exists', sql: sql, boolean: 'AND' }
230
+ @bindings.concat(subquery.bindings) if subquery.respond_to?(:bindings)
231
+ self
232
+ end
233
+
234
+ # JOIN clauses
235
+ def join(table, first, operator = nil, second = nil)
236
+ if operator.nil?
237
+ operator = '='
238
+ second = first
239
+ end
240
+ @joins << { type: 'INNER', table: table, first: first, operator: operator, second: second }
241
+ self
242
+ end
243
+
244
+ # LEFT JOIN
245
+ def left_join(table, first, operator = nil, second = nil)
246
+ if operator.nil?
247
+ operator = '='
248
+ second = first
249
+ end
250
+ @joins << { type: 'LEFT', table: table, first: first, operator: operator, second: second }
251
+ self
252
+ end
253
+
254
+ # RIGHT JOIN
255
+ def right_join(table, first, operator = nil, second = nil)
256
+ if operator.nil?
257
+ operator = '='
258
+ second = first
259
+ end
260
+ @joins << { type: 'RIGHT', table: table, first: first, operator: operator, second: second }
261
+ self
262
+ end
263
+
264
+ # CROSS JOIN
265
+ def cross_join(table)
266
+ @joins << { type: 'CROSS', table: table }
267
+ self
268
+ end
269
+
270
+ # ORDER BY
271
+ def order_by(column, direction = 'ASC')
272
+ @orders << { column: column, direction: direction.upcase }
273
+ self
274
+ end
275
+
276
+ # Set the query to order results in descending order
277
+ def order_by_desc(column)
278
+ order_by(column, 'DESC')
279
+ end
280
+
281
+ # GROUP BY
282
+ def group_by(*columns)
283
+ @groups.concat(columns.flatten)
284
+ self
285
+ end
286
+
287
+ # HAVING
288
+ def having(column, operator = nil, value = nil)
289
+ if value.nil? && !operator.nil?
290
+ value = operator
291
+ operator = '='
292
+ end
293
+
294
+ @havings << { column: column, operator: operator, value: value, boolean: 'AND' }
295
+ @bindings << value unless value.nil?
296
+ self
297
+ end
298
+
299
+ # LIMIT and OFFSET
300
+ def limit(value)
301
+ @limit_value = value
302
+ self
303
+ end
304
+
305
+ # Set the query offset
306
+ def offset(value)
307
+ @offset_value = value
308
+ self
309
+ end
310
+
311
+ # Alias methods for offset and limit
312
+ def skip(value)
313
+ offset(value)
314
+ end
315
+
316
+ # Alias methods for offset and limit
317
+ def take(value)
318
+ limit(value)
319
+ end
320
+
321
+ # Pagination
322
+ def page(page_number, per_page = 15)
323
+ offset((page_number - 1) * per_page).limit(per_page)
324
+ end
325
+
326
+ # Aggregate shortcuts
327
+ def count(column = '*')
328
+ select("COUNT(#{column}) as count")
329
+ end
330
+
331
+ def avg(column)
332
+ select("AVG(#{column}) as avg")
333
+ end
334
+
335
+ def sum(column)
336
+ select("SUM(#{column}) as sum")
337
+ end
338
+
339
+ def min(column)
340
+ select("MIN(#{column}) as min")
341
+ end
342
+
343
+ def max(column)
344
+ select("MAX(#{column}) as max")
345
+ end
346
+
347
+ # UNION / UNION ALL
348
+ def union(query)
349
+ @unions << { type: 'UNION', query: query }
350
+ self
351
+ end
352
+
353
+ def union_all(query)
354
+ @unions << { type: 'UNION ALL', query: query }
355
+ self
356
+ end
357
+
358
+ # Build SQL
359
+ def to_sql
360
+ raise "No table specified" unless @table
361
+
362
+ sql = []
363
+ sql << "SELECT"
364
+ sql << "DISTINCT" if @distinct
365
+ sql << (@selects.empty? ? '*' : @selects.join(', '))
366
+ sql << "FROM #{@table}"
367
+
368
+ # JOINs
369
+ # JOINs
370
+ @joins.each do |join|
371
+ if join[:type] == 'CROSS'
372
+ sql << "CROSS JOIN #{join[:table]}"
373
+ else
374
+ sql << "#{join[:type]} JOIN #{join[:table]} ON #{join[:first]} #{join[:operator]} #{join[:second]}"
375
+ end
376
+ end
377
+
378
+ # WHERE
379
+ unless @wheres.empty?
380
+ sql << "WHERE"
381
+ where_clauses = []
382
+ @wheres.each_with_index do |where, index|
383
+ clause = build_where_clause(where)
384
+ if index == 0
385
+ where_clauses << clause
386
+ else
387
+ where_clauses << "#{where[:boolean]} #{clause}"
388
+ end
389
+ end
390
+ sql << where_clauses.join(' ')
391
+ end
392
+
393
+ # GROUP BY
394
+ unless @groups.empty?
395
+ sql << "GROUP BY #{@groups.join(', ')}"
396
+ end
397
+
398
+ # HAVING
399
+ unless @havings.empty?
400
+ sql << "HAVING"
401
+ having_clauses = []
402
+ @havings.each_with_index do |having, index|
403
+ clause = "#{having[:column]} #{having[:operator]} ?"
404
+ if index == 0
405
+ having_clauses << clause
406
+ else
407
+ having_clauses << "#{having[:boolean]} #{clause}"
408
+ end
409
+ end
410
+ sql << having_clauses.join(' ')
411
+ end
412
+
413
+ # ORDER BY
414
+ unless @orders.empty?
415
+ sql << "ORDER BY #{@orders.map { |o| "#{o[:column]} #{o[:direction]}" }.join(', ')}"
416
+ end
417
+
418
+ # LIMIT
419
+ sql << "LIMIT #{@limit_value}" if @limit_value
420
+
421
+ # OFFSET
422
+ sql << "OFFSET #{@offset_value}" if @offset_value
423
+
424
+ # Build main query
425
+ main_sql = sql.join(' ')
426
+
427
+ # UNION / UNION ALL
428
+ unless @unions.empty?
429
+ union_parts = [main_sql]
430
+ @unions.each do |union|
431
+ union_sql = union[:query].is_a?(String) ? union[:query] : union[:query].to_sql
432
+ union_parts << "#{union[:type]} #{union_sql}"
433
+ @bindings.concat(union[:query].bindings) if union[:query].respond_to?(:bindings)
434
+ end
435
+ return union_parts.join(' ')
436
+ end
437
+
438
+ main_sql
439
+ end
440
+
441
+ def to_s
442
+ to_sql
443
+ end
444
+
445
+ private
446
+
447
+ # Build individual WHERE clause
448
+ def build_where_clause(where)
449
+ case where[:type]
450
+ when 'basic'
451
+ "#{where[:column]} #{where[:operator]} ?"
452
+ when 'in'
453
+ placeholders = (['?'] * where[:values].size).join(', ')
454
+ "#{where[:column]} IN (#{placeholders})"
455
+ when 'not_in'
456
+ placeholders = (['?'] * where[:values].size).join(', ')
457
+ "#{where[:column]} NOT IN (#{placeholders})"
458
+ when 'null'
459
+ "#{where[:column]} IS NULL"
460
+ when 'not_null'
461
+ "#{where[:column]} IS NOT NULL"
462
+ when 'between'
463
+ "#{where[:column]} BETWEEN ? AND ?"
464
+ when 'raw'
465
+ where[:sql]
466
+ when 'exists'
467
+ "EXISTS (#{where[:sql]})"
468
+ when 'not_exists'
469
+ "NOT EXISTS (#{where[:sql]})"
470
+ end
471
+ end
472
+ end
473
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryKit
4
+ # Base repository class for implementing the repository pattern
5
+ #
6
+ # Usage:
7
+ # class UserRepository < QueryKit::Repository
8
+ # table 'users'
9
+ # model User
10
+ # end
11
+ #
12
+ # repo = UserRepository.new(db)
13
+ # user = repo.find(1)
14
+ # users = repo.all
15
+ # users = repo.where('age', '>', 18)
16
+ class Repository
17
+ attr_reader :db
18
+
19
+ class << self
20
+ # Set the table name for this repository
21
+ def table(name)
22
+ @table_name = name
23
+ end
24
+
25
+ # Set the model class for this repository
26
+ def model(klass)
27
+ @model_class = klass
28
+ end
29
+
30
+ # Get the table name
31
+ def table_name
32
+ @table_name
33
+ end
34
+
35
+ # Get the model class
36
+ def model_class
37
+ @model_class
38
+ end
39
+ end
40
+
41
+ attr_reader :db
42
+
43
+ # Initialize repository with optional database connection
44
+ # If no connection provided, uses global QueryKit.connection
45
+ # @param db [QueryKit::Connection, nil] Database connection
46
+ def initialize(db = nil)
47
+ @db = db || QueryKit.connection
48
+ end
49
+
50
+ # Get all records
51
+ def all
52
+ @db.get(query, model_class)
53
+ end
54
+
55
+ # Find a record by ID
56
+ def find(id)
57
+ @db.first(query.where('id', id), model_class)
58
+ end
59
+
60
+ # Find a record by column value
61
+ def find_by(column, value)
62
+ @db.first(query.where(column, value), model_class)
63
+ end
64
+
65
+ # Find multiple records by column value
66
+ def where(column, operator_or_value, value = nil)
67
+ if value.nil?
68
+ # Two arguments: column and value (assumes =)
69
+ @db.get(query.where(column, operator_or_value), model_class)
70
+ else
71
+ # Three arguments: column, operator, value
72
+ @db.get(query.where(column, operator_or_value, value), model_class)
73
+ end
74
+ end
75
+
76
+ # Find records where column is IN array
77
+ def where_in(column, values)
78
+ @db.get(query.where_in(column, values), model_class)
79
+ end
80
+
81
+ # Find records where column is NOT IN array
82
+ def where_not_in(column, values)
83
+ @db.get(query.where_not_in(column, values), model_class)
84
+ end
85
+
86
+ # Get first record matching conditions
87
+ def first
88
+ @db.first(query, model_class)
89
+ end
90
+
91
+ # Count all records
92
+ def count
93
+ result = @db.first(query.select('COUNT(*) as count'))
94
+ result ? result['count'] : 0
95
+ end
96
+
97
+ # Check if any records exist
98
+ def exists?(id = nil)
99
+ if id
100
+ count_query = query.select('COUNT(*) as count').where('id', id)
101
+ else
102
+ count_query = query.select('COUNT(*) as count')
103
+ end
104
+
105
+ result = @db.first(count_query)
106
+ result && result['count'] > 0
107
+ end
108
+
109
+ # Insert a new record
110
+ # @param attributes [Hash] The attributes for the new record
111
+ # @return [Integer] The ID of the inserted record
112
+ def insert(attributes)
113
+ @db.execute_insert(@db.insert(table_name).values(attributes))
114
+ end
115
+ alias_method :create, :insert
116
+
117
+ # Update a record by ID
118
+ # @param id [Integer] The record ID
119
+ # @param attributes [Hash] The attributes to update
120
+ # @return [Integer] Number of affected rows
121
+ def update(id, attributes)
122
+ @db.execute_update(@db.update(table_name).set(attributes).where('id', id))
123
+ end
124
+
125
+ # Delete a record by ID
126
+ # @param id [Integer] The record ID
127
+ # @return [Integer] Number of affected rows
128
+ def delete(id)
129
+ @db.execute_delete(@db.delete(table_name).where('id', id))
130
+ end
131
+ alias_method :destroy, :delete
132
+
133
+ # Delete all records matching conditions
134
+ # @param conditions [Hash] WHERE conditions
135
+ # @return [Integer] Number of affected rows
136
+ def delete_where(conditions)
137
+ delete_query = @db.delete(table_name)
138
+ conditions.each { |column, value| delete_query.where(column, value) }
139
+ @db.execute_delete(delete_query)
140
+ end
141
+
142
+ # Execute a custom query with model mapping
143
+ # @param custom_query [QueryKit::Query] A custom query object
144
+ # @return [Array] Array of model instances
145
+ def execute(custom_query)
146
+ @db.get(custom_query, model_class)
147
+ end
148
+
149
+ # Execute a custom query and return first result
150
+ # @param custom_query [QueryKit::Query] A custom query object
151
+ # @return [Object, nil] Model instance or nil
152
+ def execute_first(custom_query)
153
+ @db.first(custom_query, model_class)
154
+ end
155
+
156
+ # Begin a transaction
157
+ def transaction(&block)
158
+ @db.transaction(&block)
159
+ end
160
+
161
+ protected
162
+
163
+ # Get a new query object for this repository's table
164
+ def query
165
+ @db.query(table_name)
166
+ end
167
+
168
+ # Get the table name from class configuration
169
+ def table_name
170
+ name = self.class.table_name
171
+ raise "Table name not configured for #{self.class.name}. Use: table 'table_name'" unless name
172
+ name
173
+ end
174
+
175
+ # Get the model class from class configuration
176
+ def model_class
177
+ klass = self.class.model_class
178
+ raise "Model class not configured for #{self.class.name}. Use: model ModelClass" unless klass
179
+ klass
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryKit
4
+ # UpdateQuery class for building SQL UPDATE statements.
5
+ class UpdateQuery
6
+ attr_reader :table, :values, :wheres, :bindings
7
+
8
+ # Initialize a new UpdateQuery instance.
9
+ def initialize(table = nil)
10
+ @table = table
11
+ @values = {}
12
+ @wheres = []
13
+ @bindings = []
14
+ end
15
+
16
+ # Set the values to update.
17
+ def set(data)
18
+ @values.merge!(data)
19
+ self
20
+ end
21
+
22
+ # Add a WHERE condition to the update query.
23
+ def where(column, operator = nil, value = nil)
24
+ if value.nil? && !operator.nil?
25
+ value = operator
26
+ operator = '='
27
+ end
28
+
29
+ @wheres << { type: 'basic', column: column, operator: operator, value: value, boolean: 'AND' }
30
+ self
31
+ end
32
+
33
+ # Generate the SQL UPDATE statement.
34
+ def to_sql
35
+ raise "No table specified" unless @table
36
+ raise "No values to update" if @values.empty?
37
+
38
+ @bindings = @values.values + @wheres.map { |w| w[:value] }
39
+
40
+ sql = []
41
+ sql << "UPDATE #{@table}"
42
+ sql << "SET"
43
+ sql << @values.keys.map { |k| "#{k} = ?" }.join(', ')
44
+
45
+ unless @wheres.empty?
46
+ sql << "WHERE"
47
+ where_clauses = @wheres.map { |w| "#{w[:column]} #{w[:operator]} ?" }
48
+ sql << where_clauses.join(' AND ')
49
+ end
50
+
51
+ sql.join(' ')
52
+ end
53
+
54
+ # Return the SQL UPDATE statement as a string.
55
+ def to_s
56
+ to_sql
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryKit
4
+ VERSION = '0.1.0'
5
+ end