has_localization_table 0.1.1 → 0.2.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.
@@ -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