├── docs └── README.md ├── _config.yml ├── .rspec ├── .yardopts ├── spec ├── support │ ├── matchers │ │ ├── not_change.rb │ │ ├── include_hash.rb │ │ ├── indifferent_hash.rb │ │ ├── have_plugin.rb │ │ └── allocate_under.rb │ └── shared_examples │ │ ├── cache_key_examples.rb │ │ ├── locale_accessor_examples.rb │ │ ├── backend_examples.rb │ │ └── dup_examples.rb ├── mobility │ ├── sequel │ │ └── plugin_spec.rb │ ├── plugins │ │ ├── sequel_spec.rb │ │ ├── active_record_spec.rb │ │ ├── dirty_spec.rb │ │ ├── sequel │ │ │ ├── backend_spec.rb │ │ │ └── cache_spec.rb │ │ ├── active_record │ │ │ ├── backend_spec.rb │ │ │ └── cache_spec.rb │ │ ├── active_model │ │ │ └── cache_spec.rb │ │ ├── attribute_methods_spec.rb │ │ ├── backend_reader_spec.rb │ │ ├── writer_spec.rb │ │ ├── reader_spec.rb │ │ ├── fallthrough_accessors_spec.rb │ │ ├── attributes_spec.rb │ │ ├── presence_spec.rb │ │ └── default_spec.rb │ ├── active_record_spec.rb │ ├── backends │ │ ├── key_value_spec.rb │ │ ├── hash_spec.rb │ │ ├── sequel │ │ │ ├── json_spec.rb │ │ │ ├── hstore_spec.rb │ │ │ ├── jsonb_spec.rb │ │ │ └── column_spec.rb │ │ ├── sequel_spec.rb │ │ ├── active_record_spec.rb │ │ └── active_record │ │ │ ├── json_spec.rb │ │ │ ├── hstore_spec.rb │ │ │ └── jsonb_spec.rb │ ├── translations_spec.rb │ └── pluggable_spec.rb ├── databases.yml ├── performance │ └── translations_spec.rb ├── database.rb ├── integration │ └── sequel_compatibility_spec.rb ├── spec_helper.rb └── generators │ └── rails │ └── mobility │ └── install_generator_spec.rb ├── img └── companies-using-mobility.png ├── bin ├── setup └── console ├── .gitignore ├── lib ├── sequel │ └── plugins │ │ └── mobility.rb ├── rails │ └── generators │ │ └── mobility │ │ ├── generators.rb │ │ ├── backend_generators │ │ ├── column_backend.rb │ │ ├── table_backend.rb │ │ └── base.rb │ │ ├── active_record_migration_compatibility.rb │ │ ├── templates │ │ ├── create_text_translations.rb │ │ ├── column_translations.rb │ │ ├── create_string_translations.rb │ │ ├── table_migration.rb │ │ └── table_translations.rb │ │ ├── install_generator.rb │ │ └── translations_generator.rb └── mobility │ ├── version.rb │ ├── plugins │ ├── column_fallback.rb │ ├── arel │ │ ├── nodes.rb │ │ └── nodes │ │ │ └── pg_ops.rb │ ├── query.rb │ ├── sequel │ │ ├── cache.rb │ │ ├── backend.rb │ │ ├── column_fallback.rb │ │ └── dirty.rb │ ├── active_model.rb │ ├── active_model │ │ └── cache.rb │ ├── active_record │ │ ├── cache.rb │ │ ├── backend.rb │ │ ├── column_fallback.rb │ │ ├── uniqueness_validation.rb │ │ └── dirty.rb │ ├── backend_reader.rb │ ├── dirty.rb │ ├── writer.rb │ ├── sequel.rb │ ├── reader.rb │ ├── active_record.rb │ ├── presence.rb │ ├── attribute_methods.rb │ ├── attributes.rb │ ├── locale_accessors.rb │ ├── fallthrough_accessors.rb │ ├── cache.rb │ ├── default.rb │ └── arel.rb │ ├── backends │ ├── json.rb │ ├── jsonb.rb │ ├── hstore.rb │ ├── null.rb │ ├── hash.rb │ ├── container.rb │ ├── active_record │ │ ├── pg_hash.rb │ │ ├── hstore.rb │ │ ├── jsonb.rb │ │ ├── json.rb │ │ ├── column.rb │ │ └── serialized.rb │ ├── sequel │ │ ├── hstore.rb │ │ ├── column.rb │ │ ├── json.rb │ │ ├── pg_hash.rb │ │ ├── jsonb.rb │ │ └── serialized.rb │ ├── active_record.rb │ ├── hash_valued.rb │ ├── column.rb │ ├── serialized.rb │ └── sequel.rb │ ├── backends.rb │ ├── plugins.rb │ ├── pluggable.rb │ ├── util.rb │ └── translations.rb ├── .codeclimate.yml ├── ISSUE_TEMPLATE.md ├── LICENSE.txt ├── certs └── shioyama.pem ├── Gemfile ├── mobility.gemspec ├── Rakefile └── CONTRIBUTING.md /docs/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --exclude /templates/ 2 | --quiet 3 | -------------------------------------------------------------------------------- /spec/support/matchers/not_change.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define_negated_matcher :not_change, :change 2 | -------------------------------------------------------------------------------- /img/companies-using-mobility.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shioyama/mobility/HEAD/img/companies-using-mobility.png -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | 11 | .ruby-version 12 | .byebug_history 13 | Guardfile 14 | *.gem 15 | -------------------------------------------------------------------------------- /spec/support/matchers/include_hash.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :include_hash do |expected| 2 | match do |actual| 3 | return false if actual.nil? 4 | expected.values == actual.values_at(*expected.keys) 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/sequel/plugins/mobility.rb: -------------------------------------------------------------------------------- 1 | module Sequel 2 | module Plugins 3 | module Mobility 4 | module InstanceMethods 5 | def self.included(base) 6 | base.extend ::Mobility 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/matchers/indifferent_hash.rb: -------------------------------------------------------------------------------- 1 | # Match structure of hash, disregarding whether keys are symbols or strings 2 | RSpec::Matchers.define :match_hash do |expected| 3 | match do |actual| 4 | stringify_keys(actual) == stringify_keys(expected) 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/rails/generators/mobility/generators.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "rails/generators" 3 | 4 | require_relative "./active_record_migration_compatibility" 5 | require_relative "./install_generator" 6 | require_relative "./translations_generator" 7 | require_relative "./backend_generators/base" 8 | -------------------------------------------------------------------------------- /spec/support/matchers/have_plugin.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :have_plugin do |expected| 2 | match do |actual| 3 | raise ArgumentError, "#{actual} should be a Mobility::Pluggable" unless Mobility::Pluggable === actual 4 | actual.class.ancestors.include? Mobility::Plugins.load_plugin(expected) 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/mobility/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mobility 4 | def self.gem_version 5 | Gem::Version.new VERSION::STRING 6 | end 7 | 8 | module VERSION 9 | MAJOR = 1 10 | MINOR = 3 11 | TINY = 2 12 | PRE = nil 13 | 14 | STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/mobility/plugins/column_fallback.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | module ColumnFallback 6 | extend Plugin 7 | 8 | default false 9 | 10 | requires :backend, include: :before 11 | end 12 | 13 | register_plugin(:column_fallback, ColumnFallback) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "mobility" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | require "pry-byebug" 11 | Pry.start 12 | -------------------------------------------------------------------------------- /spec/mobility/sequel/plugin_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Sequel::Plugins::Mobility", orm: :sequel do 4 | include Helpers::Plugins 5 | 6 | plugins :sequel, :reader, :writer 7 | 8 | before { stub_const 'Article', Class.new(Sequel::Model) } 9 | 10 | it "includes Mobility class" do 11 | Article.plugin :mobility 12 | 13 | expect(Article.ancestors).to include(Mobility) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/rails/generators/mobility/backend_generators/column_backend.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | require "rails/generators" 3 | 4 | module Mobility 5 | module BackendGenerators 6 | class ColumnBackend < Mobility::BackendGenerators::Base 7 | source_root File.expand_path("../../templates", __FILE__) 8 | 9 | def initialize(*args) 10 | super 11 | check_data_source! 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/mobility/plugins/arel/nodes.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | module Mobility 3 | module Plugins 4 | module Arel 5 | module Nodes 6 | class Binary < ::Arel::Nodes::Binary; end 7 | class Grouping < ::Arel::Nodes::Grouping; end 8 | 9 | ::Arel::Visitors::ToSql.class_eval do 10 | alias :visit_Mobility_Plugins_Arel_Nodes_Grouping :visit_Arel_Nodes_Grouping 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/databases.yml: -------------------------------------------------------------------------------- 1 | mysql: 2 | adapter: mysql2 3 | host: 127.0.0.1 4 | port: 3306 5 | database: mobility_test 6 | password: <%= ENV['MYSQL_PASSWORD'] %> 7 | username: root 8 | encoding: utf8 9 | collation: utf8_unicode_ci 10 | 11 | postgres: 12 | adapter: postgresql 13 | host: localhost 14 | port: 5432 15 | database: mobility_test 16 | username: postgres 17 | encoding: utf8 18 | 19 | sqlite3: 20 | adapter: sqlite3 21 | database: ":memory:" 22 | encoding: utf8 23 | -------------------------------------------------------------------------------- /spec/support/shared_examples/cache_key_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "cache key" do |model_class_name, attribute=:title| 2 | let(:model_class) { constantize(model_class_name) } 3 | 4 | it "changes cache key when translation updated" do 5 | model = model_class.create!(attribute => "foo") 6 | original_cache_key = model.cache_key 7 | travel 1.second do 8 | model.update!(attribute => "bar") 9 | end 10 | expect(model.cache_key).to_not eq(original_cache_key) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/rails/generators/mobility/active_record_migration_compatibility.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "rails/generators/active_record" 3 | require "active_record/migration" 4 | 5 | module Mobility 6 | module ActiveRecordMigrationCompatibility 7 | def activerecord_migration_class 8 | if ::ActiveRecord::Migration.respond_to?(:current_version) 9 | "ActiveRecord::Migration[#{::ActiveRecord::Migration.current_version}]" 10 | else 11 | "ActiveRecord::Migration" 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/mobility/plugins/query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Mobility 3 | module Plugins 4 | =begin 5 | 6 | @see {Mobility::Plugins::ActiveRecord::Query} or {Mobility::Plugins::Sequel::Query}. 7 | 8 | =end 9 | module Query 10 | extend Plugin 11 | 12 | default :i18n 13 | requires :backend, include: :before 14 | 15 | def query_method 16 | (options[:query] == true) ? self.class.defaults[:query] : options[:query] 17 | end 18 | end 19 | 20 | register_plugin(:query, Query) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/mobility/backends/json.rb: -------------------------------------------------------------------------------- 1 | module Mobility 2 | module Backends 3 | =begin 4 | 5 | Stores translations as hash on Postgres json column. 6 | 7 | ==Backend Options 8 | 9 | ===+column_prefix+ and +column_suffix+ 10 | 11 | Prefix and suffix to add to attribute name to generate json column name. 12 | 13 | @see Mobility::Backends::ActiveRecord::Json 14 | @see Mobility::Backends::Sequel::Json 15 | @see https://www.postgresql.org/docs/current/static/datatype-json.html PostgreSQL Documentation for JSON Types 16 | 17 | =end 18 | module Json 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/mobility/backends/jsonb.rb: -------------------------------------------------------------------------------- 1 | module Mobility 2 | module Backends 3 | =begin 4 | 5 | Stores translations as hash on Postgres jsonb column. 6 | 7 | ==Backend Options 8 | 9 | ===+column_prefix+ and +column_suffix+ 10 | 11 | Prefix and suffix to add to attribute name to generate jsonb column name. 12 | 13 | @see Mobility::Backends::ActiveRecord::Jsonb 14 | @see Mobility::Backends::Sequel::Jsonb 15 | @see https://www.postgresql.org/docs/current/static/datatype-json.html PostgreSQL Documentation for JSON Types 16 | 17 | =end 18 | module Jsonb 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/mobility/plugins/sequel/cache.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | module Sequel 6 | =begin 7 | 8 | Adds hook to clear Mobility cache when +refresh+ is called on Sequel model. 9 | 10 | =end 11 | module Cache 12 | extend Plugin 13 | 14 | requires :cache, include: false 15 | 16 | included_hook do |klass| 17 | define_cache_hooks(klass, :refresh) if options[:cache] 18 | end 19 | end 20 | end 21 | 22 | register_plugin(:sequel_cache, Sequel::Cache) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/mobility/backends/hstore.rb: -------------------------------------------------------------------------------- 1 | module Mobility 2 | module Backends 3 | 4 | =begin 5 | 6 | Stores translations as hash on Postgres hstore column. 7 | 8 | ==Backend Options 9 | 10 | ===+column_prefix+ and +column_suffix+ 11 | 12 | Prefix and suffix to add to attribute name to generate hstore column name. 13 | 14 | @see Mobility::Backends::ActiveRecord::Hstore 15 | @see Mobility::Backends::Sequel::Hstore 16 | @see https://www.postgresql.org/docs/current/static/hstore.html PostgreSQL Documentation for hstore 17 | 18 | =end 19 | module Hstore 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/mobility/plugins/sequel_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(Sequel) 4 | 5 | require "mobility/plugins/sequel" 6 | 7 | describe Mobility::Plugins::Sequel, orm: :sequel, type: :plugin do 8 | plugins :sequel 9 | 10 | it "raises TypeError unless class is a subclass of Sequel::Model" do 11 | klass = Class.new 12 | sequel_class = Class.new(Sequel::Model) 13 | 14 | expect { translates(klass) }.to raise_error(TypeError, /should be a subclass of Sequel\:\:Model/) 15 | expect { translates(sequel_class) }.not_to raise_error 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/mobility/plugins/active_record_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(ActiveRecord) 4 | 5 | require "mobility/plugins/active_record" 6 | 7 | describe Mobility::Plugins::ActiveRecord, orm: :active_record, type: :plugin do 8 | plugins :active_record 9 | 10 | it "raises TypeError unless class is a subclass of ActiveRecord::Base" do 11 | klass = Class.new 12 | ar_class = Class.new(ActiveRecord::Base) 13 | 14 | expect { translates(klass) }.to raise_error(TypeError, /should be a subclass of ActiveRecord\:\:Base/) 15 | expect { translates(ar_class) }.not_to raise_error 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/mobility/backends/null.rb: -------------------------------------------------------------------------------- 1 | module Mobility 2 | module Backends 3 | =begin 4 | 5 | Backend which does absolutely nothing. Mostly for testing purposes. 6 | 7 | =end 8 | class Null 9 | include Backend 10 | 11 | # @!group Backend Accessors 12 | # @return [NilClass] 13 | def read(_locale, _options = nil); end 14 | 15 | # @return [NilClass] 16 | def write(_locale, _value, _options = nil); end 17 | # @!endgroup 18 | 19 | # @!group Backend Configuration 20 | def self.configure(_); end 21 | # @!endgroup 22 | end 23 | 24 | register_backend(:null, Null) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/mobility/plugins/active_model.rb: -------------------------------------------------------------------------------- 1 | require_relative "./active_model/dirty" 2 | require_relative "./active_model/cache" 3 | 4 | module Mobility 5 | module Plugins 6 | =begin 7 | 8 | Plugin for ActiveModel models. In practice, this is simply a wrapper to include 9 | a few plugins which apply to models which include ActiveModel::Dirty but are 10 | not ActiveRecord models. 11 | 12 | =end 13 | module ActiveModel 14 | extend Plugin 15 | 16 | requires :active_model_dirty 17 | requires :active_model_cache 18 | requires :backend, include: :before 19 | end 20 | 21 | register_plugin(:active_model, ActiveModel) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/mobility/plugins/active_model/cache.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | module ActiveModel 6 | =begin 7 | 8 | Adds hooks to clear Mobility cache when AM dirty reset methods are called. 9 | 10 | =end 11 | module Cache 12 | extend Plugin 13 | 14 | requires :cache, include: false 15 | 16 | included_hook do |klass, _| 17 | if options[:cache] 18 | define_cache_hooks(klass, :changes_applied, :clear_changes_information) 19 | end 20 | end 21 | end 22 | end 23 | 24 | register_plugin(:active_model_cache, ActiveModel::Cache) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/mobility/backends.rb: -------------------------------------------------------------------------------- 1 | module Mobility 2 | module Backends 3 | @backends = {} 4 | 5 | class << self 6 | # @param [Symbol, Object] backend Name of backend to load. 7 | def load_backend(name) 8 | return name if Module === name || name.nil? 9 | 10 | unless (backend = @backends[name]) 11 | require "mobility/backends/#{name}" 12 | raise LoadError, "backend #{name} did not register itself correctly in Mobility::Backends" unless (backend = @backends[name]) 13 | end 14 | backend 15 | end 16 | end 17 | 18 | def self.register_backend(name, mod) 19 | @backends[name] = mod 20 | end 21 | 22 | class LoadError < Error; end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "2" 3 | checks: 4 | method-complexity: 5 | config: 6 | threshold: 8 7 | method-lines: 8 | enabled: false 9 | engines: 10 | duplication: 11 | enabled: false 12 | fixme: 13 | enabled: true 14 | checks: 15 | TODO: 16 | enabled: false 17 | rubocop: 18 | enabled: true 19 | checks: 20 | Rubocop/Metrics/MethodLength: 21 | enabled: false 22 | Rubocop/Metrics/CyclomaticComplexity: 23 | enabled: false 24 | ratings: 25 | paths: 26 | - "**.inc" 27 | - "**.js" 28 | - "**.jsx" 29 | - "**.module" 30 | - "**.php" 31 | - "**.py" 32 | - "**.rb" 33 | exclude_paths: 34 | - spec/ 35 | - lib/rails/generators/mobility/templates/ 36 | -------------------------------------------------------------------------------- /lib/mobility/plugins/active_record/cache.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | require "mobility/plugins/active_model/cache" 3 | 4 | module Mobility 5 | module Plugins 6 | module ActiveRecord 7 | =begin 8 | 9 | Resets cache on calls to +reload+, in addition to other AM dirty reset 10 | methods. 11 | 12 | =end 13 | module Cache 14 | extend Plugin 15 | 16 | requires :cache, include: false 17 | 18 | included_hook do |klass, _| 19 | if options[:cache] 20 | define_cache_hooks(klass, :changes_applied, :clear_changes_information, :reload) 21 | end 22 | end 23 | end 24 | end 25 | 26 | register_plugin(:active_record_cache, ActiveRecord::Cache) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/mobility/plugins/dirty_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "mobility/plugins/dirty" 3 | 4 | describe Mobility::Plugins::Dirty, type: :plugin do 5 | plugin_setup 6 | 7 | context "dirty default option" do 8 | plugins :dirty 9 | 10 | it "requires fallthrough_accessors" do 11 | expect(translations).to have_plugin(:fallthrough_accessors) 12 | end 13 | end 14 | 15 | context "fallthrough accessors is falsey" do 16 | plugins do 17 | dirty true 18 | fallthrough_accessors false 19 | end 20 | 21 | it "emits warning" do 22 | expect { instance }.to output( 23 | /The Dirty plugin depends on Fallthrough Accessors being enabled,/ 24 | ).to_stderr 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/rails/generators/mobility/templates/create_text_translations.rb: -------------------------------------------------------------------------------- 1 | class CreateTextTranslations < <%= activerecord_migration_class %> 2 | def change 3 | create_table :mobility_text_translations do |t| 4 | t.string :locale, null: false 5 | t.string :key, null: false 6 | t.text :value 7 | t.references :translatable, polymorphic: true, index: false 8 | t.timestamps null: false 9 | end 10 | add_index :mobility_text_translations, [:translatable_id, :translatable_type, :locale, :key], unique: true, name: :index_mobility_text_translations_on_keys 11 | add_index :mobility_text_translations, [:translatable_id, :translatable_type, :key], name: :index_mobility_text_translations_on_translatable_attribute 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/mobility/plugins/sequel/backend.rb: -------------------------------------------------------------------------------- 1 | module Mobility 2 | module Plugins 3 | module Sequel 4 | =begin 5 | 6 | Maps backend names to Sequel namespaced backends. 7 | 8 | =end 9 | module Backend 10 | extend Plugin 11 | 12 | requires :backend, include: :before 13 | 14 | def load_backend(backend) 15 | if Symbol === backend 16 | require "mobility/backends/sequel/#{backend}" 17 | Backends.load_backend("sequel_#{backend}".to_sym) 18 | else 19 | super 20 | end 21 | rescue LoadError => e 22 | raise unless e.message =~ /sequel\/#{backend}/ 23 | super 24 | end 25 | end 26 | end 27 | 28 | register_plugin(:sequel_backend, Sequel::Backend) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/mobility/active_record_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(ActiveRecord) 4 | 5 | require "mobility/plugins/active_record" 6 | 7 | describe "Mobility::ActiveRecord", orm: :active_record do 8 | include Helpers::Plugins 9 | # need to require these backends to trigger loading Mobility::ActiveRecord 10 | require "mobility/backends/active_record/table" 11 | require "mobility/backends/active_record/key_value" 12 | 13 | it "resolves ActiveRecord to ::ActiveRecord in model class" do 14 | ar_class = Class.new(ActiveRecord::Base) 15 | ar_class.extend Mobility 16 | 17 | aggregate_failures do 18 | expect(ar_class.instance_eval("ActiveRecord")).to eq(::ActiveRecord) 19 | expect(ar_class.class_eval("ActiveRecord")).to eq(::ActiveRecord) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/rails/generators/mobility/templates/column_translations.rb: -------------------------------------------------------------------------------- 1 | class <%= migration_class_name %> < <%= activerecord_migration_class %> 2 | def change 3 | <% attributes.each do |attribute| -%> 4 | <% Mobility.available_locales.each do |locale| -%> 5 | <% column_name = Mobility.normalize_locale_accessor(attribute.name, locale) -%> 6 | <% if connection.column_exists?(table_name, column_name) -%> 7 | <% warn "#{column_name} already exists, skipping." -%> 8 | <% else -%> 9 | add_column :<%= table_name %>, :<%= column_name %>, :<%= attribute.type %><%= attribute.inject_options %> 10 | <%- if attribute.has_index? -%> 11 | add_index :<%= table_name %>, :<%= column_name %><%= attribute.inject_index_options %>, name: :<%= translation_index_name(column_name) %> 12 | <%- end -%> 13 | <% end -%> 14 | <% end -%> 15 | <% end -%> 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/mobility/plugins/active_record/backend.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | module ActiveRecord 6 | =begin 7 | 8 | Maps backend names to ActiveRecord namespaced backends. 9 | 10 | =end 11 | module Backend 12 | extend Plugin 13 | 14 | requires :backend, include: :before 15 | 16 | def load_backend(backend) 17 | if Symbol === backend 18 | require "mobility/backends/active_record/#{backend}" 19 | Backends.load_backend("active_record_#{backend}".to_sym) 20 | else 21 | super 22 | end 23 | rescue LoadError => e 24 | raise unless e.message =~ /active_record\/#{backend}/ 25 | super 26 | end 27 | end 28 | end 29 | 30 | register_plugin(:active_record_backend, ActiveRecord::Backend) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/mobility/plugins/sequel/backend_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(Sequel) 4 | 5 | require "mobility/plugins/sequel/backend" 6 | 7 | describe Mobility::Plugins::Sequel::Backend, orm: :sequel, type: :plugin do 8 | plugins :sequel_backend 9 | plugin_setup 10 | 11 | describe "#load_backend" do 12 | context "backend with name exists in Sequel namespace" do 13 | it "attempts to load sequel variant of backend" do 14 | expect(translations.load_backend(:key_value)).to eq(Mobility::Backends::Sequel::KeyValue) 15 | end 16 | end 17 | 18 | context "backend with name does not exist in Sequel namespace" do 19 | it "raises LoadError on backend name" do 20 | expect { 21 | translations.load_backend(:foo) 22 | }.to raise_error(LoadError, /mobility\/backends\/foo/) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/mobility/backends/hash.rb: -------------------------------------------------------------------------------- 1 | module Mobility 2 | module Backends 3 | =begin 4 | 5 | Backend which stores translations in an in-memory hash. 6 | 7 | =end 8 | class Hash 9 | include Backend 10 | 11 | # @!group Backend Accessors 12 | # @!macro backend_reader 13 | # @return [Object] 14 | def read(locale, _ = {}) 15 | translations[locale] 16 | end 17 | 18 | # @!macro backend_writer 19 | # @return [Object] 20 | def write(locale, value, _ = {}) 21 | translations[locale] = value 22 | end 23 | # @!endgroup 24 | 25 | # @!macro backend_iterator 26 | def each_locale 27 | translations.each { |l, _| yield l } 28 | end 29 | 30 | private 31 | 32 | def translations 33 | @translations ||= {} 34 | end 35 | end 36 | 37 | register_backend(:hash, Hash) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/rails/generators/mobility/templates/create_string_translations.rb: -------------------------------------------------------------------------------- 1 | class CreateStringTranslations < <%= activerecord_migration_class %> 2 | def change 3 | create_table :mobility_string_translations do |t| 4 | t.string :locale, null: false 5 | t.string :key, null: false 6 | t.string :value 7 | t.references :translatable, polymorphic: true, index: false 8 | t.timestamps null: false 9 | end 10 | add_index :mobility_string_translations, [:translatable_id, :translatable_type, :locale, :key], unique: true, name: :index_mobility_string_translations_on_keys 11 | add_index :mobility_string_translations, [:translatable_id, :translatable_type, :key], name: :index_mobility_string_translations_on_translatable_attribute 12 | add_index :mobility_string_translations, [:translatable_type, :key, :value, :locale], name: :index_mobility_string_translations_on_query_keys 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/mobility/backends/container.rb: -------------------------------------------------------------------------------- 1 | module Mobility 2 | module Backends 3 | 4 | =begin 5 | 6 | Stores translations for multiple attributes on a single shared Postgres jsonb 7 | column (called a "container"). 8 | 9 | ==Backend Options 10 | 11 | ===+column_name+ 12 | 13 | Name of the column for the translations container (where translations are 14 | stored). 15 | 16 | @see Mobility::Backends::ActiveRecord::Container 17 | @see Mobility::Backends::Sequel::Container 18 | @see https://www.postgresql.org/docs/current/static/datatype-json.html PostgreSQL Documentation for JSON Types 19 | 20 | =end 21 | module Container 22 | def self.included(backend_class) 23 | backend_class.extend ClassMethods 24 | backend_class.option_reader :column_name 25 | end 26 | 27 | module ClassMethods 28 | def valid_keys 29 | [:column_name] 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/mobility/backends/key_value_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "mobility/backends/key_value" 3 | 4 | describe "Mobility::Backends::KeyValue", orm: [:active_record, :sequel] do 5 | describe "ClassMethods" do 6 | let(:backend_class) do 7 | klass = Class.new 8 | klass.extend Mobility::Backends::KeyValue::ClassMethods 9 | klass 10 | end 11 | 12 | it "raises ArgumentError if type is not defined, and class_name and association_name are also not defined" do 13 | stub_const("Foo", Class.new) 14 | error_msg = /KeyValue backend requires an explicit type option/ 15 | expect { backend_class.configure({}) }.to raise_error(ArgumentError, error_msg) 16 | expect { backend_class.configure(class_name: "Foo") }.to raise_error(ArgumentError, error_msg) 17 | expect { backend_class.configure(association_name: "foos") }.to raise_error(ArgumentError, error_msg) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/rails/generators/mobility/backend_generators/table_backend.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | require "rails/generators" 3 | 4 | module Mobility 5 | module BackendGenerators 6 | class TableBackend < Mobility::BackendGenerators::Base 7 | source_root File.expand_path("../../templates", __FILE__) 8 | 9 | def create_migration_file 10 | if data_source_exists? && !self.class.migration_exists?(migration_dir, migration_file) 11 | migration_template "#{backend}_migration.rb", "db/migrate/#{migration_file}.rb" 12 | else 13 | super 14 | end 15 | end 16 | 17 | private 18 | 19 | alias_method :model_table_name, :table_name 20 | def table_name 21 | model_table_name = super 22 | "#{model_table_name.singularize}_translations" 23 | end 24 | 25 | def foreign_key 26 | "#{model_table_name.singularize}_id" 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/mobility/plugins/active_record/backend_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(ActiveRecord) 4 | 5 | require "mobility/plugins/active_record/backend" 6 | 7 | describe Mobility::Plugins::ActiveRecord::Backend, orm: :active_record, type: :plugin do 8 | plugins :active_record_backend 9 | plugin_setup 10 | 11 | describe "#load_backend" do 12 | context "backend with name exists in ActiveRecord namespace" do 13 | it "attempts to load active_record variant of backend" do 14 | expect(translations.load_backend(:key_value)).to eq(Mobility::Backends::ActiveRecord::KeyValue) 15 | end 16 | end 17 | 18 | context "backend with name does not exist in ActiveRecord namespace" do 19 | it "raises LoadError on backend name" do 20 | expect { 21 | translations.load_backend(:foo) 22 | }.to raise_error(LoadError, /mobility\/backends\/foo/) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/mobility/plugins/sequel/cache_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(Sequel) 4 | 5 | require "mobility/plugins/sequel/cache" 6 | 7 | describe Mobility::Plugins::Sequel::Cache, orm: :sequel, type: :plugin do 8 | plugins :sequel, :cache 9 | plugin_setup :title 10 | 11 | let(:model_class) do 12 | stub_const 'Article', Class.new(Sequel::Model) 13 | Article.dataset = DB[:articles] 14 | Article 15 | end 16 | 17 | it "clears backend cache after refresh" do 18 | model_class.include translations 19 | instance = model_class.create 20 | 21 | expect(instance.mobility_backends[:title]).to receive(:clear_cache).once 22 | instance.refresh 23 | end 24 | 25 | it "does not change visibility of refresh" do 26 | priv = model_class.private_method_defined?(:refresh) 27 | model_class.include translations 28 | 29 | expect(model_class.private_method_defined?(:refresh)).to eq(priv) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/mobility/plugins/backend_reader.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | module Mobility 3 | module Plugins 4 | =begin 5 | 6 | Defines convenience methods for accessing backends, of the form 7 | "_backend". The format for this method can be customized by passing a 8 | different format string as the plugin option. 9 | 10 | =end 11 | module BackendReader 12 | extend Plugin 13 | 14 | default true 15 | requires :backend 16 | 17 | initialize_hook do |*names| 18 | if backend_reader = options[:backend_reader] 19 | backend_reader = "%s_backend" if backend_reader == true 20 | 21 | names.each do |name| 22 | module_eval <<-EOM, __FILE__, __LINE__ + 1 23 | def #{backend_reader % name} 24 | mobility_backends[:#{name}] 25 | end 26 | EOM 27 | end 28 | end 29 | end 30 | end 31 | 32 | register_plugin(:backend_reader, BackendReader) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ## Context 8 | 9 | 10 | 11 | 12 | 13 | ## Expected Behavior 14 | 15 | 16 | ## Actual Behavior 17 | 18 | 19 | ## Possible Fix 20 | 21 | 22 | -------------------------------------------------------------------------------- /spec/mobility/plugins/active_model/cache_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(ActiveModel) 4 | 5 | require "mobility/plugins/active_model/cache" 6 | 7 | describe Mobility::Plugins::ActiveModel::Cache, orm: :active_record, type: :plugin do 8 | plugins :active_model, :cache 9 | plugin_setup :title 10 | 11 | let(:model_class) do 12 | Class.new do 13 | include ::ActiveModel::Dirty 14 | end 15 | end 16 | 17 | %w[changes_applied clear_changes_information].each do |method_name| 18 | it "clears backend cache after #{method_name}" do 19 | model_class.include translations 20 | 21 | expect(instance.mobility_backends[:title]).to receive(:clear_cache).once 22 | instance.send(method_name) 23 | end 24 | 25 | it "does not change visibility of #{method_name}" do 26 | priv = model_class.private_method_defined?(method_name) 27 | model_class.include translations 28 | 29 | expect(model_class.private_method_defined?(method_name)).to eq(priv) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/support/matchers/allocate_under.rb: -------------------------------------------------------------------------------- 1 | # borrowed from: https://blog.honeybadger.io/testing-object-allocations/ 2 | begin 3 | require 'allocation_stats' 4 | rescue LoadError 5 | puts 'Skipping AllocationStats.' 6 | end 7 | 8 | RSpec::Matchers.define :allocate_under do |expected| 9 | match do |actual| 10 | return skip('AllocationStats is not available: skipping.') unless defined?(AllocationStats) 11 | @trace = actual.is_a?(Proc) ? AllocationStats.new(burn: 1).trace(&actual) : actual 12 | @trace.new_allocations.size < expected 13 | end 14 | 15 | def objects 16 | self 17 | end 18 | 19 | def supports_block_expectations? 20 | true 21 | end 22 | 23 | def output_trace_info(trace) 24 | trace.allocations(alias_paths: true).group_by(:sourcefile, :sourceline, :class).to_text 25 | end 26 | 27 | failure_message do |actual| 28 | "expected under #{ expected } objects to be allocated; got #{ @trace.new_allocations.size }:\n\n" << output_trace_info(@trace) 29 | end 30 | 31 | description do 32 | "allocates under #{ expected } objects" 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/rails/generators/mobility/templates/table_migration.rb: -------------------------------------------------------------------------------- 1 | class <%= migration_class_name %> < <%= activerecord_migration_class %> 2 | def change 3 | <% attributes.each do |attribute| -%> 4 | <%- if attribute.reference? -%> 5 | add_reference :<%= table_name %>, :<%= attribute.name %><%= attribute.inject_options %> 6 | <%- elsif attribute.respond_to?(:token?) && attribute.token? -%> 7 | add_column :<%= table_name %>, :<%= attribute.name %>, :string<%= attribute.inject_options %> 8 | add_index :<%= table_name %>, [:<%= attribute.index_name %><%= attribute.inject_index_options %>, :locale], name: :<%= translation_index_name(attribute.index_name, "locale") %>, unique: true <%- else -%> 9 | add_column :<%= table_name %>, :<%= attribute.name %>, :<%= attribute.type %><%= attribute.inject_options %> 10 | <%- if attribute.has_index? -%> 11 | add_index :<%= table_name %>, [:<%= attribute.index_name %><%= attribute.inject_index_options %>, :locale], name: :<%= translation_index_name(attribute.index_name, "locale") %> 12 | <%- end -%> 13 | <%- end -%> 14 | <% end -%> 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/mobility/backends/active_record/pg_hash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "mobility/backends/active_record" 3 | require "mobility/backends/hash_valued" 4 | 5 | module Mobility 6 | module Backends 7 | =begin 8 | 9 | Internal class used by ActiveRecord backends backed by a Postgres data type 10 | (hstore, jsonb). 11 | 12 | =end 13 | module ActiveRecord 14 | class PgHash 15 | include ActiveRecord 16 | include HashValued 17 | 18 | def read(locale, _options = nil) 19 | translations[locale.to_s] 20 | end 21 | 22 | def write(locale, value, _options = nil) 23 | if value.nil? 24 | translations.delete(locale.to_s) 25 | nil 26 | else 27 | translations[locale.to_s] = value 28 | end 29 | end 30 | 31 | # @!macro backend_iterator 32 | def each_locale 33 | super { |l| yield l.to_sym } 34 | end 35 | 36 | def translations 37 | model[column_name] 38 | end 39 | end 40 | private_constant :PgHash 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/support/shared_examples/locale_accessor_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "locale accessor" do |attribute, locale| 2 | let(:options) { { these: "options" } } 3 | 4 | it "handles getters and setters for locale=#{locale}" do 5 | instance = model_class.new 6 | normalized_locale = locale.to_s.gsub('-', '_').downcase.to_sym 7 | 8 | aggregate_failures "getter" do 9 | expect(instance).to receive(attribute).with(**options, locale: locale).and_return("foo") 10 | expect(instance.send(:"#{attribute}_#{normalized_locale}", **options)).to eq("foo") 11 | end 12 | 13 | aggregate_failures "presence" do 14 | expect(instance).to receive(:"#{attribute}?").with(**options, locale: locale).and_return(true) 15 | expect(instance.send(:"#{attribute}_#{normalized_locale}?", **options)).to eq(true) 16 | end 17 | 18 | aggregate_failures "setter" do 19 | expect(instance).to receive(:"#{attribute}=").with("value", **options, locale: locale).and_return("value") 20 | expect(instance.send(:"#{attribute}_#{normalized_locale}=", "value", **options)).to eq("value") 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Chris Salzberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/mobility/plugins/dirty.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | =begin 6 | 7 | Dirty tracking for Mobility attributes. See class-specific implementations for 8 | details. 9 | 10 | @see Mobility::Plugins::ActiveModel::Dirty 11 | @see Mobility::Plugins::ActiveRecord::Dirty 12 | @see Mobility::Plugins::Sequel::Dirty 13 | 14 | @note Dirty tracking can have unexpected results when combined with fallbacks. 15 | A change in the fallback locale value will not mark an attribute falling 16 | through to that locale as changed, even though it may look like it has 17 | changed. See the specs for details on expected behavior. 18 | 19 | =end 20 | module Dirty 21 | extend Plugin 22 | 23 | default true 24 | 25 | requires :backend, include: :before 26 | requires :fallthrough_accessors 27 | 28 | initialize_hook do 29 | if options[:dirty] && !options[:fallthrough_accessors] 30 | warn 'The Dirty plugin depends on Fallthrough Accessors being enabled, '\ 31 | 'but fallthrough_accessors option is falsey' 32 | end 33 | end 34 | end 35 | 36 | register_plugin(:dirty, Dirty) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/mobility/plugins/writer.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | module Writer 6 | =begin 7 | 8 | Defines attribute writer that delegates to +Mobility::Backend#write+. 9 | 10 | =end 11 | extend Plugin 12 | 13 | default true 14 | requires :backend 15 | 16 | initialize_hook do |*names| 17 | if options[:writer] 18 | names.each do |name| 19 | class_eval <<-EOM, __FILE__, __LINE__ + 1 20 | def #{name}=(value, locale: nil, **options) 21 | #{Writer.setup_source} 22 | mobility_backends[:#{name}].write(locale, value, **options) 23 | end 24 | EOM 25 | end 26 | end 27 | end 28 | 29 | def self.setup_source 30 | <<-EOL 31 | return super(value) if options[:super] 32 | if (locale &&= locale.to_sym) 33 | #{"Mobility.enforce_available_locales!(locale)" if I18n.enforce_available_locales} 34 | options[:locale] = true 35 | else 36 | locale = Mobility.locale 37 | end 38 | EOL 39 | end 40 | end 41 | 42 | register_plugin(:writer, Writer) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/mobility/backends/sequel/hstore.rb: -------------------------------------------------------------------------------- 1 | require 'mobility/backends/sequel/pg_hash' 2 | 3 | Sequel.extension :pg_hstore, :pg_hstore_ops 4 | 5 | module Mobility 6 | module Backends 7 | =begin 8 | 9 | Implements the {Mobility::Backends::Hstore} backend for Sequel models. 10 | 11 | @see Mobility::Backends::HashValued 12 | 13 | =end 14 | module Sequel 15 | class Hstore < PgHash 16 | # @!group Backend Accessors 17 | # @!macro backend_reader 18 | # @!method read(locale, options = {}) 19 | 20 | # @!group Backend Accessors 21 | # @!macro backend_writer 22 | def write(locale, value, options = {}) 23 | super(locale, value && value.to_s, **options) 24 | end 25 | # @!endgroup 26 | 27 | # @param [Symbol] name Attribute name 28 | # @param [Symbol] locale Locale 29 | # @return [Mobility::Backends::Sequel::Hstore::HStoreOp] 30 | def self.build_op(attr, locale) 31 | column_name = column_affix % attr 32 | HStoreOp.new(column_name.to_sym)[locale.to_s] 33 | end 34 | 35 | class HStoreOp < ::Sequel::Postgres::HStoreOp; end 36 | end 37 | end 38 | 39 | register_backend(:sequel_hstore, Sequel::Hstore) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/mobility/plugins.rb: -------------------------------------------------------------------------------- 1 | module Mobility 2 | =begin 3 | 4 | Plugins allow modular customization of backends independent of the backend 5 | itself. They are enabled through {Mobility::Translations.plugins} (delegated to 6 | from {Mobility.configure}), which takes a block within which plugins can be 7 | declared in any order (dependencies will be resolved). 8 | 9 | =end 10 | module Plugins 11 | @plugins = {} 12 | @names = {} 13 | 14 | class << self 15 | # @param [Symbol] name Name of plugin to load. 16 | def load_plugin(name) 17 | return name if Module === name || name.nil? 18 | 19 | unless (plugin = @plugins[name]) 20 | require "mobility/plugins/#{name}" 21 | raise LoadError, "plugin #{name} did not register itself correctly in Mobility::Plugins" unless (plugin = @plugins[name]) 22 | end 23 | plugin 24 | end 25 | 26 | # @param [Module] plugin Plugin module to lookup. Plugin must already be loaded. 27 | def lookup_name(plugin) 28 | @names.fetch(plugin) 29 | end 30 | 31 | def register_plugin(name, plugin) 32 | @plugins[name] = plugin 33 | @names[plugin] = name 34 | end 35 | 36 | class LoadError < Error; end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/mobility/backends/active_record/hstore.rb: -------------------------------------------------------------------------------- 1 | require 'mobility/backends/active_record/pg_hash' 2 | require 'mobility/plugins/arel/nodes/pg_ops' 3 | 4 | module Mobility 5 | module Backends 6 | =begin 7 | 8 | Implements the {Mobility::Backends::Hstore} backend for ActiveRecord models. 9 | 10 | @see Mobility::Backends::HashValued 11 | 12 | =end 13 | module ActiveRecord 14 | class Hstore < PgHash 15 | # @!group Backend Accessors 16 | # @!macro backend_reader 17 | # @!method read(locale, options = {}) 18 | 19 | # @!macro backend_writer 20 | def write(locale, value, options = {}) 21 | super(locale, value && value.to_s, **options) 22 | end 23 | # @!endgroup 24 | 25 | # @param [String] attr Attribute name 26 | # @param [Symbol] locale Locale 27 | # @return [Mobility::Plugins::Arel::Nodes::Hstore] Arel node for value of 28 | # attribute key on hstore column 29 | def self.build_node(attr, locale) 30 | column_name = column_affix % attr 31 | Plugins::Arel::Nodes::Hstore.new(model_class.arel_table[column_name], build_quoted(locale)) 32 | end 33 | end 34 | end 35 | 36 | register_backend(:active_record_hstore, ActiveRecord::Hstore) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/rails/generators/mobility/templates/table_translations.rb: -------------------------------------------------------------------------------- 1 | class <%= migration_class_name %> < <%= activerecord_migration_class %> 2 | def change 3 | create_table :<%= table_name %><%= primary_key_type if respond_to?(:primary_key_type) %> do |t| 4 | 5 | # Translated attribute(s) 6 | <% attributes.each do |attribute| -%> 7 | <% if attribute.respond_to?(:token?) && attribute.token? -%> 8 | t.string :<%= attribute.name %><%= attribute.inject_options %> 9 | <% else -%> 10 | t.<%= attribute.type %> :<%= attribute.name %><%= attribute.inject_options %> 11 | <% end -%> 12 | <% end -%> 13 | 14 | t.string :locale, null: false 15 | t.references :<%=model_table_name.singularize %>, null: false, foreign_key: true, index: false 16 | 17 | t.timestamps null: false 18 | end 19 | 20 | add_index :<%= table_name %>, :locale, name: :<%= translation_index_name("locale") %> 21 | add_index :<%= table_name %>, [:<%= foreign_key %>, :locale], name: :<%= translation_index_name(foreign_key, "locale") %>, unique: true 22 | 23 | <%- attributes_with_index.each do |attribute| -%> 24 | add_index :<%= table_name %>, [:<%= attribute.index_name %><%= attribute.inject_index_options %>, :locale], name: :<%= translation_index_name(attribute.index_name, "locale") %> 25 | <%- end -%> 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/mobility/plugins/active_record/cache_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(ActiveRecord) 4 | 5 | require "mobility/plugins/active_record/cache" 6 | 7 | describe Mobility::Plugins::ActiveRecord::Cache, orm: :active_record, type: :plugin do 8 | plugins :active_record, :cache 9 | plugin_setup :title 10 | 11 | let(:model_class) do 12 | klass = Class.new(ActiveRecord::Base) 13 | klass.table_name = :articles 14 | klass 15 | end 16 | 17 | %w[changes_applied clear_changes_information].each do |method_name| 18 | it "clears backend cache after #{method_name}" do 19 | model_class.include translations 20 | 21 | expect(instance.mobility_backends[:title]).to receive(:clear_cache).once 22 | instance.send(method_name) 23 | end 24 | 25 | it "does not change visibility of #{method_name}" do 26 | priv = model_class.private_method_defined?(method_name) 27 | model_class.include translations 28 | 29 | expect(model_class.private_method_defined?(method_name)).to eq(priv) 30 | end 31 | end 32 | 33 | it "clears cache after reload" do 34 | model_class.include translations 35 | 36 | instance = model_class.create 37 | expect(instance.mobility_backends[:title]).to receive(:clear_cache).once 38 | instance.reload 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/support/shared_examples/backend_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "Mobility backend" do |backend_class, model_class, attribute="title", **options| 2 | let(:backend) do 3 | model_class = Object.const_get(model_class) if model_class.is_a?(String) 4 | model_class = Object.const_get(model_class.name) if model_class.name 5 | 6 | klass = backend_class.build_subclass(model_class, options.dup) 7 | klass.setup_model(model_class, [attribute]) 8 | klass.new(model_class.new, attribute) 9 | end 10 | 11 | describe "accessors" do 12 | it "can be called without options hash" do 13 | backend.write(Mobility.locale, "foo") 14 | backend.read(Mobility.locale) 15 | expect(backend.read(Mobility.locale)).to eq("foo") 16 | end 17 | end 18 | 19 | describe "iterators" do 20 | it "iterates through locales" do 21 | backend.write(:en, "foo") 22 | backend.write(:ja, "bar") 23 | backend.write(:ru, "baz") 24 | 25 | expect { |b| backend.each_locale &b }.to yield_successive_args(:en, :ja, :ru) 26 | expect { |b| backend.each &b }.to yield_successive_args( 27 | Mobility::Backend::Translation.new(backend, :en), 28 | Mobility::Backend::Translation.new(backend, :ja), 29 | Mobility::Backend::Translation.new(backend, :ru)) 30 | expect(backend.locales).to eq([:en, :ja, :ru]) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/performance/translations_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Mobility::Translations, orm: :none do 4 | let!(:translations_class) do 5 | klass = Class.new(Mobility::Translations) 6 | klass.plugins do 7 | backend 8 | reader 9 | writer 10 | end 11 | klass 12 | end 13 | 14 | describe "initializing" do 15 | specify { 16 | expect { translations_class.new(backend: :null) }.to allocate_under(60).objects 17 | } 18 | end 19 | 20 | describe "including into a class" do 21 | specify { 22 | expect { 23 | klass = Class.new 24 | klass.include(translations_class.new(backend: :null)) 25 | }.to allocate_under(170).objects 26 | } 27 | end 28 | 29 | describe "accessors" do 30 | let(:klass) do 31 | klass = Class.new 32 | klass.include(translations_class.new(:title, backend: :null)) 33 | klass 34 | end 35 | 36 | describe "calling attribute getter" do 37 | specify { 38 | instance = klass.new 39 | expect { 3.times { instance.title } }.to allocate_under(20).objects 40 | } 41 | end 42 | 43 | describe "calling attribute setter" do 44 | specify { 45 | instance = klass.new 46 | title = "foo" 47 | expect { 3.times { instance.title = title } }.to allocate_under(20).objects 48 | } 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/mobility/backends/active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "mobility/backend" 3 | require "mobility/plugins/arel" 4 | 5 | module Mobility 6 | module Backends 7 | module ActiveRecord 8 | def self.included(backend_class) 9 | backend_class.include(Backend) 10 | backend_class.extend(ClassMethods) 11 | end 12 | 13 | module ClassMethods 14 | # @param [Symbol] name Attribute name 15 | # @param [Symbol] locale Locale 16 | def [](name, locale) 17 | build_node(name.to_s, locale) 18 | end 19 | 20 | # @param [String] _attr Attribute name 21 | # @param [Symbol] _locale Locale 22 | # @return Arel node for this translated attribute 23 | def build_node(_attr, _locale) 24 | raise NotImplementedError 25 | end 26 | 27 | # @param [ActiveRecord::Relation] relation Relation to scope 28 | # @param [Object] predicate Arel predicate 29 | # @param [Symbol] locale (Mobility.locale) Locale 30 | # @option [Boolean] invert 31 | # @return [ActiveRecord::Relation] Relation with scope added 32 | def apply_scope(relation, _predicate, _locale = Mobility.locale, invert: false) 33 | relation 34 | end 35 | 36 | private 37 | 38 | def build_quoted(value) 39 | ::Arel::Nodes.build_quoted(value) 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/mobility/plugins/sequel.rb: -------------------------------------------------------------------------------- 1 | require "sequel" 2 | require "sequel/plugins/mobility" 3 | unless defined?(ActiveSupport::Inflector) 4 | # TODO: avoid automatically including the inflector extension 5 | require "sequel/extensions/inflector" 6 | end 7 | require "sequel/plugins/dirty" 8 | require_relative "./sequel/backend" 9 | require_relative "./sequel/dirty" 10 | require_relative "./sequel/cache" 11 | require_relative "./sequel/query" 12 | require_relative "./sequel/column_fallback" 13 | 14 | module Mobility 15 | module Plugins 16 | =begin 17 | 18 | Plugin for Sequel models. This plugin automatically requires sequel related 19 | plugins, which are not actually "active" unless their base plugin (e.g. dirty 20 | for sequel_dirty) is also enabled. 21 | 22 | =end 23 | module Sequel 24 | extend Plugin 25 | 26 | requires :sequel_backend, include: :after 27 | requires :sequel_dirty 28 | requires :sequel_cache 29 | requires :sequel_query 30 | requires :sequel_column_fallback 31 | 32 | included_hook do |klass| 33 | unless sequel_class?(klass) 34 | name = klass.name || klass.to_s 35 | raise TypeError, "#{name} should be a subclass of Sequel::Model to use the sequel plugin" 36 | end 37 | end 38 | 39 | private 40 | 41 | def sequel_class?(klass) 42 | klass < ::Sequel::Model 43 | end 44 | end 45 | 46 | register_plugin(:sequel, Sequel) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/mobility/plugins/reader.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | module Reader 6 | =begin 7 | 8 | Defines attribute reader that delegates to +Mobility::Backend#read+. 9 | 10 | =end 11 | extend Plugin 12 | 13 | default true 14 | requires :backend 15 | 16 | initialize_hook do |*names, **| 17 | if options[:reader] 18 | names.each do |name| 19 | class_eval <<-EOM, __FILE__, __LINE__ + 1 20 | def #{name}(locale: nil, **options) 21 | #{Reader.setup_source} 22 | mobility_backends[:#{name}].read(locale, **options) 23 | end 24 | EOM 25 | class_eval <<-EOM, __FILE__, __LINE__ + 1 26 | def #{name}?(locale: nil, **options) 27 | #{Reader.setup_source} 28 | mobility_backends[:#{name}].present?(locale, **options) 29 | end 30 | EOM 31 | end 32 | end 33 | end 34 | 35 | def self.setup_source 36 | <<-EOL 37 | return super() if options[:super] 38 | if (locale &&= locale.to_sym) 39 | #{"Mobility.enforce_available_locales!(locale)" if I18n.enforce_available_locales} 40 | options[:locale] = true 41 | else 42 | locale = Mobility.locale 43 | end 44 | EOL 45 | end 46 | end 47 | 48 | register_plugin(:reader, Reader) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/mobility/plugins/active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require_relative "./active_record/backend" 3 | require_relative "./active_record/dirty" 4 | require_relative "./active_record/cache" 5 | require_relative "./active_record/query" 6 | require_relative "./active_record/uniqueness_validation" 7 | require_relative "./active_record/column_fallback" 8 | 9 | module Mobility 10 | =begin 11 | 12 | Plugin for ActiveRecord models. This plugin automatically requires activerecord 13 | related plugins, which are not actually "active" unless their base plugin (e.g. 14 | dirty for active_record_dirty) is also enabled. 15 | 16 | =end 17 | module Plugins 18 | module ActiveRecord 19 | extend Plugin 20 | 21 | requires :arel 22 | 23 | requires :active_record_backend, include: :after 24 | requires :active_record_dirty 25 | requires :active_record_cache 26 | requires :active_record_query 27 | requires :active_record_uniqueness_validation 28 | requires :active_record_column_fallback 29 | 30 | 31 | included_hook do |klass| 32 | unless active_record_class?(klass) 33 | name = klass.name || klass.to_s 34 | raise TypeError, "#{name} should be a subclass of ActiveRecord::Base to use the active_record plugin" 35 | end 36 | end 37 | 38 | private 39 | 40 | def active_record_class?(klass) 41 | klass < ::ActiveRecord::Base 42 | end 43 | end 44 | 45 | register_plugin(:active_record, ActiveRecord) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/database.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | 3 | module Mobility 4 | module Test 5 | class Database 6 | class << self 7 | def connect(orm) 8 | case orm 9 | when 'active_record' 10 | ::ActiveRecord::Base.establish_connection config[driver] 11 | ::ActiveRecord::Migration.verbose = false if in_memory? 12 | 13 | # don't really need this, but let's return something relevant 14 | ::ActiveRecord::Base.connection 15 | when 'sequel' 16 | adapter = config[driver]['adapter'].gsub(/^sqlite3$/,'sqlite') 17 | user = config[driver]['username'] 18 | password = config[driver]['password'] 19 | database = config[driver]['database'] 20 | port = config[driver]['port'] 21 | host = config[driver]['host'] 22 | ::Sequel.connect(adapter: adapter, database: database, username: user, password: password, port: port, host: host) 23 | end 24 | end 25 | 26 | def auto_migrate 27 | Schema.migrate :up if in_memory? 28 | end 29 | 30 | def config 31 | @config ||= 32 | begin 33 | erb = ERB.new(File.read(File.expand_path("../databases.yml", __FILE__))) 34 | YAML::load(erb.result) 35 | end 36 | end 37 | 38 | def driver 39 | (ENV["DB"] or "sqlite3").downcase 40 | end 41 | 42 | def in_memory? 43 | config[driver]["database"] == ":memory:" 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/mobility/backends/hash_valued.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module Mobility 3 | module Backends 4 | =begin 5 | 6 | Defines read and write methods that access the value at a key with value 7 | +locale+ on a +translations+ hash. 8 | 9 | =end 10 | module HashValued 11 | # @!method column_affix 12 | # Returns interpolation string used to generate column names. 13 | # @return [String] Affix to generate column names 14 | 15 | # @!group Backend Accessors 16 | # 17 | # @!macro backend_reader 18 | def read(locale, _options = nil) 19 | translations[locale] 20 | end 21 | 22 | # @!macro backend_writer 23 | def write(locale, value, _options = nil) 24 | translations[locale] = value 25 | end 26 | # @!endgroup 27 | 28 | # @!macro backend_iterator 29 | def each_locale 30 | translations.each { |l, _| yield l } 31 | end 32 | 33 | def self.included(backend_class) 34 | backend_class.extend ClassMethods 35 | backend_class.option_reader :column_affix 36 | end 37 | 38 | module ClassMethods 39 | def valid_keys 40 | [:column_prefix, :column_suffix] 41 | end 42 | 43 | def configure(options) 44 | options[:column_affix] = "#{options[:column_prefix]}%s#{options[:column_suffix]}" 45 | end 46 | end 47 | 48 | private 49 | 50 | def column_name 51 | @column_name ||= (column_affix % attribute) 52 | end 53 | end 54 | 55 | private_constant :HashValued 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/mobility/pluggable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mobility 4 | =begin 5 | 6 | Abstract Module subclass with methods to define plugins and defaults. 7 | Works with {Mobility::Plugin}. (Subclassed by {Mobility::Translations}.) 8 | 9 | =end 10 | class Pluggable < Module 11 | class << self 12 | def plugin(name, *args) 13 | Plugin.configure(self, defaults) { __send__ name, *args } 14 | end 15 | 16 | def plugins(&block) 17 | Plugin.configure(self, defaults, &block) 18 | end 19 | 20 | def included_plugins 21 | included_modules.grep(Plugin) 22 | end 23 | 24 | def defaults 25 | @defaults ||= {} 26 | end 27 | 28 | def inherited(klass) 29 | super 30 | klass.defaults.merge!(defaults) 31 | end 32 | end 33 | 34 | def initialize(*, **options) 35 | initialize_options(options) 36 | validate_options(@options) 37 | end 38 | 39 | attr_reader :options 40 | 41 | private 42 | 43 | def initialize_options(options) 44 | @options = self.class.defaults.merge(options) 45 | end 46 | 47 | # This is overridden by backend plugin to exclude mixed-in backend options. 48 | def validate_options(options) 49 | plugin_keys = self.class.included_plugins.map { |p| Plugins.lookup_name(p) } 50 | extra_keys = options.keys - plugin_keys 51 | raise InvalidOptionKey, "No plugin configured for these keys: #{extra_keys.join(', ')}." unless extra_keys.empty? 52 | end 53 | 54 | class InvalidOptionKey < Error; end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/mobility/backends/hash_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "mobility/backends/hash" 3 | 4 | describe Mobility::Backends::Hash, type: :backend, orm: :none do 5 | describe "#read/#write" do 6 | it "returns value for locale key" do 7 | backend = described_class.new 8 | expect(backend.read(:ja)).to eq(nil) 9 | expect(backend.read(:en)).to eq(nil) 10 | backend.write(:ja, "アアア") 11 | expect(backend.read(:ja)).to eq("アアア") 12 | expect(backend.read(:en)).to eq(nil) 13 | backend.write(:en, "foo") 14 | expect(backend.read(:ja)).to eq("アアア") 15 | expect(backend.read(:en)).to eq("foo") 16 | end 17 | end 18 | 19 | describe "#each_locale" do 20 | it "returns keys of hash to block" do 21 | backend = described_class.new 22 | backend.write(:ja, "アアア") 23 | backend.write(:en, "aaa") 24 | expect { |b| backend.each_locale(&b) }.to yield_successive_args(:ja, :en) 25 | end 26 | end 27 | 28 | context "included in model" do 29 | plugins :reader, :writer 30 | 31 | before do 32 | stub_const 'HashPost', Class.new 33 | translates HashPost, :name, backend: :hash 34 | end 35 | 36 | it "defines reader and writer methods" do 37 | instance = HashPost.new 38 | Mobility.with_locale(:en) { instance.name = "foo" } 39 | Mobility.with_locale(:ja) { instance.name = "アアア" } 40 | expect(instance.name(locale: :en)).to eq("foo") 41 | expect(instance.name(locale: :ja)).to eq("アアア") 42 | 43 | expect(backend_for(instance, :name).locales).to match_array([:en, :ja]) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/rails/generators/mobility/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "rails/generators" 3 | require "rails/generators/active_record" 4 | require_relative "./active_record_migration_compatibility" 5 | 6 | module Mobility 7 | class InstallGenerator < ::Rails::Generators::Base 8 | include ::Rails::Generators::Migration 9 | include ::Mobility::ActiveRecordMigrationCompatibility 10 | 11 | desc "Generates migrations to add translations tables." 12 | 13 | source_root File.expand_path("../templates", __FILE__) 14 | class_option( 15 | :without_tables, 16 | type: :boolean, 17 | default: false, 18 | desc: "Skip creating translations tables." 19 | ) 20 | 21 | def create_migration_file 22 | add_mobility_migration("create_text_translations") unless options.without_tables? 23 | add_mobility_migration("create_string_translations") unless options.without_tables? 24 | end 25 | 26 | def create_initializer 27 | copy_file "initializer.rb", "config/initializers/mobility.rb" 28 | end 29 | 30 | def self.next_migration_number(dirname) 31 | ::ActiveRecord::Generators::Base.next_migration_number(dirname) 32 | end 33 | 34 | protected 35 | 36 | def add_mobility_migration(template) 37 | migration_dir = File.expand_path("db/migrate") 38 | if behavior == :invoke && self.class.migration_exists?(migration_dir, template) 39 | ::Kernel.warn "Migration already exists: #{template}" 40 | else 41 | migration_template "#{template}.rb", "db/migrate/#{template}.rb" 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/mobility/plugins/presence.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "mobility/util" 3 | 4 | module Mobility 5 | module Plugins 6 | =begin 7 | 8 | Applies presence filter to values fetched from backend and to values set on 9 | backend. 10 | 11 | @note For performance reasons, the presence plugin filters only for empty 12 | strings, not other values continued "blank" like empty arrays. 13 | 14 | =end 15 | module Presence 16 | extend Plugin 17 | 18 | default true 19 | requires :backend, include: :before 20 | 21 | # Applies presence plugin to attributes. 22 | included_hook do |_, backend_class| 23 | backend_class.include(BackendMethods) if options[:presence] 24 | end 25 | 26 | module BackendMethods 27 | # @!group Backend Accessors 28 | # @!macro backend_reader 29 | # @option options [Boolean] presence 30 | # *false* to disable presence filter. 31 | def read(locale, **options) 32 | options.delete(:presence) == false ? super : Presence[super] 33 | end 34 | 35 | # @!macro backend_writer 36 | # @option options [Boolean] presence 37 | # *false* to disable presence filter. 38 | def write(locale, value, **options) 39 | if options.delete(:presence) == false 40 | super 41 | else 42 | super(locale, Presence[value], **options) 43 | end 44 | end 45 | # @!endgroup 46 | end 47 | 48 | def self.[](value) 49 | (value == "") ? nil : value 50 | end 51 | end 52 | 53 | register_plugin(:presence, Presence) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /certs/shioyama.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEcDCCAtigAwIBAgIBATANBgkqhkiG9w0BAQsFADA/MQ4wDAYDVQQDDAVjaHJp 3 | czEYMBYGCgmSJomT8ixkARkWCGRlamltYXRhMRMwEQYKCZImiZPyLGQBGRYDY29t 4 | MB4XDTI0MDMzMTA4NTQyOFoXDTI1MDMzMTA4NTQyOFowPzEOMAwGA1UEAwwFY2hy 5 | aXMxGDAWBgoJkiaJk/IsZAEZFghkZWppbWF0YTETMBEGCgmSJomT8ixkARkWA2Nv 6 | bTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBANdI2sGYV1Dg+eFvs/t8 7 | feflYB2ZHFMYZV5dB6a62L0f9I5pndIwc9qbo4HivzVICCz2yOP/v2Yxyi4UkucM 8 | dGgFEAxpBlgNzrE2vrqPJHqMN9001O0vS3jvyKwNZ0WmPO26Sf75ky1QrjPRmHEV 9 | rn15+bQGu5sRMxIj5TyRgtNmy9ORJBP+hEiGD09icRvn/FG6o0/NIRyLXnX2tuOu 10 | VBD64XQU3mhxxJtp2+F0Hb0E1nmUttaWsuATMlnRJ8Ksli9kfoxFAa87Cm/LrK2l 11 | WCar8Nc6kw6Rixq97MAZCplEXtg6KnenXzMJLvZFBRSZM6RGj1Q9IX8EpP6HoG/u 12 | WYU/rXe4YZxKy0idDBLbBfjTRKJYQu7q6bgHNTWER7Dc6cACjMhunhfgnvr4Rzu9 13 | F4UNHixNagaLq+3ng19oJJcxE/9BHVOjhZWzLRn0z122KuQJVBXiLipU4r1YIUnj 14 | E0m0QDb4DrYmL1Omp+vVNKBbXnj9AW8J8I9v0Lc/5QsK8wIDAQABo3cwdTAJBgNV 15 | HRMEAjAAMAsGA1UdDwQEAwIEsDAdBgNVHQ4EFgQUJ5UzoaDdOqk4a8OQ95no0vg3 16 | ROcwHQYDVR0RBBYwFIESY2hyaXNAZGVqaW1hdGEuY29tMB0GA1UdEgQWMBSBEmNo 17 | cmlzQGRlamltYXRhLmNvbTANBgkqhkiG9w0BAQsFAAOCAYEAmx0ugOC2yOTQTetP 18 | H8akUD7tRMeEki8Ba2F/+YP0x6cnsEBcKnp0CO5pMVY+6MssLgHh1IXDQlmKuPOW 19 | oht9yh6CWgzufzi+XApY1k/TYWAjxOMZAMdvd7iHo7igRK5pPbSaP5uubQfaLn7X 20 | ge1VLOBAn9XlSOvFZiYZ7Nk8zEvYrvLbQGVtcfceZK4BHC4M3pKsV+m7euWMYguz 21 | ctOqZgbvGDwFvsH302xC53hld7AaFLBep6XaQZSRleVqgIEKZwlG0cX8UwG482Xt 22 | WJSXNylIIbzRndVjbVdGVhhcyjnswfu1qJpl+0YlbAdHJVsd8Ux8TOXEPFMv5wz9 23 | wXhTYFvkOuleWf/45E5f8BtT1iqsH2w3P2Cfy+yOo2aReAVSeR12YDCuV0q6RjTD 24 | 3I5AfnFAG4/1IwhadqwF5cl3jOUa7n3mS2OJl3tRCGuPvwAA9MV10hmwbQTXMrNK 25 | tD9kfT9eseUE4mfPnIaHOs4FiIoHniA7zdtjB7GIQ4cEpB6o 26 | -----END CERTIFICATE----- 27 | -------------------------------------------------------------------------------- /lib/mobility/backends/sequel/column.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "mobility/backends/sequel" 3 | require "mobility/backends/column" 4 | 5 | module Mobility 6 | module Backends 7 | =begin 8 | 9 | Implements the {Mobility::Backends::Column} backend for Sequel models. 10 | 11 | =end 12 | class Sequel::Column 13 | include Sequel 14 | include Column 15 | 16 | # @!group Backend Accessors 17 | # @!macro backend_reader 18 | def read(locale, _options = nil) 19 | column = column(locale) 20 | model[column] if model.columns.include?(column) 21 | end 22 | 23 | # @!group Backend Accessors 24 | # @!macro backend_writer 25 | def write(locale, value, _options = nil) 26 | column = column(locale) 27 | model[column] = value if model.columns.include?(column) 28 | end 29 | 30 | # @!macro backend_iterator 31 | def each_locale 32 | available_locales.each { |l| yield(l) if present?(l) } 33 | end 34 | 35 | def self.build_op(attr, locale) 36 | ::Sequel::SQL::QualifiedIdentifier.new(model_class.table_name, 37 | Column.column_name_for(attr, locale)) 38 | end 39 | 40 | private 41 | 42 | def available_locales 43 | @available_locales ||= get_column_locales 44 | end 45 | 46 | def get_column_locales 47 | column_name_regex = /\A#{attribute}_([a-z]{2}(_[a-z]{2})?)\z/.freeze 48 | model.columns.map do |c| 49 | (match = c.to_s.match(column_name_regex)) && match[1].to_sym 50 | end.compact 51 | end 52 | end 53 | 54 | register_backend(:sequel_column, Sequel::Column) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/mobility/backends/sequel/json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'mobility/backends/sequel/pg_hash' 3 | 4 | Sequel.extension :pg_json, :pg_json_ops 5 | 6 | module Mobility 7 | module Backends 8 | =begin 9 | 10 | Implements the {Mobility::Backends::Json} backend for Sequel models. 11 | 12 | @see Mobility::Backends::HashValued 13 | 14 | =end 15 | module Sequel 16 | class Json < PgHash 17 | # @!group Backend Accessors 18 | # 19 | # @!method read(locale, options = {}) 20 | # @note Translation may be any json type, but querying will only work on 21 | # string-typed values. 22 | # @param [Symbol] locale Locale to read 23 | # @param [Hash] options 24 | # @return [String,Integer,Boolean] Value of translation 25 | 26 | # @!method write(locale, value, options = {}) 27 | # @note Translation may be any json type, but querying will only work 28 | # on string-typed values. 29 | # @param [Symbol] locale Locale to write 30 | # @param [String,Integer,Boolean] value Value to write 31 | # @param [Hash] options 32 | # @return [String,Integer,Boolean] Updated value 33 | # @!endgroup 34 | 35 | # @param [Symbol] name Attribute name 36 | # @param [Symbol] locale Locale 37 | # @return [Mobility::Backends::Sequel::Json::JSONOp] 38 | def self.build_op(attr, locale) 39 | column_name = column_affix % attr 40 | JSONOp.new(column_name.to_sym).get_text(locale.to_s) 41 | end 42 | 43 | class JSONOp < ::Sequel::Postgres::JSONOp; end 44 | end 45 | end 46 | 47 | register_backend(:sequel_json, Sequel::Json) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/mobility/backends/sequel/pg_hash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "mobility/util" 3 | require "mobility/backends/sequel" 4 | require "mobility/backends/hash_valued" 5 | 6 | module Mobility 7 | module Backends 8 | =begin 9 | 10 | Internal class used by Sequel backends backed by a Postgres data type (hstore, 11 | jsonb). 12 | 13 | =end 14 | module Sequel 15 | class PgHash 16 | include Sequel 17 | include HashValued 18 | 19 | def read(locale, options = {}) 20 | super(locale.to_s, options) 21 | end 22 | 23 | def write(locale, value, options = {}) 24 | super(locale.to_s, value, options) 25 | end 26 | 27 | # @!macro backend_iterator 28 | def each_locale 29 | super { |l| yield l.to_sym } 30 | end 31 | 32 | def translations 33 | model[column_name.to_sym] 34 | end 35 | 36 | setup do |attributes, options, backend_class| 37 | columns = attributes.map { |attribute| (options[:column_affix] % attribute).to_sym } 38 | 39 | mod = Module.new do 40 | define_method :before_validation do 41 | columns.each do |column| 42 | self[column].delete_if { |_, v| v.nil? } 43 | end 44 | super() 45 | end 46 | end 47 | include mod 48 | backend_class.define_hash_initializer(mod, columns) 49 | backend_class.define_column_changes(mod, attributes, column_affix: options[:column_affix]) 50 | 51 | plugin :defaults_setter 52 | columns.each { |column| default_values[column] = {} } 53 | end 54 | end 55 | private_constant :PgHash 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/mobility/plugins/attribute_methods.rb: -------------------------------------------------------------------------------- 1 | module Mobility 2 | module Plugins 3 | =begin 4 | 5 | Adds translated attribute names and values to the hash returned by #attributes. 6 | Also adds a method #translated_attributes with names and values of translated 7 | attributes only. 8 | 9 | @note Adding translated attributes to +attributes+ can have unexpected 10 | consequences, since these attributes do not have corresponding columns in the 11 | model table. Using this plugin may lead to conflicts with other gems. 12 | 13 | =end 14 | module AttributeMethods 15 | extend Plugin 16 | 17 | default true 18 | requires :attributes 19 | 20 | initialize_hook do |*names| 21 | include InstanceMethods 22 | 23 | define_method :translated_attributes do 24 | super().merge(names.inject({}) do |attributes, name| 25 | attributes.merge(name.to_s => send(name)) 26 | end) 27 | end 28 | 29 | private 30 | 31 | define_method :attribute_names_for_serialization do 32 | return unless defined?(super) 33 | 34 | super() + names.map(&:to_s) 35 | end 36 | end 37 | 38 | # Applies attribute_methods plugin for a given option value. 39 | included_hook do 40 | if options[:attribute_methods] 41 | define_method :untranslated_attributes, ::ActiveRecord::Base.instance_method(:attributes) 42 | end 43 | end 44 | 45 | module InstanceMethods 46 | def translated_attributes 47 | {} 48 | end 49 | 50 | def attributes 51 | super.merge(translated_attributes) 52 | end 53 | end 54 | end 55 | 56 | register_plugin(:attribute_methods, AttributeMethods) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/mobility/backends/column.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mobility 4 | module Backends 5 | =begin 6 | 7 | Stores translated attribute as a column on the model table. To use this 8 | backend, ensure that the model table has columns named +_+ 9 | for every locale in +Mobility.available_locales+ (i.e. +I18n.available_locales+). 10 | 11 | If you are using Rails, you can use the +mobility:translations+ generator to 12 | create a migration adding these columns to the model table with: 13 | 14 | rails generate mobility:translations post title:string 15 | 16 | The generated migration will add columns +title_+ for every locale in 17 | +Mobility.available_locales+. (The generator can be run again to add new attributes 18 | or locales.) 19 | 20 | ==Backend Options 21 | 22 | There are no options for this backend. Also, the +locale_accessors+ option will 23 | be ignored if set, since it would cause a conflict with column accessors. 24 | 25 | @see Mobility::Backends::ActiveRecord::Column 26 | @see Mobility::Backends::Sequel::Column 27 | 28 | =end 29 | module Column 30 | # Returns name of column where translated attribute is stored 31 | # @param [Symbol] locale 32 | # @return [String] 33 | def column(locale = Mobility.locale) 34 | Column.column_name_for(attribute, locale) 35 | end 36 | 37 | # Returns name of column where translated attribute is stored 38 | # @param [String] attribute 39 | # @param [Symbol] locale 40 | # @return [String] 41 | def self.column_name_for(attribute, locale = Mobility.locale) 42 | normalized_locale = Mobility.normalize_locale(locale) 43 | "#{attribute}_#{normalized_locale}".to_sym 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/mobility/translations_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Mobility::Translations, orm: :none do 4 | include Helpers::Backend 5 | before { stub_const 'Article', Class.new } 6 | 7 | let(:model_class) { Article } 8 | 9 | describe "including Translations in a model" do 10 | describe "model class methods" do 11 | describe ".mobility_attributes" do 12 | it "returns attribute names" do 13 | model_class.include described_class.new("title", "content") 14 | model_class.include described_class.new("foo") 15 | 16 | expect(model_class.mobility_attributes).to match_array(["title", "content", "foo"]) 17 | end 18 | 19 | it "only returns unique attributes" do 20 | model_class.include described_class.new("title") 21 | model_class.include described_class.new("title") 22 | 23 | expect(model_class.mobility_attributes).to eq(["title"]) 24 | end 25 | end 26 | 27 | describe ".mobility_attribute?" do 28 | it "returns true if and only if attribute name is translated" do 29 | names = %w[title content] 30 | model_class.include described_class.new(*names) 31 | names.each do |name| 32 | expect(model_class.mobility_attribute?(name)).to eq(true) 33 | expect(model_class.mobility_attribute?(name.to_sym)).to eq(true) 34 | end 35 | expect(model_class.mobility_attribute?("foo")).to eq(false) 36 | end 37 | end 38 | end 39 | end 40 | 41 | describe "#inspect" do 42 | it "returns attribute names" do 43 | attributes = described_class.new("title", "content") 44 | expect(attributes.inspect).to eq("#") 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/mobility/backends/active_record/jsonb.rb: -------------------------------------------------------------------------------- 1 | require 'mobility/backends/active_record/pg_hash' 2 | require 'mobility/plugins/arel/nodes/pg_ops' 3 | 4 | module Mobility 5 | module Backends 6 | =begin 7 | 8 | Implements the {Mobility::Backends::Jsonb} backend for ActiveRecord models. 9 | 10 | @see Mobility::Backends::HashValued 11 | 12 | =end 13 | module ActiveRecord 14 | class Jsonb < PgHash 15 | # @!group Backend Accessors 16 | # 17 | # @!method read(locale, **options) 18 | # @note Translation may be any json type, but querying will only work on 19 | # string-typed values. 20 | # @param [Symbol] locale Locale to read 21 | # @param [Hash] options 22 | # @return [String,Integer,Boolean] Value of translation 23 | 24 | # @!method write(locale, value, **options) 25 | # @note Translation may be any json type, but querying will only work on 26 | # string-typed values. 27 | # @param [Symbol] locale Locale to write 28 | # @param [String,Integer,Boolean] value Value to write 29 | # @param [Hash] options 30 | # @return [String,Integer,Boolean] Updated value 31 | # @!endgroup 32 | 33 | # @param [String] attr Attribute name 34 | # @param [Symbol] locale Locale 35 | # @return [Mobility::Plugins::Arel::Nodes::Jsonb] Arel node for value of 36 | # attribute key on jsonb column 37 | def self.build_node(attr, locale) 38 | column_name = column_affix % attr 39 | Plugins::Arel::Nodes::Jsonb.new(model_class.arel_table[column_name], build_quoted(locale)) 40 | end 41 | end 42 | end 43 | 44 | register_backend(:active_record_jsonb, ActiveRecord::Jsonb) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/mobility/backends/active_record/json.rb: -------------------------------------------------------------------------------- 1 | require 'mobility/backends/active_record/pg_hash' 2 | require 'mobility/plugins/arel/nodes/pg_ops' 3 | 4 | module Mobility 5 | module Backends 6 | =begin 7 | 8 | Implements the {Mobility::Backends::Json} backend for ActiveRecord models. 9 | 10 | @see Mobility::Backends::HashValued 11 | 12 | =end 13 | module ActiveRecord 14 | class Json < PgHash 15 | # @!group Backend Accessors 16 | # 17 | # @!method read(locale, **options) 18 | # @note Translation may be string, integer or boolean-valued since 19 | # value is stored on a JSON hash. 20 | # @param [Symbol] locale Locale to read 21 | # @param [Hash] options 22 | # @return [String,Integer,Boolean] Value of translation 23 | 24 | # @!method write(locale, value, **options) 25 | # @note Translation may be string, integer or boolean-valued since 26 | # value is stored on a JSON hash. 27 | # @param [Symbol] locale Locale to write 28 | # @param [String,Integer,Boolean] value Value to write 29 | # @param [Hash] options 30 | # @return [String,Integer,Boolean] Updated value 31 | # @!endgroup 32 | 33 | # @param [String] attr Attribute name 34 | # @param [Symbol] locale Locale 35 | # @return [Mobility::Plugins::Arel::Nodes::Json] Arel node for value of 36 | # attribute key on jsonb column 37 | def self.build_node(attr, locale) 38 | column_name = column_affix % attr 39 | Plugins::Arel::Nodes::Json.new(model_class.arel_table[column_name], build_quoted(locale)) 40 | end 41 | end 42 | end 43 | 44 | register_backend(:active_record_json, ActiveRecord::Json) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/mobility/backends/sequel/json_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(Sequel) && defined?(PG) 4 | 5 | describe "Mobility::Backends::Sequel::Json", orm: :sequel, db: :postgres, type: :backend do 6 | require "mobility/backends/sequel/json" 7 | 8 | column_options = { column_prefix: 'my_', column_suffix: '_i18n' } 9 | column_affix = "#{column_options[:column_prefix]}%s#{column_options[:column_suffix]}" 10 | 11 | let(:backend) { post.mobility_backends[:title] } 12 | let(:post) { JsonPost.new } 13 | 14 | before do 15 | stub_const 'JsonPost', Class.new(Sequel::Model) 16 | JsonPost.dataset = DB[:json_posts] 17 | end 18 | 19 | let(:backend) { post.mobility_backends[:title] } 20 | let(:post) { JsonPost.new } 21 | 22 | context "with no plugins" do 23 | include_backend_examples described_class, 'JsonPost' 24 | end 25 | 26 | context "with basic plugins" do 27 | plugins :sequel, :reader, :writer 28 | 29 | before { translates JsonPost, :title, :content, backend: :json, **column_options } 30 | 31 | include_accessor_examples 'JsonPost' 32 | include_serialization_examples 'JsonPost', column_affix: column_affix 33 | include_dup_examples 'JsonPost' 34 | end 35 | 36 | context "with query plugin" do 37 | plugins :sequel, :reader, :writer, :query 38 | 39 | before { translates JsonPost, :title, :content, backend: :json, **column_options } 40 | 41 | include_querying_examples 'JsonPost' 42 | end 43 | 44 | context "with dirty plugin" do 45 | plugins :sequel, :reader, :writer, :dirty 46 | 47 | before { translates JsonPost, :title, :content, backend: :json, **column_options } 48 | 49 | include_accessor_examples 'JsonPost' 50 | include_serialization_examples 'JsonPost', column_affix: column_affix 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in mobility.gemspec 4 | gemspec 5 | 6 | orm, orm_version = ENV['ORM'], ENV['ORM_VERSION'] 7 | 8 | group :development, :test do 9 | case orm 10 | when 'active_record' 11 | orm_version ||= '7.0' 12 | case orm_version 13 | when '7.0' 14 | gem 'activerecord', '~> 7.0' 15 | # see https://stackoverflow.com/questions/79360526/uninitialized-constant-activesupportloggerthreadsafelevellogger-nameerror 16 | gem 'concurrent-ruby', '1.3.4' 17 | when '7.1', '7.2', '8.0' 18 | gem 'activerecord', "~> #{orm_version}.0" 19 | when 'edge' 20 | git 'https://github.com/rails/rails.git', branch: 'main' do 21 | gem 'activerecord' 22 | gem 'activesupport' 23 | end 24 | else 25 | raise ArgumentError, 'Invalid ActiveRecord version' 26 | end 27 | when 'sequel' 28 | orm_version ||= '5' 29 | case orm_version 30 | when '5' 31 | gem 'sequel', "~> #{orm_version}.0" 32 | else 33 | raise ArgumentError, 'Invalid Sequel version' 34 | end 35 | when nil, '' 36 | else 37 | raise ArgumentError, "Invalid ORM: #{orm}" 38 | end 39 | 40 | gem 'allocation_stats' if ENV['FEATURE'] == 'performance' 41 | 42 | if ENV['FEATURE'] == 'rails' 43 | gem 'rails' 44 | gem 'generator_spec', '~> 0.9.4' 45 | end 46 | 47 | platforms :ruby do 48 | gem 'guard-rspec' 49 | gem 'pry-byebug' 50 | case ENV['DB'] 51 | when 'sqlite3' 52 | if orm == 'active_record' && orm_version >= '8.0' 53 | gem 'sqlite3', '>= 2.1.0' 54 | else 55 | gem 'sqlite3', '~> 1.5.0' 56 | end 57 | when 'mysql' 58 | gem 'mysql2' 59 | when 'postgres' 60 | gem 'pg' 61 | end 62 | end 63 | end 64 | 65 | group :benchmark do 66 | gem "benchmark-ips" 67 | end 68 | -------------------------------------------------------------------------------- /spec/mobility/plugins/attribute_methods_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(ActiveRecord) 4 | 5 | require "mobility/plugins/attribute_methods" 6 | 7 | describe Mobility::Plugins::AttributeMethods, orm: :active_record, type: :plugin do 8 | plugins :active_record, :attribute_methods, :reader 9 | 10 | plugin_setup :title 11 | 12 | let(:untranslated_attributes) do 13 | { 14 | "id" => nil, 15 | "slug" => nil, 16 | "published" => nil, 17 | "created_at" => nil, 18 | "updated_at" => nil 19 | } 20 | end 21 | let(:model_class) do 22 | stub_const 'Article', Class.new(ActiveRecord::Base) 23 | Article.include(translations) 24 | Article 25 | end 26 | 27 | describe "#translated_attributes" do 28 | it "returns hash of translated attribute names/values" do 29 | expect(backend).to receive(:read).once.with(Mobility.locale, any_args).and_return('foo') 30 | expect(instance.translated_attributes).to eq('title' => 'foo') 31 | end 32 | end 33 | 34 | describe "#attributes" do 35 | it "adds translated attributes to normal attributes" do 36 | expect(backend).to receive(:read).once.with(Mobility.locale, any_args).and_return('foo') 37 | expect(instance.attributes).to eq(untranslated_attributes.merge('title' => 'foo')) 38 | end 39 | end 40 | 41 | describe "#as_json" do 42 | it "adds translated attributes to normal attributes" do 43 | expect(backend).to receive(:read).with(Mobility.locale, any_args).and_return('foo').at_least(1) 44 | expect(instance.as_json).to eq(untranslated_attributes.merge('title' => 'foo')) 45 | end 46 | end 47 | 48 | describe "#untranslated_attributes" do 49 | it "returns original value of attributes method" do 50 | expect(instance.untranslated_attributes).to eq(untranslated_attributes) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/mobility/plugins/backend_reader_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "mobility/plugins/backend_reader" 3 | 4 | describe Mobility::Plugins::BackendReader, type: :plugin do 5 | context "with default format string" do 6 | plugins do 7 | backend_reader 8 | backend :null 9 | end 10 | translates :title 11 | 12 | it "defines _backend methods mapping to backend instance for " do 13 | expect(instance.respond_to?(:title_backend)).to eq(true) 14 | expect(instance.title_backend).to eq(instance.mobility_backends[:title]) 15 | end 16 | end 17 | 18 | context "with custom format string" do 19 | plugins do 20 | backend_reader "%s_translations" 21 | backend :null 22 | end 23 | translates :title 24 | 25 | it "defines backend reader methods with custom format string" do 26 | expect(instance.respond_to?(:title_translations)).to eq(true) 27 | expect(instance.respond_to?(:title_backend)).to eq(false) 28 | expect(instance.title_translations).to eq(instance.mobility_backends[:title]) 29 | end 30 | end 31 | 32 | context "with true as format string" do 33 | plugins do 34 | backend_reader true 35 | backend :null 36 | end 37 | translates :title 38 | 39 | it "defines backend reader methods with default format string" do 40 | expect(instance.respond_to?(:title_backend)).to eq(true) 41 | expect(instance.title_backend).to eq(instance.mobility_backends[:title]) 42 | end 43 | end 44 | 45 | context "with falsey format string" do 46 | plugins do 47 | backend_reader false 48 | backend :null 49 | end 50 | translates :title 51 | 52 | it "does not define backend reader methods" do 53 | expect(instance.respond_to?(:title_backend)).to eq(false) 54 | expect { instance.title_backend }.to raise_error(NoMethodError) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/mobility/backends/sequel_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(Sequel) 4 | 5 | describe "Mobility::Backends::Sequel", orm: :sequel, type: :backend do 6 | plugins :sequel, :reader, :writer, :query, :cache 7 | 8 | context "model with multiple backends" do 9 | before do 10 | stub_const 'Comment', Class.new(Sequel::Model) 11 | Comment.dataset = DB[:comments] 12 | translates Comment, :content, backend: :column 13 | translates Comment, :title, :author, backend: :key_value, type: :text 14 | @comment1 = Comment.create(content: "foo content 1", title: "foo title 1", author: "Foo author 1") 15 | @comment2 = Comment.create( title: "foo title 2", author: "Foo author 2") 16 | Mobility.with_locale(:ja) { @comment1.update(content: "コンテンツ 1", title: "タイトル 1", author: "オーサー 1") } 17 | Mobility.with_locale(:ja) { @comment2.update(content: "コンテンツ 2", author: "オーサー 2") } 18 | @comment3 = Comment.create(content: "foo content 1") 19 | @comment4 = Comment.create(content: "foo content 2", title: "foo title 2", author: "Foo author 3") 20 | end 21 | 22 | describe ".i18n (mobility scope)" do 23 | describe ".where" do 24 | it "works with multiple backends" do 25 | expect(Comment.i18n.where(content: "foo content 1", title: "foo title 1").select_all(:comments).map(&:id)).to eq([@comment1.id]) 26 | expect(Comment.i18n.where(content: "foo content 1", title: nil).select_all(:comments).map(&:id)).to eq([@comment3.id]) 27 | 28 | Mobility.locale = :ja 29 | expect(Comment.i18n.where(content: "foo content 1", title: "foo title 1").select_all(:comments).all).to eq([]) 30 | expect(Comment.i18n.where(content: "コンテンツ 1", title: "タイトル 1").select_all(:comments).map(&:id)).to eq([@comment1.id]) 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/mobility/plugins/writer_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "mobility/plugins/writer" 3 | 4 | describe Mobility::Plugins::Writer, type: :plugin do 5 | plugins :writer 6 | plugin_setup :title 7 | 8 | describe "getters" do 9 | let(:instance) { model_class.new } 10 | 11 | it "correctly maps setter method for translated attribute to backend" do 12 | expect(Mobility).to receive(:locale).and_return(:de) 13 | expect(listener).to receive(:write).with(:de, "foo", any_args) 14 | expect(instance.title = "foo").to eq("foo") 15 | end 16 | 17 | it "correctly maps locale through setter options and converts to boolean" do 18 | expect(listener).to receive(:write).with(:fr, "foo", any_args).and_return("foo") 19 | expect(instance.send(:title=, "foo", locale: :fr)).to eq("foo") 20 | end 21 | 22 | it "correctly maps other options to getter" do 23 | expect(Mobility).to receive(:locale).and_return(:de) 24 | expect(listener).to receive(:write).with(:de, "foo", someopt: "someval").and_return("foo") 25 | instance.send(:title=, "foo", someopt: "someval") 26 | end 27 | 28 | it "raises Mobility::InvalidLocale if write is called with locale not in available locales" do 29 | expect { 30 | instance.send(:title=, 'foo', locale: :ru) 31 | }.to raise_error(Mobility::InvalidLocale) 32 | end 33 | end 34 | 35 | describe "super option" do 36 | let(:instance) { model_class.new } 37 | let(:model_class) do 38 | Class.new.tap do |klass| 39 | mod = Module.new do 40 | def title=(title) 41 | "set title to #{title}" 42 | end 43 | end 44 | klass.include translations, mod 45 | klass 46 | end 47 | end 48 | 49 | it "calls original getter when super: true passed as option" do 50 | expect(instance.send(:title=, 'foo', super: true)).to eq("set title to foo") 51 | end 52 | end 53 | end 54 | 55 | -------------------------------------------------------------------------------- /spec/mobility/backends/active_record_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Mobility::Backends::ActiveRecord", orm: :active_record, type: :backend do 4 | plugins :active_record, :writer, :query 5 | 6 | context "model with multiple backends" do 7 | before do 8 | stub_const 'Comment', Class.new(ActiveRecord::Base) 9 | translates Comment, :content, backend: :column 10 | translates Comment, :title, :author, backend: :key_value, type: :text 11 | @comment1 = Comment.create(content: "foo content 1", title: "foo title 1", author: "Foo author 1") 12 | Mobility.with_locale(:ja) { @comment1.update(content: "コンテンツ 1", title: "タイトル 1", author: "オーサー 1") } 13 | @comment2 = Comment.create( title: "foo title 2", author: "Foo author 2") 14 | Mobility.with_locale(:ja) { @comment2.update(content: "コンテンツ 2", author: "オーサー 2") } 15 | @comment3 = Comment.create(content: "foo content 1") 16 | @comment4 = Comment.create(content: "foo content 2", title: "foo title 2", author: "Foo author 3") 17 | end 18 | 19 | describe ".i18n (mobility scope)" do 20 | describe ".where" do 21 | it "works with multiple backends" do 22 | expect(Comment.i18n.where(content: "foo content 1", title: "foo title 1")).to eq([@comment1]) 23 | expect(Comment.i18n.where(content: "foo content 1", title: nil)).to eq([@comment3]) 24 | 25 | Mobility.locale = :ja 26 | expect(Comment.i18n.where(content: "foo content 1", title: "foo title 1")).to eq([]) 27 | expect(Comment.i18n.where(content: "コンテンツ 1", title: "タイトル 1")).to eq([@comment1]) 28 | end 29 | end 30 | 31 | describe ".not" do 32 | it "works with multiple backends" do 33 | expect(Comment.i18n.where.not(content: "foo content 1", title: "foo title 1", author: "Foo author 1")).to match_array([@comment4]) 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/mobility/plugins/sequel/column_fallback.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Mobility 4 | =begin 5 | 6 | Plugin to use an original column for a given locale, and otherwise use the backend. 7 | 8 | =end 9 | module Plugins 10 | module Sequel 11 | module ColumnFallback 12 | extend Plugin 13 | 14 | requires :column_fallback, include: false 15 | 16 | included_hook do |_, backend_class| 17 | backend_class.include BackendInstanceMethods 18 | backend_class.extend BackendClassMethods 19 | end 20 | 21 | def self.use_column_fallback?(options, locale) 22 | case column_fallback = options[:column_fallback] 23 | when TrueClass 24 | locale == I18n.default_locale 25 | when Array 26 | column_fallback.include?(locale) 27 | when Proc 28 | column_fallback.call(locale) 29 | else 30 | false 31 | end 32 | end 33 | 34 | module BackendInstanceMethods 35 | def read(locale, **) 36 | if ColumnFallback.use_column_fallback?(options, locale) 37 | model[attribute.to_sym] 38 | else 39 | super 40 | end 41 | end 42 | 43 | def write(locale, value, **) 44 | if ColumnFallback.use_column_fallback?(options, locale) 45 | model[attribute.to_sym] = value 46 | else 47 | super 48 | end 49 | end 50 | end 51 | 52 | module BackendClassMethods 53 | def build_op(attr, locale) 54 | if ColumnFallback.use_column_fallback?(options, locale) 55 | ::Sequel::SQL::QualifiedIdentifier.new(model_class.table_name, attr.to_sym) 56 | else 57 | super 58 | end 59 | end 60 | end 61 | end 62 | end 63 | 64 | register_plugin(:sequel_column_fallback, Sequel::ColumnFallback) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/mobility/plugins/active_record/column_fallback.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | =begin 6 | 7 | Plugin to use an original column for a given locale, and otherwise use the backend. 8 | 9 | =end 10 | module ActiveRecord 11 | module ColumnFallback 12 | extend Plugin 13 | 14 | requires :column_fallback, include: false 15 | 16 | included_hook do |_, backend_class| 17 | backend_class.include BackendInstanceMethods 18 | backend_class.extend BackendClassMethods 19 | end 20 | 21 | def self.use_column_fallback?(options, locale) 22 | case column_fallback = options[:column_fallback] 23 | when TrueClass 24 | locale == I18n.default_locale 25 | when Array 26 | column_fallback.include?(locale) 27 | when Proc 28 | column_fallback.call(locale) 29 | else 30 | false 31 | end 32 | end 33 | 34 | module BackendInstanceMethods 35 | def read(locale, **) 36 | if ColumnFallback.use_column_fallback?(options, locale) 37 | model.read_attribute(attribute) 38 | else 39 | super 40 | end 41 | end 42 | 43 | def write(locale, value, **) 44 | if ColumnFallback.use_column_fallback?(options, locale) 45 | model.send(:write_attribute, attribute, value) 46 | else 47 | super 48 | end 49 | end 50 | end 51 | 52 | module BackendClassMethods 53 | def build_node(attr, locale) 54 | if ColumnFallback.use_column_fallback?(options, locale) 55 | model_class.arel_table[attr] 56 | else 57 | super 58 | end 59 | end 60 | end 61 | end 62 | end 63 | 64 | register_plugin(:active_record_column_fallback, ActiveRecord::ColumnFallback) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/mobility/pluggable_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Mobility::Pluggable do 4 | include Helpers::PluginSetup 5 | 6 | describe "#initialize" do 7 | define_plugins(:foo, :other) 8 | 9 | it "merges defaults into @options when initializing" do 10 | klass = Class.new(described_class) 11 | 12 | klass.plugin :foo, 'bar' 13 | klass.plugin :other 14 | 15 | pluggable = klass.new(other: 'param') 16 | expect(pluggable.options).to eq(foo: 'bar', other: 'param') 17 | end 18 | 19 | it "raises InvalidOptionKey error if no plugin is configured for an options key" do 20 | klass = Class.new(described_class) 21 | 22 | expect { 23 | klass.new(foo: 'foo', bar: 'bar') 24 | }.to raise_error(Mobility::Pluggable::InvalidOptionKey, "No plugin configured for these keys: foo, bar.") 25 | end 26 | end 27 | 28 | describe "subclassing" do 29 | define_plugins(:foo, :bar, :baz) 30 | 31 | it "dupes parent class defaults in descendants" do 32 | klass = Class.new(described_class) 33 | klass.plugin(:foo, 'foo') 34 | 35 | subclass = Class.new(klass) 36 | subclass.plugin(:bar, 'bar') 37 | 38 | expect(klass.defaults).to eq(foo: 'foo') 39 | expect(subclass.defaults).to eq(foo: 'foo', bar: 'bar') 40 | end 41 | 42 | it "overrides parent default in descendant if set" do 43 | klass = Class.new(described_class) 44 | klass.plugin(:foo, 'foo') 45 | 46 | subclass = Class.new(klass) 47 | subclass.plugin(:foo, 'foo2') 48 | 49 | expect(klass.defaults).to eq(foo: 'foo') 50 | expect(subclass.defaults).to eq(foo: 'foo2') 51 | end 52 | 53 | it "inherits parent default if default unset in descendant" do 54 | klass = Class.new(described_class) 55 | klass.plugin(:foo, 'foo') 56 | 57 | subclass = Class.new(klass) 58 | subclass.plugin(:foo) 59 | 60 | expect(klass.defaults).to eq(foo: 'foo') 61 | expect(subclass.defaults).to eq(foo: 'foo') 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /mobility.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'mobility/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "mobility" 8 | spec.version = Mobility.gem_version 9 | spec.authors = ["Chris Salzberg"] 10 | spec.email = ["chris@dejimata.com"] 11 | 12 | spec.required_ruby_version = '>= 2.5' 13 | 14 | spec.summary = %q{Pluggable Ruby translation framework} 15 | spec.description = %q{Stores and retrieves localized data through attributes on a Ruby class, with flexible support for different storage strategies.} 16 | 17 | spec.homepage = 'https://github.com/shioyama/mobility' 18 | spec.license = "MIT" 19 | 20 | spec.metadata['homepage_uri'] = spec.homepage 21 | spec.metadata['source_code_uri'] = 'https://github.com/shioyama/mobility' 22 | spec.metadata['bug_tracker_uri'] = 'https://github.com/shioyama/mobility/issues' 23 | spec.metadata['changelog_uri'] = 'https://github.com/shioyama/mobility/blob/master/CHANGELOG.md' 24 | spec.metadata['rubygems_mfa_required'] = 'true' 25 | 26 | spec.files = Dir['{lib/**/*,[A-Z]*}'] 27 | spec.bindir = "exe" 28 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 29 | spec.require_paths = ["lib"] 30 | 31 | spec.add_dependency 'request_store', '~> 1.0' 32 | spec.add_dependency 'i18n', '>= 0.6.10', '< 2' 33 | spec.add_development_dependency "database_cleaner", '~> 1.5', '>= 1.5.3' 34 | spec.add_development_dependency "rake", '~> 12', '>= 12.2.1' 35 | spec.add_development_dependency "rspec", "~> 3.0" 36 | spec.add_development_dependency 'yard', '~> 0.9.0' 37 | 38 | spec.cert_chain = ["certs/shioyama.pem"] 39 | spec.signing_key = File.expand_path("~/.ssh/gem-private_key.pem") if $0 =~ /gem\z/ 40 | 41 | spec.post_install_message = %q{ 42 | Warning: Mobility v1.3.x includes potentially backwards-incompatible changes 43 | for jsonb/hstore backends. 44 | 45 | Please see: 46 | - https://github.com/shioyama/mobility/issues/535 47 | } 48 | end 49 | -------------------------------------------------------------------------------- /spec/mobility/backends/sequel/hstore_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(Sequel) && defined?(PG) 4 | 5 | describe "Mobility::Backends::Sequel::Hstore", orm: :sequel, db: :postgres, type: :backend do 6 | require "mobility/backends/sequel/hstore" 7 | 8 | column_options = { column_prefix: 'my_', column_suffix: '_i18n' } 9 | column_affix = "#{column_options[:column_prefix]}%s#{column_options[:column_suffix]}" 10 | 11 | let(:backend) { post.mobility_backends[:title] } 12 | let(:post) { JsonbPost.new } 13 | 14 | before do 15 | stub_const 'HstorePost', Class.new(Sequel::Model) 16 | HstorePost.dataset = DB[:hstore_posts] 17 | end 18 | 19 | context "with no plugins applied" do 20 | include_backend_examples described_class, 'HstorePost' 21 | end 22 | 23 | context "with basic plugins" do 24 | plugins :sequel, :reader, :writer 25 | 26 | before { translates HstorePost, :title, :content, backend: :hstore, **column_options } 27 | 28 | include_accessor_examples 'HstorePost' 29 | include_serialization_examples 'HstorePost', column_affix: column_affix 30 | include_dup_examples 'HstorePost' 31 | end 32 | 33 | context "with query plugin" do 34 | plugins :sequel, :reader, :writer, :query 35 | 36 | before { translates HstorePost, :title, :content, backend: :hstore, **column_options } 37 | 38 | include_querying_examples 'HstorePost' 39 | 40 | describe "non-text values" do 41 | it "converts non-string types to strings when saving" do 42 | post = HstorePost.new 43 | backend = post.mobility_backends[:title] 44 | backend.write(:en, { foo: :bar } ) 45 | post.save 46 | expect(post[(column_affix % "title").to_sym].to_hash).to eq({ "en" => "{:foo=>:bar}" }) 47 | end 48 | end 49 | end 50 | 51 | context "with dirty plugin" do 52 | plugins :sequel, :reader, :writer, :dirty 53 | 54 | before { translates HstorePost, :title, :content, backend: :hstore, **column_options } 55 | 56 | include_accessor_examples 'HstorePost' 57 | include_serialization_examples 'HstorePost', column_affix: column_affix 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | require "yaml" 4 | 5 | RSpec::Core::RakeTask.new(:spec) do |task| 6 | task.rspec_opts = '-f p' 7 | end 8 | 9 | task :default => :spec 10 | 11 | task :setup do 12 | %w(lib spec).each do |path| 13 | $LOAD_PATH.unshift(File.expand_path("../#{path}", __FILE__)) 14 | end 15 | require "database" 16 | exit if config["database"] == ":memory:" 17 | end 18 | 19 | namespace :db do 20 | desc "Create the database" 21 | task create: :setup do 22 | commands = { 23 | "mysql" => "mysql -h #{config['host']} -P #{config['port']} -u #{config['username']} --password=#{config['password']} -e 'create database #{config["database"]} default character set #{config["encoding"]} default collate #{config["collation"]};' >/dev/null", 24 | "postgres" => "psql -c 'create database #{config['database']};' -U #{config['username']} >/dev/null" 25 | } 26 | %x{#{commands[driver] || true}} 27 | $?.success? ? puts("Database successfully created.") : puts("There was an error creating the database.") 28 | end 29 | 30 | desc "Drop the database" 31 | task drop: :setup do 32 | commands = { 33 | "mysql" => "mysql -h #{config['host']} -P #{config['port']} -u #{config['username']} --password=#{config['password']} -e 'drop database #{config["database"]};' >/dev/null", 34 | "postgres" => "psql -c 'drop database #{config['database']};' -U #{config['username']} >/dev/null" 35 | } 36 | %x{#{commands[driver] || true}} 37 | $?.success? ? puts("Database successfully dropped.") : puts("There was an error dropping the database.") 38 | end 39 | 40 | desc "Set up the database schema" 41 | task up: :setup do 42 | orm = ENV['ORM'] 43 | return unless orm 44 | 45 | require orm 46 | require "database" 47 | DB = Mobility::Test::Database.connect(orm) 48 | require "#{orm}/schema" 49 | Mobility::Test::Schema.up 50 | end 51 | 52 | desc "Drop and recreate the database schema" 53 | task :reset => [:drop, :create] 54 | 55 | def config 56 | Mobility::Test::Database.config[driver] 57 | end 58 | 59 | def driver 60 | Mobility::Test::Database.driver 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/mobility/plugins/attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | module Mobility 3 | module Plugins 4 | =begin 5 | 6 | Takes arguments, converts them to strings, and stores in an array +@names+, 7 | made available with an +attr_reader+. Also provides some convenience methods 8 | for aggregating attributes. 9 | 10 | =end 11 | module Attributes 12 | extend Plugin 13 | 14 | # Attribute names for which accessors will be defined 15 | # @return [Array] Array of names 16 | attr_reader :names 17 | 18 | initialize_hook do |*names| 19 | @names = names.map(&:to_s).freeze 20 | end 21 | 22 | # Show useful information about this module. 23 | # @return [String] 24 | def inspect 25 | "#" 26 | end 27 | 28 | included_hook do |klass| 29 | names = @names 30 | 31 | klass.class_eval do 32 | extend ClassMethods 33 | names.each { |name| mobility_attributes << name.to_s } 34 | mobility_attributes.uniq! 35 | rescue FrozenError 36 | raise FrozenAttributesError, "Attempting to translate these attributes on #{klass}, which has already been subclassed: #{names.join(', ')}." 37 | end 38 | end 39 | 40 | module ClassMethods 41 | # Return true if attribute name is translated on this model. 42 | # @param [String, Symbol] Attribute name 43 | # @return [Boolean] 44 | def mobility_attribute?(name) 45 | mobility_attributes.include?(name.to_s) 46 | end 47 | 48 | # Return translated attribute names on this model. 49 | # @return [Array] Attribute names 50 | def mobility_attributes 51 | @mobility_attributes ||= [] 52 | end 53 | 54 | def inherited(klass) 55 | super 56 | attrs = mobility_attributes.freeze # ensure attributes are not modified after being inherited 57 | klass.class_eval { @mobility_attributes = attrs.dup } 58 | end 59 | end 60 | 61 | class FrozenAttributesError < Error; end 62 | end 63 | 64 | register_plugin(:attributes, Attributes) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/integration/sequel_compatibility_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | #TODO: Add general compatibility specs for Sequel 4 | describe "Sequel compatibility", orm: :sequel do 5 | include Helpers::Plugins 6 | include Helpers::Translates 7 | # Enable all plugins that are enabled by default pre v1.0 8 | plugins :sequel, :reader, :writer, :cache, :dirty, :presence, :query, :fallbacks 9 | 10 | before do 11 | stub_const 'Article', Class.new(Sequel::Model) 12 | Article.dataset = DB[:articles] 13 | Article 14 | end 15 | 16 | describe "querying on translated and untranslated attributes" do 17 | %i[key_value table].each do |backend| 18 | #TODO: update querying examples to correctly test untranslated attributes 19 | context "#{backend} backend" do 20 | before do 21 | options = { backend: backend, fallbacks: false } 22 | options[:type] = :string if backend == :key_value 23 | translates Article, :title, **options 24 | end 25 | let!(:article1) { Article.create(title: "foo", slug: "bar") } 26 | let!(:article2) { Article.create( slug: "baz") } 27 | let!(:article4) { Article.create(title: "foo" ) } 28 | 29 | it "works with hash arguments" do 30 | expect(Article.i18n.where(title: "foo", slug: "bar").select_all(:articles).all).to eq([article1]) 31 | expect(Article.i18n.where(title: "foo" ).select_all(:articles).all).to match_array([article1, article4]) 32 | expect(Article.i18n.where(title: "foo", slug: "baz").select_all(:articles).all).to eq([]) 33 | expect(Article.i18n.where( slug: "baz").select_all(:articles).all).to match_array([article2]) 34 | end 35 | 36 | it "works with virtual rows" do 37 | expect(Article.i18n { (title =~ "foo") & (slug =~ "bar") }.select_all(:articles).all).to eq([article1]) 38 | expect(Article.i18n { (title =~ "foo") }.select_all(:articles).all).to match_array([article1, article4]) 39 | expect(Article.i18n { (title =~ "foo") & (slug =~ "baz") }.select_all(:articles).all).to eq([]) 40 | expect(Article.i18n { (slug =~ "baz") }.select_all(:articles).all).to eq([article2]) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/mobility/backends/active_record/json_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(ActiveRecord) 4 | 5 | describe "Mobility::Backends::ActiveRecord::Json", orm: :active_record, db: :postgres, type: :backend do 6 | require "mobility/backends/active_record/json" 7 | 8 | before { stub_const 'JsonPost', Class.new(ActiveRecord::Base) } 9 | 10 | column_options = { column_prefix: 'my_', column_suffix: '_i18n' } 11 | column_affix = "#{column_options[:column_prefix]}%s#{column_options[:column_suffix]}" 12 | 13 | let(:backend) { post.mobility_backends[:title] } 14 | let(:post) { JsonPost.new } 15 | 16 | context "with no plugins" do 17 | include_backend_examples described_class, 'JsonPost', column_options 18 | end 19 | 20 | context "with basic plugins" do 21 | plugins :active_record, :reader, :writer 22 | before { translates JsonPost, :title, :content, backend: :json, **column_options } 23 | 24 | include_accessor_examples 'JsonPost' 25 | include_serialization_examples 'JsonPost', column_affix: column_affix 26 | include_dup_examples 'JsonPost' 27 | include_cache_key_examples 'JsonPost' 28 | 29 | it "does not impact dirty tracking on original column" do 30 | post = JsonPost.create! 31 | post.reload 32 | 33 | expect(post.my_title_i18n).to eq({}) 34 | expect(post.changes).to eq({}) 35 | end 36 | 37 | describe "non-text values" do 38 | it "stores non-string types as-is when saving" do 39 | backend = post.mobility_backends[:title] 40 | backend.write(:en, { foo: :bar } ) 41 | post.save 42 | expect(post[column_affix % "title"]).to eq({ "en" => { "foo" => "bar" }}) 43 | end 44 | end 45 | end 46 | 47 | context "with query plugin" do 48 | plugins :active_record, :reader, :writer, :query 49 | before { translates JsonPost, :title, :content, backend: :json, **column_options } 50 | 51 | include_querying_examples 'JsonPost' 52 | include_validation_examples 'JsonPost' 53 | end 54 | 55 | context "with dirty plugin" do 56 | plugins :active_record, :reader, :writer, :dirty 57 | before { translates JsonPost, :title, :content, backend: :json, **column_options } 58 | 59 | include_accessor_examples 'JsonPost' 60 | include_serialization_examples 'JsonPost', column_affix: column_affix 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/mobility/backends/active_record/hstore_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(ActiveRecord) 4 | 5 | describe "Mobility::Backends::ActiveRecord::Hstore", orm: :active_record, db: :postgres, type: :backend do 6 | require "mobility/backends/active_record/hstore" 7 | 8 | column_options = { column_prefix: 'my_', column_suffix: '_i18n' } 9 | column_affix = "#{column_options[:column_prefix]}%s#{column_options[:column_suffix]}" 10 | 11 | let(:backend) { post.mobility_backends[:title] } 12 | let(:post) { HstorePost.new } 13 | 14 | before { stub_const 'HstorePost', Class.new(ActiveRecord::Base) } 15 | 16 | context "with no plugins" do 17 | include_backend_examples described_class, 'HstorePost', column_options 18 | end 19 | 20 | context "with basic plugins" do 21 | plugins :active_record, :reader, :writer 22 | 23 | before { translates HstorePost, :title, :content, backend: :hstore, **column_options } 24 | 25 | include_accessor_examples 'HstorePost' 26 | include_serialization_examples 'HstorePost', column_affix: column_affix 27 | include_dup_examples 'HstorePost' 28 | include_cache_key_examples 'HstorePost' 29 | 30 | it "does not impact dirty tracking on original column" do 31 | post = HstorePost.create! 32 | post.reload 33 | 34 | expect(post.my_title_i18n).to eq({}) 35 | expect(post.changes).to eq({}) 36 | end 37 | 38 | describe "non-text values" do 39 | it "converts non-string types to strings when saving" do 40 | post = HstorePost.new 41 | backend = post.mobility_backends[:title] 42 | backend.write(:en, { foo: :bar } ) 43 | post.save 44 | expect(post[column_affix % "title"]).to match_hash({ en: "{:foo=>:bar}" }) 45 | end 46 | end 47 | end 48 | 49 | context "with query plugin" do 50 | plugins :active_record, :reader, :writer, :query 51 | 52 | before { translates HstorePost, :title, :content, backend: :hstore, **column_options } 53 | 54 | include_querying_examples 'HstorePost' 55 | include_validation_examples 'HstorePost' 56 | end 57 | 58 | context "with dirty plugin" do 59 | plugins :active_record, :reader, :writer, :dirty 60 | 61 | before { translates HstorePost, :title, :content, backend: :hstore, **column_options } 62 | 63 | include_accessor_examples 'HstorePost' 64 | include_serialization_examples 'HstorePost', column_affix: column_affix 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/mobility/backends/sequel/jsonb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'mobility/backends/sequel/pg_hash' 3 | 4 | Sequel.extension :pg_json, :pg_json_ops 5 | 6 | module Mobility 7 | module Backends 8 | =begin 9 | 10 | Implements the {Mobility::Backends::Jsonb} backend for Sequel models. 11 | 12 | @see Mobility::Backends::HashValued 13 | 14 | =end 15 | module Sequel 16 | class Jsonb < PgHash 17 | # @!group Backend Accessors 18 | # 19 | # @!method read(locale, **options) 20 | # @note Translation may be string, integer or boolean-valued since 21 | # value is stored on a JSON hash. 22 | # @param [Symbol] locale Locale to read 23 | # @param [Hash] options 24 | # @return [String,Integer,Boolean] Value of translation 25 | # 26 | # @!method write(locale, value, **options) 27 | # @note Translation may be string, integer or boolean-valued since 28 | # value is stored on a JSON hash. 29 | # @param [Symbol] locale Locale to write 30 | # @param [String,Integer,Boolean] value Value to write 31 | # @param [Hash] options 32 | # @return [String,Integer,Boolean] Updated value 33 | # @!endgroup 34 | 35 | # @param [Symbol] name Attribute name 36 | # @param [Symbol] locale Locale 37 | # @return [Mobility::Backends::Sequel::Jsonb::JSONBOp] 38 | def self.build_op(attr, locale) 39 | column_name = column_affix % attr 40 | JSONBOp.new(column_name.to_sym).get_text(locale.to_s) 41 | end 42 | 43 | class JSONBOp < ::Sequel::Postgres::JSONBOp 44 | def to_dash_arrow 45 | column = @value.args[0].value 46 | locale = @value.args[1] 47 | ::Sequel.pg_jsonb_op(column)[locale] 48 | end 49 | 50 | def to_question 51 | column = @value.args[0].value 52 | locale = @value.args[1] 53 | ::Sequel.pg_jsonb_op(column).has_key?(locale) 54 | end 55 | 56 | def =~(other) 57 | case other 58 | when Integer, ::Hash 59 | to_dash_arrow =~ other.to_json 60 | when NilClass 61 | ~to_question 62 | else 63 | super 64 | end 65 | end 66 | end 67 | end 68 | end 69 | 70 | register_backend(:sequel_jsonb, Sequel::Jsonb) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/support/shared_examples/dup_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "dupable model" do |model_class_name, attribute=:title| 2 | let(:model_class) { constantize(model_class_name) } 3 | 4 | it "dups persisted model" do 5 | skip_if_duping_not_implemented 6 | instance = model_class.new 7 | instance_backend = backend_for(instance, attribute) 8 | instance_backend.write(:en, "foo") 9 | instance_backend.write(:ja, "ほげ") 10 | save_or_raise(instance) 11 | 12 | dupped_instance = instance.dup 13 | dupped_backend = backend_for(dupped_instance, attribute) 14 | expect(dupped_backend.read(:en)).to eq("foo") 15 | expect(dupped_backend.read(:ja)).to eq("ほげ") 16 | 17 | save_or_raise(dupped_instance) 18 | expect(dupped_instance.send(attribute)).to eq(instance.send(attribute)) 19 | 20 | if ENV['ORM'] == 'active_record' 21 | # Ensure duped instances are pointing to different objects 22 | instance_backend.write(:en, "bar") 23 | expect(dupped_backend.read(:en)).to eq("foo") 24 | end 25 | 26 | # Ensure we haven't mucked with the original instance 27 | instance.reload 28 | 29 | expect(instance_backend.read(:en)).to eq("foo") 30 | expect(instance_backend.read(:ja)).to eq("ほげ") 31 | end 32 | 33 | it "dups new record" do 34 | skip_if_duping_not_implemented 35 | instance = model_class.new(attribute => "foo") 36 | dupped_instance = instance.dup 37 | 38 | expect(instance.send(attribute)).to eq("foo") 39 | expect(dupped_instance.send(attribute)).to eq("foo") 40 | 41 | save_or_raise(instance) 42 | save_or_raise(dupped_instance) 43 | 44 | if ENV['ORM'] == 'active_record' 45 | instance.send("#{attribute}=", "bar") 46 | expect(dupped_instance.send(attribute)).to eq("foo") 47 | end 48 | 49 | # Ensure we haven't mucked with the original instance 50 | instance.reload 51 | dupped_instance.reload 52 | 53 | expect(instance.send(attribute)).to eq("foo") 54 | expect(dupped_instance.send(attribute)).to eq("foo") 55 | end 56 | 57 | def save_or_raise(instance) 58 | if instance.respond_to?(:save!) 59 | instance.save! 60 | else 61 | instance.save 62 | end 63 | end 64 | 65 | def skip_if_duping_not_implemented 66 | if ENV['ORM'] == 'sequel' 67 | require "mobility/backends/key_value" 68 | skip "Duping has not been properly implemented" if described_class < Mobility::Backends::KeyValue 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/mobility/backends/active_record/column.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "mobility/backends/active_record" 3 | require "mobility/backends/column" 4 | 5 | module Mobility 6 | module Backends 7 | =begin 8 | 9 | Implements the {Mobility::Backends::Column} backend for ActiveRecord models. 10 | 11 | You can use the +mobility:translations+ generator to create a migration adding 12 | translatable columns to the model table with: 13 | 14 | rails generate mobility:translations post title:string 15 | 16 | The generated migration will add columns +title_+ for every locale in 17 | +Mobility.available_locales+ (i.e. +I18n.available_locales+). (The generator 18 | can be run again to add new attributes or locales.) 19 | 20 | @example 21 | class Post < ActiveRecord::Base 22 | extend Mobility 23 | translates :title, backend: :column 24 | end 25 | 26 | Mobility.locale = :en 27 | post = Post.create(title: "foo") 28 | post.title 29 | #=> "foo" 30 | post.title_en 31 | #=> "foo" 32 | =end 33 | class ActiveRecord::Column 34 | include ActiveRecord 35 | include Column 36 | 37 | # @!group Backend Accessors 38 | # @!macro backend_reader 39 | def read(locale, _ = {}) 40 | model.read_attribute(column(locale)) 41 | end 42 | 43 | # @!macro backend_writer 44 | def write(locale, value, _ = {}) 45 | model.send(:write_attribute, column(locale), value) 46 | end 47 | # @!endgroup 48 | 49 | # @!macro backend_iterator 50 | def each_locale 51 | available_locales.each { |l| yield(l) if present?(l) } 52 | end 53 | 54 | # @param [String] attr Attribute name 55 | # @param [Symbol] locale Locale 56 | # @return [Arel::Attributes::Attribute] Arel node for translation column 57 | # on model table 58 | def self.build_node(attr, locale) 59 | model_class.arel_table[Column.column_name_for(attr, locale)] 60 | .extend(Plugins::Arel::MobilityExpressions) 61 | end 62 | 63 | private 64 | 65 | def available_locales 66 | @available_locales ||= get_column_locales 67 | end 68 | 69 | def get_column_locales 70 | column_name_regex = /\A#{attribute}_([a-z]{2}(_[a-z]{2})?)\z/.freeze 71 | model.class.columns.map do |c| 72 | (match = c.name.match(column_name_regex)) && match[1].to_sym 73 | end.compact 74 | end 75 | end 76 | 77 | register_backend(:active_record_column, ActiveRecord::Column) 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/mobility/backends/serialized.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "mobility/util" 3 | 4 | module Mobility 5 | module Backends 6 | =begin 7 | 8 | Stores translations as serialized attributes in a single text column. This 9 | implies that the translated values are not searchable, and thus this backend is 10 | not recommended unless specific constraints prevent use of other solutions. 11 | 12 | To use this backend, ensure that the model table has a text column on its table 13 | with the same name as the translated attribute. 14 | 15 | ==Backend Options 16 | 17 | ===+format+ 18 | 19 | Format for serialization. Either +:yaml+ (default) or +:json+. 20 | 21 | @see Mobility::Backends::ActiveRecord::Serialized 22 | @see Mobility::Backends::Sequel::Serialized 23 | 24 | =end 25 | module Serialized 26 | class << self 27 | 28 | # @!group Backend Configuration 29 | # @option options [Symbol] format (:yaml) Serialization format 30 | # @raise [ArgumentError] if a format other than +:yaml+ or +:json+ is passed in 31 | def configure(options) 32 | options[:format] ||= :yaml 33 | options[:format] = options[:format].downcase.to_sym 34 | raise ArgumentError, "Serialized backend only supports yaml or json formats." unless [:yaml, :json].include?(options[:format]) 35 | end 36 | # @!endgroup 37 | 38 | def serializer_for(format) 39 | lambda do |obj| 40 | return if obj.nil? 41 | if obj.is_a? ::Hash 42 | obj = obj.inject({}) do |translations, (locale, value)| 43 | translations[locale] = value.to_s unless value.nil? 44 | translations 45 | end 46 | else 47 | raise ArgumentError, "Attribute is supposed to be a Hash, but was a #{obj.class}. -- #{obj.inspect}" 48 | end 49 | 50 | obj.send("to_#{format}") 51 | end 52 | end 53 | 54 | def deserializer_for(format) 55 | case format 56 | when :yaml 57 | lambda { |v| YAML.load(v) } 58 | when :json 59 | lambda { |v| JSON.parse(v, symbolize_names: true) } 60 | end 61 | end 62 | end 63 | 64 | def check_opts(opts) 65 | if keys = extract_attributes(opts) 66 | raise ArgumentError, 67 | "You cannot query on mobility attributes translated with the Serialized backend (#{keys.join(", ")})." 68 | end 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/rails/generators/mobility/backend_generators/base.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | require "rails/generators/active_record/migration/migration_generator" 3 | 4 | module Mobility 5 | module BackendGenerators 6 | class Base < ::Rails::Generators::NamedBase 7 | argument :attributes, type: :array, default: [] 8 | include ::ActiveRecord::Generators::Migration 9 | include ::Mobility::ActiveRecordMigrationCompatibility 10 | 11 | def create_migration_file 12 | if behavior == :invoke && self.class.migration_exists?(migration_dir, migration_file) 13 | ::Kernel.warn "Migration already exists: #{migration_file}" 14 | else 15 | migration_template "#{template}.rb", "db/migrate/#{migration_file}.rb" 16 | end 17 | end 18 | 19 | def self.next_migration_number(dirname) 20 | ::ActiveRecord::Generators::Base.next_migration_number(dirname) 21 | end 22 | 23 | def backend 24 | self.class.name.split('::').last.gsub(/Backend$/,'').underscore 25 | end 26 | 27 | protected 28 | 29 | def attributes_with_index 30 | attributes.select { |a| !a.reference? && a.has_index? } 31 | end 32 | 33 | def translation_index_name(column, *columns) 34 | truncate_index_name("index_#{table_name}_on_#{[column, *columns].join('_and_')}") 35 | end 36 | 37 | private 38 | 39 | def check_data_source! 40 | unless data_source_exists? 41 | raise NoTableDefined, "The table #{table_name} does not exist. Create it first before generating translated columns." 42 | end 43 | end 44 | 45 | def data_source_exists? 46 | connection.data_source_exists?(table_name) 47 | end 48 | 49 | def connection 50 | ::ActiveRecord::Base.connection 51 | end 52 | 53 | def truncate_index_name(index_name) 54 | if index_name.size < connection.index_name_length 55 | index_name 56 | else 57 | "index_#{Digest::SHA1.hexdigest(index_name)}"[0, connection.index_name_length].freeze 58 | end 59 | end 60 | 61 | def template 62 | "#{backend}_translations" 63 | end 64 | 65 | def migration_dir 66 | File.expand_path("db/migrate") 67 | end 68 | 69 | def migration_file 70 | "create_#{file_name}_#{attributes.map(&:name).join('_and_')}_translations_for_mobility_#{backend}_backend" 71 | end 72 | end 73 | 74 | class NoTableDefined < StandardError; end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/mobility/backends/sequel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "mobility/backend" 3 | 4 | module Mobility 5 | module Backends 6 | module Sequel 7 | def self.included(backend_class) 8 | backend_class.include Backend 9 | backend_class.extend ClassMethods 10 | end 11 | 12 | module ClassMethods 13 | # @param [Symbol] name Attribute name 14 | # @param [Symbol] locale Locale 15 | def [](name, locale) 16 | build_op(name.to_s, locale) 17 | end 18 | 19 | # @param [String] _attr Attribute name 20 | # @param [Symbol] _locale Locale 21 | # @return Op for this translated attribute 22 | def build_op(_attr, _locale) 23 | raise NotImplementedError 24 | end 25 | 26 | # @param [Sequel::Dataset] dataset Dataset to prepare 27 | # @param [Object] predicate Predicate 28 | # @param [Symbol] locale Locale 29 | # @return [Sequel::Dataset] Prepared dataset 30 | def prepare_dataset(dataset, _predicate, _locale) 31 | dataset 32 | end 33 | 34 | # Forces Sequel to notice changes when Mobility setter method is 35 | # called. 36 | # TODO: Find a better way to do this. 37 | def define_column_changes(mod, attributes, column_affix: "%s") 38 | mod.class_eval do 39 | attributes.each do |attribute| 40 | define_method "#{attribute}=" do |value, **options| 41 | if !options[:super] && send(attribute) != value 42 | locale = options[:locale] || Mobility.locale 43 | column = (column_affix % attribute).to_sym 44 | attribute_with_locale = :"#{attribute}_#{Mobility.normalize_locale(locale)}" 45 | @changed_columns = changed_columns | [column, attribute.to_sym, attribute_with_locale] 46 | end 47 | super(value, **options) 48 | end 49 | end 50 | end 51 | end 52 | 53 | # Initialize column value(s) by default to a hash. 54 | # TODO: Find a better way to do this. 55 | def define_hash_initializer(mod, columns) 56 | mod.class_eval do 57 | class_eval <<-EOM, __FILE__, __LINE__ + 1 58 | def initialize_set(values) 59 | #{columns.map { |c| "self[:#{c}] = {}" }.join(';')} 60 | super 61 | end 62 | EOM 63 | end 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/mobility/plugins/reader_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "mobility/plugins/reader" 3 | 4 | describe Mobility::Plugins::Reader, type: :plugin do 5 | plugins :reader 6 | plugin_setup :title 7 | 8 | describe "getters" do 9 | let(:instance) { model_class.new } 10 | 11 | it "correctly maps getter method for translated attribute to backend" do 12 | expect(Mobility).to receive(:locale).and_return(:de) 13 | expect(listener).to receive(:read).with(:de, any_args).and_return("foo") 14 | expect(instance.title).to eq("foo") 15 | end 16 | 17 | it "correctly maps presence method for translated attribute to backend" do 18 | expect(Mobility).to receive(:locale).and_return(:de) 19 | expect(listener).to receive(:read).with(:de, any_args).and_return("foo") 20 | expect(instance.title?).to eq(true) 21 | end 22 | 23 | it "correctly maps locale through getter options and converts to boolean" do 24 | expect(listener).to receive(:read).with(:fr, any_args).and_return("foo") 25 | expect(instance.title(locale: :fr)).to eq("foo") 26 | end 27 | 28 | it "correctly handles string-valued locale option" do 29 | expect(listener).to receive(:read).with(:fr, any_args).and_return("foo") 30 | expect(instance.title(locale: 'fr')).to eq("foo") 31 | end 32 | 33 | it "correctly maps other options to getter" do 34 | expect(Mobility).to receive(:locale).and_return(:de) 35 | expect(listener).to receive(:read).with(:de, someopt: "someval").and_return("foo") 36 | expect(instance.title(someopt: "someval")).to eq("foo") 37 | end 38 | 39 | it "raises Mobility::InvalidLocale if called with locale not in available locales" do 40 | expect { 41 | instance.title(locale: :ru) 42 | }.to raise_error(Mobility::InvalidLocale) 43 | end 44 | end 45 | 46 | describe "super option" do 47 | let(:instance) { model_class.new } 48 | let(:model_class) do 49 | Class.new.tap do |klass| 50 | mod = Module.new do 51 | def title 52 | "title" 53 | end 54 | 55 | def title? 56 | "title?" 57 | end 58 | end 59 | klass.include translations, mod 60 | klass 61 | end 62 | end 63 | 64 | it "calls original getter when super: true passed as option" do 65 | expect(instance.title(super: true)).to eq("title") 66 | end 67 | 68 | it "calls original presence when super: true passed as option" do 69 | expect(instance.title?(super: true)).to eq("title?") 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/mobility/plugins/sequel/dirty.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | require "sequel/plugins/dirty" 3 | 4 | module Mobility 5 | module Plugins 6 | =begin 7 | 8 | Dirty tracking for Sequel models which use the +Sequel::Plugins::Dirty+ plugin. 9 | Automatically includes dirty plugin in model class when enabled. 10 | 11 | @see http://sequel.jeremyevans.net/rdoc-plugins/index.html Sequel dirty plugin 12 | 13 | =end 14 | module Sequel 15 | module Dirty 16 | extend Plugin 17 | 18 | requires :dirty, include: false 19 | 20 | initialize_hook do 21 | # Although we load the plugin in the included callback method, we 22 | # need to include this module here in advance to ensure that its 23 | # instance methods are included *before* the ones defined here. 24 | include ::Sequel::Plugins::Dirty::InstanceMethods 25 | end 26 | 27 | included_hook do |klass, backend_class| 28 | if options[:dirty] 29 | # this just adds Sequel::Plugins::Dirty to @plugins 30 | klass.plugin :dirty 31 | define_dirty_methods(names) 32 | backend_class.include BackendMethods 33 | end 34 | end 35 | 36 | private 37 | 38 | def define_dirty_methods(names) 39 | %w[initial_value column_change column_changed? reset_column].each do |method_name| 40 | define_method method_name do |column| 41 | if names.map(&:to_sym).include?(column) 42 | super(Mobility.normalize_locale_accessor(column).to_sym) 43 | else 44 | super(column) 45 | end 46 | end 47 | end 48 | end 49 | 50 | module BackendMethods 51 | # @!group Backend Accessors 52 | # @!macro backend_writer 53 | # @param [Hash] options 54 | def write(locale, value, **options) 55 | locale_accessor = Mobility.normalize_locale_accessor(attribute, locale).to_sym 56 | if model.column_changes.has_key?(locale_accessor) && model.initial_values[locale_accessor] == value 57 | super 58 | [model.changed_columns, model.initial_values].each { |h| h.delete(locale_accessor) } 59 | elsif read(locale, **options.merge(fallback: false)) != value 60 | model.will_change_column(locale_accessor) 61 | super 62 | end 63 | end 64 | # @!endgroup 65 | end 66 | 67 | end 68 | end 69 | 70 | register_plugin(:sequel_dirty, Sequel::Dirty) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/mobility/plugins/active_record/uniqueness_validation.rb: -------------------------------------------------------------------------------- 1 | module Mobility 2 | module Plugins 3 | module ActiveRecord 4 | module UniquenessValidation 5 | extend Plugin 6 | 7 | requires :query, include: false 8 | 9 | included_hook do |klass| 10 | klass.class_eval do 11 | unless const_defined?(:UniquenessValidator, false) 12 | self.const_set(:UniquenessValidator, Class.new(UniquenessValidator)) 13 | 14 | def self.validates_uniqueness_of(*attr_names) 15 | validates_with(UniquenessValidator, _merge_attributes(attr_names)) 16 | end 17 | end 18 | end 19 | end 20 | 21 | class UniquenessValidator < ::ActiveRecord::Validations::UniquenessValidator 22 | # @param [ActiveRecord::Base] record Translated model 23 | # @param [String] attribute Name of attribute 24 | # @param [Object] value Attribute value 25 | def validate_each(record, attribute, value) 26 | klass = record.class 27 | 28 | if ([*options[:scope]] + [attribute]).any? { |name| klass.mobility_attribute?(name) } 29 | return unless value.present? 30 | relation = Plugins::ActiveRecord::Query.build_query(klass.unscoped, Mobility.locale) do |m| 31 | node = m.__send__(attribute) 32 | options[:case_sensitive] == false ? node.lower.eq(value.downcase) : node.eq(value) 33 | end 34 | relation = relation.where.not(klass.primary_key => record.id) if record.persisted? 35 | relation = mobility_scope_relation(record, relation) 36 | relation = relation.merge(options[:conditions]) if options[:conditions] 37 | 38 | if relation.exists? 39 | error_options = options.except(:case_sensitive, :scope, :conditions) 40 | error_options[:value] = value 41 | 42 | record.errors.add(attribute, :taken, **error_options) 43 | end 44 | else 45 | super 46 | end 47 | end 48 | 49 | private 50 | 51 | def mobility_scope_relation(record, relation) 52 | [*options[:scope]].inject(relation) do |scoped_relation, scope_item| 53 | Plugins::ActiveRecord::Query.build_query(scoped_relation, Mobility.locale) do |m| 54 | m.__send__(scope_item).eq(record.send(scope_item)) 55 | end 56 | end 57 | end 58 | end 59 | end 60 | end 61 | 62 | register_plugin(:active_record_uniqueness_validation, ActiveRecord::UniquenessValidation) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/mobility/plugins/locale_accessors.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | =begin 6 | 7 | Defines methods for a set of locales to access translated attributes in those 8 | locales directly with a method call, using a suffix including the locale: 9 | 10 | article.title_pt_br 11 | 12 | If no locales are passed as an option to the initializer, 13 | +Mobility.available_locales+ (i.e. +I18n.available_locales+, or Rails-set 14 | available locales for a Rails application) will be used by default. 15 | 16 | =end 17 | module LocaleAccessors 18 | extend Plugin 19 | 20 | default true 21 | 22 | # Apply locale accessors plugin to attributes. 23 | # @param [Translations] translations 24 | # @param [Boolean] option 25 | initialize_hook do |*names| 26 | if locales = options[:locale_accessors] 27 | locales = Mobility.available_locales if locales == true 28 | names.each do |name| 29 | locales.each do |locale| 30 | define_locale_reader(name, locale) 31 | define_locale_writer(name, locale) 32 | end 33 | end 34 | end 35 | end 36 | 37 | private 38 | 39 | def define_locale_reader(name, locale) 40 | warning_message = "locale passed as option to locale accessor will be ignored" 41 | normalized_locale = Mobility.normalize_locale(locale) 42 | 43 | module_eval <<-EOM, __FILE__, __LINE__ + 1 44 | def #{name}_#{normalized_locale}(options = {}) 45 | return super() if options.delete(:super) 46 | warn "#{warning_message}" if options[:locale] 47 | #{name}(**options, locale: #{locale.inspect}) 48 | end 49 | EOM 50 | 51 | module_eval <<-EOM, __FILE__, __LINE__ + 1 52 | def #{name}_#{normalized_locale}?(options = {}) 53 | return super() if options.delete(:super) 54 | warn "#{warning_message}" if options[:locale] 55 | #{name}?(**options, locale: #{locale.inspect}) 56 | end 57 | EOM 58 | end 59 | 60 | def define_locale_writer(name, locale) 61 | warning_message = "locale passed as option to locale accessor will be ignored" 62 | normalized_locale = Mobility.normalize_locale(locale) 63 | 64 | module_eval <<-EOM, __FILE__, __LINE__ + 1 65 | def #{name}_#{normalized_locale}=(value, options = {}) 66 | return super(value) if options.delete(:super) 67 | warn "#{warning_message}" if options[:locale] 68 | public_send(:#{name}=, value, **options, locale: #{locale.inspect}) 69 | end 70 | EOM 71 | end 72 | end 73 | 74 | register_plugin(:locale_accessors, LocaleAccessors) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/mobility/plugins/fallthrough_accessors_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "mobility/plugins/fallthrough_accessors" 3 | 4 | describe Mobility::Plugins::FallthroughAccessors, type: :plugin do 5 | plugin_setup :title 6 | let(:translation_options) { {} } 7 | let(:model_class) do 8 | klass = Class.new do 9 | def title(**); end 10 | def title?(**); end 11 | def title=(_, **); end 12 | end 13 | klass.include translations 14 | klass 15 | end 16 | 17 | context "option value is default" do 18 | plugins do 19 | fallthrough_accessors 20 | end 21 | 22 | it_behaves_like "locale accessor", :title, 'en' 23 | it_behaves_like "locale accessor", :title, 'de' 24 | it_behaves_like "locale accessor", :title, 'pt-BR' 25 | it_behaves_like "locale accessor", :title, 'rus' 26 | 27 | it 'passes arguments and options to super when method does not match' do 28 | mod = Module.new do 29 | def method_missing(method_name, *args, &block) 30 | (method_name == :foo) ? args : super 31 | end 32 | end 33 | 34 | model_class = Class.new 35 | model_class.include translations, mod 36 | 37 | instance = model_class.new 38 | 39 | options = { some: 'params' } 40 | expect(instance.foo(**options)).to eq([options]) 41 | end 42 | 43 | it 'passes kwargs to super when method does not match' do 44 | mod = Module.new do 45 | def method_missing(method_name, *args, **kwargs, &block) 46 | (method_name == :foo) ? [args, kwargs] : super 47 | end 48 | end 49 | 50 | model_class = Class.new 51 | model_class.include translations, mod 52 | 53 | instance = model_class.new 54 | 55 | kwargs = { some: 'params' } 56 | expect(instance.foo(**kwargs)).to eq([[], kwargs]) 57 | end 58 | 59 | it 'does not pass on empty keyword options hash to super' do 60 | mod = Module.new do 61 | def method_missing(method_name, *args, &block) 62 | method_name == :bar ? args : super 63 | end 64 | end 65 | 66 | model_class = Class.new 67 | model_class.include translations, mod 68 | 69 | instance = model_class.new 70 | 71 | expect(instance.bar).to eq([]) 72 | end 73 | end 74 | 75 | context "option value is false" do 76 | plugins do 77 | fallthrough_accessors false 78 | end 79 | 80 | it "does not include instance of FallthroughAccessors into attributes class" do 81 | instance = model_class.new 82 | expect { instance.title_en }.to raise_error(NoMethodError) 83 | expect { instance.title_en? }.to raise_error(NoMethodError) 84 | expect { instance.send(:title_en=, "value", {}) }.to raise_error(NoMethodError) 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/mobility/backends/active_record/serialized.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "mobility/backends/active_record" 3 | require "mobility/backends/hash_valued" 4 | require "mobility/backends/serialized" 5 | 6 | module Mobility 7 | module Backends 8 | =begin 9 | 10 | Implements {Mobility::Backends::Serialized} backend for ActiveRecord models. 11 | 12 | @example Define attribute with serialized backend 13 | class Post < ActiveRecord::Base 14 | extend Mobility 15 | translates :title, backend: :serialized, format: :yaml 16 | end 17 | 18 | @example Read and write attribute translations 19 | post = Post.create(title: "foo") 20 | post.title 21 | #=> "foo" 22 | Mobility.locale = :ja 23 | post.title = "あああ" 24 | post.save 25 | post.read_attribute(:title) # get serialized value 26 | #=> {:en=>"foo", :ja=>"あああ"} 27 | 28 | =end 29 | class ActiveRecord::Serialized 30 | include ActiveRecord 31 | include HashValued 32 | 33 | def self.valid_keys 34 | super + [:format] 35 | end 36 | 37 | # @!group Backend Configuration 38 | # @param (see Backends::Serialized.configure) 39 | # @option (see Backends::Serialized.configure) 40 | # @raise (see Backends::Serialized.configure) 41 | def self.configure(options) 42 | super 43 | Serialized.configure(options) 44 | end 45 | # @!endgroup 46 | 47 | def self.build_node(attr, _locale) 48 | raise ArgumentError, 49 | "You cannot query on mobility attributes translated with the Serialized backend (#{attr})." 50 | end 51 | 52 | setup do |attributes, options| 53 | coder = { yaml: YAMLCoder, json: JSONCoder }[options[:format]] 54 | attributes.each do |attribute| 55 | if (::ActiveRecord::VERSION::STRING >= "7.1") 56 | serialize (options[:column_affix] % attribute), coder: coder 57 | else 58 | serialize (options[:column_affix] % attribute), coder 59 | end 60 | end 61 | end 62 | 63 | # @!group Cache Methods 64 | # Returns column value as a hash 65 | # @return [Hash] 66 | def translations 67 | model.read_attribute(column_name) 68 | end 69 | 70 | %w[yaml json].each do |format| 71 | class_eval <<-EOM, __FILE__, __LINE__ + 1 72 | class #{format.upcase}Coder 73 | def self.dump(obj) 74 | Serialized.serializer_for(:#{format}).call(obj) 75 | end 76 | 77 | def self.load(obj) 78 | return {} if obj.nil? 79 | Serialized.deserializer_for(:#{format}).call(obj) 80 | end 81 | end 82 | EOM 83 | end 84 | end 85 | 86 | register_backend(:active_record_serialized, ActiveRecord::Serialized) 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/mobility/plugins/fallthrough_accessors.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | =begin 6 | 7 | Defines +method_missing+ and +respond_to_missing?+ methods for a set of 8 | attributes such that a method call using a locale accessor, like: 9 | 10 | article.title_pt_br 11 | 12 | will return the value of +article.title+ with the locale set to +pt-BR+ around 13 | the method call. The class is called "FallthroughAccessors" because when 14 | included in a model class, locale-specific methods will be available even if 15 | not explicitly defined with the +locale_accessors+ option. 16 | 17 | This is a less efficient (but more open-ended) implementation of locale 18 | accessors, for use in cases where the locales to be used are not known when the 19 | model class is generated. 20 | 21 | =end 22 | module FallthroughAccessors 23 | extend Plugin 24 | 25 | default true 26 | 27 | # Apply fallthrough accessors plugin to attributes. 28 | # @param [Translations] translations 29 | # @param [Boolean] option 30 | initialize_hook do 31 | if options[:fallthrough_accessors] 32 | define_fallthrough_accessors(names) 33 | end 34 | end 35 | 36 | private 37 | 38 | def define_fallthrough_accessors(*names) 39 | method_name_regex = /\A(#{names.join('|')})_([a-z]{2,3}(_[a-z]{2})?)(=?|\??)\z/.freeze 40 | 41 | define_method :method_missing do |method_name, *args, &block| 42 | if method_name =~ method_name_regex 43 | attribute_method = "#{$1}#{$4}" 44 | locale, suffix = $2.split('_') 45 | locale = "#{locale}-#{suffix.upcase}" if suffix 46 | if $4 == '=' # writer 47 | kwargs = args[1].is_a?(Hash) ? args[1] : {} 48 | public_send(attribute_method, args[0], **kwargs, locale: locale) 49 | else # reader 50 | kwargs = args[0].is_a?(Hash) ? args[0] : {} 51 | public_send(attribute_method, **kwargs, locale: locale) 52 | end 53 | else 54 | super(method_name, *args, &block) 55 | end 56 | end 57 | 58 | # Following is needed in order to not swallow `kwargs` on ruby >= 3.0. 59 | # Otherwise `kwargs` are not passed by `super` to a possible other 60 | # `method_missing` defined like this: 61 | # 62 | # def method_missing(name, *args, **kwargs, &block); end 63 | ruby2_keywords :method_missing 64 | 65 | define_method :respond_to_missing? do |method_name, include_private = false| 66 | (method_name =~ method_name_regex) || super(method_name, include_private) 67 | end 68 | end 69 | end 70 | 71 | register_plugin :fallthrough_accessors, FallthroughAccessors 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/mobility/plugins/cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | =begin 6 | 7 | Caches values fetched from the backend so subsequent fetches can be performed 8 | more quickly. The cache stores cached values in a simple hash, which is not 9 | optimal for some storage strategies, so some backends (KeyValue, Table) use a 10 | custom module by defining a method, +include_cache+, on the backend class. 11 | 12 | The cache is reset when one of a set of events happens (saving, reloading, 13 | etc.). See {BackendResetter} for details. 14 | 15 | Values are added to the cache in two ways: 16 | 17 | 1. first read from backend 18 | 2. any write to backend 19 | 20 | =end 21 | module Cache 22 | extend Plugin 23 | 24 | default true 25 | requires :backend, include: :before 26 | 27 | # Applies cache plugin to attributes. 28 | included_hook do |_, backend_class| 29 | if options[:cache] 30 | if backend_class.respond_to?(:include_cache) 31 | backend_class.include_cache 32 | else 33 | include_cache(backend_class) 34 | end 35 | end 36 | end 37 | 38 | private 39 | 40 | def include_cache(backend_class) 41 | backend_class.include BackendMethods 42 | end 43 | 44 | # Used in ORM cache plugins 45 | def define_cache_hooks(klass, *reset_methods) 46 | mod = self 47 | private_methods = reset_methods & klass.private_instance_methods 48 | reset_methods.each do |method_name| 49 | define_method method_name do |*args| 50 | super(*args).tap do 51 | mod.names.each { |name| mobility_backends[name].clear_cache } 52 | end 53 | end 54 | end 55 | klass.class_eval { private(*private_methods) } 56 | end 57 | 58 | module BackendMethods 59 | # @group Backend Accessors 60 | # 61 | # @!macro backend_reader 62 | # @!method read(locale, value, options = {}) 63 | # @option options [Boolean] cache *false* to disable cache. 64 | def read(locale, **options) 65 | return super(locale, **options) if options.delete(:cache) == false 66 | if cache.has_key?(locale) 67 | cache[locale] 68 | else 69 | cache[locale] = super(locale, **options) 70 | end 71 | end 72 | 73 | # @!macro backend_writer 74 | # @option options [Boolean] cache 75 | # *false* to disable cache. 76 | def write(locale, value, **options) 77 | return super if options.delete(:cache) == false 78 | cache[locale] = super 79 | end 80 | # @!endgroup 81 | 82 | def clear_cache 83 | @cache = {} 84 | end 85 | 86 | private 87 | 88 | def cache 89 | @cache ||= {} 90 | end 91 | end 92 | end 93 | 94 | register_plugin(:cache, Cache) 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/rails/generators/mobility/translations_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Mobility 4 | =begin 5 | 6 | Generator to create translation tables or add translation columns to a model 7 | table, for either Table or Column backends. 8 | 9 | ==Usage 10 | 11 | To add translations for a string attribute +title+ to a model +Post+, call the 12 | generator with: 13 | 14 | rails generate mobility:translations post title:string 15 | 16 | Here, the backend is implicit in the value of +Mobility.default_backend+, but 17 | it can be explicitly set using the +backend+ option: 18 | 19 | rails generate mobility:translations post title:string --backend=table 20 | 21 | For the +table+ backend, the generator will either create a translation table 22 | (in this case, +post_translations+) or add columns to the table if it already 23 | exists. 24 | 25 | For the +column+ backend, the generator will add columns for all locales in 26 | +Mobility.available_locales+. If some columns already exist, they will simply be 27 | skipped. 28 | 29 | Other backends are not supported, for obvious reasons: 30 | * the +key_value+ backend does not need any model-specific migrations, simply 31 | run the install generator. 32 | * +json+, +jsonb+, +hstore+, +serialized+, and +container+ backends simply 33 | require a single column on a model table, which can be added with the normal 34 | Rails migration generator. 35 | 36 | =end 37 | class TranslationsGenerator < ::Rails::Generators::NamedBase 38 | SUPPORTED_BACKENDS = %w[column table].freeze 39 | BACKEND_OPTIONS = { type: :string, desc: "Backend to use for translations (defaults to Mobility.default_backend)" }.freeze 40 | argument :attributes, type: :array, default: [], banner: "field[:type][:index] field[:type][:index]" 41 | 42 | class_option(:backend, BACKEND_OPTIONS.dup) 43 | invoke_from_option :backend 44 | 45 | def self.class_options(options = nil) 46 | super 47 | @class_options[:backend] = Thor::Option.new(:backend, BACKEND_OPTIONS.merge(default: Mobility.default_backend.to_s.freeze)) 48 | @class_options 49 | end 50 | 51 | def self.prepare_for_invocation(name, value) 52 | if name == :backend 53 | if SUPPORTED_BACKENDS.include?(value) 54 | require_relative "./backend_generators/#{value}_backend" 55 | Mobility::BackendGenerators.const_get("#{value}_backend".camelcase.freeze) 56 | else 57 | begin 58 | require "mobility/backends/#{value}" 59 | raise Thor::Error, "The #{value} backend does not have a translations generator." 60 | rescue LoadError => e 61 | raise unless e.message =~ /#{value}/ 62 | raise Thor::Error, "#{value} is not a Mobility backend." 63 | end 64 | end 65 | else 66 | super 67 | end 68 | end 69 | 70 | protected 71 | 72 | def say_status(status, message, *args) 73 | if status == :invoke && SUPPORTED_BACKENDS.include?(message) 74 | super(status, "#{message}_backend", *args) 75 | else 76 | super 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | 3 | if !ENV['ORM'].nil? && (ENV['ORM'] != '') 4 | orm = ENV['ORM'] 5 | raise ArgumentError, 'Invalid ORM' unless %w[active_record sequel].include?(orm) 6 | require orm 7 | else 8 | orm = 'none' 9 | end 10 | 11 | require 'rails' if ENV['FEATURE'] == 'rails' 12 | 13 | db = ENV['DB'] || 'none' 14 | require 'pry-byebug' 15 | require 'i18n' 16 | require 'i18n/backend/fallbacks' if ENV['FEATURE'] == 'i18n_fallbacks' 17 | require 'rspec' 18 | require 'allocation_stats' if ENV['FEATURE'] == 'performance' 19 | require 'json' 20 | 21 | require 'mobility' 22 | 23 | I18n.enforce_available_locales = true 24 | I18n.available_locales = [:en, :'en-US', :ja, :fr, :de, :'de-DE', :cz, :pl, :pt, :'pt-BR'] 25 | I18n.default_locale = :en 26 | 27 | Dir[File.expand_path("./spec/support/**/*.rb")].each { |f| require f } 28 | 29 | unless orm == 'none' 30 | require "database" 31 | require "#{orm}/schema" 32 | 33 | require 'database_cleaner' 34 | DatabaseCleaner.strategy = :transaction 35 | 36 | DB = Mobility::Test::Database.connect(orm) 37 | DB.extension :pg_json, :pg_hstore if orm == 'sequel' && db == 'postgres' 38 | # for in-memory sqlite database 39 | Mobility::Test::Database.auto_migrate 40 | end 41 | 42 | RSpec.configure do |config| 43 | config.include Helpers 44 | config.include Mobility::Util 45 | if defined?(ActiveSupport) 46 | require 'active_support/testing/time_helpers' 47 | config.include ActiveSupport::Testing::TimeHelpers 48 | end 49 | 50 | config.filter_run focus: true 51 | config.run_all_when_everything_filtered = true 52 | config.mock_with :rspec do |mocks| 53 | mocks.verify_partial_doubles = true 54 | end 55 | 56 | config.include Helpers::Plugins, type: :plugin 57 | config.include Helpers::PluginSetup, type: :plugin 58 | 59 | config.extend Helpers::Backend, type: :backend 60 | config.include Helpers::Plugins, type: :backend 61 | config.include Helpers::Translates, type: :backend 62 | 63 | config.extend Helpers::ActiveRecord, orm: :active_record 64 | config.extend Helpers::Sequel, orm: :sequel 65 | 66 | config.before :each do |example| 67 | if (version = example.metadata[:active_record_geq]) && 68 | defined?(ActiveRecord) && 69 | ActiveRecord::VERSION::STRING < version 70 | skip "Unsupported for Rails < #{version}" 71 | end 72 | # Always clear I18n.fallbacks to avoid "leakage" between specs 73 | reset_i18n_fallbacks 74 | Mobility.locale = :en 75 | 76 | # Remove once lowest supported version is Rails 6.2 77 | if defined?(ActiveSupport::Dependencies::Reference) 78 | ActiveSupport::Dependencies::Reference.clear! 79 | end 80 | 81 | # ensure this is reset in each run 82 | Mobility.reset_translations_class 83 | end 84 | 85 | unless orm == 'none' 86 | config.before :each do 87 | DatabaseCleaner.start 88 | end 89 | config.after :each do 90 | DatabaseCleaner.clean 91 | end 92 | end 93 | 94 | config.order = "random" 95 | config.filter_run_excluding orm: lambda { |v| v && ![*v].include?(orm&.to_sym) }, db: lambda { |v| v && ![*v].include?(db.to_sym) } 96 | end 97 | -------------------------------------------------------------------------------- /lib/mobility/plugins/default.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | =begin 6 | 7 | Defines value or proc to fall through to if return value from getter would 8 | otherwise be nil. This plugin is disabled by default but will be enabled if any 9 | value is passed as the +default+ option key. 10 | 11 | If default is a +Proc+, it will be called with the context of the model, and 12 | passed arguments: 13 | - the attribute name (a String) 14 | - the locale (a Symbol) 15 | - hash of options passed in to accessor 16 | The proc can accept zero to three arguments (see examples below) 17 | 18 | @example With default enabled (falls through to default value) 19 | class Post 20 | extend Mobility 21 | translates :title, default: 'foo' 22 | end 23 | 24 | Mobility.locale = :en 25 | post = Post.new(title: "English title") 26 | 27 | Mobility.locale = :de 28 | post.title 29 | #=> 'foo' 30 | 31 | @example Overriding default with reader option 32 | class Post 33 | extend Mobility 34 | translates :title, default: 'foo' 35 | end 36 | 37 | Mobility.locale = :en 38 | post = Post.new(title: "English title") 39 | 40 | Mobility.locale = :de 41 | post.title 42 | #=> 'foo' 43 | 44 | post.title(default: 'bar') 45 | #=> 'bar' 46 | 47 | post.title(default: nil) 48 | #=> nil 49 | 50 | @example Using Proc as default 51 | class Post 52 | extend Mobility 53 | translates :title, default: lambda { |attribute, locale| "#{attribute} in #{locale}" } 54 | end 55 | 56 | Mobility.locale = :en 57 | post = Post.new(title: nil) 58 | post.title 59 | #=> "title in en" 60 | 61 | post.title(default: lambda { self.class.name.to_s }) 62 | #=> "Post" 63 | =end 64 | module Default 65 | extend Plugin 66 | 67 | requires :backend, include: :before 68 | 69 | # Applies default plugin to attributes. 70 | included_hook do |_klass, backend_class| 71 | backend_class.include(BackendMethods) 72 | end 73 | 74 | # Generate a default value for given parameters. 75 | # @param [Object, Proc] default_value A default value or Proc 76 | # @param [Symbol] locale 77 | # @param [Hash] accessor_options 78 | # @param [String] attribute 79 | def self.[](default_value, locale:, accessor_options:, model:, attribute:) 80 | return default_value unless default_value.is_a?(Proc) 81 | args = [attribute, locale, accessor_options] 82 | args = args.first(default_value.arity) unless default_value.arity < 0 83 | model.instance_exec(*args, &default_value) 84 | end 85 | 86 | module BackendMethods 87 | # @!group Backend Accessors 88 | # @!macro backend_reader 89 | # @option accessor_options [Boolean] default 90 | # *false* to disable presence filter. 91 | def read(locale, accessor_options = {}) 92 | default = accessor_options.has_key?(:default) ? accessor_options.delete(:default) : options[:default] 93 | if (value = super(locale, **accessor_options)).nil? 94 | Default[default, locale: locale, accessor_options: accessor_options, model: model, attribute: attribute] 95 | else 96 | value 97 | end 98 | end 99 | # @!endgroup 100 | end 101 | end 102 | 103 | register_plugin(:default, Default) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/generators/rails/mobility/install_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(Rails) && defined?(ActiveRecord) 4 | 5 | require "rails/generators/mobility/install_generator" 6 | 7 | describe Mobility::InstallGenerator, type: :generator do 8 | require "generator_spec/test_case" 9 | include GeneratorSpec::TestCase 10 | include Helpers::Generators 11 | 12 | destination File.expand_path("../tmp", __FILE__) 13 | 14 | after(:all) { prepare_destination } 15 | 16 | describe "no options" do 17 | before(:all) do 18 | prepare_destination 19 | run_generator 20 | end 21 | 22 | it "generates initializer" do 23 | expect(destination_root).to have_structure { 24 | directory "config" do 25 | directory "initializers" do 26 | file "mobility.rb" do 27 | contains "Mobility.configure do" 28 | contains "plugins do" 29 | contains "backend :key_value" 30 | contains "backend_reader" 31 | contains "query" 32 | end 33 | end 34 | end 35 | } 36 | end 37 | 38 | it "generates migration for text translations table" do 39 | version_string_ = version_string 40 | 41 | expect(destination_root).to have_structure { 42 | directory "db" do 43 | directory "migrate" do 44 | migration "create_text_translations" do 45 | contains "class CreateTextTranslations < ActiveRecord::Migration[#{version_string_}]" 46 | contains "def change" 47 | contains "create_table :mobility_text_translations" 48 | contains "t.text :value" 49 | contains "t.references :translatable, polymorphic: true, index: false" 50 | contains "add_index :mobility_text_translations" 51 | contains "name: :index_mobility_text_translations_on_keys" 52 | contains "name: :index_mobility_text_translations_on_translatable_attribute" 53 | end 54 | end 55 | end 56 | } 57 | end 58 | 59 | it "generates migration for string translations table" do 60 | version_string_ = version_string 61 | 62 | expect(destination_root).to have_structure { 63 | directory "db" do 64 | directory "migrate" do 65 | migration "create_string_translations" do 66 | contains "class CreateStringTranslations < ActiveRecord::Migration[#{version_string_}]" 67 | contains "def change" 68 | contains "create_table :mobility_string_translations" 69 | contains "t.string :value" 70 | contains "t.references :translatable, polymorphic: true, index: false" 71 | contains "add_index :mobility_string_translations" 72 | contains "name: :index_mobility_string_translations_on_keys" 73 | contains "name: :index_mobility_string_translations_on_translatable_attribute" 74 | contains "name: :index_mobility_string_translations_on_query_keys" 75 | end 76 | end 77 | end 78 | } 79 | end 80 | end 81 | 82 | describe "--without_tables set to true" do 83 | before(:all) do 84 | prepare_destination 85 | run_generator %w(--without_tables) 86 | end 87 | 88 | it "does not generate migration for translations tables" do 89 | expect((Pathname.new(destination_root) + "db" + "migrate").exist?).to eq(false) 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/mobility/plugins/attributes_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "mobility/plugins/attributes" 3 | 4 | describe Mobility::Plugins::Attributes, type: :plugin do 5 | let(:translations_class) do 6 | Class.new(Mobility::Pluggable).tap do |translations_class| 7 | translations_class.plugin :attributes 8 | end 9 | end 10 | 11 | describe "#names" do 12 | it "returns arguments passed to initialize" do 13 | translations = translations_class.new("title", "content") 14 | expect(translations.names).to eq(["title", "content"]) 15 | end 16 | end 17 | 18 | describe "#inspect" do 19 | it "includes backend name and attribute names" do 20 | translations = translations_class.new("title", "content") 21 | expect(translations.inspect).to eq("#") 22 | end 23 | end 24 | 25 | describe ".mobility_attributes" do 26 | it "returns attributes included in one of multiple pluggable modules" do 27 | translations1 = translations_class.new("title", "content") 28 | translations2 = translations_class.new("author") 29 | klass = Class.new 30 | klass.include translations1 31 | klass.include translations2 32 | 33 | expect(klass.mobility_attributes).to eq(%w[title content author]) 34 | end 35 | end 36 | 37 | describe ".mobility_attribute?" do 38 | it "returns true if name is an attribute" do 39 | translations = translations_class.new("title", "content") 40 | klass = Class.new 41 | klass.include translations 42 | 43 | expect(klass.mobility_attribute?("title")).to eq(true) 44 | expect(klass.mobility_attribute?("content")).to eq(true) 45 | expect(klass.mobility_attribute?("foo")).to eq(false) 46 | end 47 | 48 | it "returns true if name is included one of multiple pluggable modules" do 49 | translations1 = translations_class.new("title", "content") 50 | translations2 = translations_class.new("author") 51 | klass = Class.new 52 | klass.include translations1 53 | klass.include translations2 54 | 55 | expect(klass.mobility_attribute?("title")).to eq(true) 56 | expect(klass.mobility_attribute?("content")).to eq(true) 57 | expect(klass.mobility_attribute?("author")).to eq(true) 58 | expect(klass.mobility_attribute?("foo")).to eq(false) 59 | end 60 | end 61 | 62 | describe "inheritance" do 63 | it "inherits mobility attributes from parent" do 64 | mod1 = translations_class.new("title", "content") 65 | klass1 = Class.new 66 | klass1.include mod1 67 | 68 | klass2 = Class.new(klass1) 69 | 70 | expect(klass1.mobility_attributes).to match_array(%w[title content]) 71 | expect(klass2.mobility_attributes).to match_array(%w[title content]) 72 | 73 | mod2 = translations_class.new("author") 74 | klass2.include mod2 75 | 76 | expect(klass1.mobility_attributes).to match_array(%w[title content]) 77 | expect(klass2.mobility_attributes).to match_array(%w[title content author]) 78 | end 79 | 80 | it "freezes inherited attributes to ensure they are not changed after subclassing" do 81 | mod1 = translations_class.new("title") 82 | stub_const("Foo", Class.new) 83 | klass1 = Foo 84 | klass1.include mod1 85 | 86 | Class.new(klass1) 87 | expect(klass1.mobility_attributes).to be_frozen 88 | 89 | mod2 = translations_class.new("content") 90 | expect { 91 | klass1.include mod2 92 | }.to raise_error(Mobility::Plugins::Attributes::FrozenAttributesError, 93 | "Attempting to translate these attributes on Foo, which has already been subclassed: content.") 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/mobility/backends/sequel/jsonb_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(Sequel) && defined?(PG) 4 | 5 | describe "Mobility::Backends::Sequel::Jsonb", orm: :sequel, db: :postgres, type: :backend do 6 | require "mobility/backends/sequel/jsonb" 7 | 8 | column_options = { column_prefix: 'my_', column_suffix: '_i18n' } 9 | column_affix = "#{column_options[:column_prefix]}%s#{column_options[:column_suffix]}" 10 | 11 | before do 12 | stub_const 'JsonbPost', Class.new(Sequel::Model) 13 | JsonbPost.dataset = DB[:jsonb_posts] 14 | end 15 | 16 | let(:backend) { post.mobility_backends[:title] } 17 | let(:post) { JsonbPost.new } 18 | 19 | context "with no plugins" do 20 | include_backend_examples described_class, 'JsonbPost', column_options 21 | end 22 | 23 | context "with basic plugins" do 24 | plugins :sequel, :reader, :writer 25 | before { translates JsonbPost, :title, :content, backend: :jsonb, **column_options } 26 | 27 | include_accessor_examples 'JsonbPost' 28 | include_serialization_examples 'JsonbPost', column_affix: column_affix 29 | include_dup_examples 'JsonbPost' 30 | end 31 | 32 | context "with query plugin" do 33 | plugins :sequel, :reader, :writer, :query 34 | before { translates JsonbPost, :title, :content, backend: :jsonb, **column_options } 35 | 36 | include_querying_examples 'JsonbPost' 37 | 38 | it "uses existence operator instead of NULL match" do 39 | aggregate_failures do 40 | expect(JsonbPost.i18n.where(title: nil).sql).to match /\?/ 41 | expect(JsonbPost.i18n.where(title: nil).sql).not_to match /NULL/ 42 | end 43 | end 44 | 45 | it "treats array of nils like nil" do 46 | expect(JsonbPost.i18n.where(title: nil).sql).to eq(JsonbPost.i18n.where(title: [nil]).sql) 47 | end 48 | 49 | describe "non-text values" do 50 | it "stores non-string types as-is when saving" do 51 | backend = post.mobility_backends[:title] 52 | backend.write(:en, { foo: :bar } ) 53 | post.save 54 | expect(post[(column_affix % "title").to_sym]).to eq({ "en" => { "foo" => "bar" }}) 55 | end 56 | 57 | shared_examples_for "jsonb translated value" do |name, value| 58 | it "stores #{name} values" do 59 | post.title = value 60 | expect(post.title).to eq(value) 61 | post.save 62 | 63 | post = JsonbPost.last 64 | expect(post.title).to eq(value) 65 | end 66 | 67 | it "queries on #{name} values" do 68 | skip "arrays treated as array of values, not value to match" if name == :array 69 | post1 = JsonbPost.create(title: "foo") 70 | post2 = JsonbPost.create(title: value) 71 | 72 | expect(JsonbPost.i18n.where(title: "foo").first).to eq(post1) 73 | expect(JsonbPost.i18n.where(title: value).first).to eq(post2) 74 | 75 | # Only use ->> operator when matching strings 76 | expect(JsonbPost.i18n.where(title: value).sql).not_to match("->>") 77 | end 78 | end 79 | 80 | it_behaves_like "jsonb translated value", :integer, 1 81 | it_behaves_like "jsonb translated value", :hash, { "a" => "b" } do 82 | before { JsonbPost.create(title: { "a" => "b", "c" => "d" }) } 83 | end 84 | it_behaves_like "jsonb translated value", :array, [1, "a", nil] 85 | end 86 | end 87 | 88 | context "with dirty plugin" do 89 | plugins :sequel, :reader, :writer, :dirty 90 | before { translates JsonbPost, :title, :content, backend: :jsonb, **column_options } 91 | 92 | include_accessor_examples 'JsonbPost' 93 | include_serialization_examples 'JsonbPost', column_affix: column_affix 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/mobility/util.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Mobility 4 | =begin 5 | 6 | Some useful methods on strings, borrowed in parts from Sequel and ActiveSupport. 7 | 8 | @example With no methods defined on String 9 | "foos".respond_to?(:singularize) 10 | #=> false 11 | 12 | class A 13 | include Mobility::Util 14 | end 15 | 16 | A.new.singularize("foos") 17 | #=> "foo" 18 | A.new.singularize("bunnies") 19 | #=> "bunnie" 20 | 21 | @example With methods on String 22 | require "active_support" 23 | "foos".respond_to?(:singularize) 24 | #=> true 25 | 26 | class A 27 | include Mobility::Util 28 | end 29 | 30 | A.new.singularize("bunnies") 31 | #=> "bunny" 32 | =end 33 | module Util 34 | VALID_CONSTANT_NAME_REGEXP = /\A(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)\z/.freeze 35 | 36 | def self.included(klass) 37 | klass.extend(self) 38 | end 39 | 40 | # Converts strings to UpperCamelCase. 41 | # @param [String] str 42 | # @return [String] 43 | def camelize(str) 44 | call_or_yield str do 45 | str.to_s.sub(/^[a-z\d]*/) { $&.capitalize }.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{$1}#{$2.capitalize}" }.gsub('/', '::') 46 | end 47 | end 48 | 49 | # Tries to find a constant with the name specified in the argument string. 50 | # @param [String] str 51 | # @return [Object] 52 | def constantize(str) 53 | str = str.to_s 54 | call_or_yield str do 55 | raise(NameError, "#{s.inspect} is not a valid constant name!") unless m = VALID_CONSTANT_NAME_REGEXP.match(str) 56 | Object.module_eval("::#{m[1]}", __FILE__, __LINE__) 57 | end 58 | end 59 | 60 | # Returns the singular form of a word in a string. 61 | # @param [String] str 62 | # @return [String] 63 | # @note If +singularize+ is not defined on +String+, falls back to simply 64 | # stripping the trailing 's' from the string. 65 | def singularize(str) 66 | call_or_yield str do 67 | str.to_s.gsub(/s$/, '') 68 | end 69 | end 70 | 71 | # Removes the module part from the expression in the string. 72 | # @param [String] str 73 | # @return [String] 74 | def demodulize(str) 75 | call_or_yield str do 76 | str.to_s.gsub(/^.*::/, '') 77 | end 78 | end 79 | 80 | # Creates a foreign key name from a class name 81 | # @param [String] str 82 | # @return [String] 83 | def foreign_key(str) 84 | call_or_yield str do 85 | "#{underscore(demodulize(str))}_id" 86 | end 87 | end 88 | 89 | # Makes an underscored, lowercase form from the expression in the string. 90 | # @param [String] str 91 | # @return [String] 92 | def underscore(str) 93 | call_or_yield str do 94 | str.to_s.gsub(/::/, '/').gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). 95 | gsub(/([a-z\d])([A-Z])/,'\1_\2').tr("-", "_").downcase 96 | end 97 | end 98 | 99 | def present?(object) 100 | !blank?(object) 101 | end 102 | 103 | def blank?(object) 104 | return true if object.nil? 105 | object.respond_to?(:empty?) ? !!object.empty? : !object 106 | end 107 | 108 | def presence(object) 109 | object if present?(object) 110 | end 111 | 112 | private 113 | 114 | # Calls caller method on object if defined, otherwise yields to block 115 | def call_or_yield(object) 116 | caller_method = caller_locations(1,1)[0].label 117 | if object.respond_to?(caller_method) 118 | object.public_send(caller_method) 119 | else 120 | yield 121 | end 122 | end 123 | 124 | extend self 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/mobility/plugins/arel/nodes/pg_ops.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | require "mobility/plugins/arel" 3 | 4 | module Mobility 5 | module Plugins 6 | module Arel 7 | module Nodes 8 | %w[ 9 | JsonDashArrow 10 | JsonDashDoubleArrow 11 | JsonbDashArrow 12 | JsonbDashDoubleArrow 13 | JsonbQuestion 14 | HstoreDashArrow 15 | HstoreQuestion 16 | ].each do |name| 17 | const_set name, (Class.new(Binary) do 18 | include ::Arel::Predications 19 | include ::Arel::OrderPredications 20 | include ::Arel::AliasPredication 21 | include MobilityExpressions 22 | 23 | def lower 24 | super self 25 | end 26 | end) 27 | end 28 | 29 | class Jsonb < JsonbDashDoubleArrow 30 | def to_dash_arrow 31 | JsonbDashArrow.new left, right 32 | end 33 | 34 | def to_question 35 | JsonbQuestion.new left, right 36 | end 37 | 38 | def eq other 39 | case other 40 | when NilClass 41 | to_question.not 42 | when Integer, Array, ::Hash 43 | to_dash_arrow.eq other.to_json 44 | when Jsonb 45 | to_dash_arrow.eq other.to_dash_arrow 46 | when JsonbDashArrow 47 | to_dash_arrow.eq other 48 | else 49 | super 50 | end 51 | end 52 | end 53 | 54 | class Hstore < HstoreDashArrow 55 | def to_question 56 | HstoreQuestion.new left, right 57 | end 58 | 59 | def eq other 60 | other.nil? ? to_question.not : super 61 | end 62 | end 63 | 64 | class Json < JsonDashDoubleArrow; end 65 | 66 | class JsonContainer < Json 67 | def initialize column, locale, attr 68 | super(Nodes::JsonDashArrow.new(column, locale), attr) 69 | end 70 | end 71 | 72 | class JsonbContainer < Jsonb 73 | def initialize column, locale, attr 74 | @column, @locale = column, locale 75 | super(JsonbDashArrow.new(column, locale), attr) 76 | end 77 | 78 | def eq other 79 | other.nil? ? super.or(JsonbQuestion.new(@column, @locale).not) : super 80 | end 81 | end 82 | end 83 | 84 | module Visitors 85 | def visit_Mobility_Plugins_Arel_Nodes_JsonDashArrow o, a 86 | json_infix o, a, '->' 87 | end 88 | 89 | def visit_Mobility_Plugins_Arel_Nodes_JsonDashDoubleArrow o, a 90 | json_infix o, a, '->>' 91 | end 92 | 93 | def visit_Mobility_Plugins_Arel_Nodes_JsonbDashArrow o, a 94 | json_infix o, a, '->' 95 | end 96 | 97 | def visit_Mobility_Plugins_Arel_Nodes_JsonbDashDoubleArrow o, a 98 | json_infix o, a, '->>' 99 | end 100 | 101 | def visit_Mobility_Plugins_Arel_Nodes_JsonbQuestion o, a 102 | json_infix o, a, '?' 103 | end 104 | 105 | def visit_Mobility_Plugins_Arel_Nodes_HstoreDashArrow o, a 106 | json_infix o, a, '->' 107 | end 108 | 109 | def visit_Mobility_Plugins_Arel_Nodes_HstoreQuestion o, a 110 | json_infix o, a, '?' 111 | end 112 | 113 | private 114 | 115 | def json_infix o, a, opr 116 | visit(Nodes::Grouping.new(::Arel::Nodes::InfixOperation.new(opr, o.left, o.right)), a) 117 | end 118 | end 119 | 120 | ::Arel::Visitors::PostgreSQL.include Visitors 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /spec/mobility/plugins/presence_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "mobility/plugins/presence" 3 | 4 | describe Mobility::Plugins::Presence, type: :plugin do 5 | plugin_setup 6 | 7 | context "default option value" do 8 | plugins :presence 9 | 10 | describe "#read" do 11 | it "passes through present values unchanged" do 12 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return("foo") 13 | expect(backend.read(:fr)).to eq("foo") 14 | end 15 | 16 | it "converts blank strings to nil" do 17 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return("") 18 | expect(backend.read(:fr)).to eq(nil) 19 | end 20 | 21 | it "passes through nil values unchanged" do 22 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return(nil) 23 | expect(backend.read(:fr)).to eq(nil) 24 | end 25 | 26 | it "passes through false values unchanged" do 27 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return(false) 28 | expect(backend.read(:fr)).to eq(false) 29 | end 30 | 31 | it "does not convert blank string to nil if presence: false passed as option" do 32 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return("") 33 | expect(backend.read(:fr, presence: false)).to eq("") 34 | end 35 | 36 | it "does not modify options passed in" do 37 | options = { presence: false } 38 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return("") 39 | backend.read(:fr, **options) 40 | expect(options).to eq({ presence: false }) 41 | end 42 | end 43 | 44 | describe "#write" do 45 | it "passes through present values unchanged" do 46 | expect(listener).to receive(:write).once.with(:fr, "foo", any_args).and_return("foo") 47 | expect(backend.write(:fr, "foo")).to eq("foo") 48 | end 49 | 50 | it "converts blank strings to nil" do 51 | expect(listener).to receive(:write).once.with(:fr, nil, any_args).and_return(nil) 52 | expect(backend.write(:fr, "")).to eq(nil) 53 | end 54 | 55 | it "passes through nil values unchanged" do 56 | expect(listener).to receive(:write).once.with(:fr, nil, any_args).and_return(nil) 57 | expect(backend.write(:fr, nil)).to eq(nil) 58 | end 59 | 60 | it "passes through false values unchanged" do 61 | expect(listener).to receive(:write).once.with(:fr, false, any_args).and_return(false) 62 | expect(backend.write(:fr, false)).to eq(false) 63 | end 64 | 65 | it "does not convert blank string to nil if presence: false passed as option" do 66 | expect(listener).to receive(:write).once.with(:fr, "", any_args).and_return("") 67 | expect(backend.write(:fr, "", presence: false)).to eq("") 68 | end 69 | 70 | it "does not modify options passed in" do 71 | options = { presence: false } 72 | expect(listener).to receive(:write).once.with(:fr, "foo", any_args) 73 | backend.write(:fr, "foo", **options) 74 | expect(options).to eq({ presence: false }) 75 | end 76 | end 77 | end 78 | 79 | context "option = false" do 80 | plugins do 81 | presence false 82 | end 83 | 84 | describe "#read" do 85 | it "does not convert blank strings to nil" do 86 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return("") 87 | expect(backend.read(:fr)).to eq("") 88 | end 89 | end 90 | 91 | describe "#write" do 92 | it "does not convert blank strings to nil" do 93 | expect(listener).to receive(:write).once.with(:fr, "", any_args).and_return("") 94 | expect(backend.write(:fr, "")).to eq("") 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/mobility/translations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "mobility/pluggable" 3 | 4 | module Mobility 5 | =begin 6 | 7 | Module containing translation accessor methods and other methods for accessing 8 | translations. 9 | 10 | Normally this class will be created by calling +translates+ on the model class, 11 | added when extending {Mobility}, but it can also be used independent of that 12 | macro. 13 | 14 | ==Including Translations in a Class 15 | 16 | Since {Translations} is a subclass of +Module+, including an instance of it is 17 | like including a module. Options passed to the initializer are passed to 18 | plugins (if those plugins have been enabled). 19 | 20 | We can first enable plugins by subclassing `Mobility::Translations`, and 21 | calling +plugins+ to enable any plugins we want to use. We want reader and 22 | writer methods for accessing translations, so we enable those plugins: 23 | 24 | class Translations < Mobility::Translations 25 | plugins do 26 | reader 27 | writer 28 | end 29 | end 30 | 31 | Both `reader` and `writer` depend on the `backend` plugin, so this is also enabled. 32 | 33 | Then create an instance like this: 34 | 35 | Translations.new("title", backend: :my_backend) 36 | 37 | This will generate an anonymous module that behaves approximately like this: 38 | 39 | Module.new do 40 | # From Mobility::Plugins::Backend module 41 | # 42 | def mobility_backends 43 | # Returns a memoized hash with attribute name keys and backend instance 44 | # values. When a key is fetched from the hash, the hash calls 45 | # +self.class.mobility_backend_class(name)+ (where +name+ is the 46 | # attribute name) to get the backend class, then instantiate it (passing 47 | # the model instance and attribute name to its initializer) and return it. 48 | end 49 | 50 | # From Mobility::Plugins::Reader module 51 | # 52 | def title(locale: Mobility.locale) 53 | mobility_backends[:title].read(locale) 54 | end 55 | 56 | def title?(locale: Mobility.locale) 57 | mobility_backends[:title].read(locale).present? 58 | end 59 | 60 | # From Mobility::Plugins::Writer module 61 | def title=(value, locale: Mobility.locale) 62 | mobility_backends[:title].write(locale, value) 63 | end 64 | end 65 | 66 | Including this module into a model class will thus add the backends method and 67 | reader and writer methods for accessing translations. Other plugins (e.g. 68 | fallbacks, cache) modify the result returned by the backend, by hooking into 69 | the +included+ callback method on the module, see {Mobility::Plugin} for 70 | details. 71 | 72 | ==Setting up the Model Class 73 | 74 | Accessor methods alone are of limited use without a hook to actually modify the 75 | model class. This hook is provided by the {Backend::Setup#setup_model} method, 76 | which is added to every backend class when it includes the {Backend} module. 77 | 78 | Assuming the backend has defined a setup block by calling +setup+, this block 79 | will be called when {Translations} is {#included} in the model class, passed 80 | attributes and options defined when the backend was defined on the model class. 81 | This allows a backend to do things like (for example) define associations on a 82 | model class required by the backend, as happens in the {Backends::KeyValue} and 83 | {Backends::Table} backends. 84 | 85 | Since setup blocks are evaluated on the model class, it is possible that 86 | backends can conflict (for example, overwriting previously defined methods). 87 | Care should be taken to avoid defining methods on the model class, or where 88 | necessary, ensure that names are defined in such a way as to avoid conflicts 89 | with other backends. 90 | 91 | =end 92 | class Translations < Pluggable 93 | include Plugins.load_plugin(:attributes) 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/mobility/plugins/active_record/dirty.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | require "mobility/plugins/active_model/dirty" 3 | 4 | module Mobility 5 | module Plugins 6 | module ActiveRecord 7 | =begin 8 | 9 | Dirty tracking for AR models. See {Mobility::Plugins::ActiveModel::Dirty} for 10 | details on usage. 11 | 12 | In addition to methods added by {Mobility::Plugins::ActiveModel::Dirty}, the 13 | AR::Dirty plugin adds support for the following persistence-specific methods 14 | (for a model with a translated attribute +title+): 15 | - +saved_change_to_title?+ 16 | - +saved_change_to_title+ 17 | - +title_before_last_save+ 18 | - +will_save_change_to_title?+ 19 | - +title_change_to_be_saved+ 20 | - +title_in_database+ 21 | 22 | The following methods are also patched to include translated attribute changes: 23 | - +saved_changes+ 24 | - +has_changes_to_save?+ 25 | - +changes_to_save+ 26 | - +changed_attribute_names_to_save+ 27 | - +attributes_in_database+ 28 | 29 | In addition, the following ActiveModel attribute handler methods are also 30 | patched to work with translated attributes: 31 | - +saved_change_to_attribute?+ 32 | - +saved_change_to_attribute+ 33 | - +attribute_before_last_save+ 34 | - +will_save_change_to_attribute?+ 35 | - +attribute_change_to_be_saved+ 36 | - +attribute_in_database+ 37 | 38 | (When using these methods, you must pass the attribute name along with its 39 | locale suffix, so +title_en+, +title_pt_br+, etc.) 40 | 41 | =end 42 | module Dirty 43 | extend Plugin 44 | 45 | requires :dirty, include: false 46 | requires :active_model_dirty, include: :before 47 | 48 | initialize_hook do 49 | if options[:dirty] 50 | include InstanceMethods 51 | end 52 | end 53 | 54 | included_hook do |_, backend_class| 55 | if options[:dirty] 56 | backend_class.include BackendMethods 57 | end 58 | end 59 | 60 | private 61 | 62 | def dirty_handler_methods 63 | HandlerMethods 64 | end 65 | 66 | # Module which defines generic ActiveRecord::Dirty handler methods like 67 | # +attribute_before_last_save+ that are patched to work with translated 68 | # attributes. 69 | HandlerMethods = ActiveModel::Dirty::HandlerMethodsBuilder.new( 70 | Class.new do 71 | # In earlier versions of Rails, these are needed to avoid an 72 | # exception when including the AR Dirty module outside of an 73 | # AR::Base class. Eventually we should be able to drop them. 74 | def self.after_create; end 75 | def self.after_update; end 76 | 77 | include ::ActiveRecord::AttributeMethods::Dirty 78 | end 79 | ) 80 | 81 | module InstanceMethods 82 | def saved_changes 83 | super.merge(mutations_from_mobility.previous_changes) 84 | end 85 | 86 | def changes_to_save 87 | super.merge(mutations_from_mobility.changes) 88 | end 89 | 90 | def changed_attribute_names_to_save 91 | super + mutations_from_mobility.changed 92 | end 93 | 94 | def attributes_in_database 95 | super.merge(mutations_from_mobility.changed_attributes) 96 | end 97 | 98 | def has_changes_to_save? 99 | super || mutations_from_mobility.changed? 100 | end 101 | 102 | def reload(*) 103 | super.tap do 104 | @mutations_from_mobility = nil 105 | end 106 | end 107 | end 108 | 109 | BackendMethods = ActiveModel::Dirty::BackendMethods 110 | end 111 | end 112 | 113 | register_plugin(:active_record_dirty, ActiveRecord::Dirty) 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/mobility/plugins/default_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "mobility/plugins/default" 3 | 4 | describe Mobility::Plugins::Default, type: :plugin do 5 | plugin_setup :title 6 | 7 | context "option = 'foo'" do 8 | plugins do 9 | default 'default foo' 10 | end 11 | 12 | describe "#read" do 13 | it "returns value if not nil" do 14 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return("foo") 15 | expect(backend.read(:fr)).to eq("foo") 16 | end 17 | 18 | it "returns value if value is false" do 19 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return(false) 20 | expect(backend.read(:fr)).to eq(false) 21 | end 22 | 23 | it "returns default if backend return value is nil" do 24 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return(nil) 25 | expect(backend.read(:fr)).to eq("default foo") 26 | end 27 | 28 | it "returns value of default override if passed as option to reader" do 29 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return(nil) 30 | expect(backend.read(:fr, default: "default bar")).to eq("default bar") 31 | end 32 | 33 | it "returns nil if passed default: nil as option to reader" do 34 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return(nil) 35 | expect(backend.read(:fr, default: nil)).to eq(nil) 36 | end 37 | 38 | it "returns false if passed default: false as option to reader" do 39 | expect(listener).to receive(:read).once.with(:fr, any_args).and_return(nil) 40 | expect(backend.read(:fr, default: false)).to eq(false) 41 | end 42 | end 43 | end 44 | 45 | context "default is a Proc" do 46 | plugins do 47 | default Proc.new { |attribute, locale, options| "#{attribute} in #{locale} with #{options[:this]}" } 48 | end 49 | 50 | it "calls default with model and attribute as args if default is a Proc" do 51 | expect(listener).to receive(:read).once.with(:fr, this: 'option').and_return(nil) 52 | expect(backend.read(:fr, this: 'option')).to eq("title in fr with option") 53 | end 54 | 55 | it "calls default with model and attribute as args if default option is a Proc" do 56 | aggregate_failures do 57 | # with no arguments 58 | expect(listener).to receive(:read).once.with(:fr, this: 'option').and_return(nil) 59 | default_as_option = Proc.new { "default" } 60 | expect(backend.read(:fr, default: default_as_option, this: 'option')).to eq("default") 61 | 62 | # with one argument 63 | expect(listener).to receive(:read).once.with(:fr, this: 'option').and_return(nil) 64 | default_as_option = Proc.new { |attribute| "default #{attribute}" } 65 | expect(backend.read(:fr, default: default_as_option, this: 'option')).to eq("default title") 66 | 67 | # with two arguments 68 | expect(listener).to receive(:read).once.with(:fr, this: 'option').and_return(nil) 69 | default_as_option = Proc.new { |attribute, locale| "default #{attribute} #{locale}" } 70 | expect(backend.read(:fr, default: default_as_option, this: 'option')).to eq("default title fr") 71 | 72 | # with three arguments 73 | expect(listener).to receive(:read).once.with(:fr, this: 'option').and_return(nil) 74 | default_as_option = Proc.new { |attribute, locale, options| "default #{attribute} #{locale} #{options[:this]}" } 75 | expect(backend.read(:fr, default: default_as_option, this: 'option')).to eq("default title fr option") 76 | 77 | # with any arguments 78 | expect(listener).to receive(:read).once.with(:fr, this: 'option').and_return(nil) 79 | default_as_option = Proc.new { |attribute, **| "default #{attribute}" } 80 | expect(backend.read(:fr, default: default_as_option, this: 'option')).to eq("default title") 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/mobility/plugins/arel.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | module Mobility 4 | module Plugins 5 | =begin 6 | 7 | Plugin for Mobility Arel customizations. Basically used as a namespace to store 8 | Arel-specific classes and modules. 9 | 10 | =end 11 | module Arel 12 | extend Plugin 13 | 14 | module MobilityExpressions 15 | include ::Arel::Expressions 16 | 17 | # @note This is necessary in order to ensure that when a translated 18 | # attribute is selected with an alias using +AS+, the resulting 19 | # expression can still be counted without blowing up. 20 | # 21 | # Extending +::Arel::Expressions+ is necessary to convince ActiveRecord 22 | # that this node should not be stringified, which otherwise would 23 | # result in garbage SQL. 24 | # 25 | # @see https://github.com/rails/rails/blob/847342c25c61acaea988430dc3ab66a82e3ed486/activerecord/lib/active_record/relation/calculations.rb#L261 26 | def as(*) 27 | super 28 | .extend(::Arel::Expressions) 29 | .extend(Countable) 30 | end 31 | 32 | module Countable 33 | # @note This allows expressions with selected translated attributes to 34 | # be counted. 35 | def count(*args) 36 | left.count(*args) 37 | end 38 | end 39 | end 40 | 41 | class Attribute < ::Arel::Attributes::Attribute 42 | include MobilityExpressions 43 | 44 | attr_reader :backend_class 45 | attr_reader :locale 46 | attr_reader :attribute_name 47 | 48 | def initialize(relation, column_name, locale, backend_class, attribute_name = nil) 49 | @backend_class = backend_class 50 | @locale = locale 51 | @attribute_name = attribute_name || column_name 52 | super(relation, column_name) 53 | end 54 | end 55 | 56 | class Visitor < ::Arel::Visitors::Visitor 57 | INNER_JOIN = ::Arel::Nodes::InnerJoin 58 | OUTER_JOIN = ::Arel::Nodes::OuterJoin 59 | 60 | attr_reader :backend_class, :locale 61 | 62 | def initialize(backend_class, locale) 63 | super() 64 | @backend_class, @locale = backend_class, locale 65 | end 66 | 67 | private 68 | 69 | def visit(*args) 70 | super 71 | rescue TypeError 72 | visit_default(*args) 73 | end 74 | 75 | def visit_collection(_objects) 76 | raise NotImplementedError 77 | end 78 | alias :visit_Array :visit_collection 79 | 80 | def visit_Arel_Nodes_Unary(object) 81 | visit(object.expr) 82 | end 83 | 84 | def visit_Arel_Nodes_Binary(object) 85 | visit_collection([object.left, object.right]) 86 | end 87 | 88 | def visit_Arel_Nodes_Function(object) 89 | visit_collection(object.expressions) 90 | end 91 | 92 | def visit_Arel_Nodes_Case(object) 93 | visit_collection([object.case, object.conditions, object.default]) 94 | end 95 | 96 | def visit_Arel_Nodes_And(object) 97 | visit_Array(object.children) 98 | end 99 | 100 | def visit_Arel_Nodes_Node(object) 101 | visit_default(object) 102 | end 103 | 104 | def visit_Arel_Attributes_Attribute(object) 105 | visit_default(object) 106 | end 107 | 108 | def visit_default(_object) 109 | nil 110 | end 111 | end 112 | 113 | module Nodes 114 | class Binary < ::Arel::Nodes::Binary; end 115 | class Grouping < ::Arel::Nodes::Grouping; end 116 | 117 | ::Arel::Visitors::ToSql.class_eval do 118 | alias :visit_Mobility_Plugins_Arel_Nodes_Grouping :visit_Arel_Nodes_Grouping 119 | end 120 | end 121 | end 122 | 123 | register_plugin(:arel, Arel) 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Mobility 2 | 3 | Thanks for your interest in contributing to Mobility! Contributions are welcomed and encouraged. Bug reports, feature requests, and refactoring are all a great help, but please follow the instructions below to ensure things go as smoothly as possible. 4 | 5 | ## Questions 6 | 7 | If you have a question about *usage* which is not covered in the [readme](https://github.com/shioyama/mobility/blob/master/README.md), [wiki](https://github.com/shioyama/mobility/wiki) or [API documentation](http://www.rubydoc.info/gems/mobility), please post a question to StackOverflow tagged with the [mobility tag](https://stackoverflow.com/questions/tagged/mobility). Questions will not be answered in Github issues, which are reserved for actual bugs and feature requests. 8 | 9 | ## Bugs 10 | 11 | Notice a bug or something that seems not to be working correctly? Great, that's valuable information. First off, make sure you go through the [Github issues](https://github.com/shioyama/mobility/issues?utf8=%E2%9C%93&q=is%3Aissue) to see if what you're experiencing has already been reported. 12 | 13 | If not, please post a new issue explaining how the issue happens, and steps to reproduce it. Also include what backend you are using, what ORM (ActiveRecord, Sequel, etc.), what Ruby version, and if relevant what platform, etc. 14 | 15 | ## Feature Requests 16 | 17 | Have an idea for a new feature? Great! Please sketch out what you are thinking of and create an issue describing it in as much detail as possible. Note that Mobility aims to be as simple as possible, so complex features will probably not be added, but extensions and integrations with other gems may be created outside of the Mobility gem itself. 18 | 19 | ## Questions 20 | 21 | If you are having issues understanding how to apply Mobility to your particular use case, or any other questions about the gem, please post a question to [Stack Overflow](http://stackoverflow.com) tagged with "mobility". If you don't get an answer, post an issue to the repository with a link to the question and someone will try to help you asap. 22 | 23 | ## Features 24 | 25 | If you've actually built a new feature for Mobility, go ahead and make a pull request and we will consider it. In general you will need to have tests for whatever feature you are proposing. 26 | 27 | To test that your feature does not break existing specs, run the specs with: 28 | 29 | ```ruby 30 | bundle exec rspec 31 | ``` 32 | 33 | This will run specs which are not dependent on any ORM (pure Ruby specs only). To test against ActiveRecord, you will need to set the `ORM` environment variable, like this: 34 | 35 | ```ruby 36 | ORM=active_record bundle exec rspec 37 | ``` 38 | 39 | This will run AR specs with an sqlite3 in-memory database. If you want to run specs against a specific database, you will need to specify which database to use with the `DB` env (either `mysql` or `postgres`), and first create and migrate the database: 40 | 41 | ```ruby 42 | ORM=active_record DB=postgres bundle exec rspec 43 | ``` 44 | 45 | ... will run the specs against Mobility running with AR 5.1 with postgres as the database. 46 | 47 | For more info, see the [Testing Backends](https://github.com/shioyama/mobility#testing-backends) section of the README. 48 | 49 | Once you've ensured that existing specs do not break, please try to write at least one spec covering the new feature. If you have questions about how to do this, first post the PR and we can help you in the PR comments section. 50 | 51 | Note that when you submit the pull request, Travis CI will run the [test suite](https://travis-ci.org/mobility/mobility) against your branch and will highlight any failures. Unless there is a good reason for it, we do not generally accept pull requests that take Mobility from green to red. 52 | 53 | ## Other Resources 54 | 55 | Be sure to check out these resources for more detailed info on how Mobility works: 56 | 57 | - [API docs](http://www.rubydoc.info/gems/mobility) 58 | - [Wiki](https://github.com/shioyama/mobility/wiki) 59 | - [Translating with Mobility](http://dejimata.com/2017/3/3/translating-with-mobility) 60 | -------------------------------------------------------------------------------- /lib/mobility/backends/sequel/serialized.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require "mobility/backends/sequel" 3 | require "mobility/backends/hash_valued" 4 | require "mobility/backends/serialized" 5 | 6 | module Mobility 7 | module Backends 8 | =begin 9 | 10 | Implements {Mobility::Backends::Serialized} backend for Sequel models, using the 11 | Sequel serialization plugin. 12 | 13 | @see http://sequel.jeremyevans.net/rdoc-plugins/classes/Sequel/Plugins/Serialization.html Sequel serialization plugin 14 | @see http://sequel.jeremyevans.net/rdoc-plugins/classes/Sequel/Plugins/SerializationModificationDetection.html Sequel serialization_modification_detection plugin 15 | 16 | @example Define attribute with serialized backend 17 | class Post < Sequel::Model 18 | extend Mobility 19 | translates :title, backend: :serialized, format: :yaml 20 | end 21 | 22 | @example Read and write attribute translations 23 | post = Post.create(title: "foo") 24 | post.title 25 | #=> "foo" 26 | Mobility.locale = :ja 27 | post.title = "あああ" 28 | post.save 29 | post.deserialized_values[:title] # get deserialized value 30 | #=> {:en=>"foo", :ja=>"あああ"} 31 | post.title(super: true) # get serialized value 32 | #=> "---\n:en: foo\n:ja: \"あああ\"\n" 33 | 34 | =end 35 | class Sequel::Serialized 36 | include Sequel 37 | include HashValued 38 | 39 | def self.valid_keys 40 | super + [:format] 41 | end 42 | 43 | # @!group Backend Configuration 44 | # @param (see Backends::Serialized.configure) 45 | # @option (see Backends::Serialized.configure) 46 | # @raise (see Backends::Serialized.configure) 47 | def self.configure(options) 48 | super 49 | Serialized.configure(options) 50 | end 51 | # @!endgroup 52 | 53 | def self.build_op(attr, _locale) 54 | raise ArgumentError, 55 | "You cannot query on mobility attributes translated with the Serialized backend (#{attr})." 56 | end 57 | 58 | setup do |attributes, options| 59 | format = options[:format] 60 | columns = attributes.map { |attribute| (options[:column_affix] % attribute).to_sym } 61 | 62 | plugin :serialization 63 | plugin :serialization_modification_detection 64 | 65 | columns.each do |column| 66 | self.serialization_map[column] = Serialized.serializer_for(format) 67 | self.deserialization_map[column] = Serialized.deserializer_for(format) 68 | end 69 | 70 | method_overrides = Module.new do 71 | define_method :initialize_set do |values| 72 | columns.each { |column| self[column] = {}.send(:"to_#{format}") } 73 | super(values) 74 | end 75 | end 76 | include method_overrides 77 | 78 | include SerializationModificationDetectionFix 79 | end 80 | 81 | # Returns deserialized column value 82 | # @return [Hash] 83 | def translations 84 | if model.deserialized_values.has_key?(column_name) 85 | model.deserialized_values[column_name] 86 | elsif model.frozen? 87 | deserialize_value(serialized_value) 88 | else 89 | model.deserialized_values[column_name] = deserialize_value(serialized_value) 90 | end 91 | end 92 | 93 | # @note The original serialization_modification_detection plugin sets 94 | # +@original_deserialized_values+ to be +@deserialized_values+, which 95 | # doesn't work. Setting it to a new empty hash seems to work better. 96 | module SerializationModificationDetectionFix 97 | def after_save 98 | super 99 | @original_deserialized_values = {} 100 | end 101 | end 102 | 103 | private 104 | 105 | def deserialize_value(value) 106 | model.send(:deserialize_value, column_name, value) 107 | end 108 | 109 | def serialized_value 110 | model[column_name] 111 | end 112 | 113 | def column_name 114 | super.to_sym 115 | end 116 | end 117 | 118 | register_backend(:sequel_serialized, Sequel::Serialized) 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/mobility/backends/sequel/column_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(Sequel) 4 | 5 | describe "Mobility::Backends::Sequel::Column", orm: :sequel, type: :backend do 6 | require "mobility/backends/sequel/column" 7 | 8 | before do 9 | stub_const 'Comment', Class.new(Sequel::Model) 10 | Comment.dataset = DB[:comments] 11 | end 12 | let(:attributes) { %w[content author] } 13 | let(:backend) do 14 | described_class.build_subclass(Comment, {}).new(comment, attributes.first) 15 | end 16 | let(:comment) do 17 | Comment.create(content_en: "Good post!", 18 | content_ja: "なかなか面白い記事", 19 | content_pt_br: "Olá") 20 | end 21 | 22 | context "with no plugins applied" do 23 | include_backend_examples described_class, 'Comment', :content 24 | end 25 | 26 | context "with basic plugins" do 27 | plugins :sequel, :reader, :writer 28 | before { translates Comment, *attributes, backend: :column } 29 | 30 | subject { comment } 31 | 32 | describe "#read" do 33 | it "returns attribute in locale from appropriate column" do 34 | aggregate_failures do 35 | expect(backend.read(:en)).to eq("Good post!") 36 | expect(backend.read(:ja)).to eq("なかなか面白い記事") 37 | end 38 | end 39 | 40 | it "handles dashed locales" do 41 | expect(backend.read(:"pt-BR")).to eq("Olá") 42 | end 43 | end 44 | 45 | describe "#write" do 46 | it "assigns to appropriate columnn" do 47 | backend.write(:en, "Crappy post!") 48 | backend.write(:ja, "面白くない") 49 | 50 | aggregate_failures do 51 | expect(comment.content_en).to eq("Crappy post!") 52 | expect(comment.content_ja).to eq("面白くない") 53 | end 54 | end 55 | 56 | it "handles dashed locales" do 57 | backend.write(:"pt-BR", "Olá Olá") 58 | expect(comment.content_pt_br).to eq "Olá Olá" 59 | end 60 | end 61 | 62 | describe "Model accessors" do 63 | include_accessor_examples 'Comment', :content, :author 64 | end 65 | end 66 | 67 | describe "with locale accessors" do 68 | plugins :sequel, :reader, :writer, :locale_accessors 69 | before { translates Comment, *attributes, backend: :column } 70 | 71 | it "still works as usual" do 72 | translates Comment, *attributes, backend: :column 73 | backend.write(:en, "Crappy post!") 74 | expect(comment.content_en).to eq("Crappy post!") 75 | end 76 | end 77 | 78 | describe "with dirty plugin" do 79 | plugins :sequel, :reader, :writer, :dirty 80 | before { translates Comment, *attributes, backend: :column } 81 | 82 | it "still works as usual" do 83 | backend.write(:en, "Crappy post!") 84 | expect(comment.content_en).to eq("Crappy post!") 85 | end 86 | 87 | it "tracks changed attributes" do 88 | comment = Comment.new 89 | 90 | aggregate_failures do 91 | expect(comment.content).to eq(nil) 92 | comment.column_changed?(:content) 93 | expect(comment.column_changed?(:content)).to eq(false) 94 | expect(comment.column_change(:title)).to eq(nil) 95 | expect(comment.changed_columns).to eq([]) 96 | expect(comment.column_changes).to eq({}) 97 | 98 | comment.content = "foo" 99 | expect(comment.content).to eq("foo") 100 | expect(comment.column_changed?(:content)).to eq(true) 101 | expect(comment.column_change(:content)).to eq([nil, "foo"]) 102 | expect(comment.changed_columns).to eq([:content_en]) 103 | expect(comment.column_changes).to eq({ :content_en => [nil, "foo"] }) 104 | end 105 | end 106 | 107 | it "returns nil for locales with no column defined" do 108 | comment = Comment.new 109 | 110 | expect(comment.content(locale: :fr)).to eq(nil) 111 | end 112 | end 113 | 114 | context "with query plugin" do 115 | plugins :sequel, :reader, :writer, :query 116 | before { translates Comment, *attributes, backend: :column } 117 | 118 | include_querying_examples 'Comment', :content, :author 119 | include_dup_examples 'Comment', :content 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/mobility/backends/active_record/jsonb_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | return unless defined?(ActiveRecord) 4 | 5 | describe "Mobility::Backends::ActiveRecord::Jsonb", orm: :active_record, db: :postgres, type: :backend do 6 | require "mobility/backends/active_record/jsonb" 7 | 8 | before { stub_const 'JsonbPost', Class.new(ActiveRecord::Base) } 9 | 10 | column_options = { column_prefix: 'my_', column_suffix: '_i18n' } 11 | column_affix = "#{column_options[:column_prefix]}%s#{column_options[:column_suffix]}" 12 | 13 | let(:backend) { post.mobility_backends[:title] } 14 | let(:post) { JsonbPost.new } 15 | 16 | context "with no plugins" do 17 | include_backend_examples described_class, 'JsonbPost', column_options 18 | end 19 | 20 | context "with basic plugins" do 21 | plugins :active_record, :reader, :writer 22 | before { translates JsonbPost, :title, :content, backend: :jsonb, **column_options } 23 | 24 | include_accessor_examples 'JsonbPost' 25 | include_serialization_examples 'JsonbPost', column_affix: column_affix 26 | include_dup_examples 'JsonbPost' 27 | include_cache_key_examples 'JsonbPost' 28 | 29 | it "does not impact dirty tracking on original column" do 30 | post = JsonbPost.create! 31 | post.reload 32 | 33 | expect(post.my_title_i18n).to eq({}) 34 | expect(post.changes).to eq({}) 35 | end 36 | end 37 | 38 | context "with query plugin" do 39 | plugins :active_record, :reader, :writer, :query 40 | before { translates JsonbPost, :title, :content, backend: :jsonb, **column_options } 41 | 42 | include_querying_examples 'JsonbPost' 43 | include_validation_examples 'JsonbPost' 44 | 45 | it "uses existence operator instead of NULL match" do 46 | aggregate_failures do 47 | expect(JsonbPost.i18n.where(title: nil).to_sql).to match /\?/ 48 | expect(JsonbPost.i18n.where(title: nil).to_sql).not_to match /NULL/ 49 | end 50 | end 51 | 52 | it "treats array of nils like nil" do 53 | expect(JsonbPost.i18n.where(title: nil).to_sql).to eq(JsonbPost.i18n.where(title: [nil]).to_sql) 54 | end 55 | 56 | describe "non-text values" do 57 | it "stores non-string types as-is when saving" do 58 | backend = post.mobility_backends[:title] 59 | backend.write(:en, { foo: :bar } ) 60 | post.save 61 | expect(post[column_affix % "title"]).to eq({ "en" => { "foo" => "bar" }}) 62 | end 63 | 64 | shared_examples_for "jsonb translated value" do |name, value| 65 | it "stores #{name} values" do 66 | post.title = value 67 | expect(post.title).to eq(value) 68 | post.save 69 | 70 | post = JsonbPost.last 71 | expect(post.title).to eq(value) 72 | end 73 | 74 | it "queries on #{name} values" do 75 | post1 = JsonbPost.create(title: "foo") 76 | post2 = JsonbPost.create(title: value) 77 | 78 | expect(JsonbPost.i18n.find_by(title: "foo")).to eq(post1) 79 | 80 | value = [value] if Array === value 81 | expect(JsonbPost.i18n.find_by(title: value)).to eq(post2) 82 | 83 | # Only use ->> operator when matching strings 84 | expect(JsonbPost.i18n.where(title: value).to_sql).not_to match("->>") 85 | end 86 | 87 | it "uses -> operator when in a predicate with other jsonb column" do 88 | expect(JsonbPost.i18n { title.eq(content) }.to_sql).not_to match("->>") 89 | end 90 | end 91 | 92 | it_behaves_like "jsonb translated value", :integer, 1 93 | it_behaves_like "jsonb translated value", :hash, { "a" => "b" } do 94 | before { JsonbPost.create(title: { "a" => "b", "c" => "d" }) } 95 | end 96 | it_behaves_like "jsonb translated value", :array, [1, "a", nil] 97 | end 98 | end 99 | 100 | context "with dirty plugin applied" do 101 | plugins :active_record, :reader, :writer, :dirty 102 | before { translates JsonbPost, :title, :content, backend: :jsonb, **column_options } 103 | 104 | include_accessor_examples 'JsonbPost' 105 | include_serialization_examples 'JsonbPost', column_affix: column_affix 106 | end 107 | end 108 | --------------------------------------------------------------------------------