property 0.8.2 → 0.9.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/History.txt CHANGED
@@ -1,41 +1,51 @@
1
+ == 0.9.0 2010-03-20
2
+
3
+ * 3 major enhancement
4
+ * Added simple index support.
5
+ * Added complex index support.
6
+ * Added simple hooks for indexes when properties are stored in a different model.
7
+
8
+ * 1 minor enhancement
9
+ * Added 'has_column?' to schema.
10
+
1
11
  == 0.8.2 2010-02-16
2
12
 
3
13
  * 2 minor enhancements
4
- * fixed a bug where properties would be dumped even if none changed
5
- * fixed a bug where properties would not be dumped soon enough to mark
6
- storage object as dirty
14
+ * Fixed a bug where properties would be dumped even if none changed.
15
+ * Fixed a bug where properties would not be dumped soon enough to mark
16
+ storage object as dirty.
7
17
 
8
18
  == 0.8.1 2010-02-14
9
19
 
10
20
  * 2 major enhancement
11
- * enabled behavior method definitions
12
- * enabled external storage with 'store_properties_in' method
21
+ * Enabled behavior method definitions.
22
+ * Enabled external storage with 'store_properties_in' method.
13
23
 
14
24
  == 0.8.0 2010-02-11
15
25
 
16
26
  * 3 major enhancements
17
- * enabled Behaviors that can be added on an instance
18
- * enabled non-DB types
19
- * 100% test coverage
27
+ * Enabled Behaviors that can be added on an instance.
28
+ * Enabled non-DB types.
29
+ * 100% test coverage.
20
30
 
21
31
  == 0.7.0 2010-02-11
22
32
 
23
33
  * 2 major enhancement
24
- * enabled instance property definitions
25
- * Time is now natively parsed by json (no typecast)
34
+ * Enabled instance property definitions.
35
+ * Time is now natively parsed by json (no typecast).
26
36
 
27
37
  == 0.6.0 2010-02-11
28
38
 
29
39
  * 1 major enhancement
30
- * enabled ruby accessors in model
40
+ * Enabled ruby accessors in model.
31
41
 
32
42
  == 0.5.0 2010-02-11
33
43
 
34
44
  * 2 major enhancement
35
- * changed plugin into gem
36
- * using Rails columns to handle defaults and type casting
45
+ * Changed plugin into gem.
46
+ * Using Rails columns to handle defaults and type casting.
37
47
 
38
48
  == 0.4.0 2010-02-02
39
49
 
40
50
  * 1 major enhancement
41
- * initial plugin code
51
+ * Initial plugin code.
data/lib/property.rb CHANGED
@@ -5,15 +5,21 @@ require 'property/column'
5
5
  require 'property/behavior'
6
6
  require 'property/schema'
7
7
  require 'property/declaration'
8
+ require 'property/db'
9
+ require 'property/index'
8
10
  require 'property/serialization/json'
9
11
  require 'property/core_ext/time'
10
12
 
11
13
  module Property
12
- VERSION = '0.8.2'
14
+ VERSION = '0.9.0'
13
15
 
14
16
  def self.included(base)
15
17
  base.class_eval do
16
- include ::Property::Attribute
18
+ include Attribute
19
+ include Serialization::JSON
20
+ include Declaration
21
+ include Dirty
22
+ include Index
17
23
  end
18
24
  end
19
25
 
@@ -17,9 +17,6 @@ module Property
17
17
 
18
18
  base.class_eval do
19
19
  include InstanceMethods
20
- include Serialization::JSON
21
- include Declaration
22
- include Dirty
23
20
 
24
21
  store_properties_in self
25
22
 
@@ -17,6 +17,7 @@ module Property
17
17
  def initialize(name)
18
18
  @name = name
19
19
  @included_in_schemas = []
20
+ @group_indexes = []
20
21
  @accessor_module = build_accessor_module
21
22
  end
22
23
 
@@ -25,6 +26,24 @@ module Property
25
26
  @columns ||= {}
26
27
  end
27
28
 
29
+ # Return a list of index definitions in the form [type, key, proc_or_nil]
30
+ def indexes
31
+ columns.values.select do |c|
32
+ c.indexed?
33
+ end.map do |c|
34
+ if c.index == true
35
+ [c.type, c.name]
36
+ else
37
+ [c.type, c.name, c.index]
38
+ end
39
+ end + @group_indexes
40
+ end
41
+
42
+ # Return true if the Behavior contains the given column (property).
43
+ def has_column?(name)
44
+ column_names.include?(name)
45
+ end
46
+
28
47
  # Return the list of column names.
29
48
  def column_names
30
49
  columns.keys
@@ -64,6 +83,12 @@ module Property
64
83
  end
65
84
  end
66
85
 
86
+ # @internal
87
+ def add_index(type, proc)
88
+ # type, key, proc
89
+ @group_indexes << [type, nil, proc]
90
+ end
91
+
67
92
  private
68
93
  def build_accessor_module
69
94
  accessor_module = Module.new
@@ -95,6 +120,21 @@ module Property
95
120
  behavior.add_column(Property::Column.new(name, nil, klass, options))
96
121
  end
97
122
 
123
+ # This is used to create complex indexes with the following syntax:
124
+ #
125
+ # p.index(:text) do |r| # r = record
126
+ # {
127
+ # "high" => "gender:#{r.gender} age:#{r.age} name:#{r.name}",
128
+ # "name_#{r.lang}" => r.name, # multi-lingual index
129
+ # }
130
+ # end
131
+ #
132
+ # The first argument is the type (used to locate the table where the data will be stored) and the block
133
+ # will be yielded with the record and should return a hash of key => value pairs.
134
+ def index(type, &block)
135
+ behavior.add_index(type, block)
136
+ end
137
+
98
138
  alias actions class_eval
99
139
  end
100
140
  end
@@ -6,6 +6,8 @@ module Property
6
6
  # such as name, type and options. It is also used to typecast from strings to
7
7
  # the proper type (date, integer, float, etc).
8
8
  class Column < ::ActiveRecord::ConnectionAdapters::Column
9
+ attr_accessor :index
10
+
9
11
  SAFE_NAMES_REGEXP = %r{\A[a-zA-Z_]+\Z}
10
12
 
11
13
  def initialize(name, default, type, options={})
@@ -28,7 +30,7 @@ module Property
28
30
  end
29
31
 
30
32
  def indexed?
31
- @indexed
33
+ @index
32
34
  end
33
35
 
34
36
  def default_for(owner)
@@ -59,7 +61,7 @@ module Property
59
61
 
60
62
  private
61
63
  def extract_property_options(options)
62
- @indexed = options.delete(:indexed)
64
+ @index = options.delete(:index) || options.delete(:indexed)
63
65
  end
64
66
 
65
67
  def extract_default(default)
@@ -0,0 +1,52 @@
1
+ # FIXME: we should patch the connection adapters instead of having 'case, when' evaluated each time
2
+ # For example:
3
+ # module ActiveRecord
4
+ # module ConnectionAdapters
5
+ # class MysqlAdapter
6
+ # include Zena::Db::MysqlAdditions
7
+ # end
8
+ # end
9
+ # end
10
+
11
+
12
+ module Property
13
+
14
+ # This module is just a helper to fetch raw data from the database and could be removed in future versions of Rails
15
+ # if the framework provides these methods.
16
+ module Db
17
+ extend self
18
+
19
+ def adapter
20
+ connection.class.to_s[/ConnectionAdapters::(.*)Adapter/,1].downcase
21
+ end
22
+
23
+ def execute(*args)
24
+ ActiveRecord::Base.connection.execute(*args)
25
+ end
26
+
27
+ def connection
28
+ ActiveRecord::Base.connection
29
+ end
30
+
31
+ # Insert a list of values (multicolumn insert). The values should be properly escaped before
32
+ # being passed to this method.
33
+ def insert_many(table, columns, values)
34
+ values = values.compact.uniq
35
+ case adapter
36
+ when 'sqlite3'
37
+ pre_query = "INSERT INTO #{table} (#{columns.join(',')}) VALUES "
38
+ values.each do |v|
39
+ execute pre_query + "(#{v.join(',')})"
40
+ end
41
+ else
42
+ values = values.map {|v| "(#{v.join(',')})"}.join(', ')
43
+ execute "INSERT INTO #{table} (#{columns.map{|c| "`#{c}`"}.join(',')}) VALUES #{values}"
44
+ end
45
+ end
46
+
47
+ def fetch_attributes(attributes, table_name, sql)
48
+ sql = "SELECT #{attributes.map{|a| connection.quote_column_name(a)}.join(',')} FROM #{table_name} WHERE #{sql}"
49
+ connection.select_all(sql)
50
+ end
51
+ end # Db
52
+ end # Property
@@ -38,13 +38,18 @@ module Property
38
38
  schema.behave_like behavior
39
39
  end
40
40
 
41
- # Use this class method to declare properties that will be used in your models.
41
+ # Use this class method to declare properties and indexes that will be used in your models.
42
42
  # Example:
43
- # property.string 'phone', :default => ''
43
+ # property.string 'phone', :default => '', :indexed => true
44
44
  #
45
45
  # You can also use a block:
46
46
  # property do |p|
47
47
  # p.string 'phone', 'name', :default => ''
48
+ # p.index(:string) do |r|
49
+ # {
50
+ # "name_#{r.lang}" => r.name,
51
+ # }
52
+ # end
48
53
  # end
49
54
  def property(&block)
50
55
  schema.behavior.property(&block)
@@ -0,0 +1,131 @@
1
+ require 'versions/after_commit' # we need Versions gem's 'after_commit'
2
+
3
+ module Property
4
+
5
+ # Property::Declaration module is used to declare property definitions in a Class. The module
6
+ # also manages property inheritence in sub-classes.
7
+ module Index
8
+
9
+ def self.included(base)
10
+ base.class_eval do
11
+ extend ClassMethods
12
+ include InstanceMethods
13
+ before_save :property_index
14
+ before_destroy :property_index_destroy
15
+ end
16
+ end
17
+
18
+ module ClassMethods
19
+ end
20
+
21
+ module InstanceMethods
22
+
23
+ private
24
+ # Retrieve the current indexes for a given group (:string, :text, etc)
25
+ def get_indexes(group_name)
26
+ return {} if new_record?
27
+ res = {}
28
+ Property::Db.fetch_attributes(['key', 'value'], index_table_name(group_name), index_reader_sql).each do |row|
29
+ res[row['key']] = row['value']
30
+ end
31
+ res
32
+ end
33
+
34
+ def index_reader_sql
35
+ index_reader.map {|k, v| "#{k} = #{self.class.connection.quote(v)}"}.join(' AND ')
36
+ end
37
+
38
+ def index_reader
39
+ {index_foreign_key => self.id}
40
+ end
41
+
42
+ alias index_writer index_reader
43
+
44
+ def index_table_name(group_name)
45
+ "i_#{group_name}_#{self.class.table_name}"
46
+ end
47
+
48
+ def index_foreign_key
49
+ self.class.table_name.singularize.foreign_key
50
+ end
51
+
52
+ # This method prepares the index
53
+ def property_index
54
+ connection = self.class.connection
55
+ reader_sql = index_reader_sql
56
+ foreign_keys = nil
57
+ foreign_values = nil
58
+
59
+ schema.index_groups.each do |group_name, definitions|
60
+ old_indexes = get_indexes(group_name)
61
+ cur_indexes = {}
62
+ definitions.each do |key, proc|
63
+ if key
64
+ value = prop[key]
65
+ if !value.blank?
66
+ if proc
67
+ cur_indexes.merge!(proc.call(self))
68
+ else
69
+ cur_indexes[key] = value
70
+ end
71
+ end
72
+ else
73
+ cur_indexes.merge!(proc.call(self))
74
+ end
75
+ end
76
+
77
+ old_keys = old_indexes.keys
78
+ cur_keys = cur_indexes.keys
79
+
80
+ new_keys = cur_keys - old_keys
81
+ del_keys = old_keys - cur_keys
82
+ upd_keys = cur_keys & old_keys
83
+
84
+ after_commit do
85
+ table_name = index_table_name(group_name)
86
+
87
+ upd_keys.each do |key|
88
+ value = cur_indexes[key]
89
+ if value.blank?
90
+ del_keys << key
91
+ else
92
+ connection.execute "UPDATE #{table_name} SET value = #{connection.quote(cur_indexes[key])} WHERE #{reader_sql} AND key = #{connection.quote(key)}"
93
+ end
94
+ end
95
+
96
+ if !del_keys.empty?
97
+ connection.execute "DELETE FROM #{table_name} WHERE #{reader_sql} AND key IN (#{del_keys.map{|key| connection.quote(key)}.join(',')})"
98
+ end
99
+
100
+ new_keys.reject! {|k| cur_indexes[k].blank? }
101
+ if !new_keys.empty?
102
+ # we evaluate this now to have the id on record creation
103
+ foreign_keys ||= index_writer.keys
104
+ foreign_values ||= foreign_keys.map {|k| index_writer[k]}
105
+
106
+ Property::Db.insert_many(
107
+ table_name,
108
+ foreign_keys + ['key', 'value'],
109
+ new_keys.map do |key|
110
+ foreign_values + [connection.quote(key), connection.quote(cur_indexes[key])]
111
+ end
112
+ )
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ # Remove all index entries on destroy
119
+ def property_index_destroy
120
+ connection = self.class.connection
121
+ foreign_key = index_foreign_key
122
+ current_id = self.id
123
+ schema.index_groups.each do |group_name, definitions|
124
+ after_commit do
125
+ connection.execute "DELETE FROM #{index_table_name(group_name)} WHERE #{foreign_key} = #{current_id}"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -55,6 +55,15 @@ module Property
55
55
  columns.keys
56
56
  end
57
57
 
58
+ # Return true if the schema has a property with the given name.
59
+ def has_column?(name)
60
+ name = name.to_s
61
+ [@behaviors].flatten.each do |behavior|
62
+ return true if behavior.has_column?(name)
63
+ end
64
+ false
65
+ end
66
+
58
67
  # Return column definitions from all included behaviors.
59
68
  def columns
60
69
  columns = {}
@@ -64,6 +73,17 @@ module Property
64
73
  columns
65
74
  end
66
75
 
76
+ # Return a hash with indexed types as keys and index definitions as values.
77
+ def index_groups
78
+ index_groups = {}
79
+ @behaviors.flatten.uniq.each do |b|
80
+ b.indexes.each do |list|
81
+ (index_groups[list.first] ||= []) << list[1..-1]
82
+ end
83
+ end
84
+ index_groups
85
+ end
86
+
67
87
  private
68
88
  def include_behavior(behavior)
69
89
  return if behaviors.include?(behavior)
data/property.gemspec CHANGED
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{property}
8
- s.version = "0.8.2"
8
+ s.version = "0.9.0"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Renaud Kern", "Gaspard Bucher"]
12
- s.date = %q{2010-02-16}
12
+ s.date = %q{2010-03-20}
13
13
  s.description = %q{Wrap model properties into a single database column and declare properties from within the model.}
14
14
  s.email = %q{gaspard@teti.ch}
15
15
  s.extra_rdoc_files = [
@@ -27,8 +27,10 @@ Gem::Specification.new do |s|
27
27
  "lib/property/behavior.rb",
28
28
  "lib/property/column.rb",
29
29
  "lib/property/core_ext/time.rb",
30
+ "lib/property/db.rb",
30
31
  "lib/property/declaration.rb",
31
32
  "lib/property/dirty.rb",
33
+ "lib/property/index.rb",
32
34
  "lib/property/properties.rb",
33
35
  "lib/property/schema.rb",
34
36
  "lib/property/serialization/json.rb",
@@ -42,6 +44,9 @@ Gem::Specification.new do |s|
42
44
  "test/unit/property/behavior_test.rb",
43
45
  "test/unit/property/declaration_test.rb",
44
46
  "test/unit/property/dirty_test.rb",
47
+ "test/unit/property/index_complex_test.rb",
48
+ "test/unit/property/index_foreign_test.rb",
49
+ "test/unit/property/index_simple_test.rb",
45
50
  "test/unit/property/validation_test.rb",
46
51
  "test/unit/serialization/json_test.rb",
47
52
  "test/unit/serialization/marshal_test.rb",
@@ -51,7 +56,7 @@ Gem::Specification.new do |s|
51
56
  s.rdoc_options = ["--charset=UTF-8"]
52
57
  s.require_paths = ["lib"]
53
58
  s.rubyforge_project = %q{property}
54
- s.rubygems_version = %q{1.3.5}
59
+ s.rubygems_version = %q{1.3.6}
55
60
  s.summary = %q{model properties wrap into a single database column}
56
61
  s.test_files = [
57
62
  "test/fixtures.rb",
@@ -61,6 +66,9 @@ Gem::Specification.new do |s|
61
66
  "test/unit/property/behavior_test.rb",
62
67
  "test/unit/property/declaration_test.rb",
63
68
  "test/unit/property/dirty_test.rb",
69
+ "test/unit/property/index_complex_test.rb",
70
+ "test/unit/property/index_foreign_test.rb",
71
+ "test/unit/property/index_simple_test.rb",
64
72
  "test/unit/property/validation_test.rb",
65
73
  "test/unit/serialization/json_test.rb",
66
74
  "test/unit/serialization/marshal_test.rb",
data/test/fixtures.rb CHANGED
@@ -1,8 +1,8 @@
1
1
 
2
2
  class Employee < ActiveRecord::Base
3
3
  include Property
4
- property.string 'first_name', :default => '', :indexed => true
5
- property.string 'last_name', :default => '', :indexed => true
4
+ property.string 'first_name', :default => '', :index => true
5
+ property.string 'last_name', :default => '', :index => true
6
6
  property.float 'age'
7
7
  end
8
8
 
@@ -46,25 +46,51 @@ end
46
46
  begin
47
47
  class PropertyMigration < ActiveRecord::Migration
48
48
  def self.down
49
- drop_table "employees"
50
- drop_table "versions"
49
+ drop_table 'employees'
50
+ drop_table 'versions'
51
+ drop_table 'string_index'
51
52
  end
53
+
52
54
  def self.up
53
- create_table "employees" do |t|
54
- t.string "type"
55
- t.text "properties"
55
+ create_table 'employees' do |t|
56
+ t.string 'type'
57
+ t.text 'properties'
56
58
  end
57
59
 
58
- create_table "versions" do |t|
60
+ create_table 'versions' do |t|
59
61
  t.integer 'employee_id'
60
- t.string "properties"
61
- t.string "title"
62
- t.string "comment"
62
+ t.string 'properties'
63
+ t.string 'title'
64
+ t.string 'comment'
65
+ t.string 'lang'
63
66
  t.timestamps
64
67
  end
65
68
 
66
- create_table "dummies" do |t|
67
- t.text "properties"
69
+ create_table 'dummies' do |t|
70
+ t.text 'properties'
71
+ end
72
+
73
+ # index strings in employees
74
+ create_table 'i_string_employees' do |t|
75
+ t.integer 'employee_id'
76
+ t.integer 'version_id'
77
+ t.string 'key'
78
+ t.string 'value'
79
+ end
80
+
81
+ # index integer in employees
82
+ create_table 'i_integer_employees' do |t|
83
+ t.integer 'employee_id'
84
+ t.integer 'version_id'
85
+ t.string 'key'
86
+ t.integer 'value'
87
+ end
88
+
89
+ # index text in employees
90
+ create_table 'i_text_employees' do |t|
91
+ t.integer 'employee_id'
92
+ t.string 'key'
93
+ t.text 'value'
68
94
  end
69
95
  end
70
96
  end
data/test/test_helper.rb CHANGED
@@ -7,14 +7,4 @@ require 'active_record'
7
7
  require 'property'
8
8
  require 'shoulda_macros/serialization'
9
9
  require 'fixtures'
10
-
11
- class Test::Unit::TestCase
12
-
13
- def assert_attribute(value, attr_name, object=subject)
14
- assert_equal value, object.send(attr_name)
15
- assert_equal value, object[attr_name]
16
- assert_equal value, object.attributes[attr_name]
17
- assert_equal value, object.properties=erties[attr_name]
18
- end
19
-
20
- end
10
+ require 'active_support/test_case'
@@ -52,11 +52,17 @@ class BehaviorTest < Test::Unit::TestCase
52
52
  assert_equal 10, column.default
53
53
  end
54
54
 
55
- should 'allow indexed option' do
56
- subject.property.string('rolodex', :indexed => true)
55
+ should 'allow index option' do
56
+ subject.property.string('rolodex', :index => true)
57
57
  column = subject.columns['rolodex']
58
58
  assert column.indexed?
59
59
  end
60
+
61
+ should 'return a list of indexes on indexes' do
62
+ subject.property.string('rolodex', :index => true)
63
+ subject.property.integer('foobar', :index => true)
64
+ assert_equal %w{integer string}, subject.indexes.map {|i| i[0].to_s }.sort
65
+ end
60
66
  end # A Behavior
61
67
 
62
68
  context 'Adding a behavior' do
@@ -13,8 +13,12 @@ class DeclarationTest < Test::Unit::TestCase
13
13
  assert_equal %w{age first_name language last_name}, @klass.schema.column_names.sort
14
14
  end
15
15
 
16
+ should 'see its property columns in schema' do
17
+ assert @klass.schema.has_column?('language')
18
+ end
19
+
16
20
  should 'not back-propagate definitions to parent' do
17
- assert !@klass.superclass.schema.columns.include?('language')
21
+ assert !@klass.superclass.schema.has_column?('language')
18
22
  end
19
23
 
20
24
  should 'inherit new definitions in parent' do
@@ -157,8 +161,8 @@ class DeclarationTest < Test::Unit::TestCase
157
161
  assert_equal 10, column.default
158
162
  end
159
163
 
160
- should 'allow indexed option' do
161
- subject.property.string('rolodex', :indexed => true)
164
+ should 'allow index option' do
165
+ subject.property.string('rolodex', :index => true)
162
166
  column = subject.schema.columns['rolodex']
163
167
  assert column.indexed?
164
168
  end
@@ -225,7 +229,7 @@ class DeclarationTest < Test::Unit::TestCase
225
229
  :foreign_key => 'employee_id'
226
230
  end
227
231
 
228
- Contact = Class.new(ActiveRecord::Base) do
232
+ class Contact < ActiveRecord::Base
229
233
  attr_accessor :assertion
230
234
  before_save :before_save_assertion
231
235
  set_table_name :employees
@@ -0,0 +1,153 @@
1
+ require 'test_helper'
2
+ require 'fixtures'
3
+
4
+ class IndexComplexTest < ActiveSupport::TestCase
5
+ class IndexedStringEmp < ActiveRecord::Base
6
+ set_table_name :i_string_employees
7
+ end
8
+
9
+ class IndexedIntegerEmp < ActiveRecord::Base
10
+ set_table_name :i_integer_employees
11
+ end
12
+
13
+ class IndexedTextEmp < ActiveRecord::Base
14
+ set_table_name :i_text_employees
15
+ end
16
+
17
+ # Complex index definition class
18
+ class Person < ActiveRecord::Base
19
+ include Property
20
+ set_table_name :employees
21
+
22
+ def save_with_raise
23
+ if name == 'raise'
24
+ raise Exception.new
25
+ else
26
+ save_without_raise
27
+ end
28
+ end
29
+ alias_method_chain :save, :raise
30
+
31
+ property do |p|
32
+ p.string 'name'
33
+ # only runs if 'age' is not blank
34
+ p.integer 'age', :index => Proc.new {|r| {'age' => r.age == 0 ? nil : r.age + 10}}
35
+ p.string 'gender'
36
+ p.string 'lang'
37
+
38
+ p.index(:text) do |r| # r = record
39
+ {
40
+ "high" => "gender:#{r.gender} age:#{r.age} name:#{r.name}",
41
+ "name_#{r.lang}" => r.name, # multi-lingual index
42
+ }
43
+ end
44
+ end
45
+ end
46
+
47
+ context 'A schema from a class with complex index definitions' do
48
+ subject do
49
+ Person.schema
50
+ end
51
+
52
+ should 'return a Hash on index_groups' do
53
+ assert_kind_of Hash, subject.index_groups
54
+ end
55
+
56
+ should 'group indexes by type' do
57
+ assert_equal %w{integer text}, subject.index_groups.keys.map(&:to_s).sort
58
+ end
59
+ end
60
+
61
+ context 'A class with complex index definition' do
62
+ subject do
63
+ Person
64
+ end
65
+
66
+ context 'on record creation' do
67
+ should 'create index entries' do
68
+ assert_difference('IndexedTextEmp.count', 2) do
69
+ Person.create('name' => 'Juan', 'lang' => 'es', 'gender' => 'M', 'age' => 34)
70
+ end
71
+ end
72
+
73
+ should 'not create index entries for blank values' do
74
+ assert_difference('IndexedIntegerEmp.count', 0) do
75
+ Person.create('name' => 'Pavlov')
76
+ end
77
+ end
78
+
79
+ should 'store key and value pairs linked to the model' do
80
+ person = Person.create('name' => 'Juan', 'lang' => 'es', 'gender' => 'M', 'age' => 34)
81
+ high_index, name_index = IndexedTextEmp.all(:conditions => {:employee_id => person.id}, :order => 'key asc')
82
+ assert_equal 'high', high_index.key
83
+ assert_equal 'gender:M age:34 name:Juan', high_index.value
84
+ assert_equal 'name_es', name_index.key
85
+ assert_equal 'Juan', name_index.value
86
+ end
87
+
88
+ should 'execute index Proc to build value' do
89
+ person = Person.create('name' => 'Juan', 'lang' => 'es', 'gender' => 'M', 'age' => 34)
90
+ int_index = IndexedIntegerEmp.first(:conditions => {:employee_id => person.id})
91
+ assert_equal 44, int_index.value
92
+ end
93
+
94
+ should 'remove blank values built from proc execution' do
95
+ person = Person.create('name' => 'Juan', 'lang' => 'es', 'gender' => 'M', 'age' => 34)
96
+ assert_difference('IndexedIntegerEmp.count', -1) do
97
+ person.update_attributes('age' => 0)
98
+ end
99
+ end
100
+ end
101
+
102
+ context 'on record update' do
103
+ setup do
104
+ @person = Person.create('name' => 'Juan', 'lang' => 'es', 'gender' => 'M', 'age' => 34)
105
+ end
106
+
107
+ should 'update index entries' do
108
+ high_index, name_index = IndexedTextEmp.all(:conditions => {:employee_id => @person.id}, :order => 'key asc')
109
+ assert_difference('IndexedTextEmp.count', 0) do
110
+ @person.update_attributes('name' => 'Xavier')
111
+ end
112
+
113
+ high_index = IndexedTextEmp.find(high_index.id) # reload (make sure the record has been updated, not recreated)
114
+ name_index = IndexedTextEmp.find(name_index.id) # reload (make sure the record has been updated, not recreated)
115
+
116
+ assert_equal 'high', high_index.key
117
+ assert_equal 'gender:M age:34 name:Xavier', high_index.value
118
+ assert_equal 'name_es', name_index.key
119
+ assert_equal 'Xavier', name_index.value
120
+ end
121
+
122
+ context 'with key alterations' do
123
+ should 'remove and create new keys' do
124
+ high_index, name_index = IndexedTextEmp.all(:conditions => {:employee_id => @person.id}, :order => 'key asc')
125
+ assert_difference('IndexedTextEmp.count', 0) do
126
+ @person.update_attributes('lang' => 'en', 'name' => 'John')
127
+ end
128
+
129
+ assert IndexedTextEmp.find(high_index.id)
130
+ assert_nil IndexedTextEmp.find_by_id(name_index.id)
131
+
132
+ high_index, name_index = IndexedTextEmp.all(:conditions => {:employee_id => @person.id}, :order => 'key asc')
133
+
134
+ assert_equal 'high', high_index.key
135
+ assert_equal 'gender:M age:34 name:John', high_index.value
136
+ assert_equal 'name_en', name_index.key
137
+ assert_equal 'John', name_index.value
138
+ end
139
+ end
140
+ end
141
+
142
+ context 'on record destruction' do
143
+ should 'remove index entries' do
144
+ person = Person.create('name' => 'Juan', 'lang' => 'es', 'gender' => 'M', 'age' => 34)
145
+ assert_difference('IndexedTextEmp.count', -2) do
146
+ assert_difference('IndexedIntegerEmp.count', -1) do
147
+ person.destroy
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,232 @@
1
+ require 'test_helper'
2
+ require 'fixtures'
3
+
4
+ class IndexForeignTest < ActiveSupport::TestCase
5
+ class IndexedStringEmp < ActiveRecord::Base
6
+ set_table_name :i_string_employees
7
+ end
8
+
9
+ class IndexedIntegerEmp < ActiveRecord::Base
10
+ set_table_name :i_integer_employees
11
+ end
12
+
13
+ class IndexedTextEmp < ActiveRecord::Base
14
+ set_table_name :i_text_employees
15
+ end
16
+
17
+ class Version < ActiveRecord::Base
18
+ belongs_to :contact, :class_name => 'IndexForeignTest::Contact',
19
+ :foreign_key => 'employee_id'
20
+ end
21
+
22
+ class Contact < ActiveRecord::Base
23
+ set_table_name :employees
24
+
25
+ has_many :versions, :class_name => 'IndexForeignTest::Version'
26
+ def version
27
+ @version ||= begin
28
+ if new_record?
29
+ versions.build
30
+ else
31
+ Version.first(:conditions => ['employee_id = ?', self.id]) || versions.build
32
+ end
33
+ end
34
+ end
35
+
36
+ def new_version!
37
+ @version = versions.build
38
+ end
39
+
40
+ def lang=(l)
41
+ version.lang = l
42
+ end
43
+
44
+ include Property
45
+ store_properties_in :version
46
+
47
+ property do |p|
48
+ p.string 'name'
49
+ p.integer 'age', :indexed => true
50
+ p.string 'gender'
51
+
52
+ p.index(:string) do |r| # r = record
53
+ {
54
+ "high" => "gender:#{r.gender} age:#{r.age} name:#{r.name}",
55
+ "name_#{r.version.lang}" => r.name, # multi-lingual index
56
+ }
57
+ end
58
+ end
59
+
60
+ def index_reader
61
+ {'version_id' => version.id}
62
+ end
63
+
64
+ # Foreign index: we store the 'employee_id' in the index to get back directly to non-versioned class Contact (through employee_id key).
65
+ def index_writer
66
+ {'version_id' => version.id, 'employee_id' => self.id}
67
+ end
68
+ end
69
+
70
+ context 'A class with foreign index definition' do
71
+ subject do
72
+ Contact
73
+ end
74
+
75
+ context 'on record creation' do
76
+ should 'create index entries' do
77
+ assert_difference('IndexedStringEmp.count', 2) do
78
+ Contact.create('name' => 'Juan', 'lang' => 'es', 'gender' => 'M', 'age' => 34)
79
+ end
80
+ end
81
+
82
+ should 'store key and value pairs linked to the model' do
83
+ person = Contact.create('name' => 'Juan', 'lang' => 'es', 'gender' => 'M', 'age' => 34)
84
+ high_index, name_index = IndexedStringEmp.all(:conditions => {:version_id => person.version.id}, :order => 'key asc')
85
+ assert_equal 'high', high_index.key
86
+ assert_equal 'gender:M age:34 name:Juan', high_index.value
87
+ assert_equal 'name_es', name_index.key
88
+ assert_equal 'Juan', name_index.value
89
+ end
90
+
91
+ should 'store key and value pairs linked to the foreign model' do
92
+ person = Contact.create('name' => 'Juan', 'lang' => 'es', 'gender' => 'M', 'age' => 34)
93
+ high_index, name_index = IndexedStringEmp.all(:conditions => {:employee_id => person.id}, :order => 'key asc')
94
+ assert_equal 'high', high_index.key
95
+ assert_equal 'gender:M age:34 name:Juan', high_index.value
96
+ assert_equal 'name_es', name_index.key
97
+ assert_equal 'Juan', name_index.value
98
+ end
99
+ end
100
+
101
+ context 'on record update' do
102
+ setup do
103
+ @person = Contact.create('name' => 'Juan', 'lang' => 'es', 'gender' => 'M', 'age' => 34)
104
+ end
105
+
106
+ should 'update index entries' do
107
+ high_index, name_index = IndexedStringEmp.all(:conditions => {:employee_id => @person.id}, :order => 'key asc')
108
+ assert_difference('IndexedStringEmp.count', 0) do
109
+ @person.update_attributes('name' => 'Xavier')
110
+ end
111
+
112
+ high_index = IndexedStringEmp.find(high_index.id) # reload (make sure the record has been updated, not recreated)
113
+ name_index = IndexedStringEmp.find(name_index.id) # reload (make sure the record has been updated, not recreated)
114
+
115
+ assert_equal 'high', high_index.key
116
+ assert_equal 'gender:M age:34 name:Xavier', high_index.value
117
+ assert_equal 'name_es', name_index.key
118
+ assert_equal 'Xavier', name_index.value
119
+ end
120
+
121
+ context 'with key alterations' do
122
+ should 'remove and create new keys' do
123
+ high_index, name_index = IndexedStringEmp.all(:conditions => {:employee_id => @person.id}, :order => 'key asc')
124
+ assert_difference('IndexedStringEmp.count', 0) do
125
+ @person.update_attributes('lang' => 'en', 'name' => 'John')
126
+ end
127
+
128
+ assert IndexedStringEmp.find(high_index.id)
129
+ assert_nil IndexedStringEmp.find_by_id(name_index.id)
130
+
131
+ high_index, name_index = IndexedStringEmp.all(:conditions => {:employee_id => @person.id}, :order => 'key asc')
132
+
133
+ assert_equal 'high', high_index.key
134
+ assert_equal 'gender:M age:34 name:John', high_index.value
135
+ assert_equal 'name_en', name_index.key
136
+ assert_equal 'John', name_index.value
137
+ end
138
+ end
139
+ end
140
+
141
+ context 'on record update with a new version' do
142
+ should 'create new index entries' do
143
+ @person = Contact.create('name' => 'Juan', 'lang' => 'es', 'gender' => 'M', 'age' => 34)
144
+ high_index1, name_index1 = IndexedStringEmp.all(:conditions => {:version_id => @person.version.id}, :order => 'key asc')
145
+ @person.new_version!
146
+ assert_difference('IndexedStringEmp.count', 2) do
147
+ @person.update_attributes('name' => 'John', 'lang' => 'en')
148
+ end
149
+
150
+ high_index, name_index = IndexedStringEmp.all(:conditions => {:version_id => @person.version.id}, :order => 'key asc')
151
+ assert_not_equal high_index1.id, high_index.id
152
+ assert_not_equal name_index1.id, name_index.id
153
+
154
+ assert_equal 'high', high_index.key
155
+ assert_equal 'gender:M age:34 name:John', high_index.value
156
+ assert_equal 'name_en', name_index.key
157
+ assert_equal 'John', name_index.value
158
+ end
159
+
160
+ # ========== The context below is not really a test: it is used to example the index usage to sort
161
+ context 'in different languages' do
162
+ setup do
163
+ Contact.destroy_all
164
+ # People: Jean (John) and Jim
165
+ # sort order:
166
+ # fr: Jean, Jim
167
+ # en: Jim, John
168
+ @jean = Contact.create('name' => 'Jean', 'lang' => 'fr', 'gender' => 'M', 'age' => 34)
169
+ @jean.new_version!
170
+ @jean.update_attributes('name' => 'John', 'lang' => 'en')
171
+ @jim = Contact.create('name' => 'Jim', 'lang' => 'fr', 'gender' => 'M', 'age' => 17)
172
+ @jim.new_version!
173
+ @jim.update_attributes('name' => 'Jim', 'lang' => 'en')
174
+ end
175
+
176
+ should 'create index entries to sort multilingual values' do
177
+ people_fr = Contact.find(:all, :joins => "INNER JOIN i_string_employees AS ise ON ise.employee_id = employees.id AND ise.key = 'name_fr'",
178
+ :order => "ise.value asc")
179
+
180
+ people_en = Contact.find(:all, :joins => "INNER JOIN i_string_employees AS ise ON ise.employee_id = employees.id AND ise.key = 'name_en'",
181
+ :order => "ise.value asc")
182
+
183
+ assert_equal [@jean.id, @jim.id], people_fr.map {|r| r.id}
184
+ assert_equal [@jim.id, @jean.id], people_en.map {|r| r.id}
185
+ end
186
+ end
187
+
188
+ # ========== The context below is not really a test: it is used to example the index usage to sort
189
+ context 'in different languages with missing translations' do
190
+ setup do
191
+ Contact.destroy_all
192
+ # People: Jean (John) and Jim
193
+ # sort order:
194
+ # fr: Jean, Jim
195
+ # en: Jim, John
196
+ @jean = Contact.create('name' => 'Jean', 'lang' => 'fr', 'gender' => 'M', 'age' => 34)
197
+ @jean.new_version!
198
+ @jean.update_attributes('name' => 'John', 'lang' => 'en')
199
+ @jim = Contact.create('name' => 'Jim', 'lang' => 'en', 'gender' => 'M', 'age' => 17)
200
+ # no version for @jim in 'fr'
201
+ end
202
+
203
+ should 'create index entries to sort multilingual values' do
204
+ people_fr = Contact.find(:all, :joins => "INNER JOIN i_string_employees AS ise ON ise.employee_id = employees.id AND ise.key = 'name_fr'",
205
+ :order => "ise.value asc")
206
+
207
+ people_en = Contact.find(:all, :joins => "INNER JOIN i_string_employees AS ise ON ise.employee_id = employees.id AND ise.key = 'name_en'",
208
+ :order => "ise.value asc")
209
+
210
+ # This is what we would like to have (once we have found an SQL trick to get the record in 'en')
211
+ # assert_equal [@jean.id, @jim.id], people_fr.map {|r| r.id}
212
+
213
+ # But this is what we get
214
+ assert_equal [@jean.id], people_fr.map {|r| r.id}
215
+
216
+ assert_equal [@jim.id, @jean.id], people_en.map {|r| r.id}
217
+ end
218
+ end
219
+ end
220
+
221
+ context 'on record destruction' do
222
+ should 'remove index entries' do
223
+ person = Contact.create('name' => 'Juan', 'lang' => 'es', 'gender' => 'M', 'age' => 34)
224
+ assert_difference('IndexedStringEmp.count', -2) do
225
+ assert_difference('IndexedIntegerEmp.count', -1) do
226
+ person.destroy
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,145 @@
1
+ require 'test_helper'
2
+ require 'fixtures'
3
+
4
+ class IndexSimpleTest < ActiveSupport::TestCase
5
+ class IndexedStringEmp < ActiveRecord::Base
6
+ set_table_name :i_string_employees
7
+ end
8
+
9
+ class IndexedIntegerEmp < ActiveRecord::Base
10
+ set_table_name :i_integer_employees
11
+ end
12
+
13
+ # Simple index definition class
14
+ class Dog < ActiveRecord::Base
15
+ include Property
16
+ set_table_name :employees
17
+
18
+ def save_with_raise
19
+ if name == 'raise'
20
+ raise Exception.new
21
+ else
22
+ save_without_raise
23
+ end
24
+ end
25
+ alias_method_chain :save, :raise
26
+
27
+ property do |p|
28
+ p.string 'name', :index => true
29
+ p.integer 'age', :indexed => true # synonym
30
+ end
31
+ end
32
+
33
+ context 'A schema from a class with index definitions' do
34
+ subject do
35
+ Dog.schema
36
+ end
37
+
38
+ should 'return a Hash on index_groups' do
39
+ assert_kind_of Hash, subject.index_groups
40
+ end
41
+
42
+ should 'group indexes by type' do
43
+ assert_equal %w{integer string}, subject.index_groups.keys.map(&:to_s).sort
44
+ end
45
+ end
46
+
47
+ context 'A class with a simple index definition' do
48
+ subject do
49
+ Dog
50
+ end
51
+
52
+ context 'on record creation' do
53
+ should 'create index entries' do
54
+ assert_difference('IndexedStringEmp.count', 1) do
55
+ Dog.create('name' => 'Pavlov')
56
+ end
57
+ end
58
+
59
+ should 'not create index entries for blank values' do
60
+ assert_difference('IndexedIntegerEmp.count', 0) do
61
+ Dog.create('name' => 'Pavlov')
62
+ end
63
+ end
64
+
65
+ should 'store a key and value pair linked to the model' do
66
+ dog = Dog.create('name' => 'Pavlov')
67
+ index_string = IndexedStringEmp.first(:conditions => {:employee_id => dog.id})
68
+ assert_equal 'Pavlov', index_string.value
69
+ assert_equal 'name', index_string.key
70
+ end
71
+ end
72
+
73
+ context 'on record update' do
74
+ setup do
75
+ @dog = Dog.create('name' => 'Pavlov')
76
+ end
77
+
78
+ should 'update index entries' do
79
+ index_string = IndexedStringEmp.first(:conditions => {:employee_id => @dog.id})
80
+ assert_difference('IndexedStringEmp.count', 0) do
81
+ @dog.update_attributes('name' => 'Médor')
82
+ end
83
+
84
+ index_string = IndexedStringEmp.find(index_string.id)
85
+ assert_equal 'Médor', index_string.value
86
+ assert_equal 'name', index_string.key
87
+ end
88
+
89
+ should 'not create index entries for blank values' do
90
+ assert_difference('IndexedIntegerEmp.count', 0) do
91
+ @dog.update_attributes('name' => 'Médor')
92
+ end
93
+ end
94
+
95
+ should 'remove blank values' do
96
+ assert_difference('IndexedStringEmp.count', -1) do
97
+ @dog.update_attributes('name' => '')
98
+ end
99
+ end
100
+
101
+ should 'create new entries for new keys' do
102
+ assert_difference('IndexedIntegerEmp.count', 1) do
103
+ @dog.update_attributes('age' => 7)
104
+ end
105
+ end
106
+
107
+ should 'store a key and value pair linked to the model' do
108
+ @dog.update_attributes('age' => 7)
109
+ index_int = IndexedIntegerEmp.first(:conditions => {:employee_id => @dog.id})
110
+ assert_equal 7, index_int.value
111
+ assert_equal 'age', index_int.key
112
+ end
113
+
114
+ context 'that fails during save' do
115
+ setup do
116
+ @dog = Dog.create('name' => 'Pavlov')
117
+ end
118
+
119
+ should 'not alter indexes' do
120
+ assert_difference('IndexedIntegerEmp.count', 0) do
121
+ assert_raises(Exception) do
122
+ @dog.update_attributes('name' => 'raise')
123
+ end
124
+ end
125
+
126
+ index_string = IndexedStringEmp.first(:conditions => {:employee_id => @dog.id})
127
+ assert_equal 'Pavlov', index_string.value
128
+ assert_equal 'name', index_string.key
129
+ end
130
+
131
+ end
132
+ end
133
+
134
+ context 'on record destruction' do
135
+ should 'remove index entries' do
136
+ dog = Dog.create('name' => 'Pavlov', 'age' => 7)
137
+ assert_difference('IndexedStringEmp.count', -1) do
138
+ assert_difference('IndexedIntegerEmp.count', -1) do
139
+ dog.destroy
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -68,11 +68,11 @@ class ValidationTest < Test::Unit::TestCase
68
68
  end # When setting a property
69
69
 
70
70
  context 'On a class with default property values' do
71
- Cat = Class.new(ActiveRecord::Base) do
71
+ class Cat < ActiveRecord::Base
72
72
  attr_accessor :encoding
73
-
74
73
  set_table_name 'dummies'
75
- include Property::Attribute
74
+
75
+ include Property
76
76
  property do |p|
77
77
  p.string 'eat', :default => 'mouse'
78
78
  p.string 'name'
metadata CHANGED
@@ -1,7 +1,12 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: property
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 9
8
+ - 0
9
+ version: 0.9.0
5
10
  platform: ruby
6
11
  authors:
7
12
  - Renaud Kern
@@ -10,29 +15,33 @@ autorequire:
10
15
  bindir: bin
11
16
  cert_chain: []
12
17
 
13
- date: 2010-02-16 00:00:00 +01:00
18
+ date: 2010-03-20 00:00:00 +01:00
14
19
  default_executable:
15
20
  dependencies:
16
21
  - !ruby/object:Gem::Dependency
17
22
  name: shoulda
18
- type: :development
19
- version_requirement:
20
- version_requirements: !ruby/object:Gem::Requirement
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
21
25
  requirements:
22
26
  - - ">="
23
27
  - !ruby/object:Gem::Version
28
+ segments:
29
+ - 0
24
30
  version: "0"
25
- version:
31
+ type: :development
32
+ version_requirements: *id001
26
33
  - !ruby/object:Gem::Dependency
27
34
  name: activerecord
28
- type: :runtime
29
- version_requirement:
30
- version_requirements: !ruby/object:Gem::Requirement
35
+ prerelease: false
36
+ requirement: &id002 !ruby/object:Gem::Requirement
31
37
  requirements:
32
38
  - - ">="
33
39
  - !ruby/object:Gem::Version
40
+ segments:
41
+ - 0
34
42
  version: "0"
35
- version:
43
+ type: :runtime
44
+ version_requirements: *id002
36
45
  description: Wrap model properties into a single database column and declare properties from within the model.
37
46
  email: gaspard@teti.ch
38
47
  executables: []
@@ -53,8 +62,10 @@ files:
53
62
  - lib/property/behavior.rb
54
63
  - lib/property/column.rb
55
64
  - lib/property/core_ext/time.rb
65
+ - lib/property/db.rb
56
66
  - lib/property/declaration.rb
57
67
  - lib/property/dirty.rb
68
+ - lib/property/index.rb
58
69
  - lib/property/properties.rb
59
70
  - lib/property/schema.rb
60
71
  - lib/property/serialization/json.rb
@@ -68,6 +79,9 @@ files:
68
79
  - test/unit/property/behavior_test.rb
69
80
  - test/unit/property/declaration_test.rb
70
81
  - test/unit/property/dirty_test.rb
82
+ - test/unit/property/index_complex_test.rb
83
+ - test/unit/property/index_foreign_test.rb
84
+ - test/unit/property/index_simple_test.rb
71
85
  - test/unit/property/validation_test.rb
72
86
  - test/unit/serialization/json_test.rb
73
87
  - test/unit/serialization/marshal_test.rb
@@ -85,18 +99,20 @@ required_ruby_version: !ruby/object:Gem::Requirement
85
99
  requirements:
86
100
  - - ">="
87
101
  - !ruby/object:Gem::Version
102
+ segments:
103
+ - 0
88
104
  version: "0"
89
- version:
90
105
  required_rubygems_version: !ruby/object:Gem::Requirement
91
106
  requirements:
92
107
  - - ">="
93
108
  - !ruby/object:Gem::Version
109
+ segments:
110
+ - 0
94
111
  version: "0"
95
- version:
96
112
  requirements: []
97
113
 
98
114
  rubyforge_project: property
99
- rubygems_version: 1.3.5
115
+ rubygems_version: 1.3.6
100
116
  signing_key:
101
117
  specification_version: 3
102
118
  summary: model properties wrap into a single database column
@@ -108,6 +124,9 @@ test_files:
108
124
  - test/unit/property/behavior_test.rb
109
125
  - test/unit/property/declaration_test.rb
110
126
  - test/unit/property/dirty_test.rb
127
+ - test/unit/property/index_complex_test.rb
128
+ - test/unit/property/index_foreign_test.rb
129
+ - test/unit/property/index_simple_test.rb
111
130
  - test/unit/property/validation_test.rb
112
131
  - test/unit/serialization/json_test.rb
113
132
  - test/unit/serialization/marshal_test.rb