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