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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 89df9b9d8d8f4ae17c7bcd671f8f272b0339c5bc
4
- data.tar.gz: 7db7eac6d7c3d4398fc3b912f23eed7f90c631f2
3
+ metadata.gz: b4309beecbe58dd95fe10e0aa45cfe3c9ab93710
4
+ data.tar.gz: 2cc02483222c4319429bd5539879cd8c20fe00d1
5
5
  SHA512:
6
- metadata.gz: 0eb9b10ac0e5a56e2bb2307e7e2b9cde7fce35d2a409d34df9490e616a01902d801274032539db5062bfc62634a865ef7045276836972566671e43e657a0cab9
7
- data.tar.gz: 03f7d39b4504ff47099aaec9fec4b9934f58cd31314a6ad1cd0712784c5b5f4e994332b477f00ebc06025147bfa07b4deb47d81b6b2977af559244b845691a75
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.1.2)
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.0)
11
- activesupport (= 5.2.0)
12
- activesupport (5.2.0)
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.1)
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.
@@ -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"
@@ -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
@@ -0,0 +1,6 @@
1
+ module JsonApiModel
2
+ module Associations
3
+ class HasMany < Has
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ module JsonApiModel
2
+ module Associations
3
+ class HasOne < Has
4
+ include Flattable
5
+ end
6
+ end
7
+ end
@@ -1,9 +1,7 @@
1
1
  module JsonApiModel
2
2
  class Model
3
3
  class << self
4
- extend Forwardable
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 _new_scope
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
@@ -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
- results = @client_scope.find args
20
- ResultSet.new( results, @model_class )
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
- @model_class.new_from_client @client_scope.first
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
- @model_class.new_from_client @client_scope.last
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
@@ -1,3 +1,3 @@
1
1
  module JsonApiModel
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  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.2.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-03 00:00:00.000000000 Z
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