simple_query 0.3.1 → 0.4.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: fa6c4c630a5cd486970ce4ca43b444f082050924305cb20e835a861b32676275
4
- data.tar.gz: de8f1cc3758f4a65edbc4a1d4bbe1c5edde866d6016332d5b415c35d97423e7e
3
+ metadata.gz: 8a62c7c2fe2d6c43a27b9cc2dd22e7ddcf14a01f9ca335144b472db1559f4b0a
4
+ data.tar.gz: 172eddfa79634dcf78d3547031af80a9f1e008dc9e0414c0f5671c66b4264617
5
5
  SHA512:
6
- metadata.gz: c9fb69f0d5505926239e5dcc396dedb098f974b38a0a1d135ddeb50927de9b88b8dc0f0fb850a2ab2fe6b9e7ba9a4955738c36d4a1c0f974e507740fa00edb15
7
- data.tar.gz: e8acc149f25a8b898a04ec7ca5203f98898d88bff334700ff5e4b6e0bb538b56fb47edd0b92cb66fbd86ac89c3f98d7c48930f25e9870bb34208b0bbd2735c4f
6
+ metadata.gz: abbd8b5034e7b3a9a72b5de79f0d522a1ba2ac2b066bcc78387fb068ad9647b685c006318f6efafd31b5b616b48bd18ea677a5c74e7c25647a38c062a934109b
7
+ data.tar.gz: e499597efd33f703d136b0e053006ef109dea762e19148acd6bb5dcf6717f14f8b63845d61a3ea7b770353dd37e17178ca8b51ef7785e0714828ee78bda1fb8e
data/README.md CHANGED
@@ -54,11 +54,12 @@ User.simple_query.select(:name, :email).where(active: true).execute
54
54
  ```
55
55
 
56
56
  Query with join
57
+
58
+ SimpleQuery now supports **all major SQL join types** — including LEFT, RIGHT, and FULL — through the following DSL methods:
57
59
  ```ruby
58
60
  User.simple_query
59
- .select(:name, :email)
60
- .join(:users, :companies, foreign_key: :user_id, primary_key: :id)
61
- .where(Company.arel_table[:name].eq("TechCorp"))
61
+ .left_join(:users, :companies, foreign_key: :user_id, primary_key: :id)
62
+ .select("users.name", "companies.name")
62
63
  .execute
63
64
  ```
64
65
 
@@ -143,9 +144,13 @@ class User < ActiveRecord::Base
143
144
  where(admin: true)
144
145
  end
145
146
 
147
+ # Block-based scope with parameter
146
148
  simple_scope :by_name do |name|
147
149
  where(name: name)
148
150
  end
151
+
152
+ # Lambda-based scope with parameter
153
+ simple_scope :by_name, ->(name) { where(name: name) }
149
154
  end
150
155
  ```
151
156
  You can then chain these scopes seamlessly with the normal SimpleQuery DSL:
@@ -171,32 +176,70 @@ Each scope block (e.g. by_name) is evaluated in the context of the SimpleQuery b
171
176
  Parameterized scopes accept arguments — passed directly to the block (e.g. |name| above).
172
177
  Scopes return self, so you can chain multiple scopes or mix them with standard query methods.
173
178
 
174
- ## Features
179
+ ## Streaming Large Datasets
180
+
181
+ 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:
175
182
 
176
- - Efficient query building
177
- - Support for complex joins
178
- - Lazy execution
179
- - DISTINCT queries
180
- - Aggregations
181
- - LIMIT and OFFSET
182
- - ORDER BY clause
183
- - Having and Grouping
184
- - Subqueries
185
- - Custom Read models
186
- - Named Scopes
183
+ - **PostgreSQL**: Uses a **server-side cursor** via `DECLARE ... FETCH`.
184
+ - **MySQL**: Uses `mysql2` gem’s **streaming** (`stream: true, cache_rows: false, as: :hash`).
185
+
186
+ ```ruby
187
+ # Example usage:
188
+ User.simple_query
189
+ .where(active: true)
190
+ .stream_each(batch_size: 10_000) do |row|
191
+ # row is a struct or read-model instance
192
+ puts row.name
193
+ end
194
+ ```
187
195
 
188
196
  ## Performance
189
197
 
190
- 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.
198
+ 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:
199
+
200
+ ### PostgreSQL (1,000,000 records)
201
+ ```
202
+ 🚀 Performance Results (1000,000 records):
203
+ ActiveRecord Query: 10.36932 seconds
204
+ SimpleQuery Execution (Struct): 3.46136 seconds
205
+ SimpleQuery Execution (Read model): 2.20905 seconds
206
+
207
+ ----------------------------------------------------
208
+ ActiveRecord find_each: 6.10077 seconds
209
+ SimpleQuery stream_each: 2.75639 seconds
210
+
211
+ --- AR find_each Memory Report ---
212
+ Total allocated: 1.98 GB (16,001,659 objects)
213
+ Retained: ~2 KB
214
+
215
+ --- SimpleQuery stream_each Memory Report ---
216
+ Total allocated: 1.38 GB (8,000,211 objects)
217
+ Retained: ~3 KB
218
+ ```
219
+ - **Struct-based** approach remains the fastest, skipping model overhead.
220
+ - **Read model** approach is still significantly faster than standard ActiveRecord while allowing domain-specific logic.
191
221
 
222
+ ### MySQL (1,000,000 records)
192
223
  ```
193
- 🚀 Performance Results (100,000 records):
194
- ActiveRecord Query: 0.47441 seconds
195
- SimpleQuery Execution (Struct): 0.05346 seconds
196
- SimpleQuery Execution (Read model): 0.14408 seconds
224
+ 🚀 Performance Results (1000,000 records):
225
+ ActiveRecord Query: 10.45833 seconds
226
+ SimpleQuery Execution (Struct): 3.04655 seconds
227
+ SimpleQuery Execution (Read model): 3.69052 seconds
228
+
229
+ ----------------------------------------------------
230
+ ActiveRecord find_each: 5.04671 seconds
231
+ SimpleQuery stream_each: 2.96602 seconds
232
+
233
+ --- AR find_each Memory Report ---
234
+ Total allocated: 1.32 GB (11,001,445 objects)
235
+ Retained: ~2.7 KB
236
+
237
+ --- SimpleQuery stream_each Memory Report ---
238
+ Total allocated: 1.22 GB (8,000,068 objects)
239
+ Retained: ~3.9 KB
197
240
  ```
198
- - The **Struct-based** approach is the fastest.
199
- - The **Read model** approach is still significantly faster than ActiveRecord, while letting you define custom logic or domain-specific attributes.
241
+ - Even in MySQL, **Struct** was roughly **three times faster** than ActiveRecord’s overhead.
242
+ - Read models still outperform AR, though by a narrower margin in this scenario.
200
243
 
201
244
  ## Development
202
245
 
@@ -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)
@@ -34,12 +37,24 @@ module SimpleQuery
34
37
  self
35
38
  end
36
39
 
37
- def join(table1, table2, foreign_key:, primary_key:)
38
- @joins.add(table1, table2, foreign_key: foreign_key, primary_key: primary_key)
40
+ def join(table1, table2, foreign_key:, primary_key:, type: :inner)
41
+ @joins.add(table1, table2, foreign_key: foreign_key, primary_key: primary_key, join_type: type)
39
42
  reset_query
40
43
  self
41
44
  end
42
45
 
46
+ def left_join(table1, table2, foreign_key:, primary_key:)
47
+ join(table1, table2, foreign_key: foreign_key, primary_key: primary_key, type: :left)
48
+ end
49
+
50
+ def right_join(table1, table2, foreign_key:, primary_key:)
51
+ join(table1, table2, foreign_key: foreign_key, primary_key: primary_key, type: :right)
52
+ end
53
+
54
+ def full_join(table1, table2, foreign_key:, primary_key:)
55
+ join(table1, table2, foreign_key: foreign_key, primary_key: primary_key, type: :full)
56
+ end
57
+
43
58
  def order(order_conditions)
44
59
  @orders.add(order_conditions)
45
60
  reset_query
@@ -82,6 +97,30 @@ module SimpleQuery
82
97
  self
83
98
  end
84
99
 
100
+ def bulk_update(set:)
101
+ table_name = @arel_table.name
102
+ set_sql = SetClause.new(set).to_sql
103
+
104
+ raise ArgumentError, "No columns to update" if set_sql.empty?
105
+
106
+ where_sql = build_where_sql
107
+ sql = "UPDATE #{table_name} SET #{set_sql}"
108
+ sql += " WHERE #{where_sql}" unless where_sql.nil? || where_sql.empty?
109
+
110
+ ActiveRecord::Base.connection.execute(sql)
111
+ end
112
+
113
+ def stream_each(batch_size: 1000, &block)
114
+ adapter = ActiveRecord::Base.connection.adapter_name.downcase
115
+ if adapter.include?("postgres")
116
+ stream_each_postgres(batch_size, &block)
117
+ elsif adapter.include?("mysql")
118
+ stream_each_mysql(&block)
119
+ else
120
+ raise "stream_each is only implemented for Postgres and MySQL."
121
+ end
122
+ end
123
+
85
124
  def execute
86
125
  records = ActiveRecord::Base.connection.select_all(cached_sql)
87
126
  build_result_objects_from_rows(records)
@@ -121,6 +160,13 @@ module SimpleQuery
121
160
 
122
161
  private
123
162
 
163
+ def build_where_sql
164
+ condition = @wheres.to_arel
165
+ return "" unless condition
166
+
167
+ condition.to_sql
168
+ end
169
+
124
170
  def reset_query
125
171
  @query_built = false
126
172
  @query_cache.clear
@@ -8,20 +8,37 @@ module SimpleQuery
8
8
  @joins = []
9
9
  end
10
10
 
11
- def add(table1, table2, foreign_key:, primary_key:)
11
+ def add(table1, table2, foreign_key:, primary_key:, join_type: :inner)
12
12
  @joins << {
13
13
  table1: to_arel_table(table1),
14
14
  table2: to_arel_table(table2),
15
15
  foreign_key: foreign_key,
16
- primary_key: primary_key
16
+ primary_key: primary_key,
17
+ type: join_type
17
18
  }
18
19
  end
19
20
 
20
21
  def apply_to(query)
21
- @joins.each do |join|
22
- query.join(join[:table2])
23
- .on(join[:table2][join[:foreign_key]]
24
- .eq(join[:table1][join[:primary_key]]))
22
+ @joins.each do |join_def|
23
+ table1 = join_def[:table1]
24
+ table2 = join_def[:table2]
25
+ fk = join_def[:foreign_key]
26
+ pk = join_def[:primary_key]
27
+ type = join_def[:type]
28
+
29
+ join_class = case type
30
+ when :left
31
+ Arel::Nodes::OuterJoin
32
+ when :right
33
+ Arel::Nodes::RightOuterJoin
34
+ when :full
35
+ Arel::Nodes::FullOuterJoin
36
+ else
37
+ Arel::Nodes::InnerJoin
38
+ end
39
+
40
+ condition = table2[fk].eq(table1[pk])
41
+ query.join(table2, join_class).on(condition)
25
42
  end
26
43
  query
27
44
  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.1"
4
+ VERSION = "0.4.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,7 @@ 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"
14
17
 
15
18
  module SimpleQuery
16
19
  extend ActiveSupport::Concern
@@ -41,19 +44,13 @@ module SimpleQuery
41
44
  @_simple_scopes ||= {}
42
45
  end
43
46
 
44
- # A reusable scope that can be applied to a SimpleQuery::Builder instance
45
- # Example:
46
- # simple_scope :active do
47
- # where(active: true)
48
- # end
49
- #
50
- # Parameterized scope:
51
- # simple_scope :by_name do |name|
52
- # where(name: name)
53
- # end
54
- #
55
- def simple_scope(name, &block)
56
- _simple_scopes[name.to_sym] = block
47
+ def simple_scope(name, body = nil, &block)
48
+ raise ArgumentError, "Pass either a proc/lambda or a block, not both" if body && block_given?
49
+
50
+ scope_body = body || block
51
+ raise ArgumentError, "You must provide a block or a proc" unless scope_body
52
+
53
+ _simple_scopes[name.to_sym] = scope_body
57
54
  end
58
55
  end
59
56
 
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.1
4
+ version: 0.4.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-02-27 00:00:00.000000000 Z
11
+ date: 2025-03-15 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
@@ -105,8 +49,11 @@ files:
105
49
  - lib/simple_query/clauses/join_clause.rb
106
50
  - lib/simple_query/clauses/limit_offset_clause.rb
107
51
  - lib/simple_query/clauses/order_clause.rb
52
+ - lib/simple_query/clauses/set_clause.rb
108
53
  - lib/simple_query/clauses/where_clause.rb
109
54
  - lib/simple_query/read_model.rb
55
+ - lib/simple_query/stream/mysql_stream.rb
56
+ - lib/simple_query/stream/postgres_stream.rb
110
57
  - lib/simple_query/version.rb
111
58
  homepage: https://github.com/kholdrex/simple_query
112
59
  licenses:
@@ -133,5 +80,6 @@ requirements: []
133
80
  rubygems_version: 3.5.9
134
81
  signing_key:
135
82
  specification_version: 4
136
- summary: A lightweight and efficient query builder for ActiveRecord.
83
+ summary: A lightweight, multi-DB-friendly, and high-performance query builder for
84
+ ActiveRecord, featuring streaming, bulk updates, and read-model support.
137
85
  test_files: []