upgrow 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -12
  3. data/Rakefile +8 -4
  4. data/lib/upgrow.rb +13 -2
  5. data/lib/upgrow/action.rb +54 -0
  6. data/lib/upgrow/active_record_adapter.rb +73 -0
  7. data/lib/upgrow/basic_repository.rb +45 -0
  8. data/lib/upgrow/immutable_object.rb +57 -0
  9. data/lib/upgrow/immutable_struct.rb +40 -0
  10. data/lib/upgrow/input.rb +47 -0
  11. data/lib/upgrow/model.rb +35 -0
  12. data/lib/upgrow/repository.rb +12 -0
  13. data/lib/upgrow/result.rb +94 -0
  14. data/test/application_system_test_case.rb +13 -0
  15. data/test/dummy/app/actions/create_article_action.rb +13 -0
  16. data/test/dummy/app/actions/delete_article_action.rb +7 -0
  17. data/test/dummy/app/actions/edit_article_action.rb +10 -0
  18. data/test/dummy/app/actions/list_articles_action.rb +8 -0
  19. data/test/dummy/app/actions/show_article_action.rb +10 -0
  20. data/test/dummy/app/actions/update_article_action.rb +13 -0
  21. data/test/dummy/app/channels/application_cable/channel.rb +5 -0
  22. data/test/dummy/app/channels/application_cable/connection.rb +5 -0
  23. data/test/dummy/app/controllers/application_controller.rb +3 -0
  24. data/test/dummy/app/controllers/articles_controller.rb +64 -0
  25. data/test/dummy/app/helpers/application_helper.rb +102 -0
  26. data/test/dummy/app/inputs/article_input.rb +8 -0
  27. data/test/dummy/app/jobs/application_job.rb +9 -0
  28. data/test/dummy/app/mailers/application_mailer.rb +5 -0
  29. data/test/dummy/app/models/article.rb +6 -0
  30. data/test/dummy/app/records/application_record.rb +5 -0
  31. data/test/dummy/app/records/article_record.rb +5 -0
  32. data/test/dummy/app/repositories/article_repository.rb +4 -0
  33. data/test/dummy/config/application.rb +23 -0
  34. data/test/dummy/config/boot.rb +6 -0
  35. data/test/dummy/config/environment.rb +6 -0
  36. data/test/dummy/config/environments/development.rb +79 -0
  37. data/test/dummy/config/environments/production.rb +133 -0
  38. data/test/dummy/config/environments/test.rb +61 -0
  39. data/test/dummy/config/initializers/application_controller_renderer.rb +9 -0
  40. data/test/dummy/config/initializers/assets.rb +13 -0
  41. data/test/dummy/config/initializers/backtrace_silencers.rb +12 -0
  42. data/test/dummy/config/initializers/content_security_policy.rb +31 -0
  43. data/test/dummy/config/initializers/cookies_serializer.rb +6 -0
  44. data/test/dummy/config/initializers/filter_parameter_logging.rb +7 -0
  45. data/test/dummy/config/initializers/inflections.rb +17 -0
  46. data/test/dummy/config/initializers/mime_types.rb +5 -0
  47. data/test/dummy/config/initializers/permissions_policy.rb +12 -0
  48. data/test/dummy/config/initializers/wrap_parameters.rb +16 -0
  49. data/test/dummy/config/puma.rb +44 -0
  50. data/test/dummy/config/routes.rb +7 -0
  51. data/test/dummy/db/migrate/20210219211631_create_articles.rb +11 -0
  52. data/test/dummy/db/schema.rb +22 -0
  53. data/test/rails_helper.rb +21 -0
  54. data/test/system/articles_test.rb +109 -0
  55. data/test/test_helper.rb +3 -3
  56. data/test/upgrow/action_test.rb +25 -0
  57. data/test/upgrow/active_record_adapter_test.rb +94 -0
  58. data/test/upgrow/basic_repository_test.rb +73 -0
  59. data/test/upgrow/documentation_test.rb +12 -0
  60. data/test/upgrow/immutable_object_test.rb +60 -0
  61. data/test/upgrow/immutable_struct_test.rb +49 -0
  62. data/test/upgrow/input_test.rb +65 -0
  63. data/test/upgrow/model_test.rb +27 -0
  64. data/test/upgrow/result_test.rb +95 -0
  65. metadata +128 -7
  66. data/test/documentation_test.rb +0 -10
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upgrow
4
+ # Models are objects that represent core entities of the app’s business logic.
5
+ # These are usually persisted and can be fetched and created as needed. They
6
+ # have unique keys for identification (usually a numeric value), and, most
7
+ # importantly perhaps, they are immutable. This is the key difference between
8
+ # this new Model layer of objects and the Active Record instances regularly
9
+ # referred to as models in typical Rails default apps.
10
+ #
11
+ # Another difference between Models and Records is that, once instantiated,
12
+ # Models simply hold its attributes immutably, and they don’t have any
13
+ # capabilities to create or update any information in the persistence layer.
14
+ #
15
+ # The collaboration between Repositories and Models is what allows Active
16
+ # Record to be completely hidden away from any other areas of the app. There
17
+ # are no references to Records in controllers, views, and anywhere else.
18
+ # Repositories are invoked instead, which in turn return read-only Models.
19
+ class Model < ImmutableObject
20
+ attribute :id
21
+ attribute :created_at
22
+ attribute :updated_at
23
+
24
+ # Initializes a new Model with the given member values.
25
+ #
26
+ # @param args [Hash<Symbol, Object>] the list of values for each attribute.
27
+ #
28
+ # @raise [KeyError] if an attribute is missing in the list of arguments.
29
+ def initialize(**args)
30
+ self.class.attribute_names.each { |key| args.fetch(key) }
31
+
32
+ super
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upgrow
4
+ # Repositories are responsible for the persistence layer of the app. They
5
+ # encapsulate Rails’ Active Record in a subset of simple methods for querying
6
+ # and persistence of data, and return simple read-only objects as a result.
7
+ # This allows the app to isolate Active Record only to this subset, exposing
8
+ # only the desired queries and methods to other layers through Repositories.
9
+ class Repository < BasicRepository
10
+ include ActiveRecordAdapter
11
+ end
12
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+ module Upgrow
3
+ # Results are special Structs that are generated dynamically to accommodate a
4
+ # set of pre-defined members. Since different Actions might want to return
5
+ # zero to multiple values, they are always returned as members of a Result
6
+ # instance.
7
+ #
8
+ # Regardless of the values the Action might want to return, a Result has one
9
+ # default member called errors, which holds any errors that might occur when
10
+ # the Action is performed. If Result errors are empty, the Result is a
11
+ # success; if there are errors present, however, the Result is a failure. This
12
+ # empowers Actions with a predictable public interface, so callers can expect
13
+ # how to evaluate if an operation was successful or not by simply checking the
14
+ # success or failure of a Result.
15
+ #
16
+ # Additionally, Result instances behave like monadic values by offering
17
+ # bindings to be called only in case of success or failure, which further
18
+ # simplifies the caller’s code by not having to use conditional to check for
19
+ # errors.
20
+ class Result < ImmutableStruct
21
+ class << self
22
+ # Creates a new Result class that can handle the given members.
23
+ #
24
+ # @param members [Array<Symbol>] the list of members the new Result should
25
+ # be able to hold.
26
+ #
27
+ # @return [Result] the new Result class with the given members.
28
+ def new(*members)
29
+ super(*members, :errors)
30
+ end
31
+
32
+ # Returns a new Result instance populated with the given values.
33
+ #
34
+ # @param values [Hash<Symbol, Object>] the list of values for each member
35
+ # provided as keyword arguments.
36
+ #
37
+ # @return [Result] the Result instance populated with the given values.
38
+ def success(*values)
39
+ new(*values)
40
+ end
41
+
42
+ # Returns a new Result instance populated with the given errors.
43
+ #
44
+ # @param errors [ActiveModel::Errors] the errors object to be set as the
45
+ # Result errors.
46
+ #
47
+ # @return [Result] the Result instance populated with the given errors.
48
+ def failure(errors)
49
+ values = members.to_h { |member| [member, nil] }
50
+ new(**values.merge(errors: errors))
51
+ end
52
+ end
53
+
54
+ # Calls the given block only when the Result is successful.
55
+ #
56
+ # This method receives a block that is called with the Result values passed
57
+ # to the block only when the Result itself is a success, meaning its list of
58
+ # errors is empty. Otherwise the block is not called.
59
+ #
60
+ # It returns self for convenience so other methods can be chained together.
61
+ #
62
+ # @yield [values] gives the Result values to the block on a successful
63
+ # Result.
64
+ #
65
+ # @return [Result] self for chaining.
66
+ def and_then
67
+ yield(**to_h.except(:errors)) if errors.none?
68
+ self
69
+ end
70
+
71
+ # Calls the given block only when the Result is a failure.
72
+ #
73
+ # This method receives a block that is called with the Result errors passed
74
+ # to the block only when the Result itself is a failure, meaning its list of
75
+ # errors is not empty. Otherwise the block is not called.
76
+ #
77
+ # It returns self for convenience so other methods can be chained together.
78
+ #
79
+ # @yield [errors] gives the Result errors to the block on a failed Result.
80
+ #
81
+ # @return [Result] self for chaining.
82
+ def or_else
83
+ yield(errors) if errors.any?
84
+ self
85
+ end
86
+
87
+ private
88
+
89
+ def initialize(*args)
90
+ values = { errors: [] }.merge(args.first || {})
91
+ super(**values)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_helper'
4
+ require 'webdrivers/chromedriver'
5
+ require 'action_dispatch/system_testing/server'
6
+
7
+ ActionDispatch::SystemTesting::Server.silence_puma = true
8
+
9
+ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
10
+ driven_by :selenium, using: :headless_chrome do |options|
11
+ options.add_argument('--disable-dev-shm-usage')
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+ class CreateArticleAction < Upgrow::Action
3
+ result :article
4
+
5
+ def perform(input)
6
+ if input.valid?
7
+ article = ArticleRepository.new.create(input)
8
+ result.success(article: article)
9
+ else
10
+ result.failure(input.errors)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+ class DeleteArticleAction < Upgrow::Action
3
+ def perform(id)
4
+ ArticleRepository.new.delete(id)
5
+ result.success
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ class EditArticleAction < Upgrow::Action
3
+ result :article
4
+
5
+ def perform(id)
6
+ article = ArticleRepository.new.find(id)
7
+
8
+ result.success(article: article)
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+ class ListArticlesAction < Upgrow::Action
3
+ result :articles
4
+
5
+ def perform
6
+ result.success(articles: ArticleRepository.new.all)
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ class ShowArticleAction < Upgrow::Action
3
+ result :article
4
+
5
+ def perform(id)
6
+ article = ArticleRepository.new.find(id)
7
+
8
+ result.success(article: article)
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+ class UpdateArticleAction < Upgrow::Action
3
+ result :article
4
+
5
+ def perform(id, input)
6
+ if input.valid?
7
+ article = ArticleRepository.new.update(id, input)
8
+ result.success(article: article)
9
+ else
10
+ result.failure(input.errors)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ module ApplicationCable
3
+ class Channel < ActionCable::Channel::Base
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ module ApplicationCable
3
+ class Connection < ActionCable::Connection::Base
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+ class ApplicationController < ActionController::Base
3
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ArticlesController < ApplicationController
4
+ def index
5
+ @articles = ListArticlesAction.new.perform.articles
6
+ end
7
+
8
+ def show
9
+ @article = ShowArticleAction.new.perform(params[:id]).article
10
+ end
11
+
12
+ def new
13
+ @input = ArticleInput.new
14
+ end
15
+
16
+ def edit
17
+ article = EditArticleAction.new.perform(params[:id]).article
18
+ @input = ArticleInput.new(
19
+ title: article.title, body: article.body
20
+ )
21
+ end
22
+
23
+ def create
24
+ @input = ArticleInput.new(article_params)
25
+
26
+ CreateArticleAction.new.perform(@input)
27
+ .and_then do |article:|
28
+ redirect_to(
29
+ article_path(article.id), notice: 'Article was successfully created.'
30
+ )
31
+ end
32
+ .or_else do |errors|
33
+ @errors = errors
34
+ render(:new)
35
+ end
36
+ end
37
+
38
+ def update
39
+ @input = ArticleInput.new(article_params)
40
+
41
+ UpdateArticleAction.new.perform(params[:id], @input)
42
+ .and_then do |article:|
43
+ redirect_to(
44
+ article_path(article.id),
45
+ notice: 'Article was successfully updated.'
46
+ )
47
+ end
48
+ .or_else do |errors|
49
+ @errors = errors
50
+ render(:edit)
51
+ end
52
+ end
53
+
54
+ def destroy
55
+ DeleteArticleAction.new.perform(params[:id])
56
+ redirect_to(articles_url, notice: 'Article was successfully destroyed.')
57
+ end
58
+
59
+ private
60
+
61
+ def article_params
62
+ params.require(:article_input).permit(:title, :body)
63
+ end
64
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+ module ApplicationHelper
3
+ class BulmaFormBuilder < ActionView::Helpers::FormBuilder
4
+ # Generic field wrapper for form inputs.
5
+ #
6
+ # It wraps the given block in a field div. In case a method is specified it
7
+ # also prepends a label tag for the input and renders any errors for the
8
+ # given method after the input.
9
+ #
10
+ # @param method [Symbol] the optional method to be called from the object
11
+ # this form builder is attached to.
12
+ # @param label [Boolean] if the field needs a label or not.
13
+ # @param addons [Boolean] if the field will have addons.
14
+ #
15
+ # @return [String] an HTML safe markup content.
16
+ def field(method: nil, label: true, addons: false)
17
+ field_class = ['field']
18
+ field_class << 'has-addons' if addons
19
+ @template.content_tag(:div, class: field_class) do
20
+ result = ''.html_safe
21
+
22
+ result += label(method, class: 'label') if method && label
23
+ result += yield
24
+
25
+ if method
26
+ errors_for(method).each do |error|
27
+ result += @template.content_tag(:p, error, class: 'help is-danger')
28
+ end
29
+ end
30
+
31
+ result
32
+ end
33
+ end
34
+
35
+ # Render a text-type input.
36
+ #
37
+ # @param method [Symbol] the method to be called from the object this form
38
+ # builder is attached to.
39
+ # @param label [Boolean] if the input requires a label.
40
+ # @param expanded [Boolean] if the control element will be expanded.
41
+ # @param field [Boolean] if the text will be wrapped within a field.
42
+ # @param args [Hash] optional arguments for the overloaded text_field
43
+ # method.
44
+ #
45
+ # @return [String] an HTML safe markup content.
46
+ def text_field(method, label: true, expanded: false, field: true, **args)
47
+ input_class = ['input']
48
+ input_class << 'is-danger' if errors_for(method).any?
49
+ output = control(expanded: expanded) do
50
+ super(method, class: input_class, **args)
51
+ end
52
+
53
+ if field
54
+ field(method: method, label: label) { output }
55
+ else
56
+ output
57
+ end
58
+ end
59
+
60
+ def text_area(method, **args)
61
+ input_class = ['textarea']
62
+ input_class << 'is-danger' if errors_for(method).any?
63
+
64
+ output = control(expanded: false) do
65
+ super(method, class: input_class, **args)
66
+ end
67
+
68
+ field(method: method, label: true) { output }
69
+ end
70
+
71
+ # Render a form submit button.
72
+ #
73
+ # @param text [Symbol] the text for the button.
74
+ # @param field [Boolean] if the button is within a field.
75
+ # @param data [Hash] the HTML data attribute.
76
+ #
77
+ # @return [String] an HTML safe markup content.
78
+ def submit(text, field: true, data: {})
79
+ output = control { super(text, class: 'button is-primary', data: data) }
80
+ if field
81
+ field { output }
82
+ else
83
+ output
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def control(expanded: false)
90
+ control_class = ['control']
91
+ control_class << ['is-expanded'] if expanded
92
+ @template.content_tag(:p, class: control_class) do
93
+ yield
94
+ end
95
+ end
96
+
97
+ def errors_for(method)
98
+ (options[:errors] || ActiveModel::Errors.new(self))
99
+ .full_messages_for(method)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+ class ArticleInput < Upgrow::Input
3
+ attribute :title
4
+ attribute :body
5
+
6
+ validates :title, presence: true
7
+ validates :body, presence: true, length: { minimum: 10 }
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+ class ApplicationJob < ActiveJob::Base
3
+ # Automatically retry jobs that encountered a deadlock
4
+ # retry_on ActiveRecord::Deadlocked
5
+
6
+ # Most jobs are safe to ignore if the underlying records are no longer
7
+ # available
8
+ # discard_on ActiveJob::DeserializationError
9
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: 'from@example.com'
4
+ layout 'mailer'
5
+ end