test_fish0 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +174 -0
  4. data/Rakefile +22 -0
  5. data/lib/fish0/collection.rb +21 -0
  6. data/lib/fish0/concerns/base.rb +90 -0
  7. data/lib/fish0/concerns/cacheable.rb +37 -0
  8. data/lib/fish0/concerns/equalable.rb +10 -0
  9. data/lib/fish0/concerns/paginatable.rb +25 -0
  10. data/lib/fish0/concerns/view_model.rb +20 -0
  11. data/lib/fish0/configuration.rb +30 -0
  12. data/lib/fish0/engine.rb +12 -0
  13. data/lib/fish0/exceptions.rb +3 -0
  14. data/lib/fish0/model.rb +10 -0
  15. data/lib/fish0/paginator.rb +55 -0
  16. data/lib/fish0/repository.rb +125 -0
  17. data/lib/fish0/version.rb +3 -0
  18. data/lib/fish0.rb +33 -0
  19. data/spec/dummy/README.rdoc +28 -0
  20. data/spec/dummy/Rakefile +6 -0
  21. data/spec/dummy/app/assets/javascripts/application.js +13 -0
  22. data/spec/dummy/app/assets/stylesheets/application.css +15 -0
  23. data/spec/dummy/app/controllers/application_controller.rb +5 -0
  24. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  25. data/spec/dummy/app/models/article.rb +5 -0
  26. data/spec/dummy/app/models/article_id.rb +7 -0
  27. data/spec/dummy/app/views/layouts/application.html.erb +13 -0
  28. data/spec/dummy/bin/bundle +3 -0
  29. data/spec/dummy/bin/rails +4 -0
  30. data/spec/dummy/bin/rake +4 -0
  31. data/spec/dummy/bin/setup +29 -0
  32. data/spec/dummy/config/application.rb +29 -0
  33. data/spec/dummy/config/boot.rb +5 -0
  34. data/spec/dummy/config/environment.rb +5 -0
  35. data/spec/dummy/config/environments/development.rb +24 -0
  36. data/spec/dummy/config/environments/production.rb +76 -0
  37. data/spec/dummy/config/environments/test.rb +42 -0
  38. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  39. data/spec/dummy/config/initializers/cookies_serializer.rb +3 -0
  40. data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  41. data/spec/dummy/config/initializers/inflections.rb +16 -0
  42. data/spec/dummy/config/initializers/mime_types.rb +4 -0
  43. data/spec/dummy/config/initializers/session_store.rb +3 -0
  44. data/spec/dummy/config/initializers/wrap_parameters.rb +9 -0
  45. data/spec/dummy/config/locales/en.yml +23 -0
  46. data/spec/dummy/config/mongo.yml +7 -0
  47. data/spec/dummy/config/routes.rb +56 -0
  48. data/spec/dummy/config/secrets.yml +22 -0
  49. data/spec/dummy/config.ru +4 -0
  50. data/spec/dummy/public/404.html +67 -0
  51. data/spec/dummy/public/422.html +67 -0
  52. data/spec/dummy/public/500.html +66 -0
  53. data/spec/dummy/public/favicon.ico +0 -0
  54. data/spec/factories/article.rb +6 -0
  55. data/spec/lib/repository_spec.rb +24 -0
  56. data/spec/models/model_spec.rb +13 -0
  57. data/spec/spec_helper.rb +19 -0
  58. data/spec/support/factory_girl.rb +3 -0
  59. data/spec/support/mongo_cleaner.rb +5 -0
  60. data/spec/support/update_behaviour.rb +7 -0
  61. metadata +287 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3dd996ed9f7ffef651e15e2e1ccd9c308aabd0e05b488c5c3e706616b4c2656a
4
+ data.tar.gz: 799b77b1d84372b044b5cd6f338babc3a0e0140e5fb8252e855084a9a18c8db1
5
+ SHA512:
6
+ metadata.gz: 9a97c20d84d27cb4192397852df15fe60d73fdba2727314e5a99e3a008c316467cec5b2b24e94d72b5338e1e94665ed8c61f430302234dbf660c8aacf3ec45cb
7
+ data.tar.gz: 607dc072a84459d44a8c7e4acae0294856e5dfc4ce7174ca7ad5757d673a3e58456c022c750427fe402edf5ddada91a4d9c05bc07c61ec56ec4fde107affec23
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2016 Dmitry Zuev
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # Fish0
2
+
3
+ [![Build Status](https://api.travis-ci.org/rambler-digital-solutions/fish0.svg)](https://travis-ci.org/rambler-digital-solutions/fish0)
4
+ [![Code Climate](https://codeclimate.com/github/rambler-digital-solutions/fish0/badges/gpa.svg)](https://codeclimate.com/github/rambler-digital-solutions/fish0)
5
+ [![Gem Version](https://badge.fury.io/rb/fish0.svg)](https://badge.fury.io/rb/fish0)
6
+
7
+ > The fish doesn't think because the fish knows everything.
8
+
9
+ Fish0 is the plugin for read-only content websites with MongoDB storage. Works perfect with Rambler&Co CQRS architecture.
10
+
11
+ ## Installation
12
+
13
+ Simply add gem to your `Gemfile`
14
+
15
+ ````
16
+ gem 'fish0'
17
+ ````
18
+
19
+ ## Configuration
20
+
21
+ ```ruby
22
+ # config/initializers/fish0.rb
23
+
24
+ Fish0::Configuration.configure do |config|
25
+ config.mongo_uri = 'mongodb://user:password@host_1:27017,replica_host_2:27017/project_db?auth_source=admin'
26
+ config.mongo_params = { read: { mode: :secondary } }
27
+ end
28
+ ```
29
+
30
+ ## Models
31
+
32
+ Inherit your model class from `Fish0::Model` and feel the power of the Fish!
33
+
34
+ With `attribute` define your attributes and with `primary_key` set your main primary key, e.g. `id`, `slug`, etc.
35
+
36
+ ```ruby
37
+ # app/models/article.rb
38
+ class Article < Fish0::Model
39
+ # Define some attributes
40
+ attribute :headline, String
41
+ attribute :slug, String
42
+ attribute :content, Array[Hash]
43
+ attribute :published_at, DateTime
44
+
45
+ primary_key :slug
46
+
47
+ # ...
48
+ end
49
+
50
+ # app/controllers/articles_controller.rb
51
+ class ArticlesController < ApplicationController
52
+ # ...
53
+
54
+ def show
55
+ @article = Article.where(slug: params[:slug]).first
56
+ end
57
+
58
+ # ...
59
+ end
60
+ ```
61
+
62
+ ## Repository
63
+
64
+ ### Basic repository usage
65
+
66
+ This code will get first article with `slug: 'content123'` from `articles` MongoDB collection, and return content with class `Article`.
67
+
68
+ ```ruby
69
+ Fish0::Repository.new(:articles)
70
+ .where(slug: 'content123')
71
+ .first!
72
+ ```
73
+
74
+ By default Fish0::Repository will coerce `:entity_class` from `:collection`, so you can skip this parameter.
75
+
76
+ ### Writing your own repository
77
+
78
+ ```ruby
79
+ # app/services/article_repository.rb
80
+ class ArticleRepository < Fish0::Repository
81
+ def initialize
82
+ super(:articles)
83
+ end
84
+
85
+ def published
86
+ where(visible: true, published_at: { '$lt': DateTime.now })
87
+ end
88
+ end
89
+
90
+ # app/controllers/articles_controller.rb
91
+ class ArticlesController < ApplicationController
92
+ # ...
93
+
94
+ def show
95
+ @article = Article.where(slug: params[:slug]).published.first!
96
+ end
97
+
98
+ # ...
99
+ end
100
+ ```
101
+
102
+ ## Pagination
103
+
104
+ ```ruby
105
+ # app/controllers/articles_controller.rb
106
+ class ArticlesController < ApplicationController
107
+ include Fish0::Concerns::Paginatable
108
+
109
+ def index
110
+ @articles = paginate(Article.published)
111
+ end
112
+
113
+ # ...
114
+
115
+ protected
116
+
117
+ def per_page
118
+ 31
119
+ end
120
+
121
+ # ...
122
+ end
123
+ ```
124
+
125
+ ## ViewModel
126
+
127
+ ViewModel concern wraps Virtus around your models. It also adds `#to_partial_path` and `#type` methods. Method `#to_partial_path` helps render your models via `render` helper.
128
+
129
+
130
+ ```ruby
131
+ # app/models/article.rb
132
+ class Article
133
+ include Fish0::Concerns::ViewModel
134
+
135
+ attribute :headline, String
136
+ attribute :slug, String
137
+ attribute :content, Array[Hash]
138
+ attribute :published_at, DateTime
139
+
140
+ # ...
141
+ end
142
+ ```
143
+
144
+ ## Cacheable
145
+
146
+ If you want your models to support `#cache_key` method and use Rails caching, you should include Fish0::Concerns::Cacheable to such models.
147
+
148
+ Your model should respond to `:updated_at` with DateTime object.
149
+
150
+ ```ruby
151
+ # app/models/article.rb
152
+ class Article
153
+ # ...
154
+ cacheable
155
+
156
+ # ...
157
+ end
158
+
159
+ # app/controllers/articles_controller.rb
160
+ class ArticlesController < ApplicationController
161
+ # ...
162
+
163
+ def show
164
+ @article = Article.where(slug: params[:slug]).first!
165
+ if stale?(@article)
166
+ respond_to do |format|
167
+ format.html
168
+ end
169
+ end
170
+ end
171
+
172
+ # ...
173
+ end
174
+ ```
data/Rakefile ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env rake
2
+
3
+ begin
4
+ require 'bundler/setup'
5
+ rescue LoadError
6
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
7
+ end
8
+
9
+ APP_RAKEFILE = File.expand_path('../spec/dummy/Rakefile', __FILE__)
10
+ load 'rails/tasks/engine.rake'
11
+
12
+ Bundler::GemHelper.install_tasks
13
+
14
+ Dir[File.join(File.dirname(__FILE__), 'tasks/**/*.rake')].each { |f| load f }
15
+
16
+ require 'rspec/core'
17
+ require 'rspec/core/rake_task'
18
+
19
+ desc 'Run all specs in spec directory (excluding plugin specs)'
20
+ RSpec::Core::RakeTask.new(:spec)
21
+
22
+ task default: :spec
@@ -0,0 +1,21 @@
1
+ module Fish0
2
+ class Collection < Array
3
+ def cache_key
4
+ most_recent = select(&:updated_at).sort_by(&:updated_at).last
5
+
6
+ timestamp = time_to_string(most_recent ? most_recent.updated_at : Time.zone.now)
7
+
8
+ "#{objects_key}-#{timestamp}"
9
+ end
10
+
11
+ protected
12
+
13
+ def objects_key
14
+ Digest::MD5.hexdigest(map(&:primary_key_value).join)
15
+ end
16
+
17
+ def time_to_string(timestamp)
18
+ timestamp.strftime('%Y%m%d%H%M%S%9N')
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,90 @@
1
+ module Fish0
2
+ module Concerns
3
+ module Base
4
+ extend ActiveSupport::Concern
5
+
6
+ def primary_key
7
+ self.class.primary_key
8
+ end
9
+
10
+ def primary_key_value
11
+ send(primary_key)
12
+ end
13
+
14
+ included do
15
+ class << self
16
+ def primary_key(val = @primary_key)
17
+ @primary_key = val
18
+ return default_primary_key unless @primary_key
19
+ @primary_key
20
+ end
21
+
22
+ def cacheable
23
+ include Concerns::Cacheable
24
+ end
25
+
26
+ def disable_coercion
27
+ include Virtus.model(coerce: false)
28
+ end
29
+ alias_method :skip_coercion, :disable_coercion # DEPRECATED
30
+
31
+ def enable_coercion
32
+ include Virtus.model(coerce: true)
33
+ end
34
+
35
+ def scope(name, body)
36
+ scopes << [name, body]
37
+ end
38
+
39
+ def scopes
40
+ @scopes ||= []
41
+ end
42
+
43
+ # rubocop:disable Style/TrivialAccessors
44
+ def default_scope(body)
45
+ @default_scope = body
46
+ end
47
+ # rubocop:enable Style/TrivialAccessors
48
+
49
+ def method_missing(method_name, *arguments, &block)
50
+ if repository.respond_to?(method_name)
51
+ repository.send(method_name, *arguments, &block)
52
+ else
53
+ super
54
+ end
55
+ end
56
+
57
+ def respond_to_missing?(method_name, include_private = false)
58
+ repository.respond_to?(method_name) || super
59
+ end
60
+
61
+ protected
62
+
63
+ def default_primary_key
64
+ :slug
65
+ end
66
+
67
+ def entity
68
+ self
69
+ end
70
+
71
+ def collection
72
+ model_name.collection
73
+ end
74
+
75
+ def repository
76
+ rep = repository_class.new(collection: collection, entity_class: entity)
77
+ rep.instance_exec(&@default_scope) if @default_scope
78
+ scopes.each { |s| rep.scope(*s) }
79
+ rep
80
+ end
81
+
82
+ def repository_class
83
+ return "#{entity}Repository".constantize if "#{entity}Repository".safe_constantize
84
+ Fish0::Repository
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,37 @@
1
+ module Fish0
2
+ module Concerns
3
+ module Cacheable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ def cache_key(*timestamp_names)
8
+ if timestamp_names.any?
9
+ cache_key_string(max_updated_column_timestamp(timestamp_names))
10
+ elsif (timestamp = max_updated_column_timestamp)
11
+ cache_key_string(timestamp)
12
+ else
13
+ "#{self.class.to_s.tableize}/#{primary_key_value}"
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def timestamp_attributes_for_update
20
+ [:updated_at]
21
+ end
22
+
23
+ def max_updated_column_timestamp(timestamp_names = timestamp_attributes_for_update)
24
+ timestamp_names
25
+ .map { |attr| self[attr] }
26
+ .compact
27
+ .map(&:to_time)
28
+ .max
29
+ end
30
+
31
+ def cache_key_string(timestamp)
32
+ "#{self.class.to_s.tableize}/#{primary_key_value}-#{timestamp.utc.to_s(:nsec)}"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,10 @@
1
+ module Fish0
2
+ module Concerns
3
+ module Equalable
4
+ def ==(other)
5
+ other.class == self.class && other.attributes == attributes
6
+ end
7
+ alias eql? ==
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,25 @@
1
+ module Fish0
2
+ module Concerns
3
+ module Paginatable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ helper_method :page
8
+
9
+ protected
10
+
11
+ def page
12
+ @page ||= (params[:page].to_i || 1)
13
+ end
14
+
15
+ def paginate(collection)
16
+ Fish0::Paginator.new(collection, page_number: page, per_page: per_page).to_collection
17
+ end
18
+
19
+ def per_page
20
+ 22
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ module Fish0
2
+ module Concerns
3
+ module ViewModel
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ include Virtus.model
8
+ attribute :type, String
9
+
10
+ def type
11
+ (super || '').demodulize.underscore
12
+ end
13
+
14
+ def to_partial_path
15
+ type.to_s
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ module Fish0
2
+ ##
3
+ # Fish0::Configuration
4
+ #
5
+ # Usage:
6
+ # # config/initializers/fish0.rb
7
+ # Fish0::Configuration.configure do |config|
8
+ # config.mongo_uri = 'mongodb://user:password@host_1:27017,replica_host_2:27017/'\
9
+ # 'fish0_development?auth_source=admin'
10
+ # config.mongo_params = { read: { mode: :secondary } }
11
+ # end
12
+ #
13
+
14
+ class Configuration
15
+ include ActiveSupport::Configurable
16
+
17
+ # Usage:
18
+ # Enter your full mongo_uri (can include username, password, multiple hosts, ports, db name,
19
+ # and additional options as get-parameters
20
+ config_accessor :mongo_uri do
21
+ 'mongodb://localhost:27017/fish0_development'
22
+ end
23
+
24
+ # Usage:
25
+ # Enter your as second argument to MongoClient::New
26
+ config_accessor :mongo_params do
27
+ { read: { mode: :secondary } }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,12 @@
1
+ module Fish0
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Fish0
4
+
5
+ config.generators do |g|
6
+ g.test_framework :rspec, fixture: false
7
+ g.fixture_replacement :factory_girl, dir: 'spec/factories'
8
+ g.assets false
9
+ g.helper false
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,3 @@
1
+ module Fish0
2
+ class RecordNotFound < StandardError; end
3
+ end
@@ -0,0 +1,10 @@
1
+ module Fish0
2
+ class Model
3
+ extend ActiveModel::Naming
4
+ include Fish0::Concerns::Base
5
+ include Fish0::Concerns::ViewModel
6
+ include Fish0::Concerns::Equalable
7
+
8
+ skip_coercion
9
+ end
10
+ end
@@ -0,0 +1,55 @@
1
+ module Fish0
2
+ class Paginator
3
+ include Enumerable
4
+ delegate :each, :to_collection, :skip, :limit, :padding, :fetch, to: :collection
5
+
6
+ attr_reader :collection
7
+
8
+ def initialize(collection, page_number: 1, per_page: 22, padding: 0)
9
+ raise ArgumentError,
10
+ 'you can paginate only Fish0::Repository' unless collection.is_a?(Fish0::Repository)
11
+ @collection = collection
12
+ @per = per_page
13
+ @page = page_number
14
+ @padding = padding
15
+ per(per_page)
16
+ page(page_number)
17
+ end
18
+
19
+ def page(value = @page)
20
+ @page = value
21
+ skip((@page - 1) * @per + @padding)
22
+ self
23
+ end
24
+
25
+ def per(value = @per)
26
+ @per = value
27
+ skip((@page - 1) * @per + @padding)
28
+ limit(@per)
29
+ self
30
+ end
31
+
32
+ def padding(value)
33
+ @padding = value
34
+ page
35
+ skip
36
+ self
37
+ end
38
+
39
+ def total
40
+ collection.count
41
+ end
42
+
43
+ def current_page
44
+ @page
45
+ end
46
+
47
+ def pages
48
+ (total.to_f / @per).ceil
49
+ end
50
+
51
+ def last_page?
52
+ current_page >= pages
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,125 @@
1
+ module Fish0
2
+ class Repository
3
+ attr_reader :source,
4
+ :collection,
5
+ :conditions,
6
+ :order,
7
+ :skip_quantity,
8
+ :limit_quantity,
9
+ :entity_class
10
+
11
+ include Enumerable
12
+
13
+ delegate :aggregate, to: :source
14
+ delegate :each, to: :to_collection
15
+
16
+ def initialize(collection:, entity_class: nil, source: nil)
17
+ raise ArgumentError, 'you should provide collection name' unless collection
18
+ @collection = collection
19
+ @source = source ? source[collection] : Fish0.mongo_reader[collection]
20
+ @conditions = default_conditions
21
+ @order = {}
22
+ @limit_quantity = 0
23
+ @skip_quantity = 0
24
+ @entity_class = entity_class || String(collection).singularize.camelize.constantize
25
+ end
26
+
27
+ def find_one(query)
28
+ where(query).first
29
+ end
30
+
31
+ def find_one!(query)
32
+ find_one(query) || raise(RecordNotFound, "can't find in #{collection} with #{conditions}")
33
+ end
34
+
35
+ def to_collection
36
+ Fish0::Collection.new(fetch.map(&to_entity))
37
+ end
38
+
39
+ def find(filter = nil, options = {})
40
+ @source.find filter.dup, options
41
+ end
42
+
43
+ def all
44
+ self
45
+ end
46
+
47
+ def projection(values)
48
+ @projection = values
49
+ self
50
+ end
51
+
52
+ def distinct(field)
53
+ @source.distinct field, @conditions
54
+ end
55
+
56
+ def first
57
+ element = fetch.limit(1).first
58
+ to_entity.call(element) if element
59
+ end
60
+
61
+ def first!
62
+ first || raise(RecordNotFound, "can't find in #{collection} with #{conditions}")
63
+ end
64
+
65
+ def where(query)
66
+ conditions.merge!(query)
67
+ self
68
+ end
69
+
70
+ def search(string)
71
+ where('$text' => { '$search' => string })
72
+ self
73
+ end
74
+
75
+ def order_by(query)
76
+ order.merge!(query)
77
+ self
78
+ end
79
+
80
+ def limit(value)
81
+ @limit_quantity = value
82
+ self
83
+ end
84
+
85
+ def skip(value)
86
+ @skip_quantity = value
87
+ self
88
+ end
89
+
90
+ def scope(name, body)
91
+ return if respond_to?(name)
92
+
93
+ unless body.respond_to?(:call)
94
+ raise ArgumentError, 'The scope body needs to be callable.'
95
+ end
96
+
97
+ define_singleton_method(name) do |*args|
98
+ instance_exec(*args, &body)
99
+ self
100
+ end
101
+ end
102
+
103
+ def fetch
104
+ scoped = find(conditions, sort: order)
105
+ scoped = scoped.projection(@projection) if @projection
106
+ scoped = scoped.skip(skip_quantity) if skip_quantity.positive?
107
+ scoped = scoped.limit(limit_quantity) if limit_quantity.positive?
108
+ scoped
109
+ end
110
+
111
+ def count
112
+ find(conditions).count
113
+ end
114
+
115
+ protected
116
+
117
+ def default_conditions
118
+ {}
119
+ end
120
+
121
+ def to_entity
122
+ -> (attrs) { entity_class.new(attrs) }
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,3 @@
1
+ module Fish0
2
+ VERSION = '0.2.0'.freeze
3
+ end