simple_query 0.1.0 → 0.2.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: b6095e96524f3fda796bd8965a24595814091f2e84e74c3ca1789ca644269a22
4
- data.tar.gz: b665f9077a68854e225c14188ce83948390d665ef7a6d30c4a04a3f2349d5150
3
+ metadata.gz: c8295c4f43760f52dd7097852465c6d5ba007fb98789c233c25e88132113fccf
4
+ data.tar.gz: 45ced1eee79912b86eddaa4bf13ad7d56b4609ee8e68ce02390d027600d78509
5
5
  SHA512:
6
- metadata.gz: ff9b7c518565311e9a1bbd23b03a5d6ce988ac9ed3bac2ef8f1e16088d846629190818d36163c2a9cfd95b943c734c1b6a3b3ab843413b6acfaf504528388d32
7
- data.tar.gz: ce323624e8df7f73cedfea917acedc8cc07c295a10e86b9ecb896c34fa3ca151becfff1357d80d68b388cbe3998522ffe21cb2557d9cdab590d68a8bf435e43d
6
+ metadata.gz: 745e1f6bd0b85d12a5cab8e21d4839950bd98948914a54fae2ac77c47e7fe5861625081396ff4437684a1e2ade5441514c597e9c4d61577d51e580b628751f69
7
+ data.tar.gz: 1b034d21747e3740e2e390d428856a67b112e7c179d7704457bddc4983723f752f6aef87b76b3b2dd8d0c1857a6d55a834055f6749e85e7def1b005574c38cb5
data/README.md CHANGED
@@ -20,6 +20,30 @@ Or install it yourself as:
20
20
  gem install simple_query
21
21
  ```
22
22
 
23
+ ## Configuration
24
+
25
+ By default, `SimpleQuery` does **not** automatically patch `ActiveRecord::Base`. You can **manually** include the module in individual models or in a global initializer:
26
+
27
+ ```ruby
28
+ # Manual include (per model)
29
+ class User < ActiveRecord::Base
30
+ include SimpleQuery
31
+ end
32
+
33
+ # or do it globally
34
+ ActiveRecord::Base.include(SimpleQuery)
35
+ ```
36
+ If you prefer a “just works” approach (i.e., every model has `.simple_query`), you can opt in:
37
+
38
+ ```ruby
39
+ # config/initializers/simple_query.rb
40
+ SimpleQuery.configure do |config|
41
+ config.auto_include_ar = true
42
+ end
43
+ ```
44
+
45
+ This tells SimpleQuery to automatically do `ActiveRecord::Base.include(SimpleQuery)` for you.
46
+
23
47
  ## Usage
24
48
 
25
49
  SimpleQuery offers an intuitive interface for building queries with joins, conditions, and aggregations. Here are some examples:
@@ -58,6 +82,32 @@ User.simple_query
58
82
  .lazy_execute
59
83
  ```
60
84
 
85
+ ## Custom Read Models
86
+ 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.
87
+
88
+ **Create a read model** inheriting from `SimpleQuery::ReadModel`:
89
+ ```ruby
90
+ class MyUserReadModel < SimpleQuery::ReadModel
91
+ attribute :identifier, column: :id
92
+ attribute :full_name, column: :name
93
+ end
94
+ ```
95
+
96
+ **Map query results** to your read model:
97
+ ```ruby
98
+ results = User.simple_query
99
+ .select("users.id AS id", "users.name AS name")
100
+ .where(active: true)
101
+ .map_to(MyUserReadModel)
102
+ .execute
103
+
104
+ results.each do |user|
105
+ puts user.identifier # => user.id from the DB
106
+ puts user.full_name # => user.name from the DB
107
+ end
108
+ ```
109
+ This custom read model approach provides more clarity or domain-specific logic while still being faster than typical ActiveRecord instantiation.
110
+
61
111
  ## Features
62
112
 
63
113
  - Efficient query building
@@ -67,7 +117,9 @@ User.simple_query
67
117
  - Aggregations
68
118
  - LIMIT and OFFSET
69
119
  - ORDER BY clause
120
+ - Having and Grouping
70
121
  - Subqueries
122
+ - Custom Read models
71
123
 
72
124
  ## Performance
73
125
 
@@ -75,9 +127,12 @@ SimpleQuery is designed to potentially outperform standard ActiveRecord queries
75
127
 
76
128
  ```
77
129
  🚀 Performance Results (100,000 records):
78
- ActiveRecord Query: 0.43343 seconds
79
- SimpleQuery Execution: 0.06186 seconds
130
+ ActiveRecord Query: 0.47441 seconds
131
+ SimpleQuery Execution (Struct): 0.05346 seconds
132
+ SimpleQuery Execution (Read model): 0.14408 seconds
80
133
  ```
134
+ - The **Struct-based** approach is the fastest.
135
+ - The **Read model** approach is still significantly faster than ActiveRecord, while letting you define custom logic or domain-specific attributes.
81
136
 
82
137
  ## Development
83
138
 
@@ -2,21 +2,24 @@
2
2
 
3
3
  module SimpleQuery
4
4
  class Builder
5
- attr_reader :model, :arel_table, :selects, :wheres, :joins, :orders, :limits, :offsets
5
+ attr_reader :model, :arel_table
6
6
 
7
7
  def initialize(source)
8
8
  @model = source
9
9
  @arel_table = @model.arel_table
10
+
10
11
  @selects = []
11
- @wheres = []
12
- @joins = []
13
- @orders = []
14
- @limits = nil
15
- @offsets = nil
16
- @distinct_flag = false
12
+ @wheres = WhereClause.new(@arel_table)
13
+ @joins = JoinClause.new
14
+ @group_having = GroupHavingClause.new(@arel_table)
15
+ @orders = OrderClause.new(@arel_table)
16
+ @limits = LimitOffsetClause.new
17
+ @distinct_flag = DistinctClause.new
18
+
17
19
  @query_cache = {}
18
- @result_struct = nil
19
20
  @query_built = false
21
+ @read_model_class = nil
22
+ @result_struct = nil
20
23
  end
21
24
 
22
25
  def select(*fields)
@@ -26,58 +29,75 @@ module SimpleQuery
26
29
  end
27
30
 
28
31
  def where(condition)
29
- @wheres.concat(parse_where_condition(condition))
32
+ @wheres.add(condition)
30
33
  reset_query
31
34
  self
32
35
  end
33
36
 
34
37
  def join(table1, table2, foreign_key:, primary_key:)
35
- @joins << {
36
- table1: arel_table(table1),
37
- table2: arel_table(table2),
38
- foreign_key: foreign_key,
39
- primary_key: primary_key
40
- }
38
+ @joins.add(table1, table2, foreign_key: foreign_key, primary_key: primary_key)
41
39
  reset_query
42
40
  self
43
41
  end
44
42
 
45
43
  def order(order_conditions)
46
- @orders.concat(parse_order_conditions(order_conditions))
44
+ @orders.add(order_conditions)
47
45
  reset_query
48
46
  self
49
47
  end
50
48
 
51
49
  def limit(number)
52
- validate_positive_integer(number, "LIMIT")
53
- @limits = number
50
+ @limits.with_limit(number)
54
51
  reset_query
55
52
  self
56
53
  end
57
54
 
58
55
  def offset(number)
59
- validate_non_negative_integer(number, "OFFSET")
60
- @offsets = number
56
+ @limits.with_offset(number)
61
57
  reset_query
62
58
  self
63
59
  end
64
60
 
65
61
  def distinct
66
- @distinct_flag = true
62
+ @distinct_flag.set_distinct
63
+ reset_query
64
+ self
65
+ end
66
+
67
+ def group(*fields)
68
+ @group_having.add_group(*fields)
69
+ reset_query
70
+ self
71
+ end
72
+
73
+ def having(condition)
74
+ @group_having.add_having(condition)
75
+ reset_query
76
+ self
77
+ end
78
+
79
+ def map_to(klass)
80
+ @read_model_class = klass
67
81
  reset_query
68
82
  self
69
83
  end
70
84
 
71
85
  def execute
72
86
  records = ActiveRecord::Base.connection.select_all(cached_sql)
73
- build_result_objects(records)
87
+ build_result_objects_from_rows(records)
74
88
  end
75
89
 
76
90
  def lazy_execute
77
91
  Enumerator.new do |yielder|
78
92
  records = ActiveRecord::Base.connection.select_all(cached_sql)
79
- struct = result_struct(records.columns)
80
- records.rows.each { |row| yielder << struct.new(*row) }
93
+ if @read_model_class
94
+ build_read_models_enumerator(records, yielder)
95
+ else
96
+ struct = result_struct(records.columns)
97
+ records.rows.each do |row_array|
98
+ yielder << struct.new(*row_array)
99
+ end
100
+ end
81
101
  end
82
102
  end
83
103
 
@@ -87,10 +107,11 @@ module SimpleQuery
87
107
  @query = Arel::SelectManager.new(Arel::Table.engine)
88
108
  @query.from(@arel_table)
89
109
  @query.project(*(@selects.empty? ? [@arel_table[Arel.star]] : @selects))
90
- @query.distinct if @distinct_flag
91
110
 
111
+ apply_distinct
92
112
  apply_where_conditions
93
113
  apply_joins
114
+ apply_group_and_having
94
115
  apply_order_conditions
95
116
  apply_limit_and_offset
96
117
 
@@ -106,78 +127,98 @@ module SimpleQuery
106
127
  end
107
128
 
108
129
  def cached_sql
109
- @query_cache[@wheres] ||= build_query.to_sql
110
- end
111
-
112
- def result_struct(columns)
113
- @result_struct ||= Struct.new(*columns.map(&:to_sym))
114
- end
115
-
116
- def parse_select_field(field)
117
- case field
118
- when Symbol then @arel_table[field]
119
- when String then Arel.sql(field)
120
- when Arel::Nodes::Node then field
130
+ key = [
131
+ @selects,
132
+ @wheres.conditions,
133
+ @joins.joins,
134
+ @group_having.group_fields,
135
+ @group_having.having_conditions,
136
+ @orders.orders,
137
+ @limits.limit_value,
138
+ @limits.offset_value,
139
+ @distinct_flag.use_distinct?
140
+ ]
141
+
142
+ @query_cache[key] ||= build_query.to_sql
143
+ end
144
+
145
+ def build_result_objects_from_rows(records)
146
+ if @read_model_class
147
+ build_read_models_from_arrays(records)
121
148
  else
122
- raise ArgumentError, "Unsupported select field type: #{field.class}"
123
- end
124
- end
125
-
126
- def parse_where_condition(condition)
127
- case condition
128
- when Hash then condition.map { |field, value| @arel_table[field].eq(value) }
129
- when Arel::Nodes::Node then [condition]
130
- else [Arel.sql(condition.to_s)]
149
+ struct = result_struct(records.columns)
150
+ records.rows.map { |row_array| struct.new(*row_array) }
131
151
  end
132
152
  end
133
153
 
134
- def arel_table(table)
135
- table.is_a?(Arel::Table) ? table : Arel::Table.new(table)
136
- end
154
+ def build_read_models_from_arrays(records)
155
+ columns = records.columns
156
+ column_map = columns.each_with_index.to_h
157
+ rows = records.rows
137
158
 
138
- def parse_order_conditions(order_conditions)
139
- order_conditions.map do |field, direction|
140
- validate_order_direction(direction)
141
- @arel_table[field].send(direction)
159
+ rows.map do |row_array|
160
+ obj = @read_model_class.allocate
161
+ @read_model_class.attributes.each do |attr_name, col_name|
162
+ idx = column_map[col_name]
163
+ obj.instance_variable_set(:"@#{attr_name}", row_array[idx]) if idx
164
+ end
165
+ obj
142
166
  end
143
167
  end
144
168
 
145
- def validate_order_direction(direction)
146
- return if [:asc, :desc].include?(direction)
147
-
148
- raise ArgumentError, "Invalid order direction: #{direction}. Use :asc or :desc."
149
- end
150
-
151
- def validate_positive_integer(number, label)
152
- raise ArgumentError, "#{label} must be a positive integer" unless number.is_a?(Integer) && number.positive?
169
+ def build_read_models_enumerator(records, yielder)
170
+ columns = records.columns
171
+ column_map = columns.each_with_index.to_h
172
+ records.rows.each do |row_array|
173
+ obj = @read_model_class.allocate
174
+ @read_model_class.attributes.each do |attr_name, col_name|
175
+ idx = column_map[col_name]
176
+ obj.instance_variable_set(:"@#{attr_name}", row_array[idx]) if idx
177
+ end
178
+ yielder << obj
179
+ end
153
180
  end
154
181
 
155
- def validate_non_negative_integer(number, label)
156
- raise ArgumentError, "#{label} must be a non-negative integer" unless number.is_a?(Integer) && number >= 0
182
+ def result_struct(columns)
183
+ @result_struct ||= Struct.new(*columns.map(&:to_sym))
157
184
  end
158
185
 
159
- def build_result_objects(records)
160
- struct = result_struct(records.columns)
161
- records.rows.map { |row| struct.new(*row) }
186
+ def apply_distinct
187
+ @distinct_flag.apply_to(@query)
162
188
  end
163
189
 
164
190
  def apply_where_conditions
165
- @query.where(@wheres.inject(&:and)) if @wheres.any?
191
+ condition = @wheres.to_arel
192
+ @query.where(condition) if condition
166
193
  end
167
194
 
168
195
  def apply_joins
169
- @joins.each do |join|
170
- @query.join(join[:table2]).on(join[:table2][join[:foreign_key]].eq(join[:table1][join[:primary_key]]))
171
- end
196
+ @joins.apply_to(@query)
197
+ end
198
+
199
+ def apply_group_and_having
200
+ @group_having.apply_to(@query)
172
201
  end
173
202
 
174
203
  def apply_order_conditions
175
- @orders.each { |order| @query.order(order) }
204
+ @orders.apply_to(@query)
176
205
  end
177
206
 
178
207
  def apply_limit_and_offset
179
- @query.take(@limits) if @limits
180
- @query.skip(@offsets) if @offsets
208
+ @limits.apply_to(@query)
209
+ end
210
+
211
+ def parse_select_field(field)
212
+ case field
213
+ when Symbol
214
+ @arel_table[field]
215
+ when String
216
+ Arel.sql(field)
217
+ when Arel::Nodes::Node
218
+ field
219
+ else
220
+ raise ArgumentError, "Unsupported select field type: #{field.class}"
221
+ end
181
222
  end
182
223
  end
183
224
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleQuery
4
+ class DistinctClause
5
+ def initialize
6
+ @use_distinct = false
7
+ end
8
+
9
+ def use_distinct?
10
+ @use_distinct
11
+ end
12
+
13
+ def set_distinct
14
+ @use_distinct = true
15
+ end
16
+
17
+ def apply_to(query)
18
+ query.distinct if @use_distinct
19
+ query
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleQuery
4
+ class GroupHavingClause
5
+ attr_reader :group_fields, :having_conditions
6
+
7
+ def initialize(table)
8
+ @table = table
9
+ @group_fields = []
10
+ @having_conditions = []
11
+ end
12
+
13
+ def add_group(*fields)
14
+ @group_fields.concat(fields.map { |f| @table[f] })
15
+ end
16
+
17
+ def add_having(condition)
18
+ @having_conditions << condition
19
+ end
20
+
21
+ def apply_to(query)
22
+ @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
27
+ query
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleQuery
4
+ class JoinClause
5
+ attr_reader :joins
6
+
7
+ def initialize
8
+ @joins = []
9
+ end
10
+
11
+ def add(table1, table2, foreign_key:, primary_key:)
12
+ @joins << {
13
+ table1: to_arel_table(table1),
14
+ table2: to_arel_table(table2),
15
+ foreign_key: foreign_key,
16
+ primary_key: primary_key
17
+ }
18
+ end
19
+
20
+ 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]]))
25
+ end
26
+ query
27
+ end
28
+
29
+ private
30
+
31
+ def to_arel_table(obj)
32
+ obj.is_a?(Arel::Table) ? obj : Arel::Table.new(obj)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleQuery
4
+ class LimitOffsetClause
5
+ attr_reader :limit_value, :offset_value
6
+
7
+ def initialize
8
+ @limit_value = nil
9
+ @offset_value = nil
10
+ end
11
+
12
+ def with_limit(limit)
13
+ raise ArgumentError, "LIMIT must be a positive integer" unless limit.is_a?(Integer) && limit.positive?
14
+
15
+ @limit_value = limit
16
+ end
17
+
18
+ def with_offset(offset)
19
+ raise ArgumentError, "OFFSET must be a non-negative integer" unless offset.is_a?(Integer) && offset >= 0
20
+
21
+ @offset_value = offset
22
+ end
23
+
24
+ def apply_to(query)
25
+ query.take(@limit_value) if @limit_value
26
+ query.skip(@offset_value) if @offset_value
27
+ query
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleQuery
4
+ class OrderClause
5
+ attr_reader :orders
6
+
7
+ def initialize(table)
8
+ @table = table
9
+ @orders = []
10
+ end
11
+
12
+ def add(order_conditions)
13
+ order_conditions.each do |field, direction|
14
+ validate_order_direction(direction)
15
+ @orders << @table[field].send(direction)
16
+ end
17
+ end
18
+
19
+ def apply_to(query)
20
+ @orders.each { |order_node| query.order(order_node) }
21
+ query
22
+ end
23
+
24
+ private
25
+
26
+ def validate_order_direction(direction)
27
+ return if [:asc, :desc].include?(direction)
28
+
29
+ raise ArgumentError, "Invalid order direction: #{direction}. Use :asc or :desc."
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleQuery
4
+ class WhereClause
5
+ attr_reader :conditions
6
+
7
+ def initialize(table)
8
+ @table = table
9
+ @conditions = []
10
+ end
11
+
12
+ def add(condition)
13
+ parsed_conditions = parse_condition(condition)
14
+ @conditions.concat(parsed_conditions)
15
+ end
16
+
17
+ def to_arel
18
+ return nil if @conditions.empty?
19
+
20
+ @conditions.inject do |combined, current|
21
+ combined.and(current)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def parse_condition(condition)
28
+ case condition
29
+ when Hash
30
+ condition.map { |field, value| @table[field].eq(value) }
31
+ when Arel::Nodes::Node
32
+ [condition]
33
+ else
34
+ [Arel.sql(condition.to_s)]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleQuery
4
+ class ReadModel
5
+ def self.attribute(attr_name, column: attr_name)
6
+ @attributes ||= {}
7
+ @attributes[attr_name] = column.to_s
8
+ attr_reader attr_name
9
+ end
10
+
11
+ def self.attributes
12
+ @attributes || {}
13
+ end
14
+
15
+ def self.build_from_row(row_hash)
16
+ obj = allocate
17
+ attributes.each do |attr_name, column_name|
18
+ obj.instance_variable_set(:"@#{attr_name}", row_hash[column_name])
19
+ end
20
+ obj
21
+ end
22
+
23
+ def initialize; end
24
+ end
25
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleQuery
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/simple_query.rb CHANGED
@@ -1,16 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/concern"
3
4
  require "active_record"
4
- require "simple_query/builder"
5
+
6
+ require_relative "simple_query/builder"
7
+ require_relative "simple_query/read_model"
8
+ require_relative "simple_query/clauses/where_clause"
9
+ require_relative "simple_query/clauses/join_clause"
10
+ require_relative "simple_query/clauses/order_clause"
11
+ require_relative "simple_query/clauses/distinct_clause"
12
+ require_relative "simple_query/clauses/limit_offset_clause"
13
+ require_relative "simple_query/clauses/group_having_clause"
5
14
 
6
15
  module SimpleQuery
7
16
  extend ActiveSupport::Concern
8
17
 
18
+ class Configuration
19
+ attr_accessor :auto_include_ar
20
+
21
+ def initialize
22
+ @auto_include_ar = false
23
+ end
24
+ end
25
+
26
+ def self.configure
27
+ yield config
28
+ auto_include! if config.auto_include_ar
29
+ end
30
+
31
+ def self.config
32
+ @config ||= Configuration.new
33
+ end
34
+
35
+ def self.auto_include!
36
+ ActiveRecord::Base.include(SimpleQuery)
37
+ end
38
+
9
39
  included do
10
40
  def self.simple_query
11
- SimpleQuery::Builder.new(self)
41
+ Builder.new(self)
12
42
  end
13
43
  end
14
44
  end
15
-
16
- ActiveRecord::Base.include SimpleQuery
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.1.0
4
+ version: 0.2.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-18 00:00:00.000000000 Z
11
+ date: 2025-02-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -16,20 +16,20 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '5.0'
20
- - - "<"
19
+ version: '7.0'
20
+ - - "<="
21
21
  - !ruby/object:Gem::Version
22
- version: '7.1'
22
+ version: '8.0'
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
26
26
  requirements:
27
27
  - - ">="
28
28
  - !ruby/object:Gem::Version
29
- version: '5.0'
30
- - - "<"
29
+ version: '7.0'
30
+ - - "<="
31
31
  - !ruby/object:Gem::Version
32
- version: '7.1'
32
+ version: '8.0'
33
33
  - !ruby/object:Gem::Dependency
34
34
  name: rake
35
35
  requirement: !ruby/object:Gem::Requirement
@@ -100,6 +100,13 @@ files:
100
100
  - README.md
101
101
  - lib/simple_query.rb
102
102
  - lib/simple_query/builder.rb
103
+ - lib/simple_query/clauses/distinct_clause.rb
104
+ - lib/simple_query/clauses/group_having_clause.rb
105
+ - lib/simple_query/clauses/join_clause.rb
106
+ - lib/simple_query/clauses/limit_offset_clause.rb
107
+ - lib/simple_query/clauses/order_clause.rb
108
+ - lib/simple_query/clauses/where_clause.rb
109
+ - lib/simple_query/read_model.rb
103
110
  - lib/simple_query/version.rb
104
111
  homepage: https://oneruby.dev
105
112
  licenses: