simple_mapper 0.0.1
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.
- 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
|