json_api_model 0.2.0 → 0.3.0

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
  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