simple_query 0.3.2 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6aeb71d493d14743e43b8c9a37eed697f131db105ffc4dc0fa35dd0dd189255b
4
- data.tar.gz: 82b1815e569f24a4c4da9e4cd727d95ad6c938403bd2b781eccfc39398cb264b
3
+ metadata.gz: 856dc1549df83e6c82a7105b3dfa97b9c6f4e69b86f6c2f9ad653ae1f322d1e8
4
+ data.tar.gz: c10490e13ffbc3da9c629081cde483188efbc0ec3fbdcf27070d565947cc723a
5
5
  SHA512:
6
- metadata.gz: d422a6a3484d4242b6666bb6536f4828b666c0f16dd6a0c42104b12e3f077bd30e580dc9cf5eb4292ecbb1cd0eda218aabe29af6e221a86c3291fc116b62b856
7
- data.tar.gz: cf6e7c8a7e198d1b5376053237d7bdfac59819e07571adb5656e859de9a3bb840dace69e2e600703b14a5ff01bfedbf448d1aadb471a3875874c7d390599878a
6
+ metadata.gz: 627f5f7fe8a02a9bdf949f8b4d681fc811a6081b0dc2666f60baacff55d6136a3e28512d8c44a1d5cca1699a6529716b36daa4aca455a1ed070efdca935b078a
7
+ data.tar.gz: 056dbe8add051110bc32cb79264128e87804d4839b0e1f74172353d6f205a59080a54ed463f04ae82efb239237f4d3d0e4ad8f03bc96bdbc607f1c1a0ec68ff9
data/README.md CHANGED
@@ -104,6 +104,94 @@ User.simple_query
104
104
  .execute
105
105
  ```
106
106
 
107
+ ## Enhanced Aggregation Support
108
+
109
+ SimpleQuery provides a comprehensive set of aggregation methods that are more convenient and readable than writing raw SQL:
110
+
111
+ ### Basic Aggregations
112
+
113
+ ```ruby
114
+ # Count records
115
+ User.simple_query.count.execute
116
+ # => #<struct count=1000>
117
+
118
+ # Count specific column (non-null values)
119
+ User.simple_query.count(:email).execute
120
+ # => #<struct count_email=995>
121
+
122
+ # Sum values
123
+ Company.simple_query.sum(:annual_revenue).execute
124
+ # => #<struct sum_annual_revenue=50000000>
125
+
126
+ # Average values
127
+ Company.simple_query.avg(:annual_revenue).execute
128
+ # => #<struct avg_annual_revenue=1000000.5>
129
+
130
+ # Find minimum and maximum
131
+ Company.simple_query.min(:annual_revenue).max(:annual_revenue).execute
132
+ # => #<struct min_annual_revenue=100000, max_annual_revenue=5000000>
133
+ ```
134
+
135
+ ### Statistical Functions
136
+
137
+ ```ruby
138
+ # Variance and standard deviation
139
+ User.simple_query.variance(:score).stddev(:score).execute
140
+ # => #<struct variance_score=125.67, stddev_score=11.21>
141
+
142
+ # Database-specific group concatenation
143
+ User.simple_query
144
+ .select(:department)
145
+ .group_concat(:name, separator: ", ")
146
+ .group(:department)
147
+ .execute
148
+ # => #<struct department="Engineering", group_concat_name="Alice, Bob, Charlie">
149
+ ```
150
+
151
+ ### Advanced Aggregation Features
152
+
153
+ ```ruby
154
+ # Get comprehensive statistics for a column
155
+ Company.simple_query.stats(:annual_revenue).execute
156
+ # => #<struct
157
+ # annual_revenue_count=100,
158
+ # annual_revenue_sum=50000000,
159
+ # annual_revenue_avg=500000,
160
+ # annual_revenue_min=100000,
161
+ # annual_revenue_max=2000000
162
+ # >
163
+
164
+ # Custom aggregations
165
+ Company.simple_query
166
+ .custom_aggregation("COUNT(DISTINCT industry)", "unique_industries")
167
+ .execute
168
+ # => #<struct unique_industries=5>
169
+
170
+ # Combining with other features
171
+ Company.simple_query
172
+ .select(:industry)
173
+ .count
174
+ .sum(:annual_revenue)
175
+ .group(:industry)
176
+ .execute
177
+ # => [
178
+ # #<struct industry="Technology", count=50, sum_annual_revenue=25000000>,
179
+ # #<struct industry="Finance", count=30, sum_annual_revenue=20000000>
180
+ # ]
181
+ ```
182
+
183
+ ### Custom Aliases
184
+
185
+ All aggregation methods support custom aliases:
186
+
187
+ ```ruby
188
+ User.simple_query
189
+ .count(:id, alias_name: "total_users")
190
+ .sum(:score, alias_name: "total_score")
191
+ .execute
192
+ # => #<struct total_users=1000, total_score=85000>
193
+ ```
194
+
107
195
  ## Custom Read Models
108
196
  By default, SimpleQuery returns results as `Struct` objects for maximum speed. However, you can also define a lightweight model class for more explicit attribute handling or custom logic.
109
197
 
@@ -176,32 +264,70 @@ Each scope block (e.g. by_name) is evaluated in the context of the SimpleQuery b
176
264
  Parameterized scopes accept arguments — passed directly to the block (e.g. |name| above).
177
265
  Scopes return self, so you can chain multiple scopes or mix them with standard query methods.
178
266
 
179
- ## Features
267
+ ## Streaming Large Datasets
268
+
269
+ For massive queries (millions of rows), **SimpleQuery** offers a `.stream_each` method to avoid loading the entire result set into memory. It **automatically** picks a streaming approach depending on your database adapter:
180
270
 
181
- - Efficient query building
182
- - Support for complex joins
183
- - Lazy execution
184
- - DISTINCT queries
185
- - Aggregations
186
- - LIMIT and OFFSET
187
- - ORDER BY clause
188
- - Having and Grouping
189
- - Subqueries
190
- - Custom Read models
191
- - Named Scopes
271
+ - **PostgreSQL**: Uses a **server-side cursor** via `DECLARE ... FETCH`.
272
+ - **MySQL**: Uses `mysql2` gem’s **streaming** (`stream: true, cache_rows: false, as: :hash`).
273
+
274
+ ```ruby
275
+ # Example usage:
276
+ User.simple_query
277
+ .where(active: true)
278
+ .stream_each(batch_size: 10_000) do |row|
279
+ # row is a struct or read-model instance
280
+ puts row.name
281
+ end
282
+ ```
192
283
 
193
284
  ## Performance
194
285
 
195
- SimpleQuery is designed to potentially outperform standard ActiveRecord queries on large datasets. In our benchmarks with 100,000 records, SimpleQuery showed improved performance compared to equivalent ActiveRecord queries.
286
+ SimpleQuery aims to outperform standard ActiveRecord queries at scale. We’ve benchmarked **1,000,000** records on **both PostgreSQL** and **MySQL**, with the following results:
287
+
288
+ ### PostgreSQL (1,000,000 records)
289
+ ```
290
+ 🚀 Performance Results (1000,000 records):
291
+ ActiveRecord Query: 10.36932 seconds
292
+ SimpleQuery Execution (Struct): 3.46136 seconds
293
+ SimpleQuery Execution (Read model): 2.20905 seconds
294
+
295
+ ----------------------------------------------------
296
+ ActiveRecord find_each: 6.10077 seconds
297
+ SimpleQuery stream_each: 2.75639 seconds
298
+
299
+ --- AR find_each Memory Report ---
300
+ Total allocated: 1.98 GB (16,001,659 objects)
301
+ Retained: ~2 KB
302
+
303
+ --- SimpleQuery stream_each Memory Report ---
304
+ Total allocated: 1.38 GB (8,000,211 objects)
305
+ Retained: ~3 KB
306
+ ```
307
+ - **Struct-based** approach remains the fastest, skipping model overhead.
308
+ - **Read model** approach is still significantly faster than standard ActiveRecord while allowing domain-specific logic.
196
309
 
310
+ ### MySQL (1,000,000 records)
197
311
  ```
198
- 🚀 Performance Results (100,000 records):
199
- ActiveRecord Query: 0.47441 seconds
200
- SimpleQuery Execution (Struct): 0.05346 seconds
201
- SimpleQuery Execution (Read model): 0.14408 seconds
312
+ 🚀 Performance Results (1000,000 records):
313
+ ActiveRecord Query: 10.45833 seconds
314
+ SimpleQuery Execution (Struct): 3.04655 seconds
315
+ SimpleQuery Execution (Read model): 3.69052 seconds
316
+
317
+ ----------------------------------------------------
318
+ ActiveRecord find_each: 5.04671 seconds
319
+ SimpleQuery stream_each: 2.96602 seconds
320
+
321
+ --- AR find_each Memory Report ---
322
+ Total allocated: 1.32 GB (11,001,445 objects)
323
+ Retained: ~2.7 KB
324
+
325
+ --- SimpleQuery stream_each Memory Report ---
326
+ Total allocated: 1.22 GB (8,000,068 objects)
327
+ Retained: ~3.9 KB
202
328
  ```
203
- - The **Struct-based** approach is the fastest.
204
- - The **Read model** approach is still significantly faster than ActiveRecord, while letting you define custom logic or domain-specific attributes.
329
+ - Even in MySQL, **Struct** was roughly **three times faster** than ActiveRecord’s overhead.
330
+ - Read models still outperform AR, though by a narrower margin in this scenario.
205
331
 
206
332
  ## Development
207
333
 
@@ -2,6 +2,9 @@
2
2
 
3
3
  module SimpleQuery
4
4
  class Builder
5
+ include SimpleQuery::Stream::PostgresStream
6
+ include SimpleQuery::Stream::MysqlStream
7
+
5
8
  attr_reader :model, :arel_table
6
9
 
7
10
  def initialize(source)
@@ -15,6 +18,7 @@ module SimpleQuery
15
18
  @orders = OrderClause.new(@arel_table)
16
19
  @limits = LimitOffsetClause.new
17
20
  @distinct_flag = DistinctClause.new
21
+ @aggregations = AggregationClause.new(@arel_table)
18
22
 
19
23
  @query_cache = {}
20
24
  @query_built = false
@@ -88,12 +92,128 @@ module SimpleQuery
88
92
  self
89
93
  end
90
94
 
95
+ # Aggregation methods
96
+ def count(column = nil, alias_name: nil)
97
+ @aggregations.count(column, alias_name: alias_name)
98
+ reset_query
99
+ self
100
+ end
101
+
102
+ def sum(column, alias_name: nil)
103
+ @aggregations.sum(column, alias_name: alias_name)
104
+ reset_query
105
+ self
106
+ end
107
+
108
+ def avg(column, alias_name: nil)
109
+ @aggregations.avg(column, alias_name: alias_name)
110
+ reset_query
111
+ self
112
+ end
113
+
114
+ def min(column, alias_name: nil)
115
+ @aggregations.min(column, alias_name: alias_name)
116
+ reset_query
117
+ self
118
+ end
119
+
120
+ def max(column, alias_name: nil)
121
+ @aggregations.max(column, alias_name: alias_name)
122
+ reset_query
123
+ self
124
+ end
125
+
126
+ def variance(column, alias_name: nil)
127
+ @aggregations.variance(column, alias_name: alias_name)
128
+ reset_query
129
+ self
130
+ end
131
+
132
+ def stddev(column, alias_name: nil)
133
+ @aggregations.stddev(column, alias_name: alias_name)
134
+ reset_query
135
+ self
136
+ end
137
+
138
+ def group_concat(column, separator: ",", alias_name: nil)
139
+ @aggregations.group_concat(column, separator: separator, alias_name: alias_name)
140
+ reset_query
141
+ self
142
+ end
143
+
144
+ def custom_aggregation(expression, alias_name)
145
+ @aggregations.custom(expression, alias_name)
146
+ reset_query
147
+ self
148
+ end
149
+
150
+ # Convenience methods for common aggregation patterns
151
+ def total_count(alias_name: "total")
152
+ count(alias_name: alias_name)
153
+ end
154
+
155
+ def stats(column, alias_prefix: nil)
156
+ prefix = alias_prefix || column.to_s
157
+ count(alias_name: "#{prefix}_count")
158
+ sum(column, alias_name: "#{prefix}_sum")
159
+ avg(column, alias_name: "#{prefix}_avg")
160
+ min(column, alias_name: "#{prefix}_min")
161
+ max(column, alias_name: "#{prefix}_max")
162
+ self
163
+ end
164
+
165
+ # Method to get first/top record by column
166
+ def first_by(column, alias_name: nil)
167
+ alias_name ||= "first_#{column}"
168
+ custom_aggregation("FIRST_VALUE(#{resolve_column_name(column)}) OVER (ORDER BY #{resolve_column_name(column)})",
169
+ alias_name)
170
+ end
171
+
172
+ # Method to get last/bottom record by column
173
+ def last_by(column, alias_name: nil)
174
+ alias_name ||= "last_#{column}"
175
+ custom_aggregation("LAST_VALUE(#{resolve_column_name(column)}) OVER (ORDER BY #{resolve_column_name(column)})",
176
+ alias_name)
177
+ end
178
+
179
+ # Percentage calculations
180
+ def percentage_of_total(column, alias_name: nil)
181
+ alias_name ||= "#{column}_percentage"
182
+ column_expr = resolve_column_name(column)
183
+ expression = "ROUND((#{column_expr} * 100.0 / SUM(#{column_expr}) OVER ()), 2)"
184
+ custom_aggregation(expression, alias_name)
185
+ end
186
+
91
187
  def map_to(klass)
92
188
  @read_model_class = klass
93
189
  reset_query
94
190
  self
95
191
  end
96
192
 
193
+ def bulk_update(set:)
194
+ table_name = @arel_table.name
195
+ set_sql = SetClause.new(set).to_sql
196
+
197
+ raise ArgumentError, "No columns to update" if set_sql.empty?
198
+
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?
202
+
203
+ ActiveRecord::Base.connection.execute(sql)
204
+ end
205
+
206
+ def stream_each(batch_size: 1000, &block)
207
+ adapter = ActiveRecord::Base.connection.adapter_name.downcase
208
+ if adapter.include?("postgres")
209
+ stream_each_postgres(batch_size, &block)
210
+ elsif adapter.include?("mysql")
211
+ stream_each_mysql(&block)
212
+ else
213
+ raise "stream_each is only implemented for Postgres and MySQL."
214
+ end
215
+ end
216
+
97
217
  def execute
98
218
  records = ActiveRecord::Base.connection.select_all(cached_sql)
99
219
  build_result_objects_from_rows(records)
@@ -118,7 +238,10 @@ module SimpleQuery
118
238
 
119
239
  @query = Arel::SelectManager.new(Arel::Table.engine)
120
240
  @query.from(@arel_table)
121
- @query.project(*(@selects.empty? ? [@arel_table[Arel.star]] : @selects))
241
+
242
+ # Combine regular selects with aggregations
243
+ all_selects = build_select_expressions
244
+ @query.project(*all_selects)
122
245
 
123
246
  apply_distinct
124
247
  apply_where_conditions
@@ -133,6 +256,30 @@ module SimpleQuery
133
256
 
134
257
  private
135
258
 
259
+ def build_select_expressions
260
+ expressions = []
261
+
262
+ # Add regular selects
263
+ if @selects.any?
264
+ expressions.concat(@selects)
265
+ elsif @aggregations.empty?
266
+ # Only use * if no aggregations and no explicit selects
267
+ expressions << @arel_table[Arel.star]
268
+ end
269
+
270
+ # Add aggregation expressions
271
+ expressions.concat(@aggregations.to_arel_expressions)
272
+
273
+ expressions
274
+ end
275
+
276
+ def build_where_sql
277
+ condition = @wheres.to_arel
278
+ return "" unless condition
279
+
280
+ condition.to_sql
281
+ end
282
+
136
283
  def reset_query
137
284
  @query_built = false
138
285
  @query_cache.clear
@@ -148,7 +295,8 @@ module SimpleQuery
148
295
  @orders.orders,
149
296
  @limits.limit_value,
150
297
  @limits.offset_value,
151
- @distinct_flag.use_distinct?
298
+ @distinct_flag.use_distinct?,
299
+ @aggregations.aggregations
152
300
  ]
153
301
 
154
302
  @query_cache[key] ||= build_query.to_sql
@@ -233,6 +381,17 @@ module SimpleQuery
233
381
  end
234
382
  end
235
383
 
384
+ def resolve_column_name(column)
385
+ case column
386
+ when Symbol
387
+ "#{@arel_table.name}.#{column}"
388
+ when String
389
+ column.include?(".") ? column : "#{@arel_table.name}.#{column}"
390
+ else
391
+ column.to_s
392
+ end
393
+ end
394
+
236
395
  def method_missing(method_name, *args, &block)
237
396
  if (scope_block = find_scope(method_name))
238
397
  instance_exec(*args, &scope_block)
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleQuery
4
+ class AggregationClause
5
+ attr_reader :aggregations
6
+
7
+ def initialize(table)
8
+ @table = table
9
+ @aggregations = []
10
+ end
11
+
12
+ def count(column = nil, alias_name: nil)
13
+ column_expr = column ? resolve_column(column) : "*"
14
+ alias_name ||= column ? "count_#{sanitize_alias(column)}" : "count"
15
+
16
+ add_aggregation("COUNT", column_expr, alias_name)
17
+ end
18
+
19
+ def sum(column, alias_name: nil)
20
+ raise ArgumentError, "Column is required for SUM aggregation" if column.nil?
21
+
22
+ column_expr = resolve_column(column)
23
+ alias_name ||= "sum_#{sanitize_alias(column)}"
24
+
25
+ add_aggregation("SUM", column_expr, alias_name)
26
+ end
27
+
28
+ def avg(column, alias_name: nil)
29
+ raise ArgumentError, "Column is required for AVG aggregation" if column.nil?
30
+
31
+ column_expr = resolve_column(column)
32
+ alias_name ||= "avg_#{sanitize_alias(column)}"
33
+
34
+ add_aggregation("AVG", column_expr, alias_name)
35
+ end
36
+
37
+ def min(column, alias_name: nil)
38
+ raise ArgumentError, "Column is required for MIN aggregation" if column.nil?
39
+
40
+ column_expr = resolve_column(column)
41
+ alias_name ||= "min_#{sanitize_alias(column)}"
42
+
43
+ add_aggregation("MIN", column_expr, alias_name)
44
+ end
45
+
46
+ def max(column, alias_name: nil)
47
+ raise ArgumentError, "Column is required for MAX aggregation" if column.nil?
48
+
49
+ column_expr = resolve_column(column)
50
+ alias_name ||= "max_#{sanitize_alias(column)}"
51
+
52
+ add_aggregation("MAX", column_expr, alias_name)
53
+ end
54
+
55
+ def custom(expression, alias_name)
56
+ if expression.nil? || alias_name.nil?
57
+ raise ArgumentError,
58
+ "Expression and alias are required for custom aggregation"
59
+ end
60
+
61
+ @aggregations << {
62
+ expression: expression,
63
+ alias: alias_name
64
+ }
65
+ end
66
+
67
+ # Statistical functions
68
+ def variance(column, alias_name: nil)
69
+ raise ArgumentError, "Column is required for VARIANCE aggregation" if column.nil?
70
+
71
+ column_expr = resolve_column(column)
72
+ alias_name ||= "variance_#{sanitize_alias(column)}"
73
+
74
+ add_aggregation("VARIANCE", column_expr, alias_name)
75
+ end
76
+
77
+ def stddev(column, alias_name: nil)
78
+ raise ArgumentError, "Column is required for STDDEV aggregation" if column.nil?
79
+
80
+ column_expr = resolve_column(column)
81
+ alias_name ||= "stddev_#{sanitize_alias(column)}"
82
+
83
+ add_aggregation("STDDEV", column_expr, alias_name)
84
+ end
85
+
86
+ # Group concatenation (database-specific)
87
+ def group_concat(column, separator: ",", alias_name: nil)
88
+ raise ArgumentError, "Column is required for GROUP_CONCAT aggregation" if column.nil?
89
+
90
+ column_expr = resolve_column(column)
91
+ alias_name ||= "group_concat_#{sanitize_alias(column)}"
92
+
93
+ # Use database-specific group concatenation
94
+ adapter = ActiveRecord::Base.connection.adapter_name.downcase
95
+
96
+ expression = case adapter
97
+ when /mysql/
98
+ "GROUP_CONCAT(#{column_expr} SEPARATOR '#{separator}')"
99
+ when /postgres/
100
+ "STRING_AGG(#{column_expr}::text, '#{separator}')"
101
+ when /sqlite/
102
+ "GROUP_CONCAT(#{column_expr}, '#{separator}')"
103
+ else
104
+ # Fallback for other databases
105
+ "GROUP_CONCAT(#{column_expr})"
106
+ end
107
+
108
+ @aggregations << {
109
+ expression: expression,
110
+ alias: alias_name
111
+ }
112
+ end
113
+
114
+ def to_arel_expressions
115
+ @aggregations.map do |agg|
116
+ Arel.sql("#{agg[:expression]} AS #{agg[:alias]}")
117
+ end
118
+ end
119
+
120
+ def empty?
121
+ @aggregations.empty?
122
+ end
123
+
124
+ def clear
125
+ @aggregations.clear
126
+ end
127
+
128
+ private
129
+
130
+ def add_aggregation(function, column_expr, alias_name)
131
+ @aggregations << {
132
+ expression: "#{function}(#{column_expr})",
133
+ alias: alias_name
134
+ }
135
+ end
136
+
137
+ def resolve_column(column)
138
+ case column
139
+ when Symbol
140
+ "#{@table.name}.#{column}"
141
+ when String
142
+ # Allow table.column format or just column name
143
+ column.include?(".") ? column : "#{@table.name}.#{column}"
144
+ when Arel::Attributes::Attribute
145
+ column.to_sql
146
+ else
147
+ column.to_s
148
+ end
149
+ end
150
+
151
+ def sanitize_alias(column)
152
+ column.to_s.gsub(".", "_")
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleQuery
4
+ class SetClause
5
+ def initialize(set_hash)
6
+ @set_hash = set_hash
7
+ end
8
+
9
+ def to_sql
10
+ @set_hash.map do |col, val|
11
+ "#{quote_column(col)} = #{quote_value(val)}"
12
+ end.join(", ")
13
+ end
14
+
15
+ private
16
+
17
+ def quote_column(col)
18
+ ActiveRecord::Base.connection.quote_column_name(col)
19
+ end
20
+
21
+ def quote_value(val)
22
+ ActiveRecord::Base.connection.quote(val)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleQuery
4
+ module Stream
5
+ module MysqlStream
6
+ def stream_each_mysql(&block)
7
+ select_sql = cached_sql
8
+
9
+ raw_conn = ActiveRecord::Base.connection.raw_connection
10
+
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)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def build_row_object_mysql(mysql_row)
21
+ if @read_model_class
22
+ obj = @read_model_class.allocate
23
+ @read_model_class.attributes.each do |attr_name, col_name|
24
+ obj.instance_variable_set(:"@#{attr_name}", mysql_row[col_name])
25
+ end
26
+ obj
27
+ else
28
+ columns = mysql_row.keys
29
+ values = columns.map { |k| mysql_row[k] }
30
+ struct = result_struct(columns)
31
+ struct.new(*values)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleQuery
4
+ module Stream
5
+ module PostgresStream
6
+ # rubocop:disable Metrics/MethodLength
7
+ def stream_each_postgres(batch_size, &block)
8
+ select_sql = cached_sql
9
+
10
+ conn = ActiveRecord::Base.connection.raw_connection
11
+ cursor_name = "simple_query_cursor_#{object_id}"
12
+
13
+ begin
14
+ conn.exec("BEGIN")
15
+ declare_sql = "DECLARE #{cursor_name} NO SCROLL CURSOR FOR #{select_sql}"
16
+ conn.exec(declare_sql)
17
+
18
+ loop do
19
+ res = conn.exec("FETCH #{batch_size} FROM #{cursor_name}")
20
+ break if res.ntuples.zero?
21
+
22
+ res.each do |pg_row|
23
+ record = build_row_object(pg_row)
24
+ block.call(record)
25
+ end
26
+ end
27
+
28
+ conn.exec("CLOSE #{cursor_name}")
29
+ conn.exec("COMMIT")
30
+ rescue StandardError => e
31
+ begin
32
+ conn.exec("ROLLBACK")
33
+ rescue StandardError
34
+ nil
35
+ end
36
+ raise e
37
+ end
38
+ end
39
+ # rubocop:enable Metrics/MethodLength
40
+
41
+ private
42
+
43
+ def build_row_object(pg_row)
44
+ if @read_model_class
45
+ obj = @read_model_class.allocate
46
+ @read_model_class.attributes.each do |attr_name, col_name|
47
+ obj.instance_variable_set(:"@#{attr_name}", pg_row[col_name])
48
+ end
49
+ obj
50
+ else
51
+ columns = pg_row.keys
52
+ values = columns.map { |k| pg_row[k] }
53
+ struct = result_struct(columns)
54
+ struct.new(*values)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleQuery
4
- VERSION = "0.3.2"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/simple_query.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require "active_support/concern"
4
4
  require "active_record"
5
5
 
6
+ require_relative "simple_query/stream/mysql_stream"
7
+ require_relative "simple_query/stream/postgres_stream"
6
8
  require_relative "simple_query/builder"
7
9
  require_relative "simple_query/read_model"
8
10
  require_relative "simple_query/clauses/where_clause"
@@ -11,6 +13,8 @@ require_relative "simple_query/clauses/order_clause"
11
13
  require_relative "simple_query/clauses/distinct_clause"
12
14
  require_relative "simple_query/clauses/limit_offset_clause"
13
15
  require_relative "simple_query/clauses/group_having_clause"
16
+ require_relative "simple_query/clauses/set_clause"
17
+ require_relative "simple_query/clauses/aggregation_clause"
14
18
 
15
19
  module SimpleQuery
16
20
  extend ActiveSupport::Concern
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.3.2
4
+ version: 0.5.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-03-01 00:00:00.000000000 Z
11
+ date: 2025-08-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -30,62 +30,6 @@ dependencies:
30
30
  - - "<="
31
31
  - !ruby/object:Gem::Version
32
32
  version: '8.0'
33
- - !ruby/object:Gem::Dependency
34
- name: rake
35
- requirement: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '13.0'
40
- type: :development
41
- prerelease: false
42
- version_requirements: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: '13.0'
47
- - !ruby/object:Gem::Dependency
48
- name: rspec
49
- requirement: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: '3.0'
54
- type: :development
55
- prerelease: false
56
- version_requirements: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - "~>"
59
- - !ruby/object:Gem::Version
60
- version: '3.0'
61
- - !ruby/object:Gem::Dependency
62
- name: rubocop
63
- requirement: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - "~>"
66
- - !ruby/object:Gem::Version
67
- version: '1.21'
68
- type: :development
69
- prerelease: false
70
- version_requirements: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - "~>"
73
- - !ruby/object:Gem::Version
74
- version: '1.21'
75
- - !ruby/object:Gem::Dependency
76
- name: sqlite3
77
- requirement: !ruby/object:Gem::Requirement
78
- requirements:
79
- - - "~>"
80
- - !ruby/object:Gem::Version
81
- version: 1.5.0
82
- type: :development
83
- prerelease: false
84
- version_requirements: !ruby/object:Gem::Requirement
85
- requirements:
86
- - - "~>"
87
- - !ruby/object:Gem::Version
88
- version: 1.5.0
89
33
  description: SimpleQuery provides a flexible and performant way to construct complex
90
34
  database queries in Ruby on Rails applications. It offers an intuitive interface
91
35
  for building queries with joins, conditions, and aggregations, while potentially
@@ -100,13 +44,17 @@ files:
100
44
  - README.md
101
45
  - lib/simple_query.rb
102
46
  - lib/simple_query/builder.rb
47
+ - lib/simple_query/clauses/aggregation_clause.rb
103
48
  - lib/simple_query/clauses/distinct_clause.rb
104
49
  - lib/simple_query/clauses/group_having_clause.rb
105
50
  - lib/simple_query/clauses/join_clause.rb
106
51
  - lib/simple_query/clauses/limit_offset_clause.rb
107
52
  - lib/simple_query/clauses/order_clause.rb
53
+ - lib/simple_query/clauses/set_clause.rb
108
54
  - lib/simple_query/clauses/where_clause.rb
109
55
  - lib/simple_query/read_model.rb
56
+ - lib/simple_query/stream/mysql_stream.rb
57
+ - lib/simple_query/stream/postgres_stream.rb
110
58
  - lib/simple_query/version.rb
111
59
  homepage: https://github.com/kholdrex/simple_query
112
60
  licenses:
@@ -133,5 +81,6 @@ requirements: []
133
81
  rubygems_version: 3.5.9
134
82
  signing_key:
135
83
  specification_version: 4
136
- summary: A lightweight and efficient query builder for ActiveRecord.
84
+ summary: A lightweight, multi-DB-friendly, and high-performance query builder for
85
+ ActiveRecord, featuring streaming, bulk updates, and read-model support.
137
86
  test_files: []