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 16, 2019
0 parents commit 3689fe2
Show file tree
Hide file tree
Showing 39 changed files with 781 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
24 changes: 24 additions & 0 deletions .travis.yml
@@ -0,0 +1,24 @@
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
allow_failures:
- rvm: ruby-head
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.
87 changes: 87 additions & 0 deletions README.md
@@ -0,0 +1,87 @@
[![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.

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
```

## Usage

Start with creating a class for representing the hash as an object:

```ruby
class Configuration
include StoreModel::Model

attribute :model, :string
attribute :color, :string

validate :color, presence: true
end
```

This gem uses [Attributes API](https://api.rubyonrails.org/classes/ActiveRecord/Attributes/ClassMethods.html) for defining attributes. There is a number of types available out of the box, and you can always extend the type system with your own ones. Regular ActiveModel validations are supported.

Register the field in the ActiveRecord model class:

```ruby
class Product < ApplicationRecord
has_stored :configuration
end
```

And we're done! Class is guessed by the name of the field according to the Convention over configuration principle, but you can always override the class name:

```ruby
class Product < ApplicationRecord
has_stored :config, class_name: :configuration
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: ".."
8 changes: 8 additions & 0 deletions lib/store_model.rb
@@ -0,0 +1,8 @@
# frozen_string_literal: true

require "store_model/model"
require "store_model/railtie"

module StoreModel
# Your code goes here...
end
33 changes: 33 additions & 0 deletions lib/store_model/dsl.rb
@@ -0,0 +1,33 @@
# frozen_string_literal: true

require "store_model/json_model_type"

module StoreModel
module DSL
# rubocop:disable Naming/PredicateName
def has_stored(name, class_name: nil, nested_errors: true)
attribute_class_name = class_name || name
attribute(name, attribute_classes[attribute_class_name])
validates_associated(name)
copy_errors_to_base << name unless nested_errors
end
# rubocop:enable Naming/PredicateName

def copy_errors_to_base
@copy_errors_to_base ||= []
end

private

def attribute_classes
@attribute_classes ||= Hash.new do |hash, key|
store_model_klass = lookup_store_model_class(key)
hash[key] = JsonModelType.new(store_model_klass)
end
end

def lookup_store_model_class(name)
name.to_s.camelize.constantize
end
end
end
29 changes: 29 additions & 0 deletions lib/store_model/errors.rb
@@ -0,0 +1,29 @@
# frozen_string_literal: true

require "store_model/json_model_type"

module StoreModel
module Errors
def self.included(base)
base.after_validation :handle_store_model_errors
end

private

def handle_store_model_errors
store_model_attributes.each do |name, attribute|
errors.delete(name)

move_to_base = self.class.copy_errors_to_base.include?(name.to_sym)

attribute.errors.each do |field, err|
move_to_base ? errors.add(field, err) : errors.add(name, field => [err])
end
end
end

def store_model_attributes
attributes.select { |name| attributes[name].is_a?(Model) }
end
end
end
42 changes: 42 additions & 0 deletions 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
30 changes: 30 additions & 0 deletions lib/store_model/model.rb
@@ -0,0 +1,30 @@
# frozen_string_literal: true

module StoreModel
module Model
def self.included(base)
base.include ActiveModel::Model
base.include ActiveModel::Attributes
base.include ActiveModel::Validations
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

def blank?
attributes.values.all?(&:blank?)
end

def inspect
attribute_string = attributes.map { |name, value| "#{name}: #{value}" }.join(" ")
"#<#{self.class.name} #{attribute_string}>"
end
end
end

0 comments on commit 3689fe2

Please sign in to comment.