json_api_model 0.2.0 → 0.3.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/Gemfile.lock +6 -5
- data/README.md +59 -0
- data/json_api_model.gemspec +1 -0
- data/lib/json_api_model.rb +2 -0
- data/lib/json_api_model/associatable.rb +70 -0
- data/lib/json_api_model/associations.rb +10 -0
- data/lib/json_api_model/associations/base.rb +65 -0
- data/lib/json_api_model/associations/belongs_to.rb +23 -0
- data/lib/json_api_model/associations/flattable.rb +25 -0
- data/lib/json_api_model/associations/has.rb +46 -0
- data/lib/json_api_model/associations/has_many.rb +6 -0
- data/lib/json_api_model/associations/has_one.rb +7 -0
- data/lib/json_api_model/model.rb +6 -9
- data/lib/json_api_model/scope.rb +49 -11
- data/lib/json_api_model/version.rb +1 -1
- metadata +24 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b4309beecbe58dd95fe10e0aa45cfe3c9ab93710
|
4
|
+
data.tar.gz: 2cc02483222c4319429bd5539879cd8c20fe00d1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 137fd5629ae09611ef523a7092d4fe552900d78f189de0cc74d097f3b517144f81a556fbd54aa0d1768a7987617617a5751ce8baee4d8b1fcc18db5b77795502
|
7
|
+
data.tar.gz: f69d1952b0a22558d269e8549b40e1ed451e3df3c1c589541652cecb75c71c7ab45ecdff9eea550a53f3d4fd7ca2a5362462af2eeb17eb9395c0d286f2bd99be
|
data/Gemfile.lock
CHANGED
@@ -1,15 +1,16 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
json_api_model (0.
|
4
|
+
json_api_model (0.3.0)
|
5
|
+
activesupport
|
5
6
|
json_api_client
|
6
7
|
|
7
8
|
GEM
|
8
9
|
remote: https://rubygems.org/
|
9
10
|
specs:
|
10
|
-
activemodel (5.2.
|
11
|
-
activesupport (= 5.2.
|
12
|
-
activesupport (5.2.
|
11
|
+
activemodel (5.2.1)
|
12
|
+
activesupport (= 5.2.1)
|
13
|
+
activesupport (5.2.1)
|
13
14
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
14
15
|
i18n (>= 0.7, < 2)
|
15
16
|
minitest (~> 5.1)
|
@@ -25,7 +26,7 @@ GEM
|
|
25
26
|
faraday_middleware (0.12.2)
|
26
27
|
faraday (>= 0.7.4, < 1.0)
|
27
28
|
hashdiff (0.3.7)
|
28
|
-
i18n (1.0
|
29
|
+
i18n (1.1.0)
|
29
30
|
concurrent-ruby (~> 1.0)
|
30
31
|
json (2.1.0)
|
31
32
|
json_api_client (1.5.3)
|
data/README.md
CHANGED
@@ -31,6 +31,61 @@ Using the model wrappers is pretty straighforward. It thinly wraps `JsonApiClien
|
|
31
31
|
|
32
32
|
Any instance or class(non-query) level method will fall thorugh to the client.
|
33
33
|
|
34
|
+
### Associations
|
35
|
+
|
36
|
+
You can define simple associations that behave very much like ActiveRecord associations. Once you define your association, you will have a method with that name that will do the lookups and cache the results for you.
|
37
|
+
|
38
|
+
* `belongs_to association`: the base object has to have the association id
|
39
|
+
* will return a single object or nil
|
40
|
+
* `has_one`: the assiociation object has the id of the base.
|
41
|
+
* will return a single object or nil
|
42
|
+
* `has_many`: the association object has the id of the base.
|
43
|
+
* will return an array
|
44
|
+
|
45
|
+
#### Assocaition Options
|
46
|
+
|
47
|
+
Associations have some of the standard ActiveRecord options. Namely:
|
48
|
+
* `class`: specifies the class to find the record in.
|
49
|
+
|
50
|
+
```ruby
|
51
|
+
has_one :special_thing, class: Thing
|
52
|
+
```
|
53
|
+
|
54
|
+
* `class_name`: specifies the class w.o having to have the class defined. Handy for circular dependencies
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
class Person < JsonApiModel::Model
|
58
|
+
wraps MyApi::Client::Person
|
59
|
+
has_one :nickname, class_name: "Pesudonym"
|
60
|
+
end
|
61
|
+
|
62
|
+
class Pseudonym < JsonApiModel::Model
|
63
|
+
wraps MyApi::Client::Pseudonym
|
64
|
+
belongs_to :bearer, class_name: "Person"
|
65
|
+
end
|
66
|
+
```
|
67
|
+
|
68
|
+
* `through`: many to may association helper.
|
69
|
+
|
70
|
+
__NOTE__: Due to perf implications this is __only__ available for local classes (not things that come through the `relatinships` block in the payload). This will __*never ever*__ be available for remote models.
|
71
|
+
|
72
|
+
```ruby
|
73
|
+
class Person < JsonApiModel::Model
|
74
|
+
wraps MyApi::Client::Person
|
75
|
+
has_many :person_foods
|
76
|
+
has_many :favorite_foods, through: :person_foods, class_name: "Food"
|
77
|
+
end
|
78
|
+
|
79
|
+
# locally stored models that respond to #{model_class}_id
|
80
|
+
class PersonFood < ActiveRecord::Base
|
81
|
+
belongs_to :person
|
82
|
+
belongs_to :food
|
83
|
+
end
|
84
|
+
|
85
|
+
class Food < ActiveRecord::Base
|
86
|
+
end
|
87
|
+
```
|
88
|
+
|
34
89
|
### Example
|
35
90
|
|
36
91
|
If you have an app that talks to a `user` service. Here's how your `User` model might look:
|
@@ -173,6 +228,10 @@ ActiveSupport::Notifications.subscribe "find.json_api_model" do |name, started,
|
|
173
228
|
end
|
174
229
|
```
|
175
230
|
|
231
|
+
## Known Issues
|
232
|
+
|
233
|
+
* Due to an open issue in `JsonApiClient` scopes are modifiable, which means that `scope.where( params )` now permanently has those params in it.
|
234
|
+
|
176
235
|
## Development
|
177
236
|
|
178
237
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
data/json_api_model.gemspec
CHANGED
@@ -20,6 +20,7 @@ Gem::Specification.new do |spec|
|
|
20
20
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
21
21
|
spec.require_paths = ["lib"]
|
22
22
|
|
23
|
+
spec.add_dependency "activesupport"
|
23
24
|
spec.add_dependency "json_api_client"
|
24
25
|
|
25
26
|
spec.add_development_dependency "bundler", "~> 1.16"
|
data/lib/json_api_model.rb
CHANGED
@@ -2,6 +2,8 @@ require "json_api_model/version"
|
|
2
2
|
|
3
3
|
module JsonApiModel
|
4
4
|
|
5
|
+
autoload :Associations, 'json_api_model/associations'
|
6
|
+
autoload :Associatable, 'json_api_model/associatable'
|
5
7
|
autoload :Model, 'json_api_model/model'
|
6
8
|
autoload :Instrumenter, 'json_api_model/instrumenter'
|
7
9
|
autoload :ResultSet, 'json_api_model/result_set'
|
@@ -0,0 +1,70 @@
|
|
1
|
+
module JsonApiModel
|
2
|
+
module Associatable
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
|
7
|
+
class_attribute :__associations
|
8
|
+
self.__associations = {}
|
9
|
+
|
10
|
+
attr_accessor :__cached_associations
|
11
|
+
|
12
|
+
def has_relationship_ids?( name )
|
13
|
+
!!relationships[ name ]
|
14
|
+
end
|
15
|
+
|
16
|
+
def relationship_ids( name )
|
17
|
+
relationships_data = relationships[ name ]&.dig( :data )
|
18
|
+
case relationships_data
|
19
|
+
when Hash
|
20
|
+
[ relationships_data[ :id ] ]
|
21
|
+
when Array
|
22
|
+
relationships_data.map{ | datum | datum[ :id ] }
|
23
|
+
when NilClass
|
24
|
+
[ ]
|
25
|
+
else
|
26
|
+
raise "Unexpected relationship data type: #{relationships_data.class}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class << self
|
31
|
+
|
32
|
+
def belongs_to( name, opts = {} )
|
33
|
+
process Associations::BelongsTo.new( self, name, opts )
|
34
|
+
end
|
35
|
+
|
36
|
+
def has_one( name, opts = {} )
|
37
|
+
process Associations::HasOne.new( self, name, opts )
|
38
|
+
end
|
39
|
+
|
40
|
+
def has_many( name, opts = {} )
|
41
|
+
process Associations::HasMany.new( self, name, opts )
|
42
|
+
end
|
43
|
+
|
44
|
+
protected
|
45
|
+
|
46
|
+
def process( association )
|
47
|
+
associate association
|
48
|
+
methodize association
|
49
|
+
end
|
50
|
+
|
51
|
+
def associate( association )
|
52
|
+
self.__associations = __associations.merge association.name => association
|
53
|
+
end
|
54
|
+
|
55
|
+
def methodize( association )
|
56
|
+
define_method association.name do
|
57
|
+
self.__cached_associations ||= {}
|
58
|
+
|
59
|
+
unless self.__cached_associations.has_key? association.name
|
60
|
+
result = association.fetch( self )
|
61
|
+
|
62
|
+
self.__cached_associations[association.name] = result
|
63
|
+
end
|
64
|
+
self.__cached_associations[association.name]
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
module JsonApiModel
|
2
|
+
module Associations
|
3
|
+
autoload :Base, 'json_api_model/associations/base'
|
4
|
+
autoload :BelongsTo, 'json_api_model/associations/belongs_to'
|
5
|
+
autoload :Flattable, 'json_api_model/associations/flattable'
|
6
|
+
autoload :Has, 'json_api_model/associations/has'
|
7
|
+
autoload :HasMany, 'json_api_model/associations/has_many'
|
8
|
+
autoload :HasOne, 'json_api_model/associations/has_one'
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module JsonApiModel
|
2
|
+
module Associations
|
3
|
+
class Base
|
4
|
+
|
5
|
+
attr_accessor :name, :opts, :key
|
6
|
+
|
7
|
+
def initialize( base_class, name, opts = {} )
|
8
|
+
self.name = name
|
9
|
+
self.opts = opts
|
10
|
+
self.key = idify( base_class )
|
11
|
+
|
12
|
+
sanitize_opts( base_class )
|
13
|
+
end
|
14
|
+
|
15
|
+
def fetch( instance )
|
16
|
+
process klass.send( action, query( instance ) )
|
17
|
+
end
|
18
|
+
|
19
|
+
protected
|
20
|
+
|
21
|
+
def klass
|
22
|
+
@klass ||= association_class( name, opts )
|
23
|
+
end
|
24
|
+
|
25
|
+
def association_class( name, opts = {} )
|
26
|
+
a_class = opts[:class] if opts.has_key? :class
|
27
|
+
a_class_name = opts[:class_name].constantize if opts.has_key? :class_name
|
28
|
+
|
29
|
+
a_class || a_class_name || derived_class_for( name )
|
30
|
+
end
|
31
|
+
|
32
|
+
def derived_class_for( name )
|
33
|
+
name.to_s.singularize.classify.constantize
|
34
|
+
end
|
35
|
+
|
36
|
+
def idify( class_name )
|
37
|
+
"#{class_name.to_s.demodulize.underscore}_id"
|
38
|
+
end
|
39
|
+
|
40
|
+
def supported_options
|
41
|
+
[ :class, :class_name ] + additional_options
|
42
|
+
end
|
43
|
+
|
44
|
+
def additional_options
|
45
|
+
[]
|
46
|
+
end
|
47
|
+
|
48
|
+
def sanitize_opts( base_class )
|
49
|
+
if name.to_s == "object"
|
50
|
+
raise "#{base_class}: 'object_id' is a reserved keyword in ruby and cannot be overridden"
|
51
|
+
end
|
52
|
+
invalid_options = (opts.keys - supported_options)
|
53
|
+
if invalid_options.present?
|
54
|
+
list = invalid_options.map{|o|"'#{o}'"}.to_sentence
|
55
|
+
plural = invalid_options.count > 1
|
56
|
+
raise "#{base_class}: #{list} #{plural ? "are" : "is"} not supported."
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def process( results )
|
61
|
+
results
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module JsonApiModel
|
2
|
+
module Associations
|
3
|
+
class BelongsTo < Base
|
4
|
+
include Flattable
|
5
|
+
|
6
|
+
def action
|
7
|
+
:find
|
8
|
+
end
|
9
|
+
|
10
|
+
def key
|
11
|
+
"#{name}_id"
|
12
|
+
end
|
13
|
+
|
14
|
+
def query( instance )
|
15
|
+
if instance.has_relationship_ids? name
|
16
|
+
{ id: instance.relationship_ids( name ) }
|
17
|
+
else
|
18
|
+
instance.send key
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module JsonApiModel
|
2
|
+
module Associations
|
3
|
+
module Flattable
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
included do
|
7
|
+
def process( results )
|
8
|
+
if flattable? results
|
9
|
+
results.first
|
10
|
+
else
|
11
|
+
results
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def flattable?( results )
|
18
|
+
results.is_a?( JsonApiModel::ResultSet ) ||
|
19
|
+
results.is_a?( Array ) ||
|
20
|
+
results.is_a?( JsonApiModel::Scope )
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module JsonApiModel
|
2
|
+
module Associations
|
3
|
+
class Has < Base
|
4
|
+
|
5
|
+
def action
|
6
|
+
:where
|
7
|
+
end
|
8
|
+
|
9
|
+
def query( instance )
|
10
|
+
if instance.has_relationship_ids? name
|
11
|
+
{ id: instance.relationship_ids( name ) }
|
12
|
+
elsif through?
|
13
|
+
{ id: target_ids( instance ) }
|
14
|
+
else
|
15
|
+
{ key => instance.id }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def additional_options
|
20
|
+
[ :through ]
|
21
|
+
end
|
22
|
+
|
23
|
+
protected
|
24
|
+
|
25
|
+
def through
|
26
|
+
opts[:through]
|
27
|
+
end
|
28
|
+
|
29
|
+
def through?
|
30
|
+
opts.has_key? :through
|
31
|
+
end
|
32
|
+
|
33
|
+
def target_ids( instance )
|
34
|
+
intermadiates = Array(instance.send( through ) )
|
35
|
+
|
36
|
+
intermadiates.map do | intermediate |
|
37
|
+
intermediate.send( through_key )
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def through_key
|
42
|
+
idify klass
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
data/lib/json_api_model/model.rb
CHANGED
@@ -1,9 +1,7 @@
|
|
1
1
|
module JsonApiModel
|
2
2
|
class Model
|
3
3
|
class << self
|
4
|
-
|
5
|
-
|
6
|
-
def_delegators :_new_scope, :where, :order, :includes, :select, :all, :paginate, :page, :with_params, :first, :find, :last
|
4
|
+
delegate :where, :order, :includes, :select, :all, :paginate, :page, :with_params, :first, :find, :last, to: :__new_scope
|
7
5
|
|
8
6
|
attr_reader :client_class
|
9
7
|
def wraps( client_class )
|
@@ -36,13 +34,16 @@ module JsonApiModel
|
|
36
34
|
|
37
35
|
private
|
38
36
|
|
39
|
-
def
|
37
|
+
def __new_scope
|
40
38
|
Scope.new( self )
|
41
39
|
end
|
42
40
|
end
|
43
|
-
|
41
|
+
include Associatable
|
42
|
+
|
44
43
|
attr_accessor :client
|
45
44
|
|
45
|
+
delegate :as_json, to: :client
|
46
|
+
|
46
47
|
def initialize( attributes = {} )
|
47
48
|
@client = self.class.client_class.new( attributes )
|
48
49
|
end
|
@@ -53,10 +54,6 @@ module JsonApiModel
|
|
53
54
|
raise NoMethodError, "No method `#{m}' found in #{self} or #{client}"
|
54
55
|
end
|
55
56
|
|
56
|
-
def as_json
|
57
|
-
client.as_json
|
58
|
-
end
|
59
|
-
|
60
57
|
RESERVED_FIELDS = [ :type, :id ]
|
61
58
|
end
|
62
59
|
end
|
data/lib/json_api_model/scope.rb
CHANGED
@@ -3,33 +3,42 @@ module JsonApiModel
|
|
3
3
|
def initialize( model_class )
|
4
4
|
@model_class = model_class
|
5
5
|
@client_scope = JsonApiClient::Query::Builder.new( model_class.client_class )
|
6
|
+
@cache = {}
|
6
7
|
end
|
7
8
|
|
8
|
-
def to_a
|
9
|
-
@to_a ||= find
|
10
|
-
end
|
11
|
-
|
12
|
-
alias all to_a
|
13
|
-
|
14
|
-
|
15
9
|
def find( args = {} )
|
16
10
|
JsonApiModel.instrumenter.instrument 'find.json_api_model',
|
17
11
|
args: args,
|
18
12
|
url: url do
|
19
|
-
|
20
|
-
|
13
|
+
cache_or_find args do
|
14
|
+
results = @client_scope.find args
|
15
|
+
ResultSet.new( results, @model_class )
|
16
|
+
end
|
21
17
|
end
|
22
18
|
end
|
23
19
|
|
20
|
+
alias to_a find
|
21
|
+
alias all find
|
22
|
+
|
24
23
|
def first
|
25
24
|
JsonApiModel.instrumenter.instrument 'first.json_api_model', url: url do
|
26
|
-
|
25
|
+
# if the non-first query has already been executed, there's no need to make the call again
|
26
|
+
if cached?
|
27
|
+
cache.first
|
28
|
+
else
|
29
|
+
cache_or_find :first do
|
30
|
+
@model_class.new_from_client @client_scope.first
|
31
|
+
end
|
32
|
+
end
|
27
33
|
end
|
28
34
|
end
|
29
35
|
|
30
36
|
def last
|
31
37
|
JsonApiModel.instrumenter.instrument 'last.json_api_model', url: url do
|
32
|
-
|
38
|
+
# this is a separate call always because the last record may exceed page size
|
39
|
+
cache_or_find :last do
|
40
|
+
@model_class.new_from_client @client_scope.last
|
41
|
+
end
|
33
42
|
end
|
34
43
|
end
|
35
44
|
|
@@ -48,6 +57,35 @@ module JsonApiModel
|
|
48
57
|
|
49
58
|
private
|
50
59
|
|
60
|
+
def cached?
|
61
|
+
@cache.has_key? keyify
|
62
|
+
end
|
63
|
+
|
64
|
+
def cache
|
65
|
+
@cache[keyify]
|
66
|
+
end
|
67
|
+
|
68
|
+
# because a scope can be modified and then resolved, we want to cache by the full param set
|
69
|
+
def cache_or_find( opts = {} )
|
70
|
+
key = keyify( opts )
|
71
|
+
@cache.fetch key do |key|
|
72
|
+
@cache[key] = yield
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def keyify( opts = {} )
|
77
|
+
params.merge( hashify( opts ) ).sort.to_s
|
78
|
+
end
|
79
|
+
|
80
|
+
def hashify( opts = {} )
|
81
|
+
case opts
|
82
|
+
when Hash
|
83
|
+
opts
|
84
|
+
else
|
85
|
+
{ opt: opts }
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
51
89
|
def url
|
52
90
|
@model_class.client_class.requestor.send( :resource_path, params )
|
53
91
|
end
|
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: json_api_model
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Greg Orlov
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-08-
|
11
|
+
date: 2018-08-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activesupport
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
13
27
|
- !ruby/object:Gem::Dependency
|
14
28
|
name: json_api_client
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -113,6 +127,14 @@ files:
|
|
113
127
|
- bin/setup
|
114
128
|
- json_api_model.gemspec
|
115
129
|
- lib/json_api_model.rb
|
130
|
+
- lib/json_api_model/associatable.rb
|
131
|
+
- lib/json_api_model/associations.rb
|
132
|
+
- lib/json_api_model/associations/base.rb
|
133
|
+
- lib/json_api_model/associations/belongs_to.rb
|
134
|
+
- lib/json_api_model/associations/flattable.rb
|
135
|
+
- lib/json_api_model/associations/has.rb
|
136
|
+
- lib/json_api_model/associations/has_many.rb
|
137
|
+
- lib/json_api_model/associations/has_one.rb
|
116
138
|
- lib/json_api_model/instrumenter.rb
|
117
139
|
- lib/json_api_model/instrumenter/null_instrumenter.rb
|
118
140
|
- lib/json_api_model/model.rb
|