upgrow 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +45 -12
- data/Rakefile +8 -4
- data/lib/upgrow.rb +13 -2
- data/lib/upgrow/action.rb +54 -0
- data/lib/upgrow/active_record_adapter.rb +73 -0
- data/lib/upgrow/basic_repository.rb +45 -0
- data/lib/upgrow/immutable_object.rb +57 -0
- data/lib/upgrow/immutable_struct.rb +40 -0
- data/lib/upgrow/input.rb +47 -0
- data/lib/upgrow/model.rb +35 -0
- data/lib/upgrow/repository.rb +12 -0
- data/lib/upgrow/result.rb +94 -0
- data/test/application_system_test_case.rb +13 -0
- data/test/dummy/app/actions/create_article_action.rb +13 -0
- data/test/dummy/app/actions/delete_article_action.rb +7 -0
- data/test/dummy/app/actions/edit_article_action.rb +10 -0
- data/test/dummy/app/actions/list_articles_action.rb +8 -0
- data/test/dummy/app/actions/show_article_action.rb +10 -0
- data/test/dummy/app/actions/update_article_action.rb +13 -0
- data/test/dummy/app/channels/application_cable/channel.rb +5 -0
- data/test/dummy/app/channels/application_cable/connection.rb +5 -0
- data/test/dummy/app/controllers/application_controller.rb +3 -0
- data/test/dummy/app/controllers/articles_controller.rb +64 -0
- data/test/dummy/app/helpers/application_helper.rb +102 -0
- data/test/dummy/app/inputs/article_input.rb +8 -0
- data/test/dummy/app/jobs/application_job.rb +9 -0
- data/test/dummy/app/mailers/application_mailer.rb +5 -0
- data/test/dummy/app/models/article.rb +6 -0
- data/test/dummy/app/records/application_record.rb +5 -0
- data/test/dummy/app/records/article_record.rb +5 -0
- data/test/dummy/app/repositories/article_repository.rb +4 -0
- data/test/dummy/config/application.rb +23 -0
- data/test/dummy/config/boot.rb +6 -0
- data/test/dummy/config/environment.rb +6 -0
- data/test/dummy/config/environments/development.rb +79 -0
- data/test/dummy/config/environments/production.rb +133 -0
- data/test/dummy/config/environments/test.rb +61 -0
- data/test/dummy/config/initializers/application_controller_renderer.rb +9 -0
- data/test/dummy/config/initializers/assets.rb +13 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +12 -0
- data/test/dummy/config/initializers/content_security_policy.rb +31 -0
- data/test/dummy/config/initializers/cookies_serializer.rb +6 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +7 -0
- data/test/dummy/config/initializers/inflections.rb +17 -0
- data/test/dummy/config/initializers/mime_types.rb +5 -0
- data/test/dummy/config/initializers/permissions_policy.rb +12 -0
- data/test/dummy/config/initializers/wrap_parameters.rb +16 -0
- data/test/dummy/config/puma.rb +44 -0
- data/test/dummy/config/routes.rb +7 -0
- data/test/dummy/db/migrate/20210219211631_create_articles.rb +11 -0
- data/test/dummy/db/schema.rb +22 -0
- data/test/rails_helper.rb +21 -0
- data/test/system/articles_test.rb +109 -0
- data/test/test_helper.rb +3 -3
- data/test/upgrow/action_test.rb +25 -0
- data/test/upgrow/active_record_adapter_test.rb +94 -0
- data/test/upgrow/basic_repository_test.rb +73 -0
- data/test/upgrow/documentation_test.rb +12 -0
- data/test/upgrow/immutable_object_test.rb +60 -0
- data/test/upgrow/immutable_struct_test.rb +49 -0
- data/test/upgrow/input_test.rb +65 -0
- data/test/upgrow/model_test.rb +27 -0
- data/test/upgrow/result_test.rb +95 -0
- metadata +128 -7
- data/test/documentation_test.rb +0 -10
data/lib/upgrow/model.rb
ADDED
@@ -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,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,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,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
|