active_model-relation 0.1.0 → 0.2.1

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: 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: []