test_fish0 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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