simple_mapper 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +165 -0
- data/Rakefile.rb +11 -0
- data/lib/simple_mapper.rb +8 -0
- data/lib/simple_mapper/attribute.rb +119 -0
- data/lib/simple_mapper/attribute/collection.rb +130 -0
- data/lib/simple_mapper/attribute/pattern.rb +19 -0
- data/lib/simple_mapper/attributes.rb +196 -0
- data/lib/simple_mapper/attributes/types.rb +224 -0
- data/lib/simple_mapper/change_hash.rb +14 -0
- data/lib/simple_mapper/collection.rb +169 -0
- data/lib/simple_mapper/exceptions.rb +10 -0
- data/test/integration/attribute_change_tracking_test.rb +181 -0
- data/test/integration/attribute_meta_interaction_test.rb +169 -0
- data/test/integration/attribute_pattern_test.rb +77 -0
- data/test/integration/to_simple_test.rb +128 -0
- data/test/test_helper.rb +11 -0
- data/test/unit/attribute_collection_test.rb +379 -0
- data/test/unit/attribute_pattern_test.rb +55 -0
- data/test/unit/attribute_test.rb +419 -0
- data/test/unit/attributes_test.rb +561 -0
- data/test/unit/collection_array_test.rb +194 -0
- data/test/unit/collection_hash_test.rb +139 -0
- data/test/unit/types_test.rb +314 -0
- metadata +140 -0
data/README.rdoc
ADDED
@@ -0,0 +1,165 @@
|
|
1
|
+
= SimpleMapper: A Non-Relational Object Mapper
|
2
|
+
|
3
|
+
The simple data structures expressed through JSON are becoming more relevant every day.
|
4
|
+
We use them to exchange information between services via Thrift, from server to HTTP
|
5
|
+
client for RESTful services, or from server to HTTP client for Ajax-heavy web applications.
|
6
|
+
|
7
|
+
While JSON structures map nicely to core Ruby objects (Hash, Array, etc.), those structures
|
8
|
+
themselves do not provide business logic specific to an application's problem domain.
|
9
|
+
Additionally, it is increasingly the case that the JSON object structure is the canonical
|
10
|
+
representation your application receives for a given entity; a Thrift client, for instance,
|
11
|
+
will send and receive JSON to the corresponding service.
|
12
|
+
|
13
|
+
The object-relational mapper (ORM) traditionally addresses the need for business logic and
|
14
|
+
domain-specific semantics at the application tier when working with a provider of structured
|
15
|
+
data (i.e. a SQL-compliant relational database). SimpleMapper attempts to provide something
|
16
|
+
somewhat analogous to the ORM, but with JSON-based or "simple" data structures as the foundation
|
17
|
+
for structuring data, rather than a relational model of classes/relations and their constraints,
|
18
|
+
references, etc.
|
19
|
+
|
20
|
+
= What?
|
21
|
+
|
22
|
+
Say you need a service that serves and accepts JSON structures representing "users"
|
23
|
+
(because, really, who doesn't need _that_ service?). You might see data structures
|
24
|
+
like this (in JSON):
|
25
|
+
|
26
|
+
{
|
27
|
+
id: '348179ce-4d38-11df-8f4f-cd459e8422de',
|
28
|
+
registered_at: '2010-04-01 09:22:17-0400',
|
29
|
+
email: 'mister_hot_pantz@example.com',
|
30
|
+
title: 'Mr.',
|
31
|
+
first_name: 'Hot',
|
32
|
+
last_name: 'Pantz',
|
33
|
+
address: {
|
34
|
+
address: 'One My Way',
|
35
|
+
address2: 'Not That Way',
|
36
|
+
city: 'New York',
|
37
|
+
state: 'NY',
|
38
|
+
postal: '10010'
|
39
|
+
}
|
40
|
+
}
|
41
|
+
|
42
|
+
That's not a particularly complex data structure, but let's note a few things:
|
43
|
+
* The +:id+ appears to be a GUID. Fun.
|
44
|
+
* The +:registered_at+ is a timestamp with a particular format. Also fun.
|
45
|
+
* There's an +:email+ address. More fun.
|
46
|
+
|
47
|
+
All of those things and their noted funness would benefit from business logic for validation
|
48
|
+
purposes, encapsulation, etc. Our OOPified brains long for these structures to map to
|
49
|
+
an object.
|
50
|
+
|
51
|
+
So, go ahead.
|
52
|
+
|
53
|
+
class User
|
54
|
+
# Get our attribute mapping magic
|
55
|
+
include SimpleMapper::Attributes
|
56
|
+
|
57
|
+
# Define our typed attributes
|
58
|
+
maps :id, :type => :simple_uuid, :default => :from_type
|
59
|
+
maps :registered_at, :type => :timestamp, :default => :from_type
|
60
|
+
|
61
|
+
# Simple string attributes don't need a type
|
62
|
+
maps :email
|
63
|
+
maps :title
|
64
|
+
maps :first_name
|
65
|
+
maps :last_name
|
66
|
+
|
67
|
+
# nested attribute for the address.
|
68
|
+
maps :address do
|
69
|
+
# This block is evaluated in the context of new
|
70
|
+
# class that has the SimpleMapper behaviors
|
71
|
+
[:address, :address2, :city, :state, :postal].each {|attr| maps attr}
|
72
|
+
|
73
|
+
# How about a to_s that represents the full address as one string?
|
74
|
+
def to_s
|
75
|
+
"#{address}; #{address2}; #{city}, #{state} #{postal}"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
Now you have a class that describes the data structure you're working with.
|
81
|
+
What now?
|
82
|
+
|
83
|
+
You can create new objects and spit out the simple structure.
|
84
|
+
|
85
|
+
user = User.new(:email => 'mister_hot_pantz@example.com',
|
86
|
+
:title => 'Mr.',
|
87
|
+
:first_name => 'Hot',
|
88
|
+
:last_name => 'Pantz',
|
89
|
+
:address => {:address => 'One My Way',
|
90
|
+
:address2 => 'Not That Way',
|
91
|
+
:city => 'New York',
|
92
|
+
:state => 'NY',
|
93
|
+
:postal => '10010'})
|
94
|
+
# the :simple_uuid type gives GUIDs, with a :default of :from_type
|
95
|
+
# meaning it'll autopopulate
|
96
|
+
# This prints some GUID like '348179ce-4d38-11df-8f4f-cd459e8422de':
|
97
|
+
puts user.id
|
98
|
+
|
99
|
+
# And the :default of :from_type on a :timestamp type gets the current date/time.
|
100
|
+
# This will print 'DateTime'
|
101
|
+
puts user.registered_at.class
|
102
|
+
# This will print it using DateTime's default :to_s format
|
103
|
+
# like '2010-0421T07:41:46-04:00'
|
104
|
+
puts user.registered_at
|
105
|
+
|
106
|
+
# This will print out 'One My Way; Not That Way; New York, NY 10010':
|
107
|
+
puts user.address
|
108
|
+
|
109
|
+
# The :to_simple method dumps the structure out in simple object format,
|
110
|
+
# which is readily JSON-ifiable. However, it'll enforce defaults and type
|
111
|
+
# formatting and such, so that things like timestamps will be stringified with
|
112
|
+
# the correct format.
|
113
|
+
user.to_simple
|
114
|
+
|
115
|
+
# Results in a structure like:
|
116
|
+
{
|
117
|
+
:id => '348179ce-4d38-11df-8f4f-cd459e8422de',
|
118
|
+
:registered_at => '2010-04-21 07:41:46-04:00',
|
119
|
+
:email => 'mister_hot_pantz@example.com',
|
120
|
+
:title => 'Mr.',
|
121
|
+
:first_name => 'Hot',
|
122
|
+
:last_name => 'Pantz',
|
123
|
+
:address => {
|
124
|
+
:address => 'One My Way',
|
125
|
+
:address2 => 'Not That Way',
|
126
|
+
:city => 'New York',
|
127
|
+
:state => 'NY',
|
128
|
+
:postal => '10010',
|
129
|
+
},
|
130
|
+
}
|
131
|
+
|
132
|
+
So, the +:new+ constructor and the +:to_simple+ method give you input and output
|
133
|
+
from/to simple structures, while the +SimpleMapper::Attributes+ module gives you
|
134
|
+
semantics for defining higher-level classes on top of those simple structures.
|
135
|
+
|
136
|
+
What if my service needs to have something analogous to an update, such that I only
|
137
|
+
get the simple structure for attributes that were changed?
|
138
|
+
|
139
|
+
user.last_name = 'Pantalonez'
|
140
|
+
user.to_simple(:changed => true)
|
141
|
+
# Results in:
|
142
|
+
# { :last_name => 'Pantalonez' }
|
143
|
+
|
144
|
+
The +:changed+ option indicates that we only want attributes that were altered since
|
145
|
+
the instance was instantiated. It doesn't care if the values actually differ from the
|
146
|
+
original, it only cares if the attribute was assigned since creation.
|
147
|
+
|
148
|
+
Similarly, you can provide a +:defined+ option to the +:to_simple+ invocation, and
|
149
|
+
you'll only get attributes in the resulting structure that have a non-nil value.
|
150
|
+
This is useful if you're ultimately dealing with a data source that manages such
|
151
|
+
things itself (like allowing +NULL+ on a particular database column) or is sparse
|
152
|
+
and would thus prefer to not have any entry for an undefined value at all (like
|
153
|
+
Cassandra, MongoDB, etc.)
|
154
|
+
|
155
|
+
= What's Coming
|
156
|
+
|
157
|
+
SimpleMapper is young as of this writing. There's a basic type system with
|
158
|
+
defaults. Support for nested structures is pretty simple, as shown above.
|
159
|
+
|
160
|
+
Features expected in the near future include:
|
161
|
+
* collections: deal with an attribute that is a collection
|
162
|
+
* pattern-based collections: group all key/value pairs into a single collection attribute
|
163
|
+
for any keys that match a developer-specific pattern
|
164
|
+
* ActiveModel compliance to allow validation, callbacks, etc.
|
165
|
+
|
data/Rakefile.rb
ADDED
@@ -0,0 +1,119 @@
|
|
1
|
+
module SimpleMapper
|
2
|
+
class Attribute
|
3
|
+
Options = [
|
4
|
+
:default,
|
5
|
+
:key,
|
6
|
+
:type,
|
7
|
+
:mapper,
|
8
|
+
]
|
9
|
+
attr_accessor :key, :name, :default, :mapper
|
10
|
+
|
11
|
+
def type=(new_type)
|
12
|
+
@type = new_type
|
13
|
+
end
|
14
|
+
|
15
|
+
def type
|
16
|
+
@type || mapper
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(name, options = {})
|
20
|
+
self.key = self.name = name
|
21
|
+
process_options(options)
|
22
|
+
end
|
23
|
+
|
24
|
+
def process_options(options = {})
|
25
|
+
Options.each do |option|
|
26
|
+
self.send(:"#{option.to_s}=", options[option]) if options[option]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def source_value(object)
|
31
|
+
source = object.simple_mapper_source
|
32
|
+
source.has_key?(key) ? source[key] : source[key.to_s]
|
33
|
+
end
|
34
|
+
|
35
|
+
def transformed_source_value(object)
|
36
|
+
val = source_value(object)
|
37
|
+
val = default_value(object) if val.nil?
|
38
|
+
if type = self.type
|
39
|
+
if type.respond_to?(:decode)
|
40
|
+
type.decode(val)
|
41
|
+
else
|
42
|
+
type = SimpleMapper::Attributes.type_for(type)
|
43
|
+
if expected = type[:expected_type] and val.instance_of? expected
|
44
|
+
val
|
45
|
+
else
|
46
|
+
type[:converter].decode(val)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
else
|
50
|
+
val
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def value(object)
|
55
|
+
object.send(name)
|
56
|
+
end
|
57
|
+
|
58
|
+
def converter
|
59
|
+
return nil unless type
|
60
|
+
converter = type.respond_to?(:encode) || type.respond_to?(:decode) ? type : (t = SimpleMapper::Attributes.type_for(type) and t[:converter])
|
61
|
+
raise SimpleMapper::InvalidTypeException unless converter
|
62
|
+
converter
|
63
|
+
end
|
64
|
+
|
65
|
+
def encode(value)
|
66
|
+
return value unless c = converter
|
67
|
+
c.encode(value)
|
68
|
+
end
|
69
|
+
|
70
|
+
def to_simple(object, container, options = {})
|
71
|
+
raw_value = self.value(object)
|
72
|
+
if mapper
|
73
|
+
value = raw_value.nil? ? nil : raw_value.to_simple(options)
|
74
|
+
else
|
75
|
+
value = encode(raw_value)
|
76
|
+
end
|
77
|
+
container[options[:string_keys] ? key.to_s : key] = value unless value.nil? and options[:defined]
|
78
|
+
end
|
79
|
+
|
80
|
+
def change_tracking_for(object)
|
81
|
+
object.simple_mapper_changes
|
82
|
+
end
|
83
|
+
|
84
|
+
def changed!(object, flag=true)
|
85
|
+
if flag
|
86
|
+
change_tracking_for(object)[name] = true
|
87
|
+
else
|
88
|
+
change_tracking_for(object).delete(name)
|
89
|
+
false
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def changed?(object)
|
94
|
+
if mapper and val = value(object)
|
95
|
+
val.changed?
|
96
|
+
else
|
97
|
+
(change_tracking_for(object)[name] && true) || false
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def default_value(object)
|
102
|
+
case d = self.default
|
103
|
+
when :from_type
|
104
|
+
converter.default
|
105
|
+
when Symbol
|
106
|
+
object.send(d)
|
107
|
+
else
|
108
|
+
nil
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def freeze_for(object)
|
113
|
+
if mapper and val = value(object)
|
114
|
+
val.freeze
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# Include in a SimpleMapper::Attribute to give it collection behaviors:
|
2
|
+
# * have an attribute be a collection (i.e. a hash or array) of values
|
3
|
+
# rather than a single value
|
4
|
+
# * map N keys from a source value to the attribute, based on the +:member_key?+ test
|
5
|
+
# * map keys/values from the collection object back to a "source" structure
|
6
|
+
module SimpleMapper::Attribute::Collection
|
7
|
+
# Given an _object_ that has the attribute represented by the receiver,
|
8
|
+
# returns the source value for the attribute after applying defaults and types.
|
9
|
+
# The type check is applied to either the raw source value (if one exists) or
|
10
|
+
# the default value (if no source exists and a default was specified for the attribute).
|
11
|
+
#
|
12
|
+
# Type checking is not enforced here; it is expected that +:source_value+,
|
13
|
+
# +:default_value+, and +:apply_type+ will all return objects of the expected collection
|
14
|
+
# type (or consistent interface).
|
15
|
+
def transformed_source_value(object)
|
16
|
+
val = source_value(object)
|
17
|
+
val = default_value(object) if val.nil?
|
18
|
+
val = apply_type(val) if type
|
19
|
+
val.change_tracking = true if val.respond_to?(:change_tracking)
|
20
|
+
val
|
21
|
+
end
|
22
|
+
|
23
|
+
def member_key?(key)
|
24
|
+
false
|
25
|
+
end
|
26
|
+
|
27
|
+
def source_value(object)
|
28
|
+
object.simple_mapper_source.inject(new_collection) do |hash, keyval|
|
29
|
+
hash[from_simple_key(keyval[0])] = keyval[1] if member_key?(keyval[0])
|
30
|
+
hash
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# If the receiver has a valid type specified, returns a new collection based on
|
35
|
+
# _value_, with the key/value pairs from _value_ but each value encoded by
|
36
|
+
# the type converter.
|
37
|
+
def apply_type(value)
|
38
|
+
converter = self.converter
|
39
|
+
value.inject(new_collection) do |hash, keyval|
|
40
|
+
hash[keyval[0]] = converter.decode(keyval[1])
|
41
|
+
hash
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Returns a new collection object to be used as the basis for building the attribute's
|
46
|
+
# value collection; by default, returns instances of SimpleMapper::Collection::Hash.
|
47
|
+
# Override this to alter the kinds of collections your attributes work with.
|
48
|
+
def new_collection
|
49
|
+
h = SimpleMapper::Collection::Hash.new
|
50
|
+
h.attribute = self
|
51
|
+
h
|
52
|
+
end
|
53
|
+
|
54
|
+
# Given a _key_ from the source structure (the "simple structure"), returns a
|
55
|
+
# transformed key as it will be entered in the attribute's collection value.
|
56
|
+
# By default, this simply passes through _key_, but it can be overridden to
|
57
|
+
# allow for more sophisticated source/object mappings.
|
58
|
+
def from_simple_key(key)
|
59
|
+
key
|
60
|
+
end
|
61
|
+
|
62
|
+
# The reverse of +:to_simple_key+, given a _key_ from the attribute collection,
|
63
|
+
# returns the transformed version of that key as it should appear in the "simple"
|
64
|
+
# representation of the object.
|
65
|
+
def to_simple_key(key)
|
66
|
+
key
|
67
|
+
end
|
68
|
+
|
69
|
+
def freeze_for(object)
|
70
|
+
val = value(object)
|
71
|
+
if val
|
72
|
+
if mapper
|
73
|
+
if val.respond_to?(:values)
|
74
|
+
val.values.each {|member| member.freeze}
|
75
|
+
else
|
76
|
+
val.each {|member| member.freeze}
|
77
|
+
end
|
78
|
+
end
|
79
|
+
val.freeze
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def changed?(object)
|
84
|
+
val = value(object)
|
85
|
+
return true if val.changed_members.size > 0
|
86
|
+
return true if mapper and val.find {|keyval| keyval[1].changed?}
|
87
|
+
false
|
88
|
+
end
|
89
|
+
|
90
|
+
# Converts the _object_'s attribute value into its simple representation,
|
91
|
+
# putting the keys/values into _container_. This is conceptually consistent
|
92
|
+
# with +SimpleMapper::Attributes#to_simple+, but adds a few collection-oriented
|
93
|
+
# concerns:
|
94
|
+
# * the attribute's value is assumed to be a collection with N key/value pairs
|
95
|
+
# to be mapped into _container_
|
96
|
+
# * the keys are transformed via +:to_simple_key+ on their way into _container_.
|
97
|
+
#
|
98
|
+
# This will work with any kind of _container_ that can be assigned to via +[]=+,
|
99
|
+
# and any value for the attribute that supports +:inject+ in the same manner as
|
100
|
+
# a Hash (yields the accumulated value and a key/value pair to the block).
|
101
|
+
def to_simple(object, container, options = {})
|
102
|
+
val = value(object)
|
103
|
+
mapper = self.mapper
|
104
|
+
strings = options[:string_keys] || false
|
105
|
+
changed_members = change_tracking_for(val)
|
106
|
+
if options[:changed]
|
107
|
+
if mapper
|
108
|
+
change_proc = Proc.new do |key, val|
|
109
|
+
changed_members[key] or (! val.nil? and val.changed?)
|
110
|
+
end
|
111
|
+
else
|
112
|
+
change_proc = Proc.new {|k, v| changed_members[k]}
|
113
|
+
end
|
114
|
+
else
|
115
|
+
change_proc = nil
|
116
|
+
end
|
117
|
+
changes = options[:changed] || false
|
118
|
+
container = val.inject(container) do |hash, keyvalue|
|
119
|
+
key = to_simple_key(keyvalue[0])
|
120
|
+
hash[strings ? key.to_s : key] = mapper ? keyvalue[1].to_simple(options) : encode(keyvalue[1]) if ! change_proc or change_proc.call(*keyvalue)
|
121
|
+
hash
|
122
|
+
end
|
123
|
+
if (change_proc or options[:all]) and ! options[:defined]
|
124
|
+
changed_members.keys.find_all {|x| ! val.is_member?(x)}.each do |key|
|
125
|
+
container[to_simple_key(key)] = nil
|
126
|
+
end
|
127
|
+
end
|
128
|
+
container
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
class SimpleMapper::Attribute::Pattern < SimpleMapper::Attribute
|
2
|
+
include SimpleMapper::Attribute::Collection
|
3
|
+
|
4
|
+
attr_reader :pattern
|
5
|
+
|
6
|
+
def initialize(name, options = {})
|
7
|
+
super(name, options)
|
8
|
+
self.pattern = options[:pattern]
|
9
|
+
end
|
10
|
+
|
11
|
+
def pattern=(value)
|
12
|
+
raise SimpleMapper::InvalidPatternException unless value.respond_to?(:match)
|
13
|
+
@pattern = value
|
14
|
+
end
|
15
|
+
|
16
|
+
def member_key?(key)
|
17
|
+
(pattern.match(key.to_s) and true) or false
|
18
|
+
end
|
19
|
+
end
|