cartograph 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|