From 2637986e597f9b0530bedebd98126610d33a6351 Mon Sep 17 00:00:00 2001 From: DmitryTsepelev Date: Tue, 12 Feb 2019 17:16:00 +0300 Subject: [PATCH] Version 0.1.0 --- .coveralls.yml | 1 + .gitignore | 9 + .rubocop.yml | 29 +++ .travis.yml | 29 +++ CHANGELOG.md | 9 + Gemfile | 14 ++ MIT-LICENSE | 20 ++ README.md | 181 ++++++++++++++++++ Rakefile | 8 + bin/test | 5 + gemfiles/rails_4_2.gemfile | 6 + gemfiles/rails_5_0.gemfile | 6 + gemfiles/rails_5_1.gemfile | 6 + gemfiles/rails_5_2.gemfile | 6 + gemfiles/railsmaster.gemfile | 6 + .../validations/store_model_validator.rb | 29 +++ lib/store_model.rb | 13 ++ lib/store_model/combine_errors_strategies.rb | 25 +++ .../mark_invalid_error_strategy.rb | 11 ++ .../merge_error_strategy.rb | 11 ++ lib/store_model/configuration.rb | 12 ++ lib/store_model/json_model_type.rb | 42 ++++ lib/store_model/model.rb | 38 ++++ lib/store_model/version.rb | 5 + .../validations/store_model_validator_spec.rb | 28 +++ spec/dummy/app/models/settings.rb | 10 + spec/dummy/app/models/user.rb | 4 + .../app/store_model/fhtagn_error_strategy.rb | 7 + spec/dummy/config.ru | 0 spec/dummy/config/application.rb | 15 ++ spec/dummy/config/boot.rb | 7 + spec/dummy/config/database.yml | 25 +++ spec/dummy/config/environment.rb | 7 + spec/dummy/config/environments/test.rb | 4 + spec/dummy/config/routes.rb | 4 + spec/dummy/config/secrets.yml | 22 +++ spec/dummy/db/schema.rb | 7 + spec/spec_helper.rb | 45 +++++ .../mark_invalid_error_strategy_spec.rb | 28 +++ .../merge_error_strategy_spec.rb | 28 +++ .../combine_error_strategies_spec.rb | 46 +++++ spec/store_model/json_model_type_spec.rb | 74 +++++++ spec/store_model/model_spec.rb | 81 ++++++++ store_model.gemspec | 27 +++ 44 files changed, 990 insertions(+) create mode 100644 .coveralls.yml create mode 100644 .gitignore create mode 100644 .rubocop.yml create mode 100644 .travis.yml create mode 100644 CHANGELOG.md create mode 100644 Gemfile create mode 100644 MIT-LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100755 bin/test create mode 100644 gemfiles/rails_4_2.gemfile create mode 100644 gemfiles/rails_5_0.gemfile create mode 100644 gemfiles/rails_5_1.gemfile create mode 100644 gemfiles/rails_5_2.gemfile create mode 100644 gemfiles/railsmaster.gemfile create mode 100644 lib/active_model/validations/store_model_validator.rb create mode 100644 lib/store_model.rb create mode 100644 lib/store_model/combine_errors_strategies.rb create mode 100644 lib/store_model/combine_errors_strategies/mark_invalid_error_strategy.rb create mode 100644 lib/store_model/combine_errors_strategies/merge_error_strategy.rb create mode 100644 lib/store_model/configuration.rb create mode 100644 lib/store_model/json_model_type.rb create mode 100644 lib/store_model/model.rb create mode 100644 lib/store_model/version.rb create mode 100644 spec/active_model/validations/store_model_validator_spec.rb create mode 100644 spec/dummy/app/models/settings.rb create mode 100644 spec/dummy/app/models/user.rb create mode 100644 spec/dummy/app/store_model/fhtagn_error_strategy.rb create mode 100644 spec/dummy/config.ru create mode 100644 spec/dummy/config/application.rb create mode 100644 spec/dummy/config/boot.rb create mode 100644 spec/dummy/config/database.yml create mode 100644 spec/dummy/config/environment.rb create mode 100644 spec/dummy/config/environments/test.rb create mode 100644 spec/dummy/config/routes.rb create mode 100644 spec/dummy/config/secrets.yml create mode 100644 spec/dummy/db/schema.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/store_model/combine_error_strategies/mark_invalid_error_strategy_spec.rb create mode 100644 spec/store_model/combine_error_strategies/merge_error_strategy_spec.rb create mode 100644 spec/store_model/combine_error_strategies_spec.rb create mode 100644 spec/store_model/json_model_type_spec.rb create mode 100644 spec/store_model/model_spec.rb create mode 100644 store_model.gemspec diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 0000000..9160059 --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1 @@ +service_name: travis-ci diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afc9a19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.bundle/ +log/*.log +pkg/ +test/dummy/db/*.sqlite3 +test/dummy/db/*.sqlite3-journal +test/dummy/log/*.log +test/dummy/tmp/ +Gemfile.lock +coverage/ diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..6523e2c --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,29 @@ +AllCops: + TargetRubyVersion: 2.5 + Include: + - 'lib/**/*.rb' + - 'spec/**/*.rb' + Exclude: + - 'bin/**/*' + - 'vendor/**/*' + - 'gemfiles/**/*.gemfile' + - 'gemfiles/vendor/**/*' + - 'Rakefile' + - 'Gemfile' + - '*.gemspec' + +Documentation: + Enabled: false + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Metrics/LineLength: + Max: 100 + +Metrics/BlockLength: + Exclude: + - 'spec/**/*.rb' + +Style/NumericLiterals: + Enabled: false diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d6fe78f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,29 @@ +sudo: false +language: ruby +cache: +- bundler +rvm: + - 2.3 + - 2.4 + - 2.5 + - 2.6 + - ruby-head +gemfile: + # - gemfiles/rails_4_2.gemfile + # - gemfiles/rails_5_0.gemfile + # - gemfiles/rails_5_1.gemfile + - gemfiles/rails_5_2.gemfile + - gemfiles/railsmaster.gemfile + +notifications: + email: false + +matrix: + fast_finish: true + exclude: + - rvm: 2.3 + gemfile: gemfiles/railsmaster.gemfile + - rvm: 2.4 + gemfile: gemfiles/railsmaster.gemfile + allow_failures: + - rvm: ruby-head diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dd45411 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Change log + +## master + +## 0.1.0 (2019-02-20) + +- Initial version. ([@DmitryTsepelev][]) + +[@DmitryTsepelev]: https://github.com/DmitryTsepelev diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..2002693 --- /dev/null +++ b/Gemfile @@ -0,0 +1,14 @@ +source "https://rubygems.org" +git_source(:github) { |repo| "https://github.com/#{repo}.git" } + +gemspec + +gem "sqlite3", "~> 1.3.6" + +local_gemfile = File.join(__dir__, "Gemfile.local") + +if File.exist?(local_gemfile) + eval(File.read(local_gemfile)) # rubocop:disable Security/Eval +else + gem "activerecord", "~> 5.0" +end diff --git a/MIT-LICENSE b/MIT-LICENSE new file mode 100644 index 0000000..5c8d66c --- /dev/null +++ b/MIT-LICENSE @@ -0,0 +1,20 @@ +Copyright 2019 DmitryTsepelev + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a18e435 --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# Disclaimer + +**This gem hasn't been released yet! I'm going to force push a lot, be careful** + +[![Gem Version](https://badge.fury.io/rb/store_model.svg)](https://rubygems.org/gems/store_model) +[![Build Status](https://travis-ci.org/DmitryTsepelev/store_model.svg?branch=master)](https://travis-ci.org/DmitryTsepelev/store_model) +[![Coverage Status](https://coveralls.io/repos/github/DmitryTsepelev/store_model/badge.svg?branch=master)](https://coveralls.io/github/DmitryTsepelev/store_model?branch=master) + +# StoreModel + + +Sponsored by Evil Martians + +StoreModel allows to work with JSON-backed database columns in a similar way we work with ActiveRecord models. Supports Ruby >= 2.3 and Rails >= 5.2. + +For instance, imagine that you have a model `Product` with a `jsonb` column called `configuration`. Your usual workflow probably looks like: + +```ruby +product = Product.find(params[:id]) +if product.configuration["model"] = "spaceship" + product.configuration["color"] = "red" +end +product.save +``` + +This approach works fine when you don't have a lot of keys with logic around them and just read the data. However, when you start working with that data more intensively (for instance, adding some validations around it) - you may find the code a bit verbose and error-prone. With this gem, the snipped above could be rewritten this way: + +```ruby +product = Product.find(params[:id]) +if product.configuration.model = "spaceship" + product.configuration.color = "red" +end +product.save +``` + +> **Note**: if you want to work with JSON fields as an attributes, defined on the ActiveRecord model (not in the separate class) - consider using [store_attribute](https://github.com/palkan/store_attribute) or [jsonb_accessor](https://github.com/devmynd/jsonb_accessor). + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'store_model' +``` + +And then execute: +```bash +$ bundle +``` + +Or install it yourself as: +```bash +$ gem install store_model +``` + +## How to register stored model + +Start with creating a class for representing the hash as an object: + +```ruby +class Configuration + include StoreModel::Model + + attribute :model, :string + attribute :color, :string +end +``` + +Attributes shoould be defined using [Rails Attributes API](https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html). There is a number of types available out of the box, and you can always extend the type system with your own ones. + +Register the field in the ActiveRecord model class: + +```ruby +class Product < ApplicationRecord + attribute :configuration, Configuration.to_type +end +``` + +## Validations + +`StoreModel` supports all the validations shipped with `ActiveModel`. Start with defining validation for the store model: + +```ruby +class Configuration + include StoreModel::Model + + attribute :model, :string + attribute :color, :string + + validate :color, presence: true +end +``` + +Then, configure your ActiveRecord model to validate this field as a store model: + +```ruby +class Product < ApplicationRecord + attribute :configuration, Configuration.to_type + + validate :configuration, store_model: true +end +``` + +When attribute is invalid, errors are not copied to the parent model by default: + +```ruby +product = Product.new +puts product.valid? # => false +puts product.errors.messages # => { settings: ["is invalid"] } +puts product.configuration.errors.messages # => { color: ["can't be blank"] } +``` + +You can change this behavior to have these errors on the root level (instead of `["is invalid"]`): + +```ruby +class Product < ApplicationRecord + attribute :configuration, Configuration.to_type + + validate :configuration, store_model: { merge_errors: true } +end +``` + +In this case errors look this way: + +```ruby +product = Product.new +puts product.valid? # => false +puts product.errors.messages # => { color: ["can't be blank"] } +``` + +You can change the global behavior using `StoreModel.config`: + +```ruby +StoreModel.config.merge_errors = true +``` + +You can also add your own custom strategies to handle errors. All you need to do is to provide a callable object to `StoreModel.config.merge_errors` or as value of `:merge_errors`. It should accept three arguments - _attribute_, _base_errors_ and _store_model_errors_: + +```ruby +StoreModel.config.merge_errors = lambda do |attribute, base_errors, _store_model_errors| do + base_errors.add(attribute, "cthulhu fhtagn") +end +``` + +If the logic is complex enough - it worth defining a separate class with a `#call` method: + +```ruby +class FhtagnErrorStrategy + def call(attribute, base_errors, _store_model_errors) + base_errors.add(attribute, "cthulhu fhtagn") + end +end +``` + +You can provide its instance or snake-cased name when configuring global `merge_errors`: + +```ruby +StoreModel.config.merge_errors = :fhtagn_error_strategy + +class Product < ApplicationRecord + attribute :configuration, Configuration.to_type + + validate :configuration, store_model: { merge_errors: :fhtagn_error_strategy } +end +``` + +or when calling `validate` method on a class level: + +```ruby +StoreModel.config.merge_errors = FhtagnErrorStrategy.new + +class Product < ApplicationRecord + attribute :configuration, Configuration.to_type + + validate :configuration, store_model: { merge_errors: FhtagnErrorStrategy.new } +end +``` + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..c749b00 --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +require "bundler/gem_tasks" +require "rspec/core/rake_task" +require "rubocop/rake_task" + +RSpec::Core::RakeTask.new(:spec) +RuboCop::RakeTask.new + +task default: [:rubocop, :spec] diff --git a/bin/test b/bin/test new file mode 100755 index 0000000..5516a12 --- /dev/null +++ b/bin/test @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +$: << File.expand_path("../test", __dir__) + +require "bundler/setup" +require "rails/plugin/test" diff --git a/gemfiles/rails_4_2.gemfile b/gemfiles/rails_4_2.gemfile new file mode 100644 index 0000000..635a038 --- /dev/null +++ b/gemfiles/rails_4_2.gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "sqlite3" +gem "activerecord", "~> 4.2.0" + +gemspec path: "../" diff --git a/gemfiles/rails_5_0.gemfile b/gemfiles/rails_5_0.gemfile new file mode 100644 index 0000000..53b3f58 --- /dev/null +++ b/gemfiles/rails_5_0.gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "sqlite3" +gem "activerecord", "~> 5.0.0" + +gemspec path: "../" diff --git a/gemfiles/rails_5_1.gemfile b/gemfiles/rails_5_1.gemfile new file mode 100644 index 0000000..3af5790 --- /dev/null +++ b/gemfiles/rails_5_1.gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "sqlite3" +gem "activerecord", "~> 5.1.0" + +gemspec path: "../" diff --git a/gemfiles/rails_5_2.gemfile b/gemfiles/rails_5_2.gemfile new file mode 100644 index 0000000..fb8292a --- /dev/null +++ b/gemfiles/rails_5_2.gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "sqlite3", "~> 1.3.6" +gem "activerecord", "~> 5.2.0" + +gemspec path: "../" diff --git a/gemfiles/railsmaster.gemfile b/gemfiles/railsmaster.gemfile new file mode 100644 index 0000000..e49a3a4 --- /dev/null +++ b/gemfiles/railsmaster.gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "rails", github: "rails/rails" +gem "sqlite3", "~> 1.3.6" + +gemspec path: ".." diff --git a/lib/active_model/validations/store_model_validator.rb b/lib/active_model/validations/store_model_validator.rb new file mode 100644 index 0000000..24857a0 --- /dev/null +++ b/lib/active_model/validations/store_model_validator.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "active_record" +require "store_model/combine_errors_strategies" + +module ActiveModel + module Validations + class StoreModelValidator < ActiveModel::Validator + def validate(record) + options[:attributes].each do |attribute| + attribute_value = record.send(attribute) + combine_errors(record, attribute) unless attribute_value.validate + end + end + + private + + def combine_errors(record, attribute) + base_errors = record.errors + store_model_errors = record.send(attribute).errors + + base_errors.delete(attribute) + + strategy = StoreModel::CombileErrorsStrategies.configure(options) + strategy.call(attribute, base_errors, store_model_errors) + end + end + end +end diff --git a/lib/store_model.rb b/lib/store_model.rb new file mode 100644 index 0000000..c7fdb21 --- /dev/null +++ b/lib/store_model.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "store_model/model" +require "store_model/configuration" +require "active_model/validations/store_model_validator" + +module StoreModel + class << self + def config + @config ||= Configuration.new + end + end +end diff --git a/lib/store_model/combine_errors_strategies.rb b/lib/store_model/combine_errors_strategies.rb new file mode 100644 index 0000000..90531a1 --- /dev/null +++ b/lib/store_model/combine_errors_strategies.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "store_model/combine_errors_strategies/mark_invalid_error_strategy" +require "store_model/combine_errors_strategies/merge_error_strategy" + +module StoreModel + module CombileErrorsStrategies + module_function + + # Finds a strategy based on options and global config + def configure(options) + configured_strategy = options[:merge_errors] || StoreModel.config.merge_errors + + if configured_strategy.respond_to?(:call) + configured_strategy + elsif configured_strategy == true + StoreModel::CombileErrorsStrategies::MergeErrorStrategy.new + elsif configured_strategy.nil? + StoreModel::CombileErrorsStrategies::MarkInvalidErrorStrategy.new + else + const_get(configured_strategy.to_s.camelize).new + end + end + end +end diff --git a/lib/store_model/combine_errors_strategies/mark_invalid_error_strategy.rb b/lib/store_model/combine_errors_strategies/mark_invalid_error_strategy.rb new file mode 100644 index 0000000..566b4c2 --- /dev/null +++ b/lib/store_model/combine_errors_strategies/mark_invalid_error_strategy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module StoreModel + module CombileErrorsStrategies + class MarkInvalidErrorStrategy + def call(attribute, base_errors, _store_model_errors) + base_errors.add(attribute, I18n.translate("invalid", scope: "errors.messages")) + end + end + end +end diff --git a/lib/store_model/combine_errors_strategies/merge_error_strategy.rb b/lib/store_model/combine_errors_strategies/merge_error_strategy.rb new file mode 100644 index 0000000..0d95adc --- /dev/null +++ b/lib/store_model/combine_errors_strategies/merge_error_strategy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module StoreModel + module CombileErrorsStrategies + class MergeErrorStrategy + def call(_attribute, base_errors, store_model_errors) + base_errors.copy!(store_model_errors) + end + end + end +end diff --git a/lib/store_model/configuration.rb b/lib/store_model/configuration.rb new file mode 100644 index 0000000..e474b29 --- /dev/null +++ b/lib/store_model/configuration.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module StoreModel + # StoreModel configuration: + # + # - `merge_errors` - set up to `true` to merge errors or specify your + # own strategy + # + class Configuration + attr_accessor :merge_errors + end +end diff --git a/lib/store_model/json_model_type.rb b/lib/store_model/json_model_type.rb new file mode 100644 index 0000000..e3ba684 --- /dev/null +++ b/lib/store_model/json_model_type.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "active_model" + +module StoreModel + class JsonModelType < ActiveModel::Type::Value + def initialize(model_klass) + @model_klass = model_klass + end + + def type + :json + end + + # rubocop:disable Style/RescueModifier + def cast_value(value) + case value + when String + decoded = ActiveSupport::JSON.decode(value) rescue nil + @model_klass.new(decoded) unless decoded.nil? + when Hash + @model_klass.new(value) + when @model_klass + value + end + end + # rubocop:enable Style/RescueModifier + + def serialize(value) + case value + when Hash, @model_klass + ActiveSupport::JSON.encode(value) + else + super + end + end + + def changed_in_place?(raw_old_value, new_value) + cast_value(raw_old_value) != new_value + end + end +end diff --git a/lib/store_model/model.rb b/lib/store_model/model.rb new file mode 100644 index 0000000..9a072fe --- /dev/null +++ b/lib/store_model/model.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "store_model/json_model_type" + +module StoreModel + module Model + def self.included(base) + base.include ActiveModel::Model + base.include ActiveModel::Attributes + + base.extend(Module.new do + def to_type + JsonModelType.new(self) + end + end) + end + + def as_json(options = {}) + attributes.with_indifferent_access.as_json(options) + end + + def ==(other) + return super unless other.is_a?(self.class) + + attributes.all? { |name, value| value == other.send(name) } + end + + # Allows to call :presence validation on the association itself + def blank? + attributes.values.all?(&:blank?) + end + + def inspect + attribute_string = attributes.map { |name, value| "#{name}: #{value || 'nil'}" }.join(", ") + "#<#{self.class.name} #{attribute_string}>" + end + end +end diff --git a/lib/store_model/version.rb b/lib/store_model/version.rb new file mode 100644 index 0000000..5cc7683 --- /dev/null +++ b/lib/store_model/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module StoreModel + VERSION = "0.1.0" +end diff --git a/spec/active_model/validations/store_model_validator_spec.rb b/spec/active_model/validations/store_model_validator_spec.rb new file mode 100644 index 0000000..3977a9d --- /dev/null +++ b/spec/active_model/validations/store_model_validator_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ActiveModel::Validations::StoreModelValidator do + let(:custom_user_class) do + build_custom_user_class do + attribute :settings, Settings.to_type + validates :settings, store_model: true + end + end + + subject do + user = custom_user_class.new + user.valid? + user + end + + it { is_expected.not_to be_valid } + + it "returns errors inside nested object" do + expect(subject.errors.messages).to eq(settings: ["is invalid"]) + expect(subject.errors.full_messages).to eq(["Settings is invalid"]) + + expect(subject.settings.errors.messages).to eq(locale: ["can't be blank"]) + expect(subject.settings.errors.full_messages).to eq(["Locale can't be blank"]) + end +end diff --git a/spec/dummy/app/models/settings.rb b/spec/dummy/app/models/settings.rb new file mode 100644 index 0000000..cdca839 --- /dev/null +++ b/spec/dummy/app/models/settings.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Settings + include StoreModel::Model + + attribute :locale, :string + attribute :disabled_at, :datetime + + validates :locale, presence: true +end diff --git a/spec/dummy/app/models/user.rb b/spec/dummy/app/models/user.rb new file mode 100644 index 0000000..b2c40d1 --- /dev/null +++ b/spec/dummy/app/models/user.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class User < ActiveRecord::Base +end diff --git a/spec/dummy/app/store_model/fhtagn_error_strategy.rb b/spec/dummy/app/store_model/fhtagn_error_strategy.rb new file mode 100644 index 0000000..3153145 --- /dev/null +++ b/spec/dummy/app/store_model/fhtagn_error_strategy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class FhtagnErrorStrategy + def call(attribute, base_errors, _store_model_errors) + base_errors.add(attribute, "cthulhu fhtagn") + end +end diff --git a/spec/dummy/config.ru b/spec/dummy/config.ru new file mode 100644 index 0000000..e69de29 diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb new file mode 100644 index 0000000..cf2df36 --- /dev/null +++ b/spec/dummy/config/application.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require File.expand_path("boot", __dir__) + +require "rails" +require "action_controller/railtie" + +Bundler.require(*Rails.groups) + +module Dummy + class Application < Rails::Application + config.logger = Logger.new("/dev/null") + config.eager_load = false + end +end diff --git a/spec/dummy/config/boot.rb b/spec/dummy/config/boot.rb new file mode 100644 index 0000000..59459d4 --- /dev/null +++ b/spec/dummy/config/boot.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Set up gems listed in the Gemfile. +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) + +require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) +$LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) diff --git a/spec/dummy/config/database.yml b/spec/dummy/config/database.yml new file mode 100644 index 0000000..1c1a37c --- /dev/null +++ b/spec/dummy/config/database.yml @@ -0,0 +1,25 @@ +# SQLite version 3.x +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem 'sqlite3' +# +default: &default + adapter: sqlite3 + pool: 5 + timeout: 5000 + +development: + <<: *default + database: db/development.sqlite3 + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: db/test.sqlite3 + +production: + <<: *default + database: db/production.sqlite3 diff --git a/spec/dummy/config/environment.rb b/spec/dummy/config/environment.rb new file mode 100644 index 0000000..78973b1 --- /dev/null +++ b/spec/dummy/config/environment.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Load the Rails application. +require File.expand_path("application", __dir__) + +# Initialize the Rails application. +Dummy::Application.initialize! diff --git a/spec/dummy/config/environments/test.rb b/spec/dummy/config/environments/test.rb new file mode 100644 index 0000000..ca541bb --- /dev/null +++ b/spec/dummy/config/environments/test.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +Dummy::Application.configure do +end diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb new file mode 100644 index 0000000..7f68a3d --- /dev/null +++ b/spec/dummy/config/routes.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +Dummy::Application.routes.draw do +end diff --git a/spec/dummy/config/secrets.yml b/spec/dummy/config/secrets.yml new file mode 100644 index 0000000..fa74b95 --- /dev/null +++ b/spec/dummy/config/secrets.yml @@ -0,0 +1,22 @@ +# Be sure to restart your server when you modify this file. + +# Your secret key is used for verifying the integrity of signed cookies. +# If you change this key, all old signed cookies will become invalid! + +# Make sure the secret is at least 30 characters and all random, +# no regular words or you'll be exposed to dictionary attacks. +# You can use `rake secret` to generate a secure secret key. + +# Make sure the secrets in this file are kept private +# if you're sharing your code publicly. + +development: + secret_key_base: a6a03acbab7b7658ca0b82967eabfc3f2954a81db06614bbbd7ecbf5a8ef27d2bbda85fc87d71b0475c5d711d6b096fa3f0d9dfbe44bb671841c4e1396b8a9e5 + +test: + secret_key_base: b6d9c9ed45db8aa6336d82dd12332915592299b519f8a4f34045352a9e908409fe057335ee8400df2298ad0ee4e304e7630f52d391bad8b89449bfad62010930 + +# Do not keep production secrets in the repository, +# instead read values from the environment. +production: + secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb new file mode 100644 index 0000000..2723938 --- /dev/null +++ b/spec/dummy/db/schema.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +ActiveRecord::Schema.define(version: 2019_02_216_153105) do + create_table :users, id: :serial do |t| + t.json :settings, default: {} + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..27c5a59 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "coveralls" +Coveralls.wear! + +require_relative "dummy/config/environment" + +require "active_record" +require "store_model" + +ENV["RAILS_ENV"] = "test" + +RSpec.configure do |config| + config.order = :random + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.formatter = :documentation + config.color = true + + config.after(:each) do + StoreModel.remove_instance_variable(:@config) if StoreModel.instance_variable_defined?(:@config) + end +end + +ActiveRecord::Base.establish_connection( + adapter: "sqlite3", + database: ":memory:" +) + +load "#{Rails.root}/db/schema.rb" + +def build_custom_user_class(&block) + klass = Class.new(User) do + def self.model_name + ActiveModel::Name.new(self, nil, "user") + end + end + + klass.instance_eval(&block) + + klass +end diff --git a/spec/store_model/combine_error_strategies/mark_invalid_error_strategy_spec.rb b/spec/store_model/combine_error_strategies/mark_invalid_error_strategy_spec.rb new file mode 100644 index 0000000..ed347be --- /dev/null +++ b/spec/store_model/combine_error_strategies/mark_invalid_error_strategy_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe StoreModel::CombileErrorsStrategies::MarkInvalidErrorStrategy do + let(:custom_user_class) do + build_custom_user_class do + attribute :settings, Settings.to_type + validates :settings, store_model: true + end + end + + let(:record) do + user = custom_user_class.new + user.settings.validate + user + end + + it "adds message that associated object is invalid" do + described_class.new.call(:settings, record.errors, record.settings.errors) + + expect(record.errors.messages).to eq(settings: ["is invalid"]) + expect(record.errors.full_messages).to eq(["Settings is invalid"]) + + expect(record.settings.errors.messages).to eq(locale: ["can't be blank"]) + expect(record.settings.errors.full_messages).to eq(["Locale can't be blank"]) + end +end diff --git a/spec/store_model/combine_error_strategies/merge_error_strategy_spec.rb b/spec/store_model/combine_error_strategies/merge_error_strategy_spec.rb new file mode 100644 index 0000000..d945445 --- /dev/null +++ b/spec/store_model/combine_error_strategies/merge_error_strategy_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe StoreModel::CombileErrorsStrategies::MergeErrorStrategy do + let(:custom_user_class) do + build_custom_user_class do + attribute :settings, Settings.to_type + validates :settings, store_model: true + end + end + + let(:record) do + user = custom_user_class.new + user.settings.validate + user + end + + it "adds message that associated object is invalid" do + described_class.new.call(:settings, record.errors, record.settings.errors) + + expect(record.errors.messages).to eq(locale: ["can't be blank"]) + expect(record.errors.full_messages).to eq(["Locale can't be blank"]) + + expect(record.settings.errors.messages).to eq(locale: ["can't be blank"]) + expect(record.settings.errors.full_messages).to eq(["Locale can't be blank"]) + end +end diff --git a/spec/store_model/combine_error_strategies_spec.rb b/spec/store_model/combine_error_strategies_spec.rb new file mode 100644 index 0000000..c901dc9 --- /dev/null +++ b/spec/store_model/combine_error_strategies_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe StoreModel::CombileErrorsStrategies do + describe ".configure" do + let(:user) { User.new } + + LAMBDA_FHTAGN_STRATEGY = + lambda do |attribute, base_errors, _store_model_errors| + base_errors.add(attribute, "cthulhu fhtagn") + end + + subject { described_class.configure(options) } + + context "when empty hash is passed" do + let(:options) { {} } + + it { is_expected.to be_a(StoreModel::CombileErrorsStrategies::MarkInvalidErrorStrategy) } + end + + context "when true is passed" do + let(:options) { { merge_errors: true } } + + it { is_expected.to be_a(StoreModel::CombileErrorsStrategies::MergeErrorStrategy) } + end + + context "when custom strategy class name is passed" do + let(:options) { { merge_errors: :fhtagn_error_strategy } } + + it { is_expected.to be_a(FhtagnErrorStrategy) } + end + + context "when instance of custom strategy class is passed" do + let(:options) { { merge_errors: FhtagnErrorStrategy.new } } + + it { is_expected.to be_a(FhtagnErrorStrategy) } + end + + context "when labmda is passed" do + let(:options) { { merge_errors: LAMBDA_FHTAGN_STRATEGY } } + + it { is_expected.to eq(LAMBDA_FHTAGN_STRATEGY) } + end + end +end diff --git a/spec/store_model/json_model_type_spec.rb b/spec/store_model/json_model_type_spec.rb new file mode 100644 index 0000000..4570b80 --- /dev/null +++ b/spec/store_model/json_model_type_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe StoreModel::JsonModelType do + let(:attributes) do + { + locale: "hello", + disabled_at: Time.new(2019, 2, 22, 12, 30) + } + end + + let(:type) { StoreModel::JsonModelType.new(Settings) } + + describe "#type" do + subject { type.type } + + it { is_expected.to eq(:json) } + end + + describe "#cast_value" do + shared_examples "cast examples" do + subject { type.cast_value(value) } + + it { is_expected.to be_a(Settings) } + it("assigns attributes") { is_expected.to have_attributes(attributes) } + end + + context "when String is passed" do + let(:value) { attributes.as_json } + include_examples "cast examples" + end + + context "when Hash is passed" do + let(:value) { attributes } + include_examples "cast examples" + end + + context "when Settings instance is passed" do + let(:value) { Settings.new(attributes) } + include_examples "cast examples" + end + end + + describe "#serialize" do + shared_examples "serialize examples" do + subject { type.serialize(value) } + + it { is_expected.to be_a(String) } + it("is equal to attributes") { is_expected.to eq(attributes.to_json) } + end + + context "when Hash is passed" do + let(:value) { attributes } + include_examples "serialize examples" + end + + context "when String is passed" do + let(:value) { attributes.as_json } + include_examples "serialize examples" + end + + context "when Settings instance is passed" do + let(:value) { Settings.new(attributes) } + include_examples "serialize examples" + end + end + + describe "#changed_in_place?" do + it "marks object as changed" do + expect(type.changed_in_place?({}, Settings.new(locale: "change"))).to be_truthy + end + end +end diff --git a/spec/store_model/model_spec.rb b/spec/store_model/model_spec.rb new file mode 100644 index 0000000..7894ab9 --- /dev/null +++ b/spec/store_model/model_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe StoreModel::Model do + let(:attributes) do + { locale: "en", disabled_at: Time.new(2019, 2, 10, 12) } + end + + describe "#initialize" do + context "when symbolized hash is passed" do + subject { Settings.new(attributes) } + + it("assigns attrbutes") { is_expected.to have_attributes(attributes) } + end + + context "when stringified hash is passed" do + subject { Settings.new(attributes.stringify_keys) } + + it("assigns attrbutes") { is_expected.to have_attributes(attributes) } + end + end + + describe "#as_json" do + let(:instance) { Settings.new(attributes) } + + subject { instance.as_json } + + it("returns correct JSON") { is_expected.to eq(attributes.as_json) } + + context "with only" do + subject { instance.as_json(only: %i[locale]) } + + it("returns correct JSON") { is_expected.to eq(attributes.slice(:locale).as_json) } + end + end + + describe "#blank?" do + subject { Settings.new(locale: nil).blank? } + + it { is_expected.to be_truthy } + end + + describe "#inspect" do + subject { Settings.new(attributes).inspect } + + it "prints description" do + expect(subject).to eq( + "#" + ) + end + end + + describe "==" do + let(:first_user) { Settings.new(locale: "en") } + + subject { first_user == second_user } + + context "when two instances have same attributes" do + let(:second_user) { Settings.new(locale: "en") } + + it { is_expected.to be_truthy } + end + + context "when two instances have different attributes" do + let(:second_user) { Settings.new(locale: "user") } + + it { is_expected.to be_falsey } + end + end + + describe ".as_type" do + subject { custom_user_class.new } + + let(:custom_user_class) { build_custom_user_class { attribute :settings, Settings.to_type } } + + it "configures type using field name" do + expect(subject.settings).to be_a_kind_of(Settings) + end + end +end diff --git a/store_model.gemspec b/store_model.gemspec new file mode 100644 index 0000000..e55385f --- /dev/null +++ b/store_model.gemspec @@ -0,0 +1,27 @@ +$:.push File.expand_path("lib", __dir__) + +# Maintain your gem's version: +require "store_model/version" + +# Describe your gem and declare its dependencies: +Gem::Specification.new do |spec| + spec.name = "store_model" + spec.version = StoreModel::VERSION + spec.authors = ["DmitryTsepelev"] + spec.email = ["dmitry.a.tsepelev@gmail.com"] + spec.homepage = "https://github.com/DmitryTsepelev/store_model" + spec.summary = "ActiveRecord-like classes for store-based models." + spec.description = "ActiveRecord-like classes for store-based models." + spec.license = "MIT" + + spec.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] + + spec.required_ruby_version = ">= 2.3" + + spec.add_dependency "rails", ">= 4.2" + + spec.add_development_dependency "rspec" + spec.add_development_dependency "rspec-rails" + spec.add_development_dependency "rubocop" + spec.add_development_dependency "coveralls" +end