Skip to content

Commit

Permalink
Version 0.1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
DmitryTsepelev committed Feb 20, 2019
0 parents commit 2637986
Show file tree
Hide file tree
Showing 44 changed files with 990 additions and 0 deletions.
1 change: 1 addition & 0 deletions .coveralls.yml
@@ -0,0 +1 @@
service_name: travis-ci
9 changes: 9 additions & 0 deletions .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/
29 changes: 29 additions & 0 deletions .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
29 changes: 29 additions & 0 deletions .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
9 changes: 9 additions & 0 deletions CHANGELOG.md
@@ -0,0 +1,9 @@
# Change log

## master

## 0.1.0 (2019-02-20)

- Initial version. ([@DmitryTsepelev][])

[@DmitryTsepelev]: https://github.com/DmitryTsepelev
14 changes: 14 additions & 0 deletions 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
20 changes: 20 additions & 0 deletions 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.
181 changes: 181 additions & 0 deletions 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

<a href="https://evilmartians.com/?utm_source=store_model">
<img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>

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).
8 changes: 8 additions & 0 deletions 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]
5 changes: 5 additions & 0 deletions bin/test
@@ -0,0 +1,5 @@
#!/usr/bin/env ruby
$: << File.expand_path("../test", __dir__)

require "bundler/setup"
require "rails/plugin/test"
6 changes: 6 additions & 0 deletions gemfiles/rails_4_2.gemfile
@@ -0,0 +1,6 @@
source "https://rubygems.org"

gem "sqlite3"
gem "activerecord", "~> 4.2.0"

gemspec path: "../"
6 changes: 6 additions & 0 deletions gemfiles/rails_5_0.gemfile
@@ -0,0 +1,6 @@
source "https://rubygems.org"

gem "sqlite3"
gem "activerecord", "~> 5.0.0"

gemspec path: "../"
6 changes: 6 additions & 0 deletions gemfiles/rails_5_1.gemfile
@@ -0,0 +1,6 @@
source "https://rubygems.org"

gem "sqlite3"
gem "activerecord", "~> 5.1.0"

gemspec path: "../"
6 changes: 6 additions & 0 deletions 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: "../"
6 changes: 6 additions & 0 deletions gemfiles/railsmaster.gemfile
@@ -0,0 +1,6 @@
source "https://rubygems.org"

gem "rails", github: "rails/rails"
gem "sqlite3", "~> 1.3.6"

gemspec path: ".."
29 changes: 29 additions & 0 deletions 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
13 changes: 13 additions & 0 deletions 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

0 comments on commit 2637986

Please sign in to comment.