simple_query 0.5.0 → 0.6.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.
@@ -12,18 +12,18 @@ module SimpleQuery
12
12
  @arel_table = @model.arel_table
13
13
 
14
14
  @selects = []
15
- @wheres = WhereClause.new(@arel_table)
15
+ @wheres = WhereClause.new(@arel_table, @model)
16
16
  @joins = JoinClause.new
17
- @group_having = GroupHavingClause.new(@arel_table)
17
+ @group_having = GroupHavingClause.new(@arel_table, @model)
18
18
  @orders = OrderClause.new(@arel_table)
19
19
  @limits = LimitOffsetClause.new
20
20
  @distinct_flag = DistinctClause.new
21
- @aggregations = AggregationClause.new(@arel_table)
21
+ @aggregations = AggregationClause.new(@arel_table, @model)
22
22
 
23
23
  @query_cache = {}
24
24
  @query_built = false
25
25
  @read_model_class = nil
26
- @result_struct = nil
26
+ @result_structs = {}
27
27
  end
28
28
 
29
29
  def select(*fields)
@@ -172,7 +172,8 @@ module SimpleQuery
172
172
  # Method to get last/bottom record by column
173
173
  def last_by(column, alias_name: nil)
174
174
  alias_name ||= "last_#{column}"
175
- custom_aggregation("LAST_VALUE(#{resolve_column_name(column)}) OVER (ORDER BY #{resolve_column_name(column)})",
175
+ custom_aggregation("LAST_VALUE(#{resolve_column_name(column)}) OVER (ORDER BY #{resolve_column_name(column)} " \
176
+ "ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)",
176
177
  alias_name)
177
178
  end
178
179
 
@@ -191,37 +192,44 @@ module SimpleQuery
191
192
  end
192
193
 
193
194
  def bulk_update(set:)
194
- table_name = @arel_table.name
195
- set_sql = SetClause.new(set).to_sql
195
+ validate_bulk_update_scope!
196
196
 
197
+ set_sql = SetClause.new(set, @model).to_sql
197
198
  raise ArgumentError, "No columns to update" if set_sql.empty?
198
199
 
199
- where_sql = build_where_sql
200
- sql = "UPDATE #{table_name} SET #{set_sql}"
201
- sql += " WHERE #{where_sql}" unless where_sql.nil? || where_sql.empty?
200
+ update = Arel::UpdateManager.new
201
+ update.table(@arel_table)
202
+ update.set(Arel.sql(set_sql))
203
+ condition = @wheres.to_arel
204
+ update.where(condition) if condition
202
205
 
203
- ActiveRecord::Base.connection.execute(sql)
206
+ connection.execute(compile_sql(update))
204
207
  end
205
208
 
206
209
  def stream_each(batch_size: 1000, &block)
207
- adapter = ActiveRecord::Base.connection.adapter_name.downcase
210
+ validate_stream_batch_size!(batch_size)
211
+
212
+ adapter_name = connection.adapter_name
213
+ adapter = adapter_name.downcase
208
214
  if adapter.include?("postgres")
209
215
  stream_each_postgres(batch_size, &block)
210
216
  elsif adapter.include?("mysql")
211
217
  stream_each_mysql(&block)
212
218
  else
213
- raise "stream_each is only implemented for Postgres and MySQL."
219
+ message = "stream_each is only implemented for PostgreSQL and MySQL adapters " \
220
+ "(current adapter: #{adapter_name})"
221
+ raise UnsupportedAdapterError, message
214
222
  end
215
223
  end
216
224
 
217
225
  def execute
218
- records = ActiveRecord::Base.connection.select_all(cached_sql)
226
+ records = connection.select_all(cached_sql)
219
227
  build_result_objects_from_rows(records)
220
228
  end
221
229
 
222
230
  def lazy_execute
223
231
  Enumerator.new do |yielder|
224
- records = ActiveRecord::Base.connection.select_all(cached_sql)
232
+ records = connection.select_all(cached_sql)
225
233
  if @read_model_class
226
234
  build_read_models_enumerator(records, yielder)
227
235
  else
@@ -256,6 +264,22 @@ module SimpleQuery
256
264
 
257
265
  private
258
266
 
267
+ def validate_stream_batch_size!(batch_size)
268
+ return if batch_size.is_a?(Integer) && batch_size.positive?
269
+
270
+ raise ArgumentError, "stream_each batch_size must be a positive Integer"
271
+ end
272
+
273
+ def validate_bulk_update_scope!
274
+ unsupported = []
275
+ unsupported << "joins" if @joins.joins.any?
276
+ unsupported << "limit/offset" if @limits.limit_value || @limits.offset_value
277
+ unsupported << "group/having" if @group_having.group_fields.any? || @group_having.having_conditions.any?
278
+ return if unsupported.empty?
279
+
280
+ raise ArgumentError, "bulk_update only supports where conditions (found #{unsupported.join(", ")})"
281
+ end
282
+
259
283
  def build_select_expressions
260
284
  expressions = []
261
285
 
@@ -273,11 +297,13 @@ module SimpleQuery
273
297
  expressions
274
298
  end
275
299
 
276
- def build_where_sql
277
- condition = @wheres.to_arel
278
- return "" unless condition
300
+ def connection
301
+ @model.connection
302
+ end
279
303
 
280
- condition.to_sql
304
+ # Compiles with the model's own connection, inlining any bind values.
305
+ def compile_sql(arel)
306
+ connection.unprepared_statement { connection.to_sql(arel) }
281
307
  end
282
308
 
283
309
  def reset_query
@@ -299,7 +325,7 @@ module SimpleQuery
299
325
  @aggregations.aggregations
300
326
  ]
301
327
 
302
- @query_cache[key] ||= build_query.to_sql
328
+ @query_cache[key] ||= compile_sql(build_query)
303
329
  end
304
330
 
305
331
  def build_result_objects_from_rows(records)
@@ -340,7 +366,8 @@ module SimpleQuery
340
366
  end
341
367
 
342
368
  def result_struct(columns)
343
- @result_struct ||= Struct.new(*columns.map(&:to_sym))
369
+ struct_key = columns.map(&:to_s)
370
+ @result_structs[struct_key] ||= Struct.new(*struct_key.map(&:to_sym))
344
371
  end
345
372
 
346
373
  def apply_distinct
@@ -392,9 +419,10 @@ module SimpleQuery
392
419
  end
393
420
  end
394
421
 
395
- def method_missing(method_name, *args, &block)
422
+ def method_missing(method_name, *args, **kwargs, &block)
396
423
  if (scope_block = find_scope(method_name))
397
- instance_exec(*args, &scope_block)
424
+ validate_scope_arity!(method_name, scope_block, scope_arguments_for_validation(scope_block, args, kwargs))
425
+ instance_exec(*args, **kwargs, &scope_block)
398
426
  self
399
427
  else
400
428
  super
@@ -410,5 +438,69 @@ module SimpleQuery
410
438
 
411
439
  model._simple_scopes[method_name.to_sym]
412
440
  end
441
+
442
+ def validate_scope_arity!(scope_name, scope_block, args)
443
+ range = scope_argument_range(scope_block)
444
+ provided = args.length
445
+ return if scope_arity_matches?(range, provided)
446
+
447
+ raise ArgumentError,
448
+ "simple_scope :#{scope_name} expected #{scope_arity_description(range)}, provided #{provided}"
449
+ end
450
+
451
+ def scope_arguments_for_validation(scope_block, args, kwargs)
452
+ return args if kwargs.empty? || scope_uses_keyword_arguments?(scope_block)
453
+
454
+ args + [kwargs]
455
+ end
456
+
457
+ def scope_argument_range(scope_block)
458
+ # Keyword argument arity has Ruby-version-specific edge cases; let Ruby raise its native error.
459
+ return nil if scope_uses_keyword_arguments?(scope_block)
460
+
461
+ minimum = minimum_scope_arguments(scope_block)
462
+ maximum = maximum_scope_arguments(scope_block)
463
+
464
+ [minimum, maximum]
465
+ end
466
+
467
+ def scope_uses_keyword_arguments?(scope_block)
468
+ scope_block.parameters.any? { |kind, _name| [:key, :keyreq, :keyrest].include?(kind) }
469
+ end
470
+
471
+ def minimum_scope_arguments(scope_block)
472
+ arity = scope_block.arity
473
+ return arity if arity >= 0
474
+
475
+ -arity - 1
476
+ end
477
+
478
+ def maximum_scope_arguments(scope_block)
479
+ parameters = scope_block.parameters
480
+ return nil if parameters.any? { |kind, _name| kind == :rest }
481
+
482
+ parameters.count { |kind, _name| [:req, :opt].include?(kind) }
483
+ end
484
+
485
+ def scope_arity_matches?(range, provided)
486
+ return true if range.nil?
487
+
488
+ minimum, maximum = range
489
+ return provided >= minimum if maximum.nil?
490
+
491
+ provided.between?(minimum, maximum)
492
+ end
493
+
494
+ def scope_arity_description(range)
495
+ minimum, maximum = range
496
+ return "at least #{argument_count_description(minimum)}" if maximum.nil?
497
+ return "exactly #{argument_count_description(minimum)}" if minimum == maximum
498
+
499
+ "#{minimum} to #{argument_count_description(maximum)}"
500
+ end
501
+
502
+ def argument_count_description(count)
503
+ "#{count} #{count == 1 ? "argument" : "arguments"}"
504
+ end
413
505
  end
414
506
  end
@@ -4,8 +4,9 @@ module SimpleQuery
4
4
  class AggregationClause
5
5
  attr_reader :aggregations
6
6
 
7
- def initialize(table)
7
+ def initialize(table, model = ActiveRecord::Base)
8
8
  @table = table
9
+ @model = model
9
10
  @aggregations = []
10
11
  end
11
12
 
@@ -91,15 +92,17 @@ module SimpleQuery
91
92
  alias_name ||= "group_concat_#{sanitize_alias(column)}"
92
93
 
93
94
  # Use database-specific group concatenation
94
- adapter = ActiveRecord::Base.connection.adapter_name.downcase
95
+ connection = @model.connection
96
+ adapter = connection.adapter_name.downcase
97
+ quoted_separator = connection.quote(separator.to_s)
95
98
 
96
99
  expression = case adapter
97
100
  when /mysql/
98
- "GROUP_CONCAT(#{column_expr} SEPARATOR '#{separator}')"
101
+ "GROUP_CONCAT(#{column_expr} SEPARATOR #{quoted_separator})"
99
102
  when /postgres/
100
- "STRING_AGG(#{column_expr}::text, '#{separator}')"
103
+ "STRING_AGG(#{column_expr}::text, #{quoted_separator})"
101
104
  when /sqlite/
102
- "GROUP_CONCAT(#{column_expr}, '#{separator}')"
105
+ "GROUP_CONCAT(#{column_expr}, #{quoted_separator})"
103
106
  else
104
107
  # Fallback for other databases
105
108
  "GROUP_CONCAT(#{column_expr})"
@@ -4,8 +4,9 @@ module SimpleQuery
4
4
  class GroupHavingClause
5
5
  attr_reader :group_fields, :having_conditions
6
6
 
7
- def initialize(table)
7
+ def initialize(table, model = ActiveRecord::Base)
8
8
  @table = table
9
+ @model = model
9
10
  @group_fields = []
10
11
  @having_conditions = []
11
12
  end
@@ -15,15 +16,14 @@ module SimpleQuery
15
16
  end
16
17
 
17
18
  def add_having(condition)
18
- @having_conditions << condition
19
+ clause = WhereClause.new(@table, @model)
20
+ clause.add(condition)
21
+ @having_conditions.concat(clause.conditions)
19
22
  end
20
23
 
21
24
  def apply_to(query)
22
25
  @group_fields.each { |g| query.group(g) }
23
- if @having_conditions.any?
24
- combined = @having_conditions.inject { |c, a| c.and(a) }
25
- query.having(combined)
26
- end
26
+ query.having(WhereClause.combine(@having_conditions)) if @having_conditions.any?
27
27
  query
28
28
  end
29
29
  end
@@ -2,8 +2,9 @@
2
2
 
3
3
  module SimpleQuery
4
4
  class SetClause
5
- def initialize(set_hash)
5
+ def initialize(set_hash, model = ActiveRecord::Base)
6
6
  @set_hash = set_hash
7
+ @model = model
7
8
  end
8
9
 
9
10
  def to_sql
@@ -15,11 +16,11 @@ module SimpleQuery
15
16
  private
16
17
 
17
18
  def quote_column(col)
18
- ActiveRecord::Base.connection.quote_column_name(col)
19
+ @model.connection.quote_column_name(col)
19
20
  end
20
21
 
21
22
  def quote_value(val)
22
- ActiveRecord::Base.connection.quote(val)
23
+ @model.connection.quote(val)
23
24
  end
24
25
  end
25
26
  end
@@ -4,8 +4,16 @@ module SimpleQuery
4
4
  class WhereClause
5
5
  attr_reader :conditions
6
6
 
7
- def initialize(table)
7
+ def self.combine(conditions)
8
+ return nil if conditions.empty?
9
+ return conditions.first if conditions.one?
10
+
11
+ Arel::Nodes::And.new(conditions.map { |c| c.is_a?(Arel::Nodes::Grouping) ? c : Arel::Nodes::Grouping.new(c) })
12
+ end
13
+
14
+ def initialize(table, model = ActiveRecord::Base)
8
15
  @table = table
16
+ @model = model
9
17
  @conditions = []
10
18
  end
11
19
 
@@ -15,11 +23,7 @@ module SimpleQuery
15
23
  end
16
24
 
17
25
  def to_arel
18
- return nil if @conditions.empty?
19
-
20
- @conditions.inject do |combined, current|
21
- combined.and(current)
22
- end
26
+ self.class.combine(@conditions)
23
27
  end
24
28
 
25
29
  private
@@ -27,15 +31,36 @@ module SimpleQuery
27
31
  def parse_condition(condition)
28
32
  case condition
29
33
  when Hash
30
- condition.map { |field, value| @table[field].eq(value) }
31
- when Arel::Nodes::Node
34
+ condition.map { |field, value| hash_predicate(field, value) }
35
+ when Arel::Nodes::Node, Arel::Attributes::Attribute
32
36
  [condition]
33
37
  when Array
34
- sanitized_sql = ActiveRecord::Base.send(:sanitize_sql_array, condition)
38
+ sanitized_sql = @model.send(:sanitize_sql_array, condition)
35
39
  [Arel.sql(sanitized_sql)]
36
40
  else
37
41
  [Arel.sql(condition.to_s)]
38
42
  end
39
43
  end
44
+
45
+ def hash_predicate(field, value)
46
+ attribute = @table[field]
47
+ return attribute.eq(value) if force_equality?(field, value)
48
+
49
+ case value
50
+ when Array
51
+ values = value.compact
52
+ predicate = attribute.in(values)
53
+ values.size == value.size ? predicate : predicate.or(attribute.eq(nil))
54
+ when Range
55
+ attribute.between(value)
56
+ else
57
+ attribute.eq(value)
58
+ end
59
+ end
60
+
61
+ # PostgreSQL array/range columns compare arrays and ranges by equality, like ActiveRecord does.
62
+ def force_equality?(field, value)
63
+ @table.able_to_type_cast? && @table.type_for_attribute(field.to_s).force_equality?(value)
64
+ end
40
65
  end
41
66
  end
@@ -6,12 +6,17 @@ module SimpleQuery
6
6
  def stream_each_mysql(&block)
7
7
  select_sql = cached_sql
8
8
 
9
- raw_conn = ActiveRecord::Base.connection.raw_connection
9
+ raw_conn = connection.raw_connection
10
10
 
11
11
  result = raw_conn.query(select_sql, stream: true, cache_rows: false, as: :hash)
12
- result.each do |mysql_row|
13
- record = build_row_object_mysql(mysql_row)
14
- block.call(record)
12
+ begin
13
+ result.each do |mysql_row|
14
+ record = build_row_object_mysql(mysql_row)
15
+ block.call(record)
16
+ end
17
+ ensure
18
+ # Discards unread rows so the connection can run the next query after an error or break.
19
+ result.free
15
20
  end
16
21
  end
17
22
 
@@ -3,17 +3,27 @@
3
3
  module SimpleQuery
4
4
  module Stream
5
5
  module PostgresStream
6
+ PQTRANS_IDLE = 0 # PG::PQTRANS_IDLE
7
+
6
8
  # rubocop:disable Metrics/MethodLength
7
9
  def stream_each_postgres(batch_size, &block)
10
+ validate_postgres_stream_batch_size!(batch_size)
11
+
8
12
  select_sql = cached_sql
9
13
 
10
- conn = ActiveRecord::Base.connection.raw_connection
14
+ conn = connection.raw_connection
11
15
  cursor_name = "simple_query_cursor_#{object_id}"
12
16
 
17
+ # Cursors need a transaction. Reuse the caller's one instead of committing it.
18
+ own_transaction = conn.transaction_status == PQTRANS_IDLE
19
+ cursor_declared = false
20
+ cursor_close_started = false
21
+ completed = false
22
+
13
23
  begin
14
- conn.exec("BEGIN")
15
- declare_sql = "DECLARE #{cursor_name} NO SCROLL CURSOR FOR #{select_sql}"
16
- conn.exec(declare_sql)
24
+ conn.exec("BEGIN") if own_transaction
25
+ conn.exec("DECLARE #{cursor_name} NO SCROLL CURSOR FOR #{select_sql}")
26
+ cursor_declared = true
17
27
 
18
28
  loop do
19
29
  res = conn.exec("FETCH #{batch_size} FROM #{cursor_name}")
@@ -25,21 +35,45 @@ module SimpleQuery
25
35
  end
26
36
  end
27
37
 
38
+ cursor_close_started = true
28
39
  conn.exec("CLOSE #{cursor_name}")
29
- conn.exec("COMMIT")
30
- rescue StandardError => e
31
- begin
32
- conn.exec("ROLLBACK")
33
- rescue StandardError
34
- nil
40
+ conn.exec("COMMIT") if own_transaction
41
+ completed = true
42
+ ensure
43
+ # Also runs when the caller leaves the block early with break/return.
44
+ unless completed
45
+ abort_postgres_stream(conn, cursor_name, close_cursor: cursor_declared && !cursor_close_started,
46
+ rollback: own_transaction)
35
47
  end
36
- raise e
37
48
  end
38
49
  end
39
50
  # rubocop:enable Metrics/MethodLength
40
51
 
41
52
  private
42
53
 
54
+ def validate_postgres_stream_batch_size!(batch_size)
55
+ return if batch_size.is_a?(Integer) && batch_size.positive?
56
+
57
+ raise ArgumentError, "stream_each batch_size must be a positive Integer"
58
+ end
59
+
60
+ def abort_postgres_stream(conn, cursor_name, close_cursor:, rollback:)
61
+ close_postgres_stream_cursor(conn, cursor_name) if close_cursor
62
+ rollback_postgres_stream_transaction(conn) if rollback
63
+ end
64
+
65
+ def close_postgres_stream_cursor(conn, cursor_name)
66
+ conn.exec("CLOSE #{cursor_name}")
67
+ rescue StandardError
68
+ nil
69
+ end
70
+
71
+ def rollback_postgres_stream_transaction(conn)
72
+ conn.exec("ROLLBACK")
73
+ rescue StandardError
74
+ nil
75
+ end
76
+
43
77
  def build_row_object(pg_row)
44
78
  if @read_model_class
45
79
  obj = @read_model_class.allocate
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleQuery
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/simple_query.rb CHANGED
@@ -19,6 +19,8 @@ require_relative "simple_query/clauses/aggregation_clause"
19
19
  module SimpleQuery
20
20
  extend ActiveSupport::Concern
21
21
 
22
+ class UnsupportedAdapterError < StandardError; end
23
+
22
24
  class Configuration
23
25
  attr_accessor :auto_include_ar
24
26
 
@@ -46,12 +48,16 @@ module SimpleQuery
46
48
  end
47
49
 
48
50
  def simple_scope(name, body = nil, &block)
49
- raise ArgumentError, "Pass either a proc/lambda or a block, not both" if body && block_given?
51
+ raise ArgumentError, "Pass either a proc/lambda or a block, not both" if !body.nil? && block_given?
52
+
53
+ scope_body = body.nil? ? block : body
54
+ raise ArgumentError, "You must provide a block or a proc" if scope_body.nil?
55
+ raise ArgumentError, "simple_scope body must respond to #to_proc" unless scope_body.respond_to?(:to_proc)
50
56
 
51
- scope_body = body || block
52
- raise ArgumentError, "You must provide a block or a proc" unless scope_body
57
+ scope_proc = scope_body.to_proc
58
+ raise ArgumentError, "simple_scope body #to_proc must return a Proc" unless scope_proc.is_a?(Proc)
53
59
 
54
- _simple_scopes[name.to_sym] = scope_body
60
+ _simple_scopes[name.to_sym] = scope_proc
55
61
  end
56
62
  end
57
63
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simple_query
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Kholodniak
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-08-30 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -17,9 +17,9 @@ dependencies:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '7.0'
20
- - - "<="
20
+ - - "<"
21
21
  - !ruby/object:Gem::Version
22
- version: '8.0'
22
+ version: '8.2'
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -27,9 +27,9 @@ dependencies:
27
27
  - - ">="
28
28
  - !ruby/object:Gem::Version
29
29
  version: '7.0'
30
- - - "<="
30
+ - - "<"
31
31
  - !ruby/object:Gem::Version
32
- version: '8.0'
32
+ version: '8.2'
33
33
  description: SimpleQuery provides a flexible and performant way to construct complex
34
34
  database queries in Ruby on Rails applications. It offers an intuitive interface
35
35
  for building queries with joins, conditions, and aggregations, while potentially