diff --git a/Gemfile b/Gemfile index 94f9e46c..a2a7ce94 100644 --- a/Gemfile +++ b/Gemfile @@ -37,3 +37,5 @@ gem "csv" # required for Ruby 3.4+ # for unit testing optional sorbet support gem "sorbet-runtime" + +gem "tapioca" diff --git a/Gemfile.lock b/Gemfile.lock index 99e75ffc..bf7c6456 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -35,12 +35,14 @@ GEM tzinfo (~> 2.0) ast (2.4.2) base64 (0.2.0) + benchmark (0.4.0) bigdecimal (3.1.9) coderay (1.1.3) concurrent-ruby (1.3.4) connection_pool (2.4.1) csv (3.3.2) drb (2.2.1) + erubi (1.13.1) globalid (1.2.1) activesupport (>= 6.1) i18n (1.14.6) @@ -54,10 +56,12 @@ GEM mono_logger (1.1.2) multi_json (1.15.0) mutex_m (0.2.0) + netrc (0.11.0) parallel (1.25.1) parser (3.3.3.0) ast (~> 2.4.1) racc + prism (1.3.0) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) @@ -65,6 +69,9 @@ GEM rack (3.1.8) rainbow (3.1.1) rake (13.2.1) + rbi (0.2.3) + prism (~> 1.0) + sorbet-runtime (>= 0.5.9204) redis (5.3.0) redis-client (>= 0.22.0) redis-client (0.23.0) @@ -102,12 +109,37 @@ GEM redis-client (>= 0.19.0) sinatra (1.0) rack (>= 1.0) + sorbet (0.5.11460) + sorbet-static (= 0.5.11460) sorbet-runtime (0.5.11460) + sorbet-static (0.5.11460-universal-darwin) + sorbet-static-and-runtime (0.5.11460) + sorbet (= 0.5.11460) + sorbet-runtime (= 0.5.11460) + spoom (1.5.0) + erubi (>= 1.10.0) + prism (>= 0.28.0) + sorbet-static-and-runtime (>= 0.5.10187) + thor (>= 0.19.2) + tapioca (0.16.7) + benchmark + bundler (>= 2.2.25) + netrc (>= 0.11.0) + parallel (>= 1.21.0) + rbi (~> 0.2) + sorbet-static-and-runtime (>= 0.5.11087) + spoom (>= 1.2.0) + thor (>= 1.2.0) + yard-sorbet + thor (1.3.2) timeout (0.4.1) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) yard (0.9.37) + yard-sorbet (0.9.0) + sorbet-runtime + yard PLATFORMS ruby @@ -127,6 +159,7 @@ DEPENDENCIES rubocop-shopify sidekiq sorbet-runtime + tapioca yard BUNDLED WITH diff --git a/lib/tapioca/dsl/compilers/job_iteration.rb b/lib/tapioca/dsl/compilers/job_iteration.rb new file mode 100644 index 00000000..1d8d8d37 --- /dev/null +++ b/lib/tapioca/dsl/compilers/job_iteration.rb @@ -0,0 +1,96 @@ +# typed: strict +# frozen_string_literal: true + +return unless defined?(JobIteration::Iteration) + +require "prism" + +module Tapioca + module Dsl + module Compilers + class JobIteration < Compiler + extend T::Sig + + ConstantType = type_member { { fixed: T.class_of(::JobIteration::Iteration) } } + + sig { override.void } + def decorate + return unless constant.instance_methods(false).include?(:build_enumerator) + + root.create_path(constant) do |job| + method = constant.instance_method(:build_enumerator) + constant_name = name_of(constant) + parameters = compile_method_parameters_to_rbi(method).reject do |typed_param| + typed_param.param.name == "cursor" + end + + expanded_parameters = parameters.flat_map do |typed_param| + hash_param = typed_param.type.match(/\A\{.*\}\z/) + if hash_param + key_value_pairs = parse_hash_parameter(typed_param) + key_value_pairs.map do |key, value| + create_kw_param(key, type: value) + end + else + typed_param + end + end + + return_type = compile_method_return_type_to_rbi(method) + + job.create_method( + "perform_later", + parameters: expanded_parameters, + return_type: "T.any(#{constant_name}, FalseClass)", + class_method: true, + ) + + job.create_method( + "perform_now", + parameters: expanded_parameters, + return_type: return_type, + class_method: true, + ) + end + end + + private + + def parse_hash_parameter(typed_param) + parse_result = Prism.parse(typed_param.type) + return "T.untyped" if parse_result.failure? + + visitor = HashParamVisitor.new + parse_result.value.accept(visitor) + visitor.key_value_pairs + end + + class HashParamVisitor < Prism::Visitor + attr_reader :key_value_pairs + + def initialize + super + @key_value_pairs = [] + end + + def visit_hash_node(node) + node.elements.each do |element| + key = element.key.unescaped + value = element.value.slice + @key_value_pairs << [key, value] + end + end + end + + class << self + extend T::Sig + + sig { override.returns(T::Enumerable[Module]) } + def gather_constants + all_classes.select { |c| c < ::JobIteration::Iteration } + end + end + end + end + end +end diff --git a/test/tapioca/dsl/compilers/job_iteration_test.rb b/test/tapioca/dsl/compilers/job_iteration_test.rb new file mode 100644 index 00000000..4a7a1c14 --- /dev/null +++ b/test/tapioca/dsl/compilers/job_iteration_test.rb @@ -0,0 +1,240 @@ +# typed: strict +# frozen_string_literal: true + +require "test_helper" +require "tapioca/helpers/test/dsl_compiler" +require "tapioca/dsl/compilers/job_iteration" + +module Tapioca + module Dsl + module Compilers + class JobIterationTest < Minitest::Test + extend T::Sig + extend Tapioca::Helpers::Test::Template + include Tapioca::Helpers::Test::DslCompiler + + def setup + require "job-iteration" + require "tapioca/dsl/compilers/job_iteration" + use_dsl_compiler(Tapioca::Dsl::Compilers::JobIteration) + end + + def test_gathers_constants_only_for_jobs_that_include_job_iteration + add_ruby_file("job.rb", <<~RUBY) + class FooJob < ActiveJob::Base + end + + class BarJob < ActiveJob::Base + include JobIteration::Iteration + end + RUBY + + assert_includes(gathered_constants, "BarJob") + refute_includes(gathered_constants, "FooJob") + end + + def test_generates_an_empty_rbi_file_if_there_is_no_build_enumerator_method + add_ruby_file("job.rb", <<~RUBY) + class NotifyJob < ActiveJob::Base + include JobIteration::Iteration + end + RUBY + + expected = <<~RBI + # typed: strong + RBI + + assert_equal(expected, rbi_for(:NotifyJob)) + end + + def test_generates_correct_rbi_file_for_job_with_build_enumerator_method + add_ruby_file("job.rb", <<~RUBY) + class NotifyJob < ActiveJob::Base + include JobIteration::Iteration + + def build_enumerator(user_id, cursor:) + # ... + end + end + RUBY + + expected = template(<<~RBI) + # typed: strong + + class NotifyJob + class << self + sig { params(user_id: T.untyped).returns(T.any(NotifyJob, FalseClass)) } + def perform_later(user_id); end + + sig { params(user_id: T.untyped).returns(T.untyped) } + def perform_now(user_id); end + end + end + RBI + assert_equal(expected, rbi_for(:NotifyJob)) + end + + def test_generates_correct_rbi_file_for_job_with_build_enumerator_method_signature + add_ruby_file("job.rb", <<~RUBY) + class NotifyJob < ActiveJob::Base + include JobIteration::Iteration + + extend T::Sig + sig { params(user_id: Integer, cursor: T.untyped).void } + def build_enumerator(user_id, cursor:) + # ... + end + end + RUBY + + expected = template(<<~RBI) + # typed: strong + + class NotifyJob + class << self + sig { params(user_id: Integer).returns(T.any(NotifyJob, FalseClass)) } + def perform_later(user_id); end + + sig { params(user_id: Integer).void } + def perform_now(user_id); end + end + end + RBI + assert_equal(expected, rbi_for(:NotifyJob)) + end + + def test_generates_correct_rbi_file_for_job_with_build_enumerator_method_with_multiple_parameters + add_ruby_file("job.rb", <<~RUBY) + class NotifyJob < ActiveJob::Base + include JobIteration::Iteration + + extend T::Sig + sig { params(user_id: Integer, name: String, cursor: T.untyped).void } + def build_enumerator(user_id, name, cursor:) + # ... + end + end + RUBY + + expected = template(<<~RBI) + # typed: strong + + class NotifyJob + class << self + sig { params(user_id: Integer, name: String).returns(T.any(NotifyJob, FalseClass)) } + def perform_later(user_id, name); end + + sig { params(user_id: Integer, name: String).void } + def perform_now(user_id, name); end + end + end + RBI + assert_equal(expected, rbi_for(:NotifyJob)) + end + + def test_generates_correct_rbi_file_for_job_with_build_enumerator_method_with_aliased_hash_parameter + add_ruby_file("job.rb", <<~RUBY) + class NotifyJob < ActiveJob::Base + include JobIteration::Iteration + + Params = T.type_alias { { user_id: Integer, name: String } } + + extend T::Sig + sig { params(params: Params, cursor: T.untyped).void } + def build_enumerator(params, cursor:) + # ... + end + end + RUBY + + expected = template(<<~RBI) + # typed: strong + + class NotifyJob + class << self + sig { params(user_id: Integer, name: String).returns(T.any(NotifyJob, FalseClass)) } + def perform_later(user_id:, name:); end + + sig { params(user_id: Integer, name: String).void } + def perform_now(user_id:, name:); end + end + end + RBI + assert_equal(expected, rbi_for(:NotifyJob)) + end + + def test_generates_correct_rbi_file_for_job_with_build_enumerator_method_with_nested_hash_parameter + add_ruby_file("job.rb", <<~RUBY) + class ResourceType; end + class Locale; end + + class NotifyJob < ActiveJob::Base + include JobIteration::Iteration + + extend T::Sig + sig { params(params: { shop_id: Integer, resource_types: T::Array[ResourceType], locale: Locale, metadata: T.nilable(String) }, cursor: T.untyped).void } + def build_enumerator(params, cursor:) + # ... + end + end + RUBY + + expected = template(<<~RBI) + # typed: strong + + class NotifyJob + class << self + sig { params(shop_id: Integer, resource_types: T::Array[ResourceType], locale: Locale, metadata: T.nilable(String)).returns(T.any(NotifyJob, FalseClass)) } + def perform_later(shop_id:, resource_types:, locale:, metadata:); end + + sig { params(shop_id: Integer, resource_types: T::Array[ResourceType], locale: Locale, metadata: T.nilable(String)).void } + def perform_now(shop_id:, resource_types:, locale:, metadata:); end + end + end + RBI + assert_equal(expected, rbi_for(:NotifyJob)) + end + + def test_generates_correct_rbi_file_for_job_with_build_enumerator_method_with_complex_hash_parameter + add_ruby_file("job.rb", <<~RUBY) + class NotifyJob < ActiveJob::Base + include JobIteration::Iteration + + extend T::Sig + sig do + params( + params: { + shop_ids: T.any(Integer, T::Array[Integer]), + profile_ids: T.any(Integer, T::Array[Integer]), + extension_ids: T.any(Integer, T::Array[Integer]), + foo: Symbol, + bar: String + }, + cursor: T.untyped + ).void + end + def build_enumerator(params, cursor:) + # ... + end + end + RUBY + + expected = template(<<~RBI) + # typed: strong + + class NotifyJob + class << self + sig { params(shop_ids: T.any(Integer, T::Array[Integer]), profile_ids: T.any(Integer, T::Array[Integer]), extension_ids: T.any(Integer, T::Array[Integer]), foo: Symbol, bar: String).returns(T.any(NotifyJob, FalseClass)) } + def perform_later(shop_ids:, profile_ids:, extension_ids:, foo:, bar:); end + + sig { params(shop_ids: T.any(Integer, T::Array[Integer]), profile_ids: T.any(Integer, T::Array[Integer]), extension_ids: T.any(Integer, T::Array[Integer]), foo: Symbol, bar: String).void } + def perform_now(shop_ids:, profile_ids:, extension_ids:, foo:, bar:); end + end + end + RBI + assert_equal(expected, rbi_for(:NotifyJob)) + end + end + end + end +end