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