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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b332e16457e8d2d1e8d1392f217f9f8755b998cd5888dedba58a7ebce9f213b
4
- data.tar.gz: fb61e9ee12281e326a9468847a9b3eb6753c5df7e39acf8978d00f4a783f6838
3
+ metadata.gz: ade9c609af4d3b6a3dbcf14e3391c279b0043ca68a7b3abca290c0558d31d7d5
4
+ data.tar.gz: d7f3ad187cc54e12c7000bbb1bc6e489f160b0d24c9e815c4d338c0654ff2980
5
5
  SHA512:
6
- metadata.gz: 8fe7ac0a75bc514a24ed3656f68b926b40007dfd5aa596c7fa7b58fcd64fb5081b5f17435ee508cb174b9c1da98526bb189ece5d88acec56b2b28d4b710b5abe
7
- data.tar.gz: 2e12cb7c99f5ce534e9f4c55c338b31d0772838765b69832669503f3d81a373340ebb5bfa916028d8ccdb58340ec79015afb520cb66fea5e945eed97617aecf0
6
+ metadata.gz: a94d9ab2c7d815c912670e58e234f7052c6c062c54cb2ab6c65a729280db7fb1867ee5314aa6599893a6aabbbebdb8763b2cddf9e5e01056e8a532fa7eef7f7b
7
+ data.tar.gz: 9ad9d668636f257c12c1a9dec5bd6cfcc4c209feb2f18adaf63717d8c0725535b347025305a391ace8d67e6fffef87827015d93784887cf79ea8e7b37aa70915
data/README.md CHANGED
@@ -1,23 +1,56 @@
1
+ <!--
2
+ # @title Upgrow
3
+ -->
4
+
1
5
  # Upgrow
2
6
 
3
- TODO: Delete this and the text above, and describe your gem
7
+ **Heads Up!**
4
8
 
5
- ## Installation
9
+ **This project is a work in progress. We are working on documenting our
10
+ best practices to help sustainable development for the long term with
11
+ Ruby on Rails. The Upgrow gem is not yet available and the contents of
12
+ the guide are still being amended, matured, and reviewed. That being
13
+ said, feel free to follow the GitHub repo and stay tuned for the
14
+ latest updates!**
6
15
 
7
- Add this line to your application's Gemfile:
16
+ ## A sustainable architecture for Ruby on Rails
8
17
 
9
- ```ruby
10
- gem 'upgrow'
11
- ```
18
+ Ruby on Rails is the framework of choice for web apps at Shopify. It is an
19
+ opinionated stack for quick and easy development of apps that need standard
20
+ persistence with relational databases, an HTTP server, and HTML views.
21
+
22
+ By design, Rails does not define conventions for structuring business logic and
23
+ domain-specific code, leaving developers to define their own architecture and
24
+ best practices for a sustainable codebase.
25
+
26
+ In fast product development teams, budgets and deadlines interfere with this
27
+ architectural work, leading to poorly written business logic and complicated
28
+ code that is very hard to maintain long term. Even when developer teams take
29
+ the time to think about what a good architecture in Rails look like, this work
30
+ is likely required to be done all over again when a new Rails app needs to be
31
+ created.
12
32
 
13
- And then execute:
33
+ This project aims to make it easier for both new and existing Rails apps to
34
+ adopt patterns that are proven to make code more sustainable long term, and
35
+ codebases easier to maintain and extend. We will recommend a set of abstractions
36
+ and practices that are simple, yet powerful in organizing code in Rails apps in
37
+ a way that allows fast-growing apps to remain easy to change.
14
38
 
15
- $ bundle install
39
+ ## The Upgrow Guide
16
40
 
17
- Or install it yourself as:
41
+ Visit [https://upgrow.shopify.io](https://upgrow.shopify.io) to learn more about
42
+ creating a sustainable architecture for your Rails apps.
18
43
 
19
- $ gem install upgrow
44
+ ## The Upgrow Gem
45
+
46
+ This project offers a Ruby gem to make it easier for Rails apps to adopt the
47
+ sustainable architecture proposed in the Upgrow Guide. To install, add this line
48
+ to your application's Gemfile and run `bundle install`:
49
+
50
+ ```ruby
51
+ gem 'upgrow'
52
+ ```
20
53
 
21
- ## Usage
54
+ ## License
22
55
 
23
- TODO: Write usage instructions here
56
+ For copyright and licensing please refer to `LICENSE.txt`.
data/Rakefile CHANGED
@@ -1,17 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'bundler/setup'
3
4
  require 'bundler/gem_tasks'
4
5
  require 'rake/testtask'
6
+ require 'rubocop/rake_task'
7
+
8
+ APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
9
+ load('rails/tasks/engine.rake')
10
+ task 'test:system' => 'app:test:system' # Hack to use the test environment.
5
11
 
6
12
  Rake::TestTask.new do |t|
7
13
  t.libs << 'test'
14
+ t.pattern = 'test/upgrow/**/*_test.rb'
8
15
  t.warning = true
9
16
  t.verbose = true
10
- t.test_files = FileList['test/**/*.rb']
11
17
  end
12
18
 
13
- require 'rubocop/rake_task'
14
-
15
19
  RuboCop::RakeTask.new
16
20
 
17
- task default: [:test, :rubocop]
21
+ task default: ['rubocop', 'test', 'db:setup', 'test:system']
data/lib/upgrow.rb CHANGED
@@ -1,6 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_model'
4
+
5
+ require_relative 'upgrow/action'
6
+ require_relative 'upgrow/active_record_adapter'
7
+ require_relative 'upgrow/immutable_object'
8
+ require_relative 'upgrow/basic_repository'
9
+ require_relative 'upgrow/immutable_struct'
10
+ require_relative 'upgrow/repository'
11
+ require_relative 'upgrow/input'
12
+ require_relative 'upgrow/model'
13
+ require_relative 'upgrow/result'
14
+
15
+ # The gem's main namespace.
3
16
  module Upgrow
4
- class Error < StandardError; end
5
- # Your code goes here...
6
17
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upgrow
4
+ # Actions represent the entry points to the app’s core logic. These objects
5
+ # coordinate workflows in order to get operations and activities done.
6
+ # Ultimately, Actions are the public interface of the app’s business layers.
7
+ #
8
+ # Rails controllers talk to the app’s internals by sending messages to
9
+ # specific Actions, optionally with the required inputs. Actions have a
10
+ # one-to-one relationship with incoming requests: they are paired
11
+ # symmetrically with end-user intents and demands. This is quite a special
12
+ # requirement from this layer: any given HTTP request handled by the app
13
+ # should be handled by a single Action.
14
+ #
15
+ # The fact that each Action represents a meaningful and complete
16
+ # request-response cycle forces modularization for the app’s business logic,
17
+ # exposing immediately complex relationships between objects at the same time
18
+ # that frees up the app from scenarios such as interdependent requests. In
19
+ # other words, Actions do not have knowledge or coupling between other Actions
20
+ # whatsoever.
21
+ #
22
+ # Actions respond to a single public method perform. Each Action defines its
23
+ # own set of required arguments for perform, as well what can be expected as
24
+ # the result of that method.
25
+ class Action
26
+ class << self
27
+ attr_writer :result_class
28
+
29
+ # Each Action class has its own Result class with the expected members to
30
+ # be returned when the Action is called successfully.
31
+ #
32
+ # @return [Result] the Result class for this Action, as previously
33
+ # defined, or a Result class with no members by default.
34
+ def result_class
35
+ @result_class ||= Result.new
36
+ end
37
+
38
+ # Sets the Action Result class with the given members.
39
+ #
40
+ # @param args [Array<Symbol>] the list of members for the Result class.
41
+ def result(*args)
42
+ @result_class = Result.new(*args)
43
+ end
44
+ end
45
+
46
+ # Instance method to return the Action's Result class. This method delegates
47
+ # to the Action class's method (see #result_class).
48
+ #
49
+ # @return [Result] the Result class for this Action.
50
+ def result
51
+ self.class.result_class
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upgrow
4
+ # Mixin that implements Repository methods with an Active Record Base. When
5
+ # included in a Repository class, it sets the default base to be a class
6
+ # ending with `Record`.
7
+ module ActiveRecordAdapter
8
+ # Fetches all Records and returns them as an Array of Models.
9
+ #
10
+ # @return [Array<Model>] a collection of Models representing all persisted
11
+ # Records.
12
+ def all
13
+ base.all.map { |record| to_model(record.attributes) }
14
+ end
15
+
16
+ # Persists a new Record with the given input, and materializes the newly
17
+ # created Record as the returned Model instance.
18
+ #
19
+ # @param input [Input] the Input with the attributes for the new Record.
20
+ #
21
+ # @return [Model] the Model with the attributes of the newly created
22
+ # Record.
23
+ def create(input)
24
+ record = base.create!(input.attributes)
25
+ to_model(record.attributes)
26
+ end
27
+
28
+ # Retrieves the Record with the given ID, representing its data as a Model.
29
+ #
30
+ # @param id [Integer] the ID of the Record to be fetched.
31
+ #
32
+ # @return [Model] the Model with the attributes of the Record with the given
33
+ # ID.
34
+ def find(id)
35
+ record = base.find(id)
36
+ to_model(record.attributes)
37
+ end
38
+
39
+ # Updates the Record with the given ID with the given Input attributes.
40
+ #
41
+ # @param id [Integer] the ID of the Record to be updated.
42
+ # @param input [Input] the Input with the attributes to be set in the
43
+ # Record.
44
+ #
45
+ # @return [Model] the Model instance with the updated data of the Record.
46
+ def update(id, input)
47
+ record = base.update(id, input.attributes)
48
+ to_model(record.attributes)
49
+ end
50
+
51
+ # Deletes the Record that has the given ID.
52
+ #
53
+ # @param id [Integer] the ID of the Record to be deleted.
54
+ def delete(id)
55
+ base.destroy(id)
56
+ end
57
+
58
+ # Callback method used by Basic Repository to set a default Repository base
59
+ # when one is not explicitly provided at the Repository initialization.
60
+ #
61
+ # It attempts to find a constant based on the Repository name, with the
62
+ # `Record` suffix as a convention. For example, a `UserRepository` would
63
+ # have the `UserRecord` as its base. That is the naming convention for
64
+ # Active Record classes under this architecture.
65
+ #
66
+ # @return [Class] the Active Record Base class to be used as the Repository
67
+ # base according to the architecture's naming convention.
68
+ def default_base
69
+ base_name = self.class.name[/\A(.+)Repository\z/, 1] + 'Record'
70
+ Object.const_get(base_name)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upgrow
4
+ # Base class for Repositories. It offers a basic API for the state all
5
+ # Repositories should have, as well as the logic on how to materialize data
6
+ # into Models.
7
+ class BasicRepository
8
+ attr_reader :base, :model_class
9
+
10
+ # Sets the Basic Repositorie's state.
11
+ #
12
+ # @param base [Object] the base object to be used internally to retrieve the
13
+ # persisted data. For example, a base class in which queries can be
14
+ # performed for a relational database adapter. Defaults to `nil`.
15
+ #
16
+ # @param model_class [Class] the Model class to be used to map and return
17
+ # the materialized data as instances of the domain. Defaults to a constant
18
+ # derived from the Repository class' name. For example, a `UserRepository`
19
+ # will have its default Model class set to `User`.
20
+ def initialize(base: default_base, model_class: default_model_class)
21
+ @base = base
22
+ @model_class = model_class
23
+ end
24
+
25
+ # Represents the raw Hash of data attributes as a Model instance from the
26
+ # Repositorie's Model class.
27
+ #
28
+ # @param attributes [Hash<Symbol, Object>] the list of attributes the Model
29
+ # will have.
30
+ #
31
+ # @return [Model] the Model instance populated with the given attributes.
32
+ def to_model(attributes = {})
33
+ model_class.new(**attributes.transform_keys(&:to_sym))
34
+ end
35
+
36
+ private
37
+
38
+ def default_base; end
39
+
40
+ def default_model_class
41
+ model_class_name = self.class.name[/\A(.+)Repository\z/, 1]
42
+ Object.const_get(model_class_name)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+ module Upgrow
3
+ # A read-only Object. An Immutable Object is initialized with its attributes
4
+ # and subsequent state changes are not permitted.
5
+ class ImmutableObject
6
+ @attribute_names = []
7
+
8
+ class << self
9
+ attr_reader :attribute_names
10
+
11
+ # Specify an attribute for the Immutable Object. This enables the object
12
+ # to be instantiated with the attribute, as well as creates an attribute
13
+ # reader for it.
14
+ #
15
+ # @param name [Symbol] the name of the attribute.
16
+ def attribute(name)
17
+ @attribute_names << name
18
+ attr_reader(name)
19
+ end
20
+
21
+ private
22
+
23
+ def inherited(subclass)
24
+ super
25
+ subclass.instance_variable_set(:@attribute_names, @attribute_names.dup)
26
+ end
27
+ end
28
+
29
+ # Initializes a new Immutable Object with the given member values.
30
+ #
31
+ # @param args [Hash<Symbol, Object>] the list of values for each attribute
32
+ # of the Immutable Object.
33
+ #
34
+ # @raise [ArgumentError] if the given argument is not an attribute.
35
+ def initialize(**args)
36
+ absent_attributes = args.keys - self.class.attribute_names
37
+
38
+ if absent_attributes.any?
39
+ raise ArgumentError, "Unknown attribute #{absent_attributes}"
40
+ end
41
+
42
+ args.each do |name, value|
43
+ instance_variable_set("@#{name}", value)
44
+ end
45
+
46
+ freeze
47
+ end
48
+
49
+ # The collection of attributes and their values.
50
+ #
51
+ # @return [Hash<Symbol, Object>] the collection of attributes and their
52
+ # values.
53
+ def attributes
54
+ self.class.attribute_names.to_h { |name| [name, public_send(name)] }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+ module Upgrow
3
+ # A read-only Struct. An Immutable Struct is initialized with its member
4
+ # values and subsequent state changes are not permitted.
5
+ class ImmutableStruct < Struct
6
+ class << self
7
+ # Creates a new Immutable Struct class with the given members.
8
+ #
9
+ # @param args [Array<Symbol>] the list of members for the new class.
10
+ #
11
+ # @return [ImmutableStruct] the new Immutable Struct class able to
12
+ # accommodate the given members.
13
+ def new(*args, &block)
14
+ if args.any? { |member| !member.is_a?(Symbol) }
15
+ raise ArgumentError, 'all members must be symbols'
16
+ end
17
+
18
+ struct_class = super(*args, keyword_init: true, &block)
19
+
20
+ struct_class.members.each do |member|
21
+ struct_class.send(:undef_method, :"#{member}=")
22
+ end
23
+
24
+ struct_class
25
+ end
26
+ end
27
+
28
+ undef []=
29
+
30
+ # Initializes a new Immutable Struct with the given member values.
31
+ #
32
+ # @param args [Hash<Symbol, Object>] the list of values for each member of
33
+ # the Immutable Struct.
34
+ def initialize(**args)
35
+ members.each { |key| args.fetch(key) }
36
+ super(**args)
37
+ freeze
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Upgrow
4
+ # Inputs are objects that represent user-entered data. They are populated with
5
+ # information that is available for modification, such as in HTML forms or in
6
+ # API payloads, and they are passed on to Repositories as arguments for
7
+ # persistence operations, as provided by the app user. Inputs have knowledge
8
+ # about which attributes should be present in a payload, among other
9
+ # constraints, and are able to tell if its own state is valid or not.
10
+ #
11
+ #
12
+ # It is important to note that Inputs differ from Records for not representing
13
+ # domain entities, but simply data entered by the user. Inputs do not have
14
+ # numeric identifiers, for example, as these are generated by the system and
15
+ # not set by users. There are also no strong expectations in regards to data
16
+ # integrity for inputs, since user-entered data can contain any information of
17
+ # different types, or even not to be present at all.
18
+ #
19
+ # User input validation is a core part of any app’s business logic. It ensures
20
+ # that incoming data is sane, proper, and respects a predefined schema. A
21
+ # default Rails app overloads Record objects with yet another responsibility:
22
+ # being the place where validation rules are written and checked. While there
23
+ # is value in making sure that database constraints are respected, input
24
+ # validation should happen as part of the business logic layer, before
25
+ # persistence is invoked with invalid input. Input objects are a great fit for
26
+ # that task. By leveraging validation utilities from Active Model, Input
27
+ # objects can not only perform the same validations as Records but also
28
+ # seamlessly integrate with view helpers such as Rails form builders.
29
+ class Input < ImmutableObject
30
+ include ActiveModel::Validations
31
+
32
+ # Creates a new Input instance.
33
+ #
34
+ # @param attributes [Hash<String, Object>] the values for each attribute.
35
+ def initialize(attributes = {})
36
+ @errors = ActiveModel::Errors.new(self)
37
+ super(**attributes.to_hash.transform_keys(&:to_sym))
38
+ end
39
+
40
+ private
41
+
42
+ # Overwrites the validation context writer so the Input's state is not
43
+ # mutated.
44
+ def validation_context=(_)
45
+ end
46
+ end
47
+ end