active_model-relation 0.1.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2440274ca803bb2b2d571a3c7753730c66a86a5c8e5ccba873dbd19541e236a3
4
- data.tar.gz: cab3d4c534dc3ada120d5660a029cffdd343d5869653f183434cf8fd3793da4b
3
+ metadata.gz: 317f4bd9c497d254fabc65f125c743badd36fe4f91e41876bd40819f77703e01
4
+ data.tar.gz: 937df0db69de0e3983531fae564b0cefa8efbb3cc9d9aac499238a601d9e6503
5
5
  SHA512:
6
- metadata.gz: 12fe2562f8977cb3b2ac5db4f32dce3dd886d9c4d09996e2bf8fbb98e699ac54f6cad071625cc430c4de1615a3089fdf178bdc2d9c738d1f96b2fbe58a185ff7
7
- data.tar.gz: 7cf7a6a159a8b5df87c5112644f2dc1118cf5e91b26bdf273c182e8ea4d84768291618ec4c0b47bd3b985217d26331551d9bec5a8f8e5a8c6bc59f69b00f3f7e
6
+ metadata.gz: f147ee5f25e5b5cd8570dae9d0117c4cbbf015491d8820ae07cf6cca831075d3c9a70f5cc0ec0bf7a15dd522bd691103fa12ee9b6e9b3dcc9a12484ca0761df3
7
+ data.tar.gz: d61a00b7833c030be0ca14d814f8aeb8e0f23d913e071dd068a09fac050106ca1f3c46cd80b8dea597c21632397fec4d0ccd276cfb7193b29e09b73d1b2ebe68
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.0] - 2024-08-30
3
+ - Treat `ActiveModel::RecordNotFound` like `ActiveRecord::RecordNotFound` in `ActionDispatch`
4
+ - Properly type cast values when they are defined as attribute
5
+ - Ensure that there is always at least an empty array of records
6
+
7
+ ## [0.2.0] - 2024-09-16
8
+
9
+ - Rename `ActiveModel::ModelNotFound` to `ActiveModel::RecordNotFound`
10
+ - Allow creating a `ActiveModel::Relation` without passing a collection
11
+ - Don't require a `.records` class method on model classes
12
+ - Allow passing a block to `ActiveModel::Relation#find`
13
+
14
+ ## [0.1.0] - 2024-09-09
4
15
 
5
16
  - Initial release
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ActiveModel::Relation
2
2
 
3
- This library allows querying of collections of Ruby objects, with a similar interfaces to `ActiveRecord::Relation`.
3
+ Query a collection of ActiveModel objects like an ActiveRecord::Relation.
4
4
 
5
5
  ## Installation
6
6
 
@@ -14,6 +14,8 @@ If bundler is not being used to manage dependencies, install the gem by executin
14
14
 
15
15
  ## Usage
16
16
 
17
+ ### Initialization
18
+
17
19
  Create a new relation by passing the model class and a collection:
18
20
 
19
21
  ```ruby
@@ -25,16 +27,144 @@ relation = ActiveModel::Relation.new(Project, [
25
27
  ])
26
28
  ```
27
29
 
28
- Afterwards you can use it (almost) like an `ActiveRecord::Relation`.
30
+ As an alternative, it's also possible to create a collection for a model without explicitly passing a collection.
31
+ In this case, the library will attempt to call `Project.records` to get the default collection. If the method doesn't exist or returns `nil`, the collection will default to an empty array.
32
+
33
+ ```ruby
34
+ class Project
35
+ def self.records
36
+ [
37
+ Project.new(id: 1, state: 'draft', priority: 1),
38
+ Project.new(id: 2, state: 'running', priority: 2),
39
+ Project.new(id: 3, state: 'completed', priority: 3),
40
+ Project.new(id: 4, state: 'completed', priority: 1)
41
+ ]
42
+ end
43
+ end
44
+
45
+ relation = ActiveModel::Relation.new(Project)
46
+ ```
47
+
48
+ ### Querying
49
+
50
+ An `ActiveModel::Relation` can be queried almost exactly like an `ActiveRecord::Relation`.
51
+
52
+ #### `#find`
53
+
54
+ You can look up a record by it's primary key, using the `find` method. If no record is found, it will raise a `ActiveModel::RecordNotFound` error.
55
+
56
+ ```ruby
57
+ project = relation.find(1)
58
+ ```
59
+
60
+ By default, `ActiveModel::Relation` will assume `:id` as the primary key. You can customize this behavior by setting a `primary_key` on the model class.
61
+
62
+ ```ruby
63
+ class Project
64
+ def self.primary_key = :identifier
65
+ end
66
+ ```
67
+
68
+ When passed a block, the `find` method will behave like `Enumerable#find`.
69
+
70
+ ```ruby
71
+ project = relation.find { |p| p.id == 1 }
72
+ ```
73
+
74
+ #### `#find_by`
75
+
76
+ To look up a record based on a set of arbitary attributes, you can use `find_by`. It accepts the same arguments as `#where` and will return the first matching record.
77
+
78
+ ```ruby
79
+ project = relation.find_by(state: 'draft')
80
+ ```
81
+
82
+ #### `#where`
83
+
84
+ To filter a relation, you can use `where` and pass a set of attributes and the expected values. This method will return a new `ActiveModel::Relation` that only returns the matching records, so it's possible to chain multiple calls. The filtering will only happen when actually accessing records.
29
85
 
30
86
  ```ruby
31
87
  relation.where(state: 'completed')
32
- relation.offset(3)
33
- relation.limit(2)
34
- relation.order(priority: :asc, state: :desc)
35
88
  ```
36
89
 
37
- You can also write named filter methods on the model class, after including `ActiveModel::Relation::Model`.
90
+ The following two lines will return the same filtered results:
91
+
92
+ ```ruby
93
+ relation.where(state: 'completed', priority: 1)
94
+ relation.where(state: 'completed').where(priority: 1)
95
+ ```
96
+
97
+ To allow for more advanced filtering, `#where` allows filtering using a block. This works similar to `Enumerable#select`, but will return a new `ActiveModel::Relation` instead of an already filtered array.
98
+
99
+ ```ruby
100
+ relation.where { |p| p.state == 'completed' && p.priority == 1 }
101
+ ```
102
+
103
+ #### `#where.not`
104
+
105
+ Similar to `#where`, the `#where.not` chain allows you to filter a relation. It will also return a new `ActiveModel::Relation` with that returns only the matching records.
106
+
107
+ ```ruby
108
+ relation.where.not(state: 'draft')
109
+ ```
110
+
111
+ To allow for more advanced filtering, `#where.not` allows filtering using a block. This works similar to `Enumerable#reject`, but will return a new `ActiveModel::Relation` instead of an already filtered array.
112
+
113
+ ```ruby
114
+ relation.where.not { |p| p.state == 'draft' && p.priority == 1 }
115
+ ```
116
+
117
+ ### Sorting
118
+
119
+ It is possible to sort an `ActiveModel::Relation` by a given set of attribute names. Sorting will be applied after filtering, but before limits and offsets.
120
+
121
+ #### `#order`
122
+
123
+ To sort by a single attribute in ascending order, you can just pass the attribute name to the `order` method.
124
+
125
+ ```ruby
126
+ relation.order(:priority)
127
+ ```
128
+
129
+ To specify the sort direction, you can pass a hash with the attribute name as key and either `:asc`, or `:desc` as value.
130
+
131
+ ```ruby
132
+ relation.order(priorty: :desc)
133
+ ```
134
+
135
+ To order by multiple attributes, you can pass them in the order of specificity you want.
136
+
137
+ ```ruby
138
+ relation.order(:state, :priority)
139
+ ```
140
+
141
+ For multiple attributes, it's also possible to specify the direction.
142
+
143
+ ```ruby
144
+ relation.order(state: :desc, priority: :asc)
145
+ ```
146
+
147
+ ### Limiting and offsets
148
+
149
+ #### `#limit`
150
+
151
+ To limit the amount of records returned in the collection, you can call `limit` on the relation. It will return a new `ActiveModel::Relation` that only returns the given limit of records, allowing you to chain multiple other calls. The limit will only be applied when actually accessing the records later on.
152
+
153
+ ```ruby
154
+ relation.limit(10)
155
+ ```
156
+
157
+ #### `#offset`
158
+
159
+ To skip a certain number of records in the collection, you can use `offset` on the relation. It will return a new `ActiveModel::Relation` that skips the given number of records at the beginning. The offset will only be applied when actually accessing the records later on.
160
+
161
+ ```ruby
162
+ relation.offset(20)
163
+ ```
164
+
165
+ ### Scopes
166
+
167
+ After including `ActiveModel::Relation::Model`, the library also supports calling class methods defined on the model class as part of the relation.
38
168
 
39
169
  ```ruby
40
170
  class Project
@@ -52,6 +182,71 @@ class Project
52
182
  end
53
183
  ```
54
184
 
185
+ Given the example above, you can now create relations like you're used to from `ActiveRecord::Relation`.
186
+
187
+ ```ruby
188
+ projects = Project.all
189
+ completed_projects = all_projects.completed
190
+ important_projects = all_projects.where(priority: 1)
191
+ ```
192
+
193
+ ### Spawning
194
+
195
+ It's possilbe to create new versions of a `ActiveModel::Relation` that only includes certain aspects of the `ActiveModel::Relation` it is based on. It's currently possible to customize the following aspects: `:where`, `:limit`, `:offset`.
196
+
197
+ #### `#except`
198
+
199
+ To create a new `ActiveModel::Relation` without certain aspects, you can use `except` and pass a list of aspects, you'd like to exclude from the newly created instance. The following example will create a new `ActiveModel::Relation` without any previously defined limit or offset.
200
+
201
+ ```ruby
202
+ relation.except(:limit, :offset)
203
+ ```
204
+ #### `#only`
205
+
206
+ Similar to `except`, the `only` method will return a new instance of the `ActiveModel::Relation` it is based on but with only the passed list of aspects applied to it.
207
+
208
+ ```ruby
209
+ relation.only(:where)
210
+ ```
211
+
212
+ ### Extending relations
213
+
214
+ #### `#extending`
215
+
216
+ In order to add additional methods to a relation, you can use `extending`. You can either pass a list of modules that will be included in this particular instance, or a block defining additional methods.
217
+
218
+ ```ruby
219
+ module Pagination
220
+ def page_size = 25
221
+
222
+ def page(page)
223
+ limit(page_size).offset(page.to_i * page_size)
224
+ end
225
+
226
+ def total_count
227
+ except(:limit, :offset).count
228
+ end
229
+ end
230
+
231
+ relation.extending(Pagination)
232
+ ```
233
+
234
+ The following example is equivalent to the example above:
235
+
236
+ ```ruby
237
+ relation.extending do
238
+ def page_size = 25
239
+
240
+ def page(page)
241
+ limit(page_size).offset(page.to_i * page_size)
242
+ end
243
+
244
+ def total_count
245
+ except(:limit, :offset).count
246
+ end
247
+ end
248
+ ```
249
+
55
250
  ## Development
56
251
 
57
252
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -62,6 +257,10 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
62
257
 
63
258
  Bug reports and pull requests are welcome on GitHub at https://github.com/userlist/active_model-relation. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/userlist/active_model-relation/blob/main/CODE_OF_CONDUCT.md).
64
259
 
260
+ ## Acknowledgements
261
+
262
+ This library is _heavily_ inspired by [`ActiveRecord::Relation`](https://github.com/rails/rails/blob/main/activerecord/lib/active_record/relation.rb) and uses similar patterns and implementations in various parts.
263
+
65
264
  ## License
66
265
 
67
266
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -69,3 +268,11 @@ The gem is available as open source under the terms of the [MIT License](https:/
69
268
  ## Code of Conduct
70
269
 
71
270
  Everyone interacting in the ActiveModel::Relation project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/userlist/active_model-relation/blob/main/CODE_OF_CONDUCT.md).
271
+
272
+ ## What is Userlist?
273
+
274
+ [![Userlist](https://userlist.com/images/external/userlist-logo-github.svg)](https://userlist.com/)
275
+
276
+ [Userlist](https://userlist.com/) allows you to onboard and engage your SaaS users with targeted behavior-based campaigns using email or in-app messages.
277
+
278
+ Userlist was started in 2017 as an alternative to bulky enterprise messaging tools. We believe that running SaaS products should be more enjoyable. Learn more [about us](https://userlist.com/about-us/).
@@ -10,20 +10,16 @@ module ActiveModel
10
10
  @name = name
11
11
  end
12
12
 
13
- def call(_, _)
14
- 0
15
- end
16
- end
17
-
18
- class Ascending < OrderExpression
19
13
  def call(record, other)
20
14
  record.public_send(name) <=> other.public_send(name)
21
15
  end
22
16
  end
23
17
 
18
+ class Ascending < OrderExpression; end
19
+
24
20
  class Descending < OrderExpression
25
21
  def call(record, other)
26
- other.public_send(name) <=> record.public_send(name)
22
+ super(other, record)
27
23
  end
28
24
  end
29
25
 
@@ -9,11 +9,7 @@ module ActiveModel
9
9
  delegate :where, :find, :find_by, :offset, :limit, :first, :last, to: :all
10
10
 
11
11
  def all
12
- current_scope || ActiveModel::Relation.new(self, records)
13
- end
14
-
15
- def records
16
- raise NotImplementedError
12
+ current_scope || ActiveModel::Relation.new(self)
17
13
  end
18
14
  end
19
15
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+
5
+ module ActiveModel
6
+ class Relation
7
+ class Railtie < Rails::Railtie # :nodoc:
8
+ config.action_dispatch.rescue_responses.merge!(
9
+ 'ActiveModel::RecordNotFound' => :not_found
10
+ )
11
+ end
12
+ end
13
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveModel
4
4
  class Relation
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.1'
6
6
  end
7
7
  end
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_model'
4
+ require_relative 'relation/railtie' if defined?(Rails::Railtie)
4
5
 
5
6
  module ActiveModel
6
- class ModelNotFound < StandardError
7
+ class RecordNotFound < StandardError
7
8
  def initialize(message = nil, model = nil, primary_key = nil, id = nil) # rubocop:disable Metrics/ParameterLists
8
9
  @primary_key = primary_key
9
10
  @model = model
@@ -29,9 +30,9 @@ module ActiveModel
29
30
 
30
31
  delegate :each, :size, :last, to: :records
31
32
 
32
- def initialize(model, records = [])
33
+ def initialize(model, records = model.try(:records))
33
34
  @model = model
34
- @records = records
35
+ @records = records || []
35
36
  @where_clause = WhereClause.new
36
37
  @order_clause = OrderClause.new
37
38
  @offset_value = nil
@@ -39,15 +40,17 @@ module ActiveModel
39
40
  @extending_values = []
40
41
  end
41
42
 
42
- def find(id)
43
+ def find(id = nil, &)
44
+ return records.find(id, &) if block_given?
45
+
43
46
  primary_key = model.try(:primary_key) || :id
44
47
 
45
48
  find_by(primary_key => id) ||
46
- raise(ModelNotFound.new("Couldn't find #{model} with '#{primary_key}'=#{id}", model, primary_key, id))
49
+ raise(RecordNotFound.new("Couldn't find #{model} with '#{primary_key}'=#{id}", model, primary_key, id))
47
50
  end
48
51
 
49
52
  def find_by(attributes = {})
50
- where_clause = self.where_clause + WhereClause.from_hash(attributes)
53
+ where_clause = self.where_clause + WhereClause.from_hash(type_cast_values(attributes))
51
54
 
52
55
  records.find(&where_clause)
53
56
  end
@@ -59,7 +62,7 @@ module ActiveModel
59
62
  def where!(attributes = {}, &)
60
63
  return WhereChain.new(spawn) unless attributes.any? || block_given?
61
64
 
62
- self.where_clause += WhereClause.build(attributes, &)
65
+ self.where_clause += WhereClause.build(type_cast_values(attributes), &)
63
66
  self
64
67
  end
65
68
 
@@ -178,5 +181,14 @@ module ActiveModel
178
181
  relation.limit_value = values[:limit]
179
182
  end
180
183
  end
184
+
185
+ def type_cast_values(attributes)
186
+ attributes.to_h do |key, value|
187
+ type = model.try(:type_for_attribute, key)
188
+ value = type.cast(value) if type
189
+
190
+ [key, value]
191
+ end
192
+ end
181
193
  end
182
194
  end
metadata CHANGED
@@ -1,29 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_model-relation
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benedikt Deicke
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-09-09 00:00:00.000000000 Z
11
+ date: 2024-11-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activemodel
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '7.2'
20
+ - - "~>"
21
+ - !ruby/object:Gem::Version
22
+ version: '8.0'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
- - - "~>"
27
+ - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: '7.2'
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
27
33
  description: This library allows querying of collections of Ruby objects, with a similar
28
34
  interface to ActiveRecord::Relation.
29
35
  email:
@@ -43,17 +49,18 @@ files:
43
49
  - lib/active_model/relation/model.rb
44
50
  - lib/active_model/relation/order_clause.rb
45
51
  - lib/active_model/relation/querying.rb
52
+ - lib/active_model/relation/railtie.rb
46
53
  - lib/active_model/relation/scoping.rb
47
54
  - lib/active_model/relation/version.rb
48
55
  - lib/active_model/relation/where_chain.rb
49
56
  - lib/active_model/relation/where_clause.rb
50
- homepage: https://github.com/benedikt/active_model-relation/
57
+ homepage: https://github.com/userlist/active_model-relation/
51
58
  licenses:
52
59
  - MIT
53
60
  metadata:
54
- homepage_uri: https://github.com/benedikt/active_model-relation/
55
- source_code_uri: https://github.com/benedikt/active_model-relation/
56
- changelog_uri: https://github.com/benedikt/active_model-relation/blob/main/CHANGELOG.md
61
+ homepage_uri: https://github.com/userlist/active_model-relation/
62
+ source_code_uri: https://github.com/userlist/active_model-relation/
63
+ changelog_uri: https://github.com/userlist/active_model-relation/blob/main/CHANGELOG.md
57
64
  rubygems_mfa_required: 'true'
58
65
  post_install_message:
59
66
  rdoc_options: []
@@ -73,5 +80,5 @@ requirements: []
73
80
  rubygems_version: 3.5.3
74
81
  signing_key:
75
82
  specification_version: 4
76
- summary: Query collection of ActiveModel objects like an ActiveRecord::Relation
83
+ summary: Query collections of ActiveModel objects like an ActiveRecord::Relation
77
84
  test_files: []