cistern 2.5.0 → 2.6.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: 51b4a1fc74578d0a0a8f89bb9b041501ab850ac9
4
- data.tar.gz: 3f24e1b8145497a84d6f6b0fa449aa11b7d5e7f1
3
+ metadata.gz: 2f58960f3c738abbdd7203d8973981f71c370a65
4
+ data.tar.gz: 94a23880c49416884ece0e3615ca7e170bba7234
5
5
  SHA512:
6
- metadata.gz: 2f7a72df05bb6c95d5176a36b8e8d0d17f9c9bd3179cec7424e4fc66e216e24f148bff977e4ba19f1bcee0d5afad60757e193a83bf926401a9d40be70e75a223
7
- data.tar.gz: f2221f974e0817e5410e88a8d4586f28881756fcbea1836e52d8440ec97f50dfe689ae3758820e1225c2de006bfcfb0dbc0065ab70cbc0980fc2fda2f8a96b78
6
+ metadata.gz: 56fd5780ad10f6c44ff6704743cf927bb50600b3c45a00d1aa1afe8c2d4e237f5413bdc416e282255f924763c3fc19d2bc0afd11c52bcfb7ecf7e2f3e6c39fbd
7
+ data.tar.gz: aee0f212eb1923eb92131faa80a5a8fa1c8ae021e0fd1c485fb5b9ac653fba99558d71757d77fdd9baf9558dbdf27d24b72d11c5ce1cd55e52f6c2aee1fbc771
@@ -2,7 +2,18 @@
2
2
 
3
3
  ## [Unreleased](https://github.com/lanej/cistern/tree/HEAD)
4
4
 
5
- [Full Changelog](https://github.com/lanej/cistern/compare/v2.4.1...HEAD)
5
+ [Full Changelog](https://github.com/lanej/cistern/compare/v2.5.0...HEAD)
6
+
7
+ **Implemented enhancements:**
8
+
9
+ - Associations [\#66](https://github.com/lanej/cistern/pull/66) ([lanej](https://github.com/lanej))
10
+
11
+ **Fixed bugs:**
12
+
13
+ - fix\(model\): \#has\_many is not loaded without records present [\#67](https://github.com/lanej/cistern/pull/67) ([lanej](https://github.com/lanej))
14
+
15
+ ## [v2.5.0](https://github.com/lanej/cistern/tree/v2.5.0) (2016-07-19)
16
+ [Full Changelog](https://github.com/lanej/cistern/compare/v2.4.1...v2.5.0)
6
17
 
7
18
  **Implemented enhancements:**
8
19
 
data/README.md CHANGED
@@ -404,14 +404,62 @@ class Blog::Posts < Blog::Collection
404
404
  end
405
405
  ```
406
406
 
407
+ ### Associations
408
+
409
+ Associations allow the use of a resource's attributes to reference other resources. They act as lazy loaded attributes
410
+ and push any loaded data into the resource's `attributes`.
411
+
412
+ There are two types of associations available.
413
+
414
+ * `belongs_to` references a specific resource and defines a reader.
415
+ * `has_many` references a collection of resources and defines a reader / writer.
416
+
417
+ ```ruby
418
+ class Blog::Tag < Blog::Model
419
+ identity :id
420
+ attribute :author_id
421
+
422
+ has_many :posts -> { cistern.posts(tag_id: identity) }
423
+ belongs_to :creator -> { cistern.authors.get(author_id) }
424
+ end
425
+ ```
426
+
427
+ Relationships store the collection's attributes within the resources' attributes on write / load.
428
+
429
+ ```ruby
430
+ tag = blog.tags.get('ruby')
431
+ tag.posts = blog.posts.load({'id' => 1, 'author_id' => '2'}, {'id' => 2, 'author_id' => 3})
432
+ tag.attributes[:posts] #=> {'id' => 1, 'author_id' => '2'}, {'id' => 2, 'author_id' => 3}
433
+
434
+ tag.creator = blogs.author.get(name: 'phil')
435
+ tag.attributes[:creator] #=> { 'id' => 2, 'name' => 'phil' }
436
+ ```
437
+
438
+ Foreign keys can be updated with association writing by overwriting the writer.
439
+
440
+ ```ruby
441
+ Blog::Tag.class_eval do
442
+ alias cistern_creator= creator=
443
+ def creator=(creator)
444
+ self.cistern_creator = creator
445
+ self.author_id = attributes[:creator][:id]
446
+ end
447
+ end
448
+
449
+ tag = blog.tags.get('ruby')
450
+ tag.author_id = 4
451
+ tag.creator = blogs.author.get(name: 'phil') #=> #<Blog::Author id=2 name='phil'>
452
+ tag.author_id #=> 2
453
+ ```
454
+
407
455
  #### Data
408
456
 
409
457
  A uniform interface for mock data is mixed into the `Mock` class by default.
410
458
 
411
459
  ```ruby
412
460
  Blog.mock!
413
- client = Blog.new # Blog::Mock
414
- client.data # Cistern::Data::Hash
461
+ client = Blog.new # Blog::Mock
462
+ client.data # Cistern::Data::Hash
415
463
  client.data["posts"] += ["x"] # ["x"]
416
464
  ```
417
465
 
@@ -426,7 +474,7 @@ Blog::Mock.data["posts"] # ["x"]
426
474
  ```ruby
427
475
  client.data.object_id # 70199868585600
428
476
  client.reset!
429
- client.data["posts"] # []
477
+ client.data["posts"] # []
430
478
  client.data.object_id # 70199868566840
431
479
  ```
432
480
 
@@ -13,6 +13,7 @@ module Cistern
13
13
  require 'cistern/hash_support'
14
14
  require 'cistern/string'
15
15
  require 'cistern/mock'
16
+ require 'cistern/associations'
16
17
  require 'cistern/wait_for'
17
18
  require 'cistern/attributes'
18
19
  require 'cistern/collection'
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+ module Cistern::Associations
3
+
4
+ # Lists the associations defined on the resource
5
+ # @return [Hash{Symbol=>Array}] mapping of association type to name
6
+ def associations
7
+ @associations ||= Hash.new { |h,k| h[k] = [] }
8
+ end
9
+
10
+ # Define an assocation that references a collection.
11
+ # @param name [Symbol] name of association and corresponding reader and writer.
12
+ # @param scope [Proc] returning {Cistern::Collection} instance to load models into. {#scope} is evaluated within the
13
+ # context of the model.
14
+ # @return [Cistern::Collection] as defined by {#scope}
15
+ # @example
16
+ # class Firm < Law::Model
17
+ # identity :registration_id
18
+ # has_many :lawyers, -> { cistern.associates(firm_id: identity) }
19
+ # end
20
+ def has_many(name, scope)
21
+ name_sym = name.to_sym
22
+
23
+ reader_method = name
24
+ writer_method = "#{name}="
25
+
26
+ attribute name, type: :array
27
+
28
+ define_method reader_method do
29
+ collection = instance_exec(&scope)
30
+ records = attributes[name_sym] || []
31
+
32
+ collection.load(records) if records.any?
33
+ collection
34
+ end
35
+
36
+ define_method writer_method do |models|
37
+ attributes[name] = Array(models).map do |model|
38
+ model.respond_to?(:attributes) ? model.attributes : model
39
+ end
40
+ end
41
+
42
+ associations[:has_many] << name_sym
43
+ end
44
+
45
+ # Define an assocation that references a model.
46
+ # @param name [Symbol] name of association and corresponding reader.
47
+ # @param scope [Proc] returning a {Cistern::Model} that is evaluated within the context of the model.
48
+ # @return [Cistern::Model] as defined by {#scope}
49
+ # @example
50
+ # class Firm < Law::Model
51
+ # identity :registration_id
52
+ # belongs_to :leader, -> { cistern.employees.get(:ceo) }
53
+ # end
54
+ def belongs_to(name, block)
55
+ name_sym = name.to_sym
56
+
57
+ reader_method = name
58
+ writer_method = "#{name}="
59
+
60
+ attribute name_sym
61
+
62
+ define_method reader_method do
63
+ model = instance_exec(&block)
64
+ attributes[name_sym] = model.attributes
65
+ model
66
+ end
67
+
68
+ define_method writer_method do |model|
69
+ data = model.respond_to?(:attributes) ? model.attributes : model
70
+ attributes[name_sym] = data
71
+ model
72
+ end
73
+
74
+ associations[:belongs_to] << name_sym
75
+ end
76
+ end
@@ -55,6 +55,7 @@ module Cistern::Collection
55
55
  alias_method :build, :initialize
56
56
 
57
57
  def initialize(attributes = {})
58
+ @loaded = false
58
59
  merge_attributes(attributes)
59
60
  end
60
61
 
@@ -6,6 +6,7 @@ module Cistern::Model
6
6
  klass.send(:extend, Cistern::Attributes::ClassMethods)
7
7
  klass.send(:include, Cistern::Attributes::InstanceMethods)
8
8
  klass.send(:extend, Cistern::Model::ClassMethods)
9
+ klass.send(:extend, Cistern::Associations)
9
10
  end
10
11
 
11
12
  def self.cistern_model(cistern, klass, name)
@@ -1,3 +1,3 @@
1
1
  module Cistern
2
- VERSION = '2.5.0'
2
+ VERSION = '2.6.0'
3
3
  end
@@ -0,0 +1,155 @@
1
+ require 'spec_helper'
2
+
3
+ describe Cistern::Associations do
4
+ subject { Class.new(Sample::Model) }
5
+
6
+ describe '#associations' do
7
+ before {
8
+ Sample::Associate = Class.new(Sample::Model) do
9
+ identity :id
10
+
11
+ belongs_to :association, -> { [] }
12
+ has_many :associates, -> { [] }
13
+ end
14
+ }
15
+
16
+ it 'returns a mapping of associations types to names' do
17
+ expect(Sample::Associate.associations).to eq(
18
+ {
19
+ :belongs_to => [:association],
20
+ :has_many => [:associates],
21
+ }
22
+ )
23
+ end
24
+ end
25
+
26
+ describe '#belongs_to' do
27
+ before {
28
+ Sample::Associate = Class.new(Sample::Model) do
29
+ identity :id
30
+ end
31
+
32
+ subject.class_eval do
33
+ identity :id
34
+ attribute :associate_id
35
+
36
+ belongs_to :associate, -> { Sample::Associate.new(id: associate_id) }
37
+ end
38
+ }
39
+
40
+ it 'returns an associated model' do
41
+ sample = subject.new(id: 1, associate_id: 2)
42
+
43
+ belongs_to = Sample::Associate.new(id: 2)
44
+
45
+ expect(sample.associate).to eq(belongs_to)
46
+ end
47
+
48
+ describe '{belongs_to}=' do
49
+ it 'accepts a model' do
50
+ sample = subject.new(id: 1, associate_id: 2)
51
+
52
+ belongs_to = Sample::Associate.new(id: 2)
53
+
54
+ expect(sample.associate).to eq(belongs_to)
55
+ expect(sample.attributes[:associate]).to eq(id: 2)
56
+ end
57
+
58
+ it 'accepts raw data' do
59
+ sample = subject.new
60
+ sample.associate = { id: 2 }
61
+
62
+ expect(sample.attributes[:associate]).to eq(id: 2)
63
+ end
64
+
65
+ it 'allows for overrides' do
66
+ subject.class_eval do
67
+ alias cistern_associate= associate=
68
+ def associate=(associate)
69
+ self.cistern_associate = associate
70
+ self.associate_id = attributes[:associate][:id]
71
+ end
72
+ end
73
+
74
+ sample = subject.new(id: 1, associate_id: 3)
75
+
76
+ belongs_to = Sample::Associate.new(id: 2)
77
+
78
+ expect {
79
+ sample.associate = belongs_to
80
+ }.to change(sample, :associate_id).to(2)
81
+ end
82
+ end
83
+ end
84
+
85
+ describe '#has_many' do
86
+ before {
87
+ Sample::Associate = Class.new(Sample::Model) do
88
+ identity :id
89
+ end
90
+
91
+ Sample::Associates = Class.new(Sample::Collection) do
92
+
93
+ attribute :associate_id
94
+
95
+ model Sample::Associate
96
+
97
+ def all
98
+ load([{id: associate_id + 1}])
99
+ end
100
+ end
101
+
102
+ subject.class_eval do
103
+ identity :id
104
+ attribute :associate_id
105
+
106
+ has_many :associates, -> { Sample::Associates.new(associate_id: associate_id) }
107
+ end
108
+ }
109
+
110
+ it 'returns associated models' do
111
+ expected = Sample::Associates.new(associate_id: 2).load([{id: 3}])
112
+
113
+ expect(subject.new(associate_id: 2).associates.all).to eq(expected)
114
+ end
115
+
116
+ it 'does not consider the associated collection loaded without records' do
117
+ model = subject.new(associate_id: 2)
118
+ model.associates = []
119
+
120
+ expect(model.associates.loaded).to eq(false)
121
+ end
122
+
123
+ it 'considers the associated collection loaded with records' do
124
+ model = subject.new(associate_id: 2)
125
+ model.associates = Sample::Associates.new(associate_id: 2).load([{id: 3}])
126
+
127
+ expect(model.associates.loaded).to eq(true)
128
+ expect(model.associates.records).to contain_exactly(Sample::Associate.new(id: 3))
129
+ end
130
+
131
+ describe '{has_many}=' do
132
+ it 'accepts models' do
133
+ model = subject.new(associate_id: 2)
134
+ associates_data = [ { id: 1 }, { id: 2 }]
135
+ associates = Sample::Associates.new.load(associates_data)
136
+
137
+ model.associates = [ Sample::Associate.new(id: 1), Sample::Associate.new(id: 2) ]
138
+
139
+ expect(model.attributes[:associates]).to eq(associates_data)
140
+ expect(model.associates).to eq(associates)
141
+ end
142
+
143
+ it 'accepts raw data' do
144
+ model = subject.new(associate_id: 2)
145
+ associates_data = [ { id: 1 }, { id: 2 }]
146
+ associates = Sample::Associates.new.load(associates_data)
147
+
148
+ model.associates = associates_data
149
+
150
+ expect(model.attributes[:associates]).to eq(associates_data)
151
+ expect(model.associates).to eq(associates)
152
+ end
153
+ end
154
+ end
155
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cistern
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.0
4
+ version: 2.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Lane
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-07-19 00:00:00.000000000 Z
11
+ date: 2016-07-26 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: API client framework extracted from Fog
14
14
  email:
@@ -31,6 +31,7 @@ files:
31
31
  - cistern.gemspec
32
32
  - gemfiles/ruby_lt_2.0.gemfile
33
33
  - lib/cistern.rb
34
+ - lib/cistern/associations.rb
34
35
  - lib/cistern/attributes.rb
35
36
  - lib/cistern/client.rb
36
37
  - lib/cistern/collection.rb
@@ -53,6 +54,7 @@ files:
53
54
  - lib/cistern/timeout.rb
54
55
  - lib/cistern/version.rb
55
56
  - lib/cistern/wait_for.rb
57
+ - spec/associations_spec.rb
56
58
  - spec/attributes_spec.rb
57
59
  - spec/client_spec.rb
58
60
  - spec/collection_spec.rb
@@ -93,6 +95,7 @@ signing_key:
93
95
  specification_version: 4
94
96
  summary: API client framework
95
97
  test_files:
98
+ - spec/associations_spec.rb
96
99
  - spec/attributes_spec.rb
97
100
  - spec/client_spec.rb
98
101
  - spec/collection_spec.rb