upgrow 0.0.1 → 0.0.2

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