has_localization_table 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,47 @@
1
+ module HasLocalizationTable
2
+ module ActiveRecord
3
+ module Attributes
4
+ def read_localized_attribute(attribute, locale = HasLocalizationTable.current_locale)
5
+ attribute_cache[attribute.to_sym][locale.id] ||= localization_association.detect{ |a| a.send(HasLocalizationTable.locale_foreign_key) == locale.id }.send(attribute) rescue nil
6
+ end
7
+
8
+ def write_localized_attribute(attribute, value, locale = HasLocalizationTable.current_locale)
9
+ value = value.to_s
10
+ localization = localization_association.detect{ |a| a.send(HasLocalizationTable.locale_foreign_key) == locale.id } ||
11
+ localization_association.build(HasLocalizationTable.locale_foreign_key => locale.id)
12
+
13
+ localization.send(:"#{attribute}=", value)
14
+ attribute_cache[attribute.to_sym][locale.id] = value.blank? ? nil : value
15
+ end
16
+
17
+ # Define attribute getters and setters
18
+ def method_missing(name, *args, &block)
19
+ if name.to_s =~ /\A([a-z0-9_]+)(=)?\Z/i
20
+ if localized_attributes.include?($1.to_sym)
21
+ if $2.nil? # No equals sign -- not a setter
22
+ # Try to load a string for the given locale
23
+ # If that fails, try for the primary locale
24
+ raise ArgumentError, "wrong number of arguments (#{args.size} for 0 or 1)" unless args.size.between?(0, 1)
25
+ return read_localized_attribute($1, *args) || read_localized_attribute($1, HasLocalizationTable.primary_locale)
26
+ else
27
+ raise ArgumentError, "wrong number of arguments (#{args.size} for 1)" unless args.size == 1
28
+ return write_localized_attribute($1, args.first)
29
+ end
30
+ end
31
+ end
32
+
33
+ super
34
+ end
35
+
36
+ def respond_to?(*args)
37
+ return true if args.first.to_s =~ /\A([a-z0-9_]+)=?\Z/i and localized_attributes.include?($1.to_sym)
38
+ super
39
+ end
40
+
41
+ private
42
+ def attribute_cache
43
+ @localization_attribute_cache ||= localized_attributes.inject({}) { |memo, attr| memo[attr] = {}; memo }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,24 @@
1
+ module HasLocalizationTable
2
+ module ActiveRecord
3
+ module Callbacks
4
+ def setup_localization_callbacks!
5
+ # Initialize string records after main record initialization
6
+ after_initialize do
7
+ build_missing_localizations!
8
+ end
9
+
10
+ before_validation do
11
+ reject_empty_localizations!
12
+ build_missing_localizations!
13
+ end
14
+
15
+ # Reject any blank strings before saving the record
16
+ # Validation will have happened by this point, so if there is a required string that is needed, it won't be rejected
17
+ before_save do
18
+ reject_empty_localizations!
19
+ end
20
+ end
21
+ private :setup_localization_callbacks!
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,33 @@
1
+ module HasLocalizationTable
2
+ module ActiveRecord
3
+ module FinderMethods
4
+ def method_missing(name, *args, &block)
5
+ if name.to_s =~ /\Afind_by_([a-z0-9_]+(_and_[a-z0-9_]+)*)\Z/
6
+ attributes = $1.split("_and_").map(&:to_sym)
7
+ if (attributes & localized_attributes).size == attributes.size
8
+ raise ArgumentError, "expected #{attributes.size} #{"argument".pluralize(attributes.size)}: #{attributes.join(", ")}" unless args.size == attributes.size
9
+ args = attributes.zip(args).inject({}) { |memo, (key, val)| memo[key] = val; memo }
10
+ return find_by_localized_attributes(args)
11
+ end
12
+ end
13
+
14
+ super
15
+ end
16
+
17
+ def respond_to?(*args)
18
+ if args.first.to_s =~ /\Afind_by_([a-z0-9_]+(_and_[a-z0-9_]+)*)\Z/
19
+ attributes = $1.split("_and_").map(&:to_sym)
20
+ return true if (attributes & localized_attributes).size == attributes.size
21
+ end
22
+
23
+ super
24
+ end
25
+
26
+ private
27
+ # Find a record by multiple localization values
28
+ def find_by_localized_attributes(attributes, locale = HasLocalizationTable.current_locale)
29
+ with_localizations.where(localization_class.table_name => attributes).first
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,55 @@
1
+ module HasLocalizationTable
2
+ module ActiveRecord
3
+ module MetaMethods
4
+ def self.extended(klass)
5
+ klass.send(:include, InstanceMethods)
6
+ end
7
+
8
+ def localization_class
9
+ localization_table_options[:class_name].constantize
10
+ end
11
+
12
+ def localization_association_name
13
+ localization_table_options[:association_name]
14
+ end
15
+
16
+ def localized_attributes
17
+ # Determine which attributes of the association model should be accessable through the base class
18
+ # ie. everything that's not a primary key, foreign key, or timestamp attribute
19
+ association_name = self.localization_table_options[:association_name] || :strings
20
+ association = reflect_on_association(association_name)
21
+
22
+ attribute_names = association.klass.attribute_names
23
+ timestamp_attrs = association.klass.new.send(:all_timestamp_attributes_in_model).map(&:to_s)
24
+ foreign_keys = association.klass.reflect_on_all_associations.map{ |a| a.association_foreign_key }
25
+ primary_keys = [association.klass.primary_key]
26
+ # protected_attrs = association.klass.protected_attributes.to_a
27
+
28
+ (attribute_names - timestamp_attrs - foreign_keys - primary_keys).map(&:to_sym)
29
+ end
30
+
31
+ def localized_attribute_required?(attribute)
32
+ return false unless localization_table_options[:required] || false
33
+ return true unless localization_table_options[:optional]
34
+
35
+ !localization_table_options[:optional].include?(attribute)
36
+ end
37
+
38
+ module InstanceMethods
39
+ # Helper method for getting the localization association without having to look up the name each time
40
+ def localization_association
41
+ association_name = localization_table_options[:association_name]
42
+ send(association_name)
43
+ end
44
+
45
+ def localized_attributes
46
+ self.class.localized_attributes
47
+ end
48
+
49
+ def localization_table_options
50
+ self.class.localization_table_options
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,28 @@
1
+ module HasLocalizationTable
2
+ module ActiveRecord
3
+ module OrderedBy
4
+ def method_missing(name, *args, &block)
5
+ if name.to_s =~ /\Aordered_by_([a-z0-9_]+)\Z/
6
+ attribute = $1.to_sym
7
+ return ordered_by_localized_attribute(attribute, *args) if localized_attributes.include?(attribute)
8
+ end
9
+
10
+ super
11
+ end
12
+
13
+ def respond_to?(*args)
14
+ if args.first.to_s =~ /\Aordered_by_([a-z0-9_]+)\Z/
15
+ return true if localized_attributes.include?($1.to_sym)
16
+ end
17
+
18
+ super
19
+ end
20
+
21
+ private
22
+ # Order records by localization value
23
+ def ordered_by_localized_attribute(attribute, asc = true, locale = HasLocalizationTable.current_locale)
24
+ with_localizations.order("#{localization_class.table_name}.#{attribute} #{asc ? "ASC" : "DESC"}")
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,46 @@
1
+ module HasLocalizationTable
2
+ module ActiveRecord
3
+ module Relation
4
+ def self.extended(klass)
5
+ klass.send(:include, InstanceMethods)
6
+
7
+ # Alias the scoping method to use the actual association name
8
+ alias_method :"with_#{klass.localization_association_name}", :with_localizations
9
+ end
10
+
11
+ def with_localizations
12
+ lcat = localization_class.arel_table
13
+
14
+ scoped.joins(
15
+ arel_table.join(lcat, Arel::Nodes::OuterJoin).
16
+ on(lcat[:"#{self.name.underscore}_id"].eq(arel_table[self.primary_key]).and(lcat[HasLocalizationTable.locale_foreign_key].eq(HasLocalizationTable.current_locale.id))).
17
+ join_sql
18
+ )
19
+ end
20
+
21
+ def create_localization_association!
22
+ self.has_many localization_association_name, localization_table_options.except(:association_name, :required, :optional)
23
+ end
24
+ private :create_localization_association!
25
+
26
+ module InstanceMethods
27
+ # Add localization objects for any available locale that doesn't have one
28
+ def build_missing_localizations!
29
+ locale_ids = HasLocalizationTable.all_locales.map(&:id)
30
+ HasLocalizationTable.all_locales.each do |locale|
31
+ unless localization_association.detect{ |str| str.send(HasLocalizationTable.locale_foreign_key) == locale.id }
32
+ localization_association.build(HasLocalizationTable.locale_foreign_key => locale.id)
33
+ end
34
+
35
+ localization_association.sort_by!{ |l| locale_ids.index(l.send(HasLocalizationTable.locale_foreign_key)) || 0 }
36
+ end
37
+ end
38
+
39
+ # Remove localization objects that are not filled in
40
+ def reject_empty_localizations!
41
+ localization_association.reject! { |l| !l.persisted? and localized_attributes.all?{ |attr| l.send(attr).blank? } }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,26 @@
1
+ module HasLocalizationTable
2
+ module ActiveRecord
3
+ module Validations
4
+ def setup_localization_validations!
5
+ localized_attributes.each do |attribute|
6
+ # Add validation to make all string fields required for the primary locale
7
+ obj = self
8
+ localization_class.class_eval do
9
+ validates attribute, presence: { message: :custom_this_field_is_required },
10
+ if: proc { |model| obj.name.constantize.localized_attribute_required?(attribute) && model.send(HasLocalizationTable.locale_foreign_key) == HasLocalizationTable.current_locale.id }
11
+ end
12
+ end
13
+
14
+ # Add validation to ensure a string for the primary locale exists if the string is required
15
+ validate do
16
+ if localization_table_options[:required] || false
17
+ errors.add(localization_association_name, :primary_lang_string_required) unless localization_association.any? do |string|
18
+ string.send(HasLocalizationTable.locale_foreign_key) == HasLocalizationTable.primary_locale.id
19
+ end
20
+ end
21
+ end
22
+ end
23
+ private :setup_localization_validations!
24
+ end
25
+ end
26
+ end
@@ -1,5 +1,13 @@
1
1
  module HasLocalizationTable
2
2
  module ActiveRecord
3
+ autoload :Attributes, 'has_localization_table/active_record/attributes'
4
+ autoload :Callbacks, 'has_localization_table/active_record/callbacks'
5
+ autoload :FinderMethods, 'has_localization_table/active_record/finder_methods'
6
+ autoload :MetaMethods, 'has_localization_table/active_record/meta_methods'
7
+ autoload :OrderedBy, 'has_localization_table/active_record/ordered_by'
8
+ autoload :Relation, 'has_localization_table/active_record/relation'
9
+ autoload :Validations, 'has_localization_table/active_record/validations'
10
+
3
11
  def has_localization_table(*args)
4
12
  options = args.extract_options!
5
13
  options[:association_name] = args.first || HasLocalizationTable.default_association_name
@@ -8,8 +16,12 @@ module HasLocalizationTable
8
16
  class_attribute :localization_table_options
9
17
  self.localization_table_options = { dependent: :delete_all, class_name: self.name + HasLocalizationTable.class_suffix }.merge(options)
10
18
 
11
- extend(ClassMethods)
12
- include(InstanceMethods)
19
+ extend Relation, FinderMethods, OrderedBy, Callbacks, Validations, MetaMethods
20
+ include Attributes
21
+
22
+ create_localization_association!
23
+ setup_localization_callbacks!
24
+ setup_localization_validations!
13
25
  end
14
26
  end
15
27
  end
@@ -1,3 +1,3 @@
1
1
  module HasLocalizationTable
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -1,7 +1,5 @@
1
1
  require "has_localization_table/version"
2
2
  require "has_localization_table/config"
3
- require "has_localization_table/class_methods"
4
- require "has_localization_table/instance_methods"
5
3
  require "has_localization_table/active_record"
6
4
 
7
5
  ActiveRecord::Base.extend(HasLocalizationTable::ActiveRecord) if defined?(ActiveRecord::Base)
@@ -0,0 +1,132 @@
1
+ require 'spec_helper'
2
+
3
+ describe HasLocalizationTable do
4
+ before do
5
+ # Configure HLT
6
+ HasLocalizationTable.configure do |c|
7
+ c.primary_locale = Locale.first
8
+ c.current_locale = Locale.first
9
+ c.all_locales = Locale.all
10
+ end
11
+
12
+ Object.send(:remove_const, :Article) rescue nil
13
+ Article = Class.new(ActiveRecord::Base)
14
+ Article.has_localization_table
15
+ end
16
+
17
+ let(:a) { Article.new(name: "Test", description: "Description") }
18
+
19
+ it "should set localized attributes" do
20
+ a.localizations.first.name.must_equal "Test"
21
+ a.localizations.first.description.must_equal "Description"
22
+ end
23
+
24
+ it "should create accessor methods" do
25
+ a.name.must_equal "Test"
26
+ a.description.must_equal "Description"
27
+ end
28
+
29
+ it "should save localized attributes" do
30
+ a.save!
31
+ a.reload
32
+ a.name.must_equal "Test"
33
+ a.description.must_equal "Description"
34
+ end
35
+
36
+ it "should create mutator methods" do
37
+ a.name = "Changed"
38
+ a.description = "Changed Description"
39
+ a.name.must_equal "Changed"
40
+ a.description.must_equal "Changed Description"
41
+ a.localizations.first.name.must_equal "Changed"
42
+ a.localizations.first.description.must_equal "Changed Description"
43
+ end
44
+
45
+ it "should use the current locale when setting" do
46
+ a
47
+
48
+ HasLocalizationTable.configure do |c|
49
+ c.current_locale = Locale.last
50
+ end
51
+
52
+ a.name = "French Name"
53
+ a.description = "French Description"
54
+
55
+ eng = a.localizations.detect{ |s| s.locale_id == Locale.first.id }
56
+ fre = a.localizations.detect{ |s| s.locale_id == Locale.last.id }
57
+
58
+ eng.name.must_equal "Test"
59
+ eng.description.must_equal "Description"
60
+ fre.name.must_equal "French Name"
61
+ fre.description.must_equal "French Description"
62
+ end
63
+
64
+ it "should return the correct value when the current locale changes" do
65
+ Locale.class_eval { cattr_accessor :current }
66
+ eng = Locale.find_by_name("English")
67
+ fre = Locale.find_by_name("French")
68
+
69
+ HasLocalizationTable.configure do |c|
70
+ c.current_locale = ->{ Locale.current }
71
+ end
72
+
73
+ Locale.current = eng
74
+ a.name = "English Name"
75
+ a.description = "English Description"
76
+
77
+ Locale.current = fre
78
+ a.name = "French Name"
79
+ a.description = "French Description"
80
+
81
+ Locale.current = eng
82
+ a.name.must_equal "English Name"
83
+ a.description.must_equal "English Description"
84
+
85
+ Locale.current = fre
86
+ a.name.must_equal "French Name"
87
+ a.description.must_equal "French Description"
88
+ end
89
+
90
+ it "should return the correct locale's value even if the cache is empty" do
91
+ Locale.class_eval { cattr_accessor :current }
92
+ Locale.current = eng = Locale.find_by_name("English")
93
+ fre = Locale.find_by_name("French")
94
+
95
+ HasLocalizationTable.configure do |c|
96
+ c.current_locale = ->{ Locale.current }
97
+ end
98
+
99
+ a.localizations.last.attributes = { name: "French Name", description: "French Description" }
100
+
101
+ # Force empty cache
102
+ a.instance_variable_set(:@localization_attribute_cache, { name: {}, description: {} })
103
+
104
+ Locale.current = fre
105
+ a.name.must_equal "French Name"
106
+ a.description.must_equal "French Description"
107
+ end
108
+
109
+ it "should return the correct locale's value even if a language was added" do
110
+ Locale.class_eval { cattr_accessor :current }
111
+ Locale.current = eng = Locale.find_by_name("English")
112
+ fre = Locale.find_by_name("French")
113
+
114
+ HasLocalizationTable.configure do |c|
115
+ c.current_locale = ->{ Locale.current }
116
+ c.all_locales = [eng]
117
+ end
118
+
119
+ Object.send(:remove_const, :Article) rescue nil
120
+ Article = Class.new(ActiveRecord::Base)
121
+ Article.has_localization_table
122
+
123
+ aa = Article.create!(name: "Name", description: "Description")
124
+ l = ArticleLocalization.create!(article: aa, locale: fre, name: "French Name", description: "French Description")
125
+
126
+ aa.reload
127
+
128
+ Locale.current = fre
129
+ aa.name.must_equal "French Name"
130
+ aa.description.must_equal "French Description"
131
+ end
132
+ end
@@ -0,0 +1,29 @@
1
+ require 'spec_helper'
2
+
3
+ describe HasLocalizationTable do
4
+ before do
5
+ # Configure HLT
6
+ HasLocalizationTable.configure do |c|
7
+ c.primary_locale = Locale.first
8
+ c.current_locale = Locale.first
9
+ c.all_locales = Locale.all
10
+ end
11
+
12
+ Object.send(:remove_const, :Article) rescue nil
13
+ Article = Class.new(ActiveRecord::Base)
14
+ Article.has_localization_table
15
+ end
16
+
17
+ let(:a) { Article.new(name: "Test", description: "Description") }
18
+
19
+ it "should create finder methods" do
20
+ a.save!
21
+ Article.find_by_name("Test").must_equal a
22
+ Article.find_by_description("Description").must_equal a
23
+ Article.find_by_name_and_description("Test", "Description").must_equal a
24
+ Article.find_by_description_and_name("Description", "Test").must_equal a
25
+
26
+ Article.find_by_name("Wrong").must_be_nil
27
+ Article.find_by_description("Wrong").must_be_nil
28
+ end
29
+ end
@@ -0,0 +1,34 @@
1
+ require 'spec_helper'
2
+
3
+ describe HasLocalizationTable do
4
+ before do
5
+ # Configure HLT
6
+ HasLocalizationTable.configure do |c|
7
+ c.primary_locale = Locale.first
8
+ c.current_locale = Locale.first
9
+ c.all_locales = Locale.all
10
+ end
11
+
12
+ Object.send(:remove_const, :Article) rescue nil
13
+ Article = Class.new(ActiveRecord::Base)
14
+ Article.has_localization_table
15
+ end
16
+
17
+ let(:a) { Article.new(name: "Test", description: "Description") }
18
+
19
+ it "should create ordered_by methods" do
20
+ a.save!
21
+ b = Article.create!(name: "Name", description: "Another Description")
22
+ c = Article.create!(name: "Once Upon a Time...", description: "Fairytale")
23
+ Article.ordered_by_name.must_equal [b, c, a]
24
+ Article.ordered_by_description.must_equal [b, a, c]
25
+ Article.ordered_by_name(false).must_equal [a, c, b]
26
+ end
27
+
28
+ it "should allow ordered_by methods to apply to scope chains" do
29
+ a.save!
30
+ b = Article.create!(name: "Name", description: "Another Description")
31
+ c = Article.create!(name: "Once Upon a Time...", description: "Fairytale")
32
+ Article.scoped.ordered_by_name.must_equal [b, c, a]
33
+ end
34
+ end
@@ -0,0 +1,20 @@
1
+ require 'spec_helper'
2
+
3
+ describe HasLocalizationTable do
4
+ before do
5
+ # Configure HLT
6
+ HasLocalizationTable.configure do |c|
7
+ c.primary_locale = Locale.first
8
+ c.current_locale = Locale.first
9
+ c.all_locales = Locale.all
10
+ end
11
+
12
+ Object.send(:remove_const, :Article) rescue nil
13
+ Article = Class.new(ActiveRecord::Base)
14
+ end
15
+
16
+ it "should alias with_localizations with the actual association name" do
17
+ Article.has_localization_table :strings
18
+ assert Article.respond_to? :with_strings
19
+ end
20
+ end
@@ -0,0 +1,62 @@
1
+ require 'spec_helper'
2
+
3
+ describe HasLocalizationTable do
4
+ before do
5
+ # Configure HLT
6
+ HasLocalizationTable.configure do |c|
7
+ c.primary_locale = Locale.first
8
+ c.current_locale = Locale.first
9
+ c.all_locales = Locale.all
10
+ end
11
+
12
+ Object.send(:remove_const, :Article) rescue nil
13
+ Article = Class.new(ActiveRecord::Base)
14
+ Article.has_localization_table
15
+ end
16
+
17
+ let(:a) { Article.new(name: "Test", description: "Description") }
18
+
19
+ it "should add validations if given required: true" do
20
+ Article.has_localization_table required: true
21
+ a = Article.new
22
+ refute a.valid?
23
+ a.errors[:localizations].wont_be_empty
24
+
25
+ a = Article.new(description: "Wishing the world hello!")
26
+ s = a.localizations.first
27
+ refute s.valid?
28
+ s.errors[:name].wont_be_empty
29
+ end
30
+
31
+ it "should not add validations if given required: false" do
32
+ Article.has_localization_table required: false
33
+ a = Article.new
34
+ assert a.valid?
35
+ a.errors[:localizations].must_be_empty
36
+
37
+ a = Article.new(description: "Wishing the world hello!")
38
+ s = a.localizations.first
39
+ assert s.valid?
40
+ s.errors[:name].must_be_empty
41
+ end
42
+
43
+ it "should not add validations if required is not given" do
44
+ Article.has_localization_table
45
+ a = Article.new
46
+ assert a.valid?
47
+ a.errors[:localizations].must_be_empty
48
+
49
+ a = Article.new(description: "Wishing the world hello!")
50
+ s = a.localizations.first
51
+ assert s.valid?
52
+ s.errors[:name].must_be_empty
53
+ end
54
+
55
+ it "should not add validations for optional fields" do
56
+ Article.has_localization_table required: true, optional: [:description]
57
+ a = Article.new(name: "Test")
58
+ assert a.valid?
59
+ a.errors[:localizations].must_be_empty
60
+ assert a.localizations.all?{ |s| s.errors[:description].empty? }
61
+ end
62
+ end
@@ -1,34 +1,5 @@
1
1
  require 'spec_helper'
2
2
 
3
- # Setup in-memory database so AR can work
4
- ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
5
- ActiveRecord::Migration.tap do |m|
6
- m.create_table :articles do |t|
7
- t.timestamps
8
- end
9
-
10
- m.create_table :locales do |t|
11
- t.string :name
12
- end
13
-
14
- m.create_table :article_localizations do |t|
15
- t.integer :article_id
16
- t.integer :locale_id
17
- t.string :name
18
- t.string :description
19
- end
20
- end
21
-
22
- # Set up locales
23
- Locale = Class.new(ActiveRecord::Base)
24
- Locale.create!(name: "English")
25
- Locale.create!(name: "French")
26
-
27
- ArticleLocalization = Class.new(ActiveRecord::Base) do
28
- belongs_to :article
29
- belongs_to :locale
30
- end
31
-
32
3
  describe HasLocalizationTable do
33
4
  before do
34
5
  # Configure HLT
@@ -76,123 +47,5 @@ describe HasLocalizationTable do
76
47
 
77
48
  Object.send(:remove_const, :ArticleText)
78
49
  end
79
-
80
- it "should add validations if given required: true" do
81
- Article.has_localization_table required: true
82
- a = Article.new
83
- refute a.valid?
84
- a.errors[:localizations].wont_be_empty
85
-
86
- a = Article.new(description: "Wishing the world hello!")
87
- s = a.localizations.first
88
- refute s.valid?
89
- s.errors[:name].wont_be_empty
90
- end
91
-
92
- it "should not add validations if given required: false" do
93
- Article.has_localization_table required: false
94
- a = Article.new
95
- a.valid? or raise a.localizations.map(&:errors).inspect
96
- a.errors[:localizations].must_be_empty
97
-
98
- a = Article.new(description: "Wishing the world hello!")
99
- s = a.localizations.first
100
- assert s.valid?
101
- s.errors[:name].must_be_empty
102
- end
103
-
104
- it "should not add validations if required is not given" do
105
- Article.has_localization_table
106
- a = Article.new
107
- assert a.valid?
108
- a.errors[:localizations].must_be_empty
109
-
110
- a = Article.new(description: "Wishing the world hello!")
111
- s = a.localizations.first
112
- assert s.valid?
113
- s.errors[:name].must_be_empty
114
- end
115
-
116
- it "should not add validations for optional fields" do
117
- Article.has_localization_table required: true, optional: [:description]
118
- a = Article.new(name: "Test")
119
- assert a.valid?
120
- a.errors[:localizations].must_be_empty
121
- assert a.localizations.all?{ |s| s.errors[:description].empty? }
122
- end
123
- end
124
-
125
- describe "other methods" do
126
- before do
127
- Object.send(:remove_const, :Article) rescue nil
128
- Article = Class.new(ActiveRecord::Base)
129
- Article.has_localization_table
130
- end
131
-
132
- let(:a) { Article.new(name: "Test", description: "Description") }
133
-
134
- it "should set localized attributes" do
135
- a.localizations.first.name.must_equal "Test"
136
- a.localizations.first.description.must_equal "Description"
137
- end
138
-
139
- it "should create accessor methods" do
140
- a.name.must_equal "Test"
141
- a.description.must_equal "Description"
142
- end
143
-
144
- it "should save localized attributes" do
145
- a.save!
146
- a.reload
147
- a.name.must_equal "Test"
148
- a.description.must_equal "Description"
149
- end
150
-
151
- it "should create mutator methods" do
152
- a.name = "Changed"
153
- a.description = "Changed Description"
154
- a.name.must_equal "Changed"
155
- a.description.must_equal "Changed Description"
156
- a.localizations.first.name.must_equal "Changed"
157
- a.localizations.first.description.must_equal "Changed Description"
158
- end
159
-
160
- it "should use the current locale when setting" do
161
- a
162
-
163
- HasLocalizationTable.configure do |c|
164
- c.current_locale = Locale.last
165
- end
166
-
167
- a.name = "French Name"
168
- a.description = "French Description"
169
-
170
- eng = a.localizations.detect{ |s| s.locale_id == Locale.first.id }
171
- fre = a.localizations.detect{ |s| s.locale_id == Locale.last.id }
172
-
173
- eng.name.must_equal "Test"
174
- eng.description.must_equal "Description"
175
- fre.name.must_equal "French Name"
176
- fre.description.must_equal "French Description"
177
- end
178
-
179
- it "should create finder methods" do
180
- a.save!
181
- Article.find_by_name("Test").must_equal a
182
- Article.find_by_description("Description").must_equal a
183
- Article.find_by_name_and_description("Test", "Description").must_equal a
184
- Article.find_by_description_and_name("Description", "Test").must_equal a
185
-
186
- Article.find_by_name("Wrong").must_be_nil
187
- Article.find_by_description("Wrong").must_be_nil
188
- end
189
-
190
- it "should create ordered_by methods" do
191
- a.save!
192
- b = Article.create!(name: "Name", description: "Another Description")
193
- c = Article.create!(name: "Once Upon a Time...", description: "Fairytale")
194
- Article.ordered_by_name.must_equal [b, c, a]
195
- Article.ordered_by_description.must_equal [b, a, c]
196
- end
197
50
  end
198
51
  end
data/spec/spec_helper.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require 'minitest/autorun'
2
2
  require 'active_record'
3
3
  require 'has_localization_table'
4
+ require 'support/setup'
4
5
 
5
6
  class MiniTest::Spec
6
7
  def run(*args, &block)
@@ -0,0 +1,28 @@
1
+ # Setup in-memory database so AR can work
2
+ ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
3
+ ActiveRecord::Migration.tap do |m|
4
+ m.create_table :articles do |t|
5
+ t.timestamps
6
+ end
7
+
8
+ m.create_table :locales do |t|
9
+ t.string :name
10
+ end
11
+
12
+ m.create_table :article_localizations do |t|
13
+ t.integer :article_id
14
+ t.integer :locale_id
15
+ t.string :name
16
+ t.string :description
17
+ end
18
+ end
19
+
20
+ # Set up locales
21
+ Locale = Class.new(ActiveRecord::Base)
22
+ Locale.create!(name: "English")
23
+ Locale.create!(name: "French")
24
+
25
+ ArticleLocalization = Class.new(ActiveRecord::Base) do
26
+ belongs_to :article
27
+ belongs_to :locale
28
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: has_localization_table
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -13,7 +13,7 @@ date: 2012-08-16 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport
16
- requirement: &11607560 !ruby/object:Gem::Requirement
16
+ requirement: &10582840 !ruby/object:Gem::Requirement
17
17
  none: false
18
18
  requirements:
19
19
  - - ! '>='
@@ -21,10 +21,10 @@ dependencies:
21
21
  version: 3.0.0
22
22
  type: :runtime
23
23
  prerelease: false
24
- version_requirements: *11607560
24
+ version_requirements: *10582840
25
25
  - !ruby/object:Gem::Dependency
26
26
  name: activerecord
27
- requirement: &11606680 !ruby/object:Gem::Requirement
27
+ requirement: &10581980 !ruby/object:Gem::Requirement
28
28
  none: false
29
29
  requirements:
30
30
  - - ! '>='
@@ -32,10 +32,10 @@ dependencies:
32
32
  version: 3.0.0
33
33
  type: :runtime
34
34
  prerelease: false
35
- version_requirements: *11606680
35
+ version_requirements: *10581980
36
36
  - !ruby/object:Gem::Dependency
37
37
  name: minitest
38
- requirement: &11606080 !ruby/object:Gem::Requirement
38
+ requirement: &10581320 !ruby/object:Gem::Requirement
39
39
  none: false
40
40
  requirements:
41
41
  - - ! '>='
@@ -43,10 +43,10 @@ dependencies:
43
43
  version: '0'
44
44
  type: :development
45
45
  prerelease: false
46
- version_requirements: *11606080
46
+ version_requirements: *10581320
47
47
  - !ruby/object:Gem::Dependency
48
48
  name: sqlite3
49
- requirement: &11605360 !ruby/object:Gem::Requirement
49
+ requirement: &10580720 !ruby/object:Gem::Requirement
50
50
  none: false
51
51
  requirements:
52
52
  - - ! '>='
@@ -54,7 +54,7 @@ dependencies:
54
54
  version: '0'
55
55
  type: :development
56
56
  prerelease: false
57
- version_requirements: *11605360
57
+ version_requirements: *10580720
58
58
  description: Automatically sets up usage of a relational table to contain user-created
59
59
  multi-locale string attributes
60
60
  email:
@@ -71,12 +71,23 @@ files:
71
71
  - has_localization_table.gemspec
72
72
  - lib/has_localization_table.rb
73
73
  - lib/has_localization_table/active_record.rb
74
- - lib/has_localization_table/class_methods.rb
74
+ - lib/has_localization_table/active_record/attributes.rb
75
+ - lib/has_localization_table/active_record/callbacks.rb
76
+ - lib/has_localization_table/active_record/finder_methods.rb
77
+ - lib/has_localization_table/active_record/meta_methods.rb
78
+ - lib/has_localization_table/active_record/ordered_by.rb
79
+ - lib/has_localization_table/active_record/relation.rb
80
+ - lib/has_localization_table/active_record/validations.rb
75
81
  - lib/has_localization_table/config.rb
76
- - lib/has_localization_table/instance_methods.rb
77
82
  - lib/has_localization_table/version.rb
83
+ - spec/active_record/attributes_spec.rb
84
+ - spec/active_record/finder_method_spec.rb
85
+ - spec/active_record/ordered_by_spec.rb
86
+ - spec/active_record/relation_spec.rb
87
+ - spec/active_record/validation_spec.rb
78
88
  - spec/active_record_spec.rb
79
89
  - spec/spec_helper.rb
90
+ - spec/support/setup.rb
80
91
  homepage: https://github.com/dvandersluis/has_localization_table
81
92
  licenses: []
82
93
  post_install_message:
@@ -103,6 +114,12 @@ specification_version: 3
103
114
  summary: Sets up associations and attribute methods for AR models that have a relational
104
115
  table to contain user-created data in multiple languages.
105
116
  test_files:
117
+ - spec/active_record/attributes_spec.rb
118
+ - spec/active_record/finder_method_spec.rb
119
+ - spec/active_record/ordered_by_spec.rb
120
+ - spec/active_record/relation_spec.rb
121
+ - spec/active_record/validation_spec.rb
106
122
  - spec/active_record_spec.rb
107
123
  - spec/spec_helper.rb
124
+ - spec/support/setup.rb
108
125
  has_rdoc:
@@ -1,127 +0,0 @@
1
- module HasLocalizationTable
2
- module ClassMethods
3
- def self.extended(klass)
4
- klass.class_eval do
5
- create_localization_association!
6
-
7
- # Initialize string records after main record initialization
8
- after_initialize do
9
- build_missing_localizations!
10
- end
11
-
12
- before_validation do
13
- reject_empty_localizations!
14
- build_missing_localizations!
15
- end
16
-
17
- # Reject any blank strings before saving the record
18
- # Validation will have happened by this point, so if there is a required string that is needed, it won't be rejected
19
- before_save do
20
- reject_empty_localizations!
21
- end
22
-
23
- # Add validation to ensure a string for the primary locale exists if the string is required
24
- validate do
25
- if localization_table_options[:required] || false
26
- errors.add(localization_association_name, :primary_lang_string_required) unless localization_association.any? do |string|
27
- string.send(HasLocalizationTable.locale_foreign_key) == HasLocalizationTable.primary_locale.id
28
- end
29
- end
30
- end
31
- end
32
-
33
- klass.localized_attributes.each do |attribute|
34
- # Add validation to make all string fields required for the primary locale
35
- klass.send(:localization_class).class_eval do
36
- validates attribute, presence: { message: :custom_this_field_is_required },
37
- if: proc { |model| klass.name.constantize.localized_attribute_required?(attribute) && model.send(HasLocalizationTable.locale_foreign_key) == HasLocalizationTable.current_locale.id }
38
- end
39
- end
40
-
41
- # Alias the scoping method to use the actual association name
42
- alias_method :"with_#{klass.localization_association_name}", :with_localizations
43
- end
44
-
45
- def localization_class
46
- localization_table_options[:class_name].constantize
47
- end
48
-
49
- def localization_association_name
50
- localization_table_options[:association_name]
51
- end
52
-
53
- def localized_attributes
54
- # Determine which attributes of the association model should be accessable through the base class
55
- # ie. everything that's not a primary key, foreign key, or timestamp attribute
56
- association_name = self.localization_table_options[:association_name] || :strings
57
- association = reflect_on_association(association_name)
58
-
59
- attribute_names = association.klass.attribute_names
60
- timestamp_attrs = association.klass.new.send(:all_timestamp_attributes_in_model).map(&:to_s)
61
- foreign_keys = association.klass.reflect_on_all_associations.map{ |a| a.association_foreign_key }
62
- primary_keys = [association.klass.primary_key]
63
- # protected_attrs = association.klass.protected_attributes.to_a
64
-
65
- (attribute_names - timestamp_attrs - foreign_keys - primary_keys).map(&:to_sym)
66
- end
67
-
68
- def localized_attribute_required?(attribute)
69
- return false unless localization_table_options[:required] || false
70
- return true unless localization_table_options[:optional]
71
-
72
- !localization_table_options[:optional].include?(attribute)
73
- end
74
-
75
- def method_missing(name, *args, &block)
76
- if name.to_s =~ /\Afind_by_([a-z0-9_]+(_and_[a-z0-9_]+)*)\Z/
77
- attributes = $1.split("_and_").map(&:to_sym)
78
- if (attributes & localized_attributes).size == attributes.size
79
- raise ArgumentError, "expected #{attributes.size} #{"argument".pluralize(attributes.size)}: #{attributes.join(", ")}" unless args.size == attributes.size
80
- args = attributes.zip(args).inject({}) { |memo, (key, val)| memo[key] = val; memo }
81
- return find_by_localized_attributes(args)
82
- end
83
- elsif name.to_s =~ /\Aordered_by_([a-z0-9_]+)\Z/
84
- attribute = $1.to_sym
85
- return ordered_by_localized_attribute(attribute, *args) if localized_attributes.include?(attribute)
86
- end
87
-
88
- super
89
- end
90
-
91
- def respond_to?(*args)
92
- if args.first.to_s =~ /\Afind_by_([a-z0-9_]+(_and_[a-z0-9_]+)*)\Z/
93
- attributes = $1.split("_and_").map(&:to_sym)
94
- return true if (attributes & localized_attributes).size == attributes.size
95
- elsif name.to_s =~ /\Aordered_by_([a-z0-9_]+)\Z/
96
- return true if localized_attributes.include?($1.to_sym)
97
- end
98
-
99
- super
100
- end
101
-
102
- def with_localizations
103
- lcat = localization_class.arel_table
104
-
105
- scoped.joins(
106
- arel_table.join(lcat, Arel::Nodes::OuterJoin).
107
- on(lcat[:"#{self.name.underscore}_id"].eq(arel_table[self.primary_key]).and(lcat[HasLocalizationTable.locale_foreign_key].eq(HasLocalizationTable.current_locale.id))).
108
- join_sql
109
- )
110
- end
111
-
112
- private
113
- def create_localization_association!
114
- self.has_many localization_association_name, localization_table_options.except(:association_name, :required, :optional)
115
- end
116
-
117
- # Find a record by multiple localization values
118
- def find_by_localized_attributes(attributes, locale = HasLocalizationTable.current_locale)
119
- with_localizations.where(localization_class.table_name => attributes).first
120
- end
121
-
122
- # Order records by localization value
123
- def ordered_by_localized_attribute(attribute, asc = true, locale = HasLocalizationTable.current_locale)
124
- with_localizations.order("#{localization_class.table_name}.#{attribute} #{asc ? "ASC" : "DESC"}")
125
- end
126
- end
127
- end
@@ -1,78 +0,0 @@
1
- module HasLocalizationTable
2
- module InstanceMethods
3
- def read_localized_attribute(attribute, locale = HasLocalizationTable.current_locale)
4
- attribute_cache[attribute.to_sym][locale.id] ||= localization_association.detect{ |a| a.send(HasLocalizationTable.locale_foreign_key) == locale.id }.send(attribute) rescue nil
5
- end
6
-
7
- def write_localized_attribute(attribute, value, locale = HasLocalizationTable.current_locale)
8
- value = value.to_s
9
- localization = localization_association.detect{ |a| a.send(HasLocalizationTable.locale_foreign_key) == locale.id } ||
10
- localization_association.build(HasLocalizationTable.locale_foreign_key => locale.id)
11
-
12
- localization.send(:"#{attribute}=", value)
13
- attribute_cache[attribute.to_sym][locale.id] = value.blank? ? nil : value
14
- end
15
-
16
- # Define attribute getters and setters
17
- def method_missing(name, *args, &block)
18
- if name.to_s =~ /\A([a-z0-9_]+)(=)?\Z/i
19
- if localized_attributes.include?($1.to_sym)
20
- if $2.nil? # No equals sign -- not a setter
21
- # Try to load a string for the given locale
22
- # If that fails, try for the primary locale
23
- raise ArgumentError, "wrong number of arguments (#{args.size} for 0 or 1)" unless args.size.between?(0, 1)
24
- return read_localized_attribute($1, args.first) || read_localized_attribute($1, HasLocalizationTable.primary_locale)
25
- else
26
- raise ArgumentError, "wrong number of arguments (#{args.size} for 1)" unless args.size == 1
27
- return write_localized_attribute($1, args.first)
28
- end
29
- end
30
- end
31
-
32
- super
33
- end
34
-
35
- def respond_to?(*args)
36
- return true if args.first.to_s =~ /\A([a-z0-9_]+)=?\Z/i and localized_attributes.include?($1.to_sym)
37
- super
38
- end
39
-
40
- private
41
- # Add localization objects for any available locale that doesn't have one
42
- def build_missing_localizations!
43
- locale_ids = HasLocalizationTable.all_locales.map(&:id)
44
- HasLocalizationTable.all_locales.each do |l|
45
- unless localization_association.detect{ |str| str.send(HasLocalizationTable.locale_foreign_key) == l.id }
46
- localization_association.build(HasLocalizationTable.locale_foreign_key => l.id)
47
- end
48
-
49
- localization_association.sort_by!{ |l| locale_ids.index(l.send(HasLocalizationTable.locale_foreign_key)) || 0 }
50
- end
51
- end
52
-
53
- # Remove localization objects that are not filled in
54
- def reject_empty_localizations!
55
- localization_association.reject! { |l| !l.persisted? and localized_attributes.all?{ |attr| l.send(attr).blank? } }
56
- end
57
-
58
- # Helper method for getting the localization association without having to look up the name each time
59
- def localization_association
60
- @localization_association ||= begin
61
- association_name = localization_table_options[:association_name]
62
- send(association_name)
63
- end
64
- end
65
-
66
- def attribute_cache
67
- @localized_attribute_cache ||= localized_attributes.inject({}) { |memo, attr| memo[attr] = {}; memo }
68
- end
69
-
70
- def localized_attributes
71
- self.class.localized_attributes
72
- end
73
-
74
- def localization_table_options
75
- self.class.localization_table_options
76
- end
77
- end
78
- end