property 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -0
- data/History.txt +10 -0
- data/MIT-LICENSE +19 -0
- data/README.rdoc +48 -0
- data/Rakefile +51 -0
- data/generators/property/property_generator.rb +12 -0
- data/lib/property.rb +16 -0
- data/lib/property/attribute.rb +89 -0
- data/lib/property/column.rb +35 -0
- data/lib/property/declaration.rb +120 -0
- data/lib/property/dirty.rb +98 -0
- data/lib/property/properties.rb +80 -0
- data/lib/property/serialization/json.rb +38 -0
- data/lib/property/serialization/marshal.rb +35 -0
- data/lib/property/serialization/yaml.rb +29 -0
- data/test/fixtures.rb +57 -0
- data/test/shoulda_macros/serialization.rb +71 -0
- data/test/test_helper.rb +19 -0
- data/test/unit/property/attribute_test.rb +334 -0
- data/test/unit/property/declaration_test.rb +127 -0
- data/test/unit/property/dirty_test.rb +157 -0
- data/test/unit/property/validation_test.rb +97 -0
- data/test/unit/serialization/json_test.rb +12 -0
- data/test/unit/serialization/marshal_test.rb +12 -0
- data/test/unit/serialization/yaml_test.rb +12 -0
- metadata +108 -0
data/.gitignore
ADDED
data/History.txt
ADDED
data/MIT-LICENSE
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Copyright (c) 2010 Gaspard Bucher (http://teti.ch)
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
5
|
+
in the Software without restriction, including without limitation the rights
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
8
|
+
furnished to do so, subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in
|
11
|
+
all copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
19
|
+
THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
== DESCRIPTION:
|
2
|
+
|
3
|
+
Wrap model properties into a single database column and declare properties from within the model.
|
4
|
+
|
5
|
+
website: http://zenadmin.org/635
|
6
|
+
license: MIT
|
7
|
+
|
8
|
+
== Status: Beta
|
9
|
+
|
10
|
+
The gem works fine, even though it still needs some more features like property definition
|
11
|
+
changes detections and migrations.
|
12
|
+
|
13
|
+
== Usage
|
14
|
+
|
15
|
+
You first need to create a migration to add a 'text' field named 'properties' to
|
16
|
+
your model. Something like this:
|
17
|
+
|
18
|
+
class AddPropertyToContact < ActiveRecord::Migration
|
19
|
+
def self.up
|
20
|
+
add_column :contacts, :properties, :text
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.down
|
24
|
+
remove_column :contacts, :properties
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
Once your database is ready, you need to declare the property columns:
|
29
|
+
|
30
|
+
class Contact < ActiveRecord::Base
|
31
|
+
include Property
|
32
|
+
property do |p|
|
33
|
+
p.string 'first_name', 'name', 'phone'
|
34
|
+
p.datetime 'contacted_at', :default => Proc.new {Time.now}
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
You can now read property values with:
|
39
|
+
|
40
|
+
@contact.prop['first_name']
|
41
|
+
@contact.first_name
|
42
|
+
|
43
|
+
And set them with:
|
44
|
+
|
45
|
+
@contact.update_attributes('first_name' => 'Mahatma')
|
46
|
+
@contact.prop['name'] = 'Gandhi'
|
47
|
+
@contact.name = 'Gandhi'
|
48
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
$LOAD_PATH.unshift((Pathname(__FILE__).dirname + 'lib').expand_path)
|
3
|
+
|
4
|
+
require 'property'
|
5
|
+
require 'rake'
|
6
|
+
require 'rake/testtask'
|
7
|
+
|
8
|
+
Rake::TestTask.new(:test) do |test|
|
9
|
+
test.libs << 'lib' << 'test'
|
10
|
+
test.pattern = 'test/**/**_test.rb'
|
11
|
+
test.verbose = true
|
12
|
+
end
|
13
|
+
|
14
|
+
begin
|
15
|
+
require 'rcov/rcovtask'
|
16
|
+
Rcov::RcovTask.new do |test|
|
17
|
+
test.libs << 'test' << 'lib'
|
18
|
+
test.pattern = 'test/**/**_test.rb'
|
19
|
+
test.verbose = true
|
20
|
+
test.rcov_opts = ['-T', '--exclude-only', '"test\/,^\/"']
|
21
|
+
end
|
22
|
+
rescue LoadError
|
23
|
+
task :rcov do
|
24
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install rcov"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
task :default => :test
|
29
|
+
|
30
|
+
|
31
|
+
# GEM management
|
32
|
+
begin
|
33
|
+
require 'jeweler'
|
34
|
+
Jeweler::Tasks.new do |gemspec|
|
35
|
+
gemspec.name = 'property'
|
36
|
+
gemspec.summary = 'model properties wrap into a single database column'
|
37
|
+
gemspec.description = "Wrap model properties into a single database column and declare properties from within the model."
|
38
|
+
gemspec.email = "gaspard@teti.ch"
|
39
|
+
gemspec.homepage = "http://zenadmin.org/635"
|
40
|
+
gemspec.authors = ['Renaud Kern', 'Gaspard Bucher']
|
41
|
+
gemspec.version = Property::VERSION
|
42
|
+
gemspec.rubyforge_project = 'property'
|
43
|
+
|
44
|
+
# Gem dependecies
|
45
|
+
gemspec.add_development_dependency('shoulda')
|
46
|
+
gemspec.add_dependency('active_record')
|
47
|
+
end
|
48
|
+
rescue LoadError
|
49
|
+
puts "Jeweler not available. Gem packaging tasks not available."
|
50
|
+
end
|
51
|
+
#
|
data/lib/property.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'property/attribute'
|
2
|
+
require 'property/dirty'
|
3
|
+
require 'property/properties'
|
4
|
+
require 'property/column'
|
5
|
+
require 'property/declaration'
|
6
|
+
require 'property/serialization/json'
|
7
|
+
|
8
|
+
module Property
|
9
|
+
VERSION = '0.5.0'
|
10
|
+
|
11
|
+
def self.included(base)
|
12
|
+
base.class_eval do
|
13
|
+
include ::Property::Attribute
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
module Property
|
2
|
+
# The Property::Attribute module is included in ActiveRecord model for CRUD operations
|
3
|
+
# on properties. These ared stored in a table field called 'properties' and are accessed
|
4
|
+
# with #properties or #prop and properties= methods.
|
5
|
+
#
|
6
|
+
# The properties are encoded et decoded with a serialization tool than you can change by including
|
7
|
+
# a Serialization module that should implement 'encode_properties' and 'decode_properties'.
|
8
|
+
# The default is to use Marshal through Property::Serialization::Marshal.
|
9
|
+
#
|
10
|
+
# The attributes= method filters native attributes and properties in order to store
|
11
|
+
# them apart.
|
12
|
+
#
|
13
|
+
module Attribute
|
14
|
+
|
15
|
+
def self.included(base)
|
16
|
+
base.class_eval do
|
17
|
+
include InstanceMethods
|
18
|
+
include Serialization::JSON
|
19
|
+
include Declaration
|
20
|
+
include Dirty
|
21
|
+
|
22
|
+
before_save :dump_properties
|
23
|
+
|
24
|
+
alias_method_chain :attributes=, :properties
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
module InstanceMethods
|
29
|
+
def properties
|
30
|
+
@properties ||= load_properties
|
31
|
+
end
|
32
|
+
|
33
|
+
alias_method :prop, :properties
|
34
|
+
|
35
|
+
# Define a set of properties. This acts like 'attributes=': it merges the current
|
36
|
+
# properties with the list of provided key/values. Note that unlike 'attributes=',
|
37
|
+
# the keys must be provided as strings, not symbols. For efficiency reasons and
|
38
|
+
# simplification of the API, we do not convert from symbols.
|
39
|
+
def properties=(new_properties)
|
40
|
+
return if new_properties.nil?
|
41
|
+
properties.merge!(new_properties)
|
42
|
+
end
|
43
|
+
|
44
|
+
alias_method :prop=, :properties=
|
45
|
+
|
46
|
+
# Force a reload of the properties from the ones stored in the database.
|
47
|
+
def reload_properties!
|
48
|
+
@properties = load_properties
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
def attributes_with_properties=(attributes, guard_protected_attributes = true)
|
53
|
+
columns = self.class.column_names
|
54
|
+
properties = {}
|
55
|
+
|
56
|
+
attributes.keys.each do |k|
|
57
|
+
if !respond_to?("#{k}=") && !columns.include?(k)
|
58
|
+
properties[k] = attributes.delete(k)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
self.properties = properties
|
63
|
+
self.attributes_without_properties = attributes
|
64
|
+
end
|
65
|
+
|
66
|
+
def load_properties
|
67
|
+
raw_data = read_attribute('properties')
|
68
|
+
prop = raw_data ? decode_properties(raw_data) : Properties.new
|
69
|
+
# We need to set the owner to access property definitions and enable
|
70
|
+
# type casting on write.
|
71
|
+
prop.owner = self
|
72
|
+
prop
|
73
|
+
end
|
74
|
+
|
75
|
+
def dump_properties
|
76
|
+
if @properties
|
77
|
+
@properties.compact!
|
78
|
+
if !@properties.empty?
|
79
|
+
write_attribute('properties', encode_properties(@properties))
|
80
|
+
else
|
81
|
+
write_attribute('properties', nil)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
@properties.clear_changes!
|
85
|
+
true
|
86
|
+
end
|
87
|
+
end # InstanceMethods
|
88
|
+
end # Attribute
|
89
|
+
end # Property
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
ActiveRecord.load_all!
|
3
|
+
|
4
|
+
module Property
|
5
|
+
# The Column class is used to hold information about a Property declaration,
|
6
|
+
# such as name, type and options. It is also used to typecast from strings to
|
7
|
+
# the proper type (date, integer, float, etc).
|
8
|
+
class Column < ::ActiveRecord::ConnectionAdapters::Column
|
9
|
+
|
10
|
+
def initialize(name, default, type, options={})
|
11
|
+
name = name.to_s
|
12
|
+
extract_property_options(options)
|
13
|
+
super(name, default, type, options)
|
14
|
+
end
|
15
|
+
|
16
|
+
def validate(value, errors)
|
17
|
+
if !value.kind_of?(klass)
|
18
|
+
if value.nil?
|
19
|
+
default
|
20
|
+
else
|
21
|
+
errors.add("#{name}", "invalid data type. Received #{value.class}, expected #{klass}.")
|
22
|
+
nil
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def indexed?
|
28
|
+
@indexed
|
29
|
+
end
|
30
|
+
|
31
|
+
def extract_property_options(options)
|
32
|
+
@indexed = options.delete(:indexed)
|
33
|
+
end
|
34
|
+
end # Column
|
35
|
+
end # Property
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module Property
|
2
|
+
|
3
|
+
# Property::Declaration module is used to declare property definitions in a Class. The module
|
4
|
+
# also manages property inheritence in sub-classes.
|
5
|
+
module Declaration
|
6
|
+
|
7
|
+
def self.included(base)
|
8
|
+
base.class_eval do
|
9
|
+
extend ClassMethods
|
10
|
+
include InstanceMethods
|
11
|
+
|
12
|
+
class << self
|
13
|
+
attr_accessor :own_property_columns
|
14
|
+
attr_accessor :property_definition_proxy
|
15
|
+
end
|
16
|
+
|
17
|
+
validate :properties_validation, :if => :properties
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
module ClassMethods
|
22
|
+
class DefinitionProxy
|
23
|
+
def initialize(klass)
|
24
|
+
@klass = klass
|
25
|
+
end
|
26
|
+
|
27
|
+
def column(name, default, type, options)
|
28
|
+
if columns[name.to_s]
|
29
|
+
raise TypeError.new("Property '#{name}' is already defined.")
|
30
|
+
else
|
31
|
+
own_columns[name] = Property::Column.new(name, default, type, options)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# If someday we find the need to insert other native classes directly in the DB, we
|
36
|
+
# could use this:
|
37
|
+
# p.serialize MyClass, xxx, xxx
|
38
|
+
# def serialize(klass, name, options={})
|
39
|
+
# if @klass.super_property_columns[name.to_s]
|
40
|
+
# raise TypeError.new("Property '#{name}' is already defined in a superclass.")
|
41
|
+
# elsif !@klass.validate_property_class(type)
|
42
|
+
# raise TypeError.new("Custom type '#{type}' cannot be serialized.")
|
43
|
+
# else
|
44
|
+
# # Find a way to insert the type (maybe with 'serialize'...)
|
45
|
+
# # (@klass.own_property_columns ||= {})[name] = Property::Column.new(name, type, options)
|
46
|
+
# end
|
47
|
+
# end
|
48
|
+
|
49
|
+
# def string(*args)
|
50
|
+
# options = args.extract_options!
|
51
|
+
# column_names = args
|
52
|
+
# default = options.delete(:default)
|
53
|
+
# column_names.each { |name| column(name, default, 'string', options) }
|
54
|
+
# end
|
55
|
+
%w( string text integer float decimal datetime timestamp time date binary boolean ).each do |column_type|
|
56
|
+
class_eval <<-EOV
|
57
|
+
def #{column_type}(*args)
|
58
|
+
options = args.extract_options!
|
59
|
+
column_names = args
|
60
|
+
default = options.delete(:default)
|
61
|
+
column_names.each { |name| column(name, default, '#{column_type}', options) }
|
62
|
+
end
|
63
|
+
EOV
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
def own_columns
|
68
|
+
@klass.own_property_columns ||= {}
|
69
|
+
end
|
70
|
+
|
71
|
+
def columns
|
72
|
+
@klass.property_columns
|
73
|
+
end
|
74
|
+
|
75
|
+
end
|
76
|
+
|
77
|
+
# Use this class method to declare properties that will be used in your models. Note
|
78
|
+
# that you must provide string keys. Example:
|
79
|
+
# property.string 'phone', :default => ''
|
80
|
+
#
|
81
|
+
# You can also use a block:
|
82
|
+
# property do |p|
|
83
|
+
# p.string 'phone', 'name', :default => ''
|
84
|
+
# end
|
85
|
+
def property
|
86
|
+
proxy = self.property_definition_proxy ||= DefinitionProxy.new(self)
|
87
|
+
if block_given?
|
88
|
+
yield proxy
|
89
|
+
end
|
90
|
+
proxy
|
91
|
+
end
|
92
|
+
|
93
|
+
# Return the list of all properties defined for the current class, including the properties
|
94
|
+
# defined in the parent class.
|
95
|
+
def property_columns
|
96
|
+
super_property_columns.merge(self.own_property_columns || {})
|
97
|
+
end
|
98
|
+
|
99
|
+
def property_column_names
|
100
|
+
property_columns.keys
|
101
|
+
end
|
102
|
+
|
103
|
+
def super_property_columns
|
104
|
+
if superclass.respond_to?(:property_columns)
|
105
|
+
superclass.property_columns
|
106
|
+
else
|
107
|
+
{}
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end # ClassMethods
|
111
|
+
|
112
|
+
module InstanceMethods
|
113
|
+
|
114
|
+
protected
|
115
|
+
def properties_validation
|
116
|
+
properties.validate
|
117
|
+
end
|
118
|
+
end # InsanceMethods
|
119
|
+
end # Declaration
|
120
|
+
end # Property
|
@@ -0,0 +1,98 @@
|
|
1
|
+
module Property
|
2
|
+
# This module implement ActiveRecord::Dirty functionalities with Property attributes. It
|
3
|
+
# enables the usual 'changed?' and 'changes' to include property changes. Unlike dirty,
|
4
|
+
# 'foo_changed?' and 'foo_was' are not defined in the model and should be replaced by
|
5
|
+
# #prop.foo_changed? and prop.foo_was.
|
6
|
+
#
|
7
|
+
# If you need to find the property changes only, you can use #prop.changes or prop.changed?
|
8
|
+
#
|
9
|
+
module Dirty
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def self.included(base)
|
14
|
+
base.class_eval do
|
15
|
+
alias_method_chain :changed?, :properties
|
16
|
+
alias_method_chain :changed, :properties
|
17
|
+
alias_method_chain :changes, :properties
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def changed_with_properties?
|
22
|
+
changed_without_properties? || properties.changed?
|
23
|
+
end
|
24
|
+
|
25
|
+
def changed_with_properties
|
26
|
+
changed_without_properties + properties.changed
|
27
|
+
end
|
28
|
+
|
29
|
+
def changes_with_properties
|
30
|
+
changes_without_properties.merge properties.changes
|
31
|
+
end
|
32
|
+
|
33
|
+
end # Dirty
|
34
|
+
|
35
|
+
# This module implements ActiveRecord::Dirty functionalities for the properties hash.
|
36
|
+
module DirtyProperties
|
37
|
+
CHANGED_REGEXP = %r{(.+)_changed\?$}
|
38
|
+
WAS_REGEXP = %r{(.+)_was$}
|
39
|
+
|
40
|
+
def []=(key, value)
|
41
|
+
@original_hash ||= self.dup
|
42
|
+
super
|
43
|
+
end
|
44
|
+
|
45
|
+
def delete(key)
|
46
|
+
@original_hash ||= self.dup
|
47
|
+
super
|
48
|
+
end
|
49
|
+
|
50
|
+
def merge!(other_hash)
|
51
|
+
@original_hash ||= self.dup
|
52
|
+
super
|
53
|
+
end
|
54
|
+
|
55
|
+
def changed?
|
56
|
+
!changes.empty?
|
57
|
+
end
|
58
|
+
|
59
|
+
def changed
|
60
|
+
changes.keys
|
61
|
+
end
|
62
|
+
|
63
|
+
def changes
|
64
|
+
return {} unless @original_hash
|
65
|
+
compact!
|
66
|
+
changes = {}
|
67
|
+
|
68
|
+
# look for updated value
|
69
|
+
each do |key, new_value|
|
70
|
+
if new_value != (old_value = @original_hash[key])
|
71
|
+
changes[key] = [old_value, new_value]
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# look for deleted value
|
76
|
+
(@original_hash.keys - keys).each do |key|
|
77
|
+
changes[key] = [@original_hash[key], nil]
|
78
|
+
end
|
79
|
+
|
80
|
+
changes
|
81
|
+
end
|
82
|
+
|
83
|
+
# This method should be called to reset dirty information before dump
|
84
|
+
def clear_changes!
|
85
|
+
remove_instance_variable(:@original_hash) if defined?(@original_hash)
|
86
|
+
end
|
87
|
+
|
88
|
+
def method_missing(method, *args)
|
89
|
+
if method.to_s =~ CHANGED_REGEXP
|
90
|
+
!changes[$1].nil?
|
91
|
+
elsif method.to_s =~ WAS_REGEXP
|
92
|
+
(@original_hash || self)[$1]
|
93
|
+
else
|
94
|
+
super
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end # Property
|