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 +4 -4
- data/README.md +145 -19
- data/lib/simple_query/builder.rb +161 -2
- data/lib/simple_query/clauses/aggregation_clause.rb +155 -0
- data/lib/simple_query/clauses/set_clause.rb +25 -0
- data/lib/simple_query/stream/mysql_stream.rb +36 -0
- data/lib/simple_query/stream/postgres_stream.rb +59 -0
- data/lib/simple_query/version.rb +1 -1
- data/lib/simple_query.rb +4 -0
- metadata +8 -59
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 856dc1549df83e6c82a7105b3dfa97b9c6f4e69b86f6c2f9ad653ae1f322d1e8
|
4
|
+
data.tar.gz: c10490e13ffbc3da9c629081cde483188efbc0ec3fbdcf27070d565947cc723a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
##
|
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
|
-
-
|
182
|
-
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
-
|
190
|
-
|
191
|
-
|
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
|
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 (
|
199
|
-
ActiveRecord Query:
|
200
|
-
SimpleQuery Execution (Struct):
|
201
|
-
SimpleQuery Execution (Read model):
|
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
|
-
-
|
204
|
-
-
|
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
|
|
data/lib/simple_query/builder.rb
CHANGED
@@ -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
|
-
|
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
|
data/lib/simple_query/version.rb
CHANGED
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.
|
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-
|
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
|
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: []
|