lib/temping.rb in temping-3.10.0 vs lib/temping.rb in temping-4.0.0

- old
+ new

@@ -1,94 +1,149 @@ require "active_record" require "active_support/core_ext/string" +class Temping; end + +require "temping/namespace_factory" +require "temping/model_factory" + class Temping - @model_klasses = [] + @namespaces = [] + @models = [] class << self - def create(model_name, options = {}, &block) - factory = ModelFactory.new(model_name.to_s.classify, options, &block) - klass = factory.klass - @model_klasses << klass - klass + # Create a new temporary ActiveRecord model with a name specified by `name`. + # + # Provided `options` are all passed to the inner `create_table` call so anything + # acceptable by `create_table` method can be passed here. + # In addition `options` can include `parent_class` key to specify parent class for the model. + # When `block` is passed, it is evaluated in the context of the class. This means anything you + # do in an ActiveRecord model class body can be accomplished in `block` including method + # definitions, validations, module includes, etc. + # Additional database columns can be specified via `with_columns` method inside `block`, + # which uses Rails migration syntax. + def create(name, options = {}, &block) + namespace_name, model_name = split_name(name) + namespace = namespace_name ? NamespaceFactory.new(namespace_name).klass : Object + @namespaces << namespace if namespace_name + model = ModelFactory.new(model_name, namespace, options, &block).klass + @models << model + model end + # Completely destroy everything created by Temping. + # + # This includes: + # * removing all the records in the models created by Temping and dropping their tables + # from the database; + # * undefining model constants so they cannot be pointed to anymore in the code. def teardown - if @model_klasses.any? - @model_klasses.each do |klass| - if Object.const_defined?(klass.name) - klass.connection.drop_table(klass.table_name) - Object.send(:remove_const, klass.name) - end - end - @model_klasses.clear - ActiveSupport::Dependencies::Reference.clear! + if @models.any? + teardown_models + teardown_namespaces + ActiveSupport::Dependencies::Reference.clear! if ActiveRecord::VERSION::MAJOR < 7 end end + # Destroy all records from each of the models created by Temping. + # + # This does not undefine the models themselves or drop their tables. + # This method is an alternative to `teardown` if you want to keep the models and tables. def cleanup - @model_klasses.each(&:destroy_all) + @models.reverse_each(&:destroy_all) end - end - class ModelFactory - def initialize(model_name, options = {}, &block) - @model_name = model_name - @options = options - klass.class_eval(&block) if block_given? - klass.reset_column_information + # Split the provided name finding the namespace (if any) and the model name without namespace. + def split_name(name) + classified_name = name.to_s.classify + name_parts = classified_name.split("::") + namespace_name = name_parts[0...-1].join("::") + return [nil, classified_name] if namespace_name.empty? + + [namespace_name, name_parts.last] end + private :split_name - def klass - @klass ||= Object.const_get(@model_name) - rescue NameError - @klass = build + # Iterate over `@models`, undefine model constants, drop tables, and remove the constants + # from the array one by one starting with the models defined last. (Models defined later + # can point to older models by using foreign keys so they have to be removed first). + def teardown_models + @models.reverse_each do |model| + model_name_without_namespace = model.name.split("::").last + if model.namespace.const_defined?(model_name_without_namespace) + model.connection.drop_table(model.table_name) + model.namespace.send(:remove_const, model_name_without_namespace) + end + end + @models.clear end + private :teardown_models - private - - def build - Class.new(@options.fetch(:parent_class, default_parent_class)).tap do |klass| - Object.const_set(@model_name, klass) - - klass.primary_key = @options[:primary_key] || :id - create_table(@options) - add_methods + # Iterate over `@namespaces`, undefine modules and remove them from the array one by one + # starting with the deepest ones first. + def teardown_namespaces + @namespaces.select! { |namespace| namespace_still_defined?(namespace) } + until @namespaces.empty? + namespace, index = @namespaces.each_with_index.max_by { |n, _i| n.name.split("::").length } + parts = namespace.name.split("::") + parent = parts.length == 1 ? Object : parts[0...-1].join("::").constantize + parent.send(:remove_const, parts.last) if namespace_removable?(namespace, parts, parent) + delete_or_trim_in_namespaces(parent, parts, index) end end + private :teardown_namespaces - def default_parent_class - if ActiveRecord::VERSION::MAJOR > 4 && defined?(ApplicationRecord) - ApplicationRecord - else - ActiveRecord::Base + # Check if namespace is still defined. + # It could be already removed if it were inside a model created by Temping. + # Since `@models` are teared down first, it means that in such a case all modules that were + # inside that model are no longer defined. + def namespace_still_defined?(namespace) + parent = Object + outer_namespace_parts = [] + namespace.to_s.split("::").each do |part| + return false unless parent.const_defined?(part) + + outer_namespace_parts.push(part) + parent = outer_namespace_parts.join("::").constantize end + true end + private :namespace_still_defined? - DEFAULT_OPTIONS = { :temporary => true } - def create_table(options = {}) - connection.create_table(table_name, DEFAULT_OPTIONS.merge(options)) + # Namespace can be removed only if it's still defined and if it was created by Temping, + # we use `defined_by_temping?` to indicate the latter. + def namespace_removable?(namespace, parts, parent) + parent.const_defined?(parts.last) && namespace.defined_by_temping? + rescue NoMethodError + false end + private :namespace_removable? - def add_methods - class << klass - def with_columns - connection.change_table(table_name) do |table| - yield(table) - end - end + # Clean `@namespaces` array by either removing current namespace or replacing it with its + # parent. + # + # Case 1: @namespaces = [A, B, C, D]; index = 3 + # This is an outer-most module, we just remove it from `@namespaces`. + # + # Case 2: @namespaces = [A::B, A::B::C, A::D]; index = 1 + # Once we remove C from A::B::C, it becomes A::B, but we already have A::B, so just remove it. + # + # Case 3: @namespaces = [A::B, A::D]; index = 1 + # Once we remove D from A::D, it becomes A, replace A::D with A. + def delete_or_trim_in_namespaces(parent, parts, index) + is_last_module = parts.length == 1 + if is_last_module + @namespaces.delete_at(index) + return + end - def table_exists? - true - end + parent_already_in_namespaces = @namespaces.include?(parent) + if parent_already_in_namespaces + @namespaces.delete_at(index) + return end - end - def connection - klass.connection + @namespaces[index] = parent end - - def table_name - klass.table_name - end + private :delete_or_trim_in_namespaces end end