property 0.5.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.
- 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
|