cartograph 1.0.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 +7 -0
- data/.gitignore +23 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/CHANGELOG.md +11 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +211 -0
- data/Rakefile +6 -0
- data/cartograph.gemspec +24 -0
- data/examples/collection_representation.rb +41 -0
- data/examples/domains.rb +54 -0
- data/examples/representation_for.rb +24 -0
- data/lib/cartograph.rb +24 -0
- data/lib/cartograph/artist.rb +34 -0
- data/lib/cartograph/dsl.rb +104 -0
- data/lib/cartograph/map.rb +91 -0
- data/lib/cartograph/property.rb +69 -0
- data/lib/cartograph/property_collection.rb +27 -0
- data/lib/cartograph/root_key.rb +19 -0
- data/lib/cartograph/sculptor.rb +40 -0
- data/lib/cartograph/version.rb +3 -0
- data/spec/lib/cartograph/artist_spec.rb +115 -0
- data/spec/lib/cartograph/dsl_spec.rb +257 -0
- data/spec/lib/cartograph/map_spec.rb +160 -0
- data/spec/lib/cartograph/property_collection_spec.rb +15 -0
- data/spec/lib/cartograph/property_spec.rb +235 -0
- data/spec/lib/cartograph/root_key_spec.rb +38 -0
- data/spec/lib/cartograph/sculptor_spec.rb +107 -0
- data/spec/spec_helper.rb +77 -0
- data/spec/support/dsl_contexts.rb +22 -0
- data/spec/support/dummy_comment.rb +2 -0
- data/spec/support/dummy_user.rb +2 -0
- metadata +132 -0
data/lib/cartograph.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
require "cartograph/version"
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Cartograph
|
5
|
+
autoload :DSL, 'cartograph/dsl'
|
6
|
+
autoload :Map, 'cartograph/map'
|
7
|
+
autoload :Property, 'cartograph/property'
|
8
|
+
autoload :PropertyCollection, 'cartograph/property_collection'
|
9
|
+
autoload :RootKey, 'cartograph/root_key'
|
10
|
+
autoload :ScopeProxy, 'cartograph/scope_proxy'
|
11
|
+
|
12
|
+
autoload :Artist, 'cartograph/artist'
|
13
|
+
autoload :Sculptor, 'cartograph/sculptor'
|
14
|
+
|
15
|
+
class << self
|
16
|
+
attr_accessor :default_dumper
|
17
|
+
attr_accessor :default_loader
|
18
|
+
attr_accessor :default_cache
|
19
|
+
attr_accessor :default_cache_key
|
20
|
+
end
|
21
|
+
|
22
|
+
self.default_dumper = JSON
|
23
|
+
self.default_loader = JSON
|
24
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Cartograph
|
2
|
+
class Artist
|
3
|
+
attr_reader :object, :map
|
4
|
+
|
5
|
+
def initialize(object, map)
|
6
|
+
@object = object
|
7
|
+
@map = map
|
8
|
+
end
|
9
|
+
|
10
|
+
def properties
|
11
|
+
map.properties
|
12
|
+
end
|
13
|
+
|
14
|
+
def draw(scope = nil)
|
15
|
+
if map.cache
|
16
|
+
cache_key = map.cache_key.call(object, scope)
|
17
|
+
map.cache.fetch(cache_key) { build_properties(scope) }
|
18
|
+
else
|
19
|
+
build_properties(scope)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def build_properties(scope)
|
24
|
+
scoped_properties = scope ? properties.filter_by_scope(scope) : properties
|
25
|
+
scoped_properties.each_with_object({}) do |property, mapped|
|
26
|
+
begin
|
27
|
+
mapped[property.key] = property.value_for(object, scope)
|
28
|
+
rescue NoMethodError => e
|
29
|
+
raise ArgumentError, "#{object} does not respond to #{property.name}, so we can't map it"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
module Cartograph
|
2
|
+
module DSL
|
3
|
+
def self.included(base)
|
4
|
+
base.extend ClassMethods
|
5
|
+
end
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
def cartograph(&block)
|
9
|
+
@cartograph_map ||= Map.new
|
10
|
+
|
11
|
+
if block_given?
|
12
|
+
block.arity > 0 ? block.call(@cartograph_map) : @cartograph_map.instance_eval(&block)
|
13
|
+
end
|
14
|
+
|
15
|
+
@cartograph_map
|
16
|
+
end
|
17
|
+
|
18
|
+
# Returns a hash representation of the object based on the mapping
|
19
|
+
#
|
20
|
+
# @param scope [Symbol] the scope of the mapping
|
21
|
+
# @param object the object to be mapped
|
22
|
+
# @return [Hash, Array]
|
23
|
+
def hash_for(scope, object)
|
24
|
+
drawn_object = Artist.new(object, @cartograph_map).draw(scope)
|
25
|
+
prepend_root_key(scope, :singular, drawn_object)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Returns a hash representation of the collection of objects based on the mapping
|
29
|
+
#
|
30
|
+
# @param scope [Symbol] the scope of the mapping
|
31
|
+
# @params objects [Array] the array of objects to be mapped
|
32
|
+
# @return [Hash, Array]
|
33
|
+
def hash_collection_for(scope, objects)
|
34
|
+
drawn_objects = objects.map do |object|
|
35
|
+
Artist.new(object, @cartograph_map).draw(scope)
|
36
|
+
end
|
37
|
+
|
38
|
+
prepend_root_key(scope, :plural, drawn_objects)
|
39
|
+
end
|
40
|
+
|
41
|
+
def representation_for(scope, object, dumper = Cartograph.default_dumper)
|
42
|
+
dumper.dump(hash_for(scope, object))
|
43
|
+
end
|
44
|
+
|
45
|
+
def represent_collection_for(scope, objects, dumper = Cartograph.default_dumper)
|
46
|
+
dumper.dump(hash_collection_for(scope, objects))
|
47
|
+
end
|
48
|
+
|
49
|
+
def extract_single(content, scope, loader = Cartograph.default_loader)
|
50
|
+
loaded = loader.load(content)
|
51
|
+
|
52
|
+
retrieve_root_key(scope, :singular) do |root_key|
|
53
|
+
# Reassign loaded if a root key exists
|
54
|
+
loaded = loaded[root_key]
|
55
|
+
end
|
56
|
+
|
57
|
+
Sculptor.new(loaded, @cartograph_map).sculpt(scope)
|
58
|
+
end
|
59
|
+
|
60
|
+
def extract_into_object(object, content, scope, loader = Cartograph.default_loader)
|
61
|
+
loaded = loader.load(content)
|
62
|
+
|
63
|
+
retrieve_root_key(scope, :singular) do |root_key|
|
64
|
+
# Reassign loaded if a root key exists
|
65
|
+
loaded = loaded[root_key]
|
66
|
+
end
|
67
|
+
|
68
|
+
sculptor = Sculptor.new(loaded, @cartograph_map)
|
69
|
+
sculptor.sculpted_object = object
|
70
|
+
sculptor.sculpt(scope)
|
71
|
+
end
|
72
|
+
|
73
|
+
def extract_collection(content, scope, loader = Cartograph.default_loader)
|
74
|
+
loaded = loader.load(content)
|
75
|
+
|
76
|
+
retrieve_root_key(scope, :plural) do |root_key|
|
77
|
+
# Reassign loaded if a root key exists
|
78
|
+
loaded = loaded[root_key]
|
79
|
+
end
|
80
|
+
|
81
|
+
loaded.map do |object|
|
82
|
+
Sculptor.new(object, @cartograph_map).sculpt(scope)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
def prepend_root_key(scope, plurality, payload)
|
88
|
+
retrieve_root_key(scope, plurality) do |root_key|
|
89
|
+
# Reassign drawed if a root key exists
|
90
|
+
payload = { root_key => payload }
|
91
|
+
end
|
92
|
+
|
93
|
+
payload
|
94
|
+
end
|
95
|
+
|
96
|
+
def retrieve_root_key(scope, type, &block)
|
97
|
+
if root_key = @cartograph_map.root_key_for(scope, type)
|
98
|
+
yield root_key
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'thread'
|
2
|
+
|
3
|
+
module Cartograph
|
4
|
+
class Map
|
5
|
+
def initialize
|
6
|
+
@scope_mutex = Mutex.new
|
7
|
+
end
|
8
|
+
|
9
|
+
def property(*args, &block)
|
10
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
11
|
+
|
12
|
+
# Append scopes if we're currently mapping in a scoped block
|
13
|
+
options[:scopes] ||= []
|
14
|
+
options[:scopes] += Array(@current_scopes)
|
15
|
+
|
16
|
+
args.each do |prop|
|
17
|
+
properties << Property.new(prop, options, &block)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def properties
|
22
|
+
@properties ||= PropertyCollection.new
|
23
|
+
end
|
24
|
+
|
25
|
+
def scoped(*scopes, &block)
|
26
|
+
@scope_mutex.synchronize do
|
27
|
+
@current_scopes = scopes
|
28
|
+
|
29
|
+
instance_eval(&block) if block_given?
|
30
|
+
|
31
|
+
@current_scopes = nil
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def root_keys
|
36
|
+
@root_keys ||= []
|
37
|
+
end
|
38
|
+
|
39
|
+
def mapping(klass = nil)
|
40
|
+
@mapping = klass if klass
|
41
|
+
@mapping
|
42
|
+
end
|
43
|
+
|
44
|
+
def root_key(options)
|
45
|
+
root_keys << RootKey.new(options)
|
46
|
+
end
|
47
|
+
|
48
|
+
def cache(object = nil)
|
49
|
+
@cache = object unless object.nil?
|
50
|
+
@cache.nil? ? Cartograph.default_cache : @cache
|
51
|
+
end
|
52
|
+
|
53
|
+
def cache_key(&calculator)
|
54
|
+
@cache_calculator = calculator if block_given?
|
55
|
+
@cache_calculator.nil? ? Cartograph.default_cache_key : @cache_calculator
|
56
|
+
end
|
57
|
+
|
58
|
+
def root_key_for(scope, type)
|
59
|
+
return unless %i(singular plural).include?(type)
|
60
|
+
|
61
|
+
if (root_key = root_keys.select {|rk| rk.scopes.include?(scope) }[0])
|
62
|
+
root_key.send(type)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def dup
|
67
|
+
Cartograph::Map.new.tap do |map|
|
68
|
+
self.properties.each do |property|
|
69
|
+
map.properties << property.dup
|
70
|
+
end
|
71
|
+
|
72
|
+
map.mapping self.mapping
|
73
|
+
|
74
|
+
self.root_keys.each do |rk|
|
75
|
+
map.root_keys << rk
|
76
|
+
end
|
77
|
+
|
78
|
+
map.cache self.cache
|
79
|
+
map.cache_key &self.cache_key if self.cache_key
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def ==(other)
|
84
|
+
methods = %i(properties root_keys mapping cache cache_key)
|
85
|
+
methods.inject(true) do |current_value, method|
|
86
|
+
break unless current_value
|
87
|
+
send(method) == other.send(method)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
module Cartograph
|
2
|
+
class Property
|
3
|
+
attr_reader :name, :options
|
4
|
+
attr_accessor :map
|
5
|
+
|
6
|
+
def initialize(name, options = {}, &block)
|
7
|
+
@name = name
|
8
|
+
@options = options
|
9
|
+
|
10
|
+
if mapped_class = options[:include]
|
11
|
+
# Perform a safe duplication into our properties map
|
12
|
+
# This allows the user to define more attributes on the map should they need to
|
13
|
+
@map = mapped_class.cartograph.dup
|
14
|
+
end
|
15
|
+
|
16
|
+
if block_given?
|
17
|
+
@map ||= Map.new
|
18
|
+
block.arity > 0 ? block.call(map) : map.instance_eval(&block)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def key
|
23
|
+
(options[:key] || name).to_s
|
24
|
+
end
|
25
|
+
|
26
|
+
def value_for(object, scope = nil)
|
27
|
+
value = object.send(name)
|
28
|
+
return if value.nil?
|
29
|
+
map ? artist_value(value, scope) : value
|
30
|
+
end
|
31
|
+
|
32
|
+
def value_from(object, scope = nil)
|
33
|
+
return if object.nil?
|
34
|
+
value = object.has_key?(key) ? object[key] : object[key.to_sym]
|
35
|
+
map ? sculpt_value(value, scope) : value
|
36
|
+
end
|
37
|
+
|
38
|
+
def scopes
|
39
|
+
Array(options[:scopes] || [])
|
40
|
+
end
|
41
|
+
|
42
|
+
def plural?
|
43
|
+
!!options[:plural]
|
44
|
+
end
|
45
|
+
|
46
|
+
def dup
|
47
|
+
Property.new(name, options.dup).tap do |property|
|
48
|
+
property.map = map.dup if self.map
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def ==(other)
|
53
|
+
%i(name options map).inject(true) do |equals, method|
|
54
|
+
break unless equals
|
55
|
+
send(method) == other.send(method)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def sculpt_value(value, scope)
|
62
|
+
plural? ? Array(value).map {|v| Sculptor.new(v, map).sculpt(scope) } : Sculptor.new(value, map).sculpt(scope)
|
63
|
+
end
|
64
|
+
|
65
|
+
def artist_value(value, scope)
|
66
|
+
plural? ? Array(value).map {|v| Artist.new(v, map).draw(scope) } : Artist.new(value, map).draw(scope)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module Cartograph
|
4
|
+
class PropertyCollection
|
5
|
+
# Make this collection quack like an array
|
6
|
+
# http://words.steveklabnik.com/beware-subclassing-ruby-core-classes
|
7
|
+
extend Forwardable
|
8
|
+
def_delegators :@collection, *(Array.instance_methods - Object.instance_methods)
|
9
|
+
|
10
|
+
def initialize(*)
|
11
|
+
@collection = []
|
12
|
+
end
|
13
|
+
|
14
|
+
def filter_by_scope(scope)
|
15
|
+
select do |property|
|
16
|
+
property.scopes.include?(scope)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def ==(other)
|
21
|
+
each_with_index.inject(true) do |current_value, (property, index)|
|
22
|
+
break unless current_value
|
23
|
+
property == other[index]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Cartograph
|
2
|
+
class RootKey
|
3
|
+
attr_reader :options
|
4
|
+
|
5
|
+
def initialize(options = {})
|
6
|
+
@options = options
|
7
|
+
end
|
8
|
+
|
9
|
+
def scopes
|
10
|
+
Array(options[:scopes]) || []
|
11
|
+
end
|
12
|
+
|
13
|
+
%i(singular plural).each do |method|
|
14
|
+
define_method(method) do
|
15
|
+
options[method]
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Cartograph
|
2
|
+
class Sculptor
|
3
|
+
attr_reader :object, :map
|
4
|
+
|
5
|
+
def initialize(object, map)
|
6
|
+
@object = object
|
7
|
+
@map = map
|
8
|
+
end
|
9
|
+
|
10
|
+
def properties
|
11
|
+
map.properties
|
12
|
+
end
|
13
|
+
|
14
|
+
# Set this to pass in an object to extract into. Must
|
15
|
+
# @param object must be of the same class as the map#mapping
|
16
|
+
def sculpted_object=(object)
|
17
|
+
raise ArgumentError unless object.is_a?(map.mapping)
|
18
|
+
|
19
|
+
@sculpted_object = object
|
20
|
+
end
|
21
|
+
|
22
|
+
def sculpt(scope = nil)
|
23
|
+
return unless @object
|
24
|
+
|
25
|
+
scoped_properties = scope ? properties.filter_by_scope(scope) : properties
|
26
|
+
|
27
|
+
attributes = scoped_properties.each_with_object({}) do |property, h|
|
28
|
+
h[property.name] = property.value_from(object, scope)
|
29
|
+
end
|
30
|
+
|
31
|
+
return map.mapping.new(attributes) unless @sculpted_object
|
32
|
+
|
33
|
+
attributes.each do |name, val|
|
34
|
+
@sculpted_object[name] = val
|
35
|
+
end
|
36
|
+
|
37
|
+
@sculpted_object
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Cartograph::Artist do
|
4
|
+
let(:map) { Cartograph::Map.new }
|
5
|
+
let(:properties) { map.properties }
|
6
|
+
|
7
|
+
describe '#initialize' do
|
8
|
+
it 'initializes with an object and a map' do
|
9
|
+
object = double('object', name: 'hello')
|
10
|
+
properties << Cartograph::Property.new(:name)
|
11
|
+
|
12
|
+
artist = Cartograph::Artist.new(object, map)
|
13
|
+
|
14
|
+
expect(artist.object).to be(object)
|
15
|
+
expect(artist.map).to be(map)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '#draw' do
|
20
|
+
it 'returns a hash of mapped properties' do
|
21
|
+
object = double('object', hello: 'world')
|
22
|
+
properties << Cartograph::Property.new(:hello)
|
23
|
+
|
24
|
+
artist = Cartograph::Artist.new(object, map)
|
25
|
+
masterpiece = artist.draw
|
26
|
+
|
27
|
+
expect(masterpiece).to include('hello' => 'world')
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'raises for a property that the object does not have' do
|
31
|
+
class TestArtistNoMethod; end
|
32
|
+
object = TestArtistNoMethod.new
|
33
|
+
properties << Cartograph::Property.new(:bunk)
|
34
|
+
artist = Cartograph::Artist.new(object, map)
|
35
|
+
|
36
|
+
expect { artist.draw }.to raise_error(ArgumentError).with_message("#{object} does not respond to bunk, so we can't map it")
|
37
|
+
end
|
38
|
+
|
39
|
+
context 'for a property with a key set on it' do
|
40
|
+
it 'returns the hash with the key set correctly' do
|
41
|
+
object = double('object', hello: 'world')
|
42
|
+
properties << Cartograph::Property.new(:hello, key: :hola)
|
43
|
+
|
44
|
+
artist = Cartograph::Artist.new(object, map)
|
45
|
+
masterpiece = artist.draw
|
46
|
+
|
47
|
+
expect(masterpiece).to include('hola' => 'world')
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context 'for filtered drawing' do
|
52
|
+
it 'only returns the scoped properties' do
|
53
|
+
object = double('object', hello: 'world', foo: 'bar')
|
54
|
+
properties << Cartograph::Property.new(:hello, scopes: [:create, :read])
|
55
|
+
properties << Cartograph::Property.new(:foo, scopes: [:create])
|
56
|
+
|
57
|
+
artist = Cartograph::Artist.new(object, map)
|
58
|
+
masterpiece = artist.draw(:read)
|
59
|
+
|
60
|
+
expect(masterpiece).to eq('hello' => 'world')
|
61
|
+
end
|
62
|
+
|
63
|
+
context 'on nested properties' do
|
64
|
+
it 'only returns the nested properties within the same scope' do
|
65
|
+
child = double('child', hello: 'world', foo: 'bunk')
|
66
|
+
object = double('object', child: child)
|
67
|
+
|
68
|
+
root_property = Cartograph::Property.new(:child, scopes: [:create, :read]) do
|
69
|
+
property :hello, scopes: [:create]
|
70
|
+
property :foo, scopes: [:read]
|
71
|
+
end
|
72
|
+
|
73
|
+
properties << root_property
|
74
|
+
|
75
|
+
artist = Cartograph::Artist.new(object, map)
|
76
|
+
masterpiece = artist.draw(:read)
|
77
|
+
|
78
|
+
expect(masterpiece).to eq('child' => { 'foo' => child.foo })
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
context "with caching enabled" do
|
84
|
+
let(:cacher) { double('cacher', fetch: { foo: 'cached-value' }) }
|
85
|
+
let(:object) { double('object', foo: 'bar', cache_key: 'test-cache-key') }
|
86
|
+
|
87
|
+
it "uses the cache fetch for values" do
|
88
|
+
map.cache(cacher)
|
89
|
+
map.cache_key { |obj, scope| obj.cache_key }
|
90
|
+
map.property :foo
|
91
|
+
|
92
|
+
artist = Cartograph::Artist.new(object, map)
|
93
|
+
masterpiece = artist.draw
|
94
|
+
|
95
|
+
expect(masterpiece).to eq(cacher.fetch)
|
96
|
+
|
97
|
+
expect(cacher).to have_received(:fetch).with('test-cache-key')
|
98
|
+
end
|
99
|
+
|
100
|
+
it "uses the cache key for the object and scope" do
|
101
|
+
called = double(call: 'my-cache')
|
102
|
+
|
103
|
+
map.cache(cacher)
|
104
|
+
map.cache_key { |obj, scope| called.call(obj, scope) }
|
105
|
+
|
106
|
+
map.property :foo, scopes: [:read]
|
107
|
+
|
108
|
+
artist = Cartograph::Artist.new(object, map)
|
109
|
+
masterpiece = artist.draw(:read)
|
110
|
+
|
111
|
+
expect(called).to have_received(:call).with(object, :read)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|