diff --git a/lib/active_form.rb b/lib/active_form.rb index 069fed5..13f6226 100644 --- a/lib/active_form.rb +++ b/lib/active_form.rb @@ -2,7 +2,8 @@ require 'active_form/form' require 'active_form/form_collection' require 'active_form/form_definition' -require 'active_form/too_many_records' +require 'active_form/errors' +require 'active_form/multiparameter_attribute' require 'active_form/view_helpers' module ActiveForm diff --git a/lib/active_form/base.rb b/lib/active_form/base.rb index 5cf28d8..c3109fd 100644 --- a/lib/active_form/base.rb +++ b/lib/active_form/base.rb @@ -16,13 +16,21 @@ def initialize(model) end def submit(params) + multi_parameter_attributes = [] + params.each do |key, value| - if nested_params?(value) + if key.to_s.include?("(") + multi_parameter_attributes << [ key, value ] + elsif nested_params?(value) fill_association_with_attributes(key, value) else send("#{key}=", value) end end + + if multi_parameter_attributes.present? + assign_multiparameter_attributes(multi_parameter_attributes) + end end def get_model(assoc_name) @@ -164,6 +172,49 @@ def collect_errors_from(validatable_object) errors.add(key, error) end end + + def assign_multiparameter_attributes(pairs) + execute_callstack_for_multiparameter_attributes( + extract_callstack_for_multiparameter_attributes(pairs) + ) + end + + def execute_callstack_for_multiparameter_attributes(callstack) + errors = [] + callstack.each do |name, values_with_empty_parameters| + begin + send("#{name}=", MultiparameterAttribute.new(self, name, values_with_empty_parameters).read_value) + rescue => ex + errors << AttributeAssignmentError.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name) + end + end + if errors.present? + error_descriptions = errors.map { |ex| ex.message }.join(",") + raise MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes [#{error_descriptions}]" + end + end + + def extract_callstack_for_multiparameter_attributes(pairs) + attributes = {} + + pairs.each do |(multiparameter_name, value)| + attribute_name = multiparameter_name.split("(").first + attributes[attribute_name] ||= {} + + parameter_value = value.empty? ? nil : type_cast_attribute_value(multiparameter_name, value) + attributes[attribute_name][find_parameter_position(multiparameter_name)] ||= parameter_value + end + + attributes + end + + def type_cast_attribute_value(multiparameter_name, value) + multiparameter_name =~ /\([0-9]*([if])\)/ ? value.send("to_" + $1) : value + end + + def find_parameter_position(multiparameter_name) + multiparameter_name.scan(/\(([0-9]*).*\)/).first.first.to_i + end end end diff --git a/lib/active_form/errors.rb b/lib/active_form/errors.rb new file mode 100644 index 0000000..95d5cb8 --- /dev/null +++ b/lib/active_form/errors.rb @@ -0,0 +1,22 @@ +module ActiveForm + class TooManyRecords < RuntimeError;end + + class AttributeAssignmentError < RuntimeError + attr_reader :exception, :attribute + def initialize(message, exception, attribute) + super(message) + @exception = exception + @attribute = attribute + end + end + + # Raised when there are multiple errors while doing a mass assignment through the +attributes+ + # method. The exception has an +errors+ property that contains an array of AttributeAssignmentError + # objects, each corresponding to the error while assigning to an attribute. + class MultiparameterAssignmentErrors < RuntimeError + attr_reader :errors + def initialize(errors) + @errors = errors + end + end +end diff --git a/lib/active_form/form.rb b/lib/active_form/form.rb index e756a37..4f6b8b8 100644 --- a/lib/active_form/form.rb +++ b/lib/active_form/form.rb @@ -82,13 +82,19 @@ def submit(params) @model = parent.send("build_#{association_name}") unless call_reject_if(params_for_current_scope(params)) end + multi_parameter_attributes = [] + params.each do |key, value| - if nested_params?(value) + if key.to_s.include?("(") + multi_parameter_attributes << [ key, value ] + elsif nested_params?(value) fill_association_with_attributes(key, value) else model.send("#{key}=", value) end end + + assign_multiparameter_attributes(multi_parameter_attributes) if multi_parameter_attributes.present? end def get_model(assoc_name) @@ -199,9 +205,53 @@ def collect_errors_from(validatable_object) else attribute end + puts "adding #{key} error: #{error} from #{validatable_object}" errors.add(key, error) end end + + def assign_multiparameter_attributes(pairs) + execute_callstack_for_multiparameter_attributes( + extract_callstack_for_multiparameter_attributes(pairs) + ) + end + + def execute_callstack_for_multiparameter_attributes(callstack) + errors = [] + callstack.each do |name, values_with_empty_parameters| + begin + send("#{name}=", MultiparameterAttribute.new(self, name, values_with_empty_parameters).read_value) + rescue => ex + errors << AttributeAssignmentError.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name) + end + end + if errors.present? + error_descriptions = errors.map { |ex| ex.message }.join(",") + raise MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes [#{error_descriptions}]" + end + end + + def extract_callstack_for_multiparameter_attributes(pairs) + attributes = {} + + pairs.each do |(multiparameter_name, value)| + attribute_name = multiparameter_name.split("(").first + attributes[attribute_name] ||= {} + + parameter_value = value.empty? ? nil : type_cast_attribute_value(multiparameter_name, value) + attributes[attribute_name][find_parameter_position(multiparameter_name)] ||= parameter_value + end + + attributes + end + + def type_cast_attribute_value(multiparameter_name, value) + multiparameter_name =~ /\([0-9]*([if])\)/ ? value.send("to_" + $1) : value + end + + def find_parameter_position(multiparameter_name) + multiparameter_name.scan(/\(([0-9]*).*\)/).first.first.to_i + end end end diff --git a/lib/active_form/multiparameter_attribute.rb b/lib/active_form/multiparameter_attribute.rb new file mode 100644 index 0000000..ac26d06 --- /dev/null +++ b/lib/active_form/multiparameter_attribute.rb @@ -0,0 +1,93 @@ +module ActiveForm + class MultiparameterAttribute #:nodoc: + attr_reader :object, :name, :values, :column + + def initialize(object, name, values) + @object = object + @name = name + @values = values + end + + def read_value + return if values.values.compact.empty? + + @column = object.model.class.reflect_on_aggregation(name.to_sym) || object.model.column_for_attribute(name) + klass = column.klass + + if klass == Time + read_time + elsif klass == Date + read_date + else + read_other(klass) + end + end + + private + + def instantiate_time_object(set_values) + Time.zone.local(*set_values) + end + + def read_time + # If column is a :time (and not :date or :timestamp) there is no need to validate if + # there are year/month/day fields + if column.type == :time + # if the column is a time set the values to their defaults as January 1, 1970, but only if they're nil + { 1 => 1970, 2 => 1, 3 => 1 }.each do |key,value| + values[key] ||= value + end + else + # else column is a timestamp, so if Date bits were not provided, error + validate_required_parameters!([1,2,3]) + + # If Date bits were provided but blank, then return nil + return if blank_date_parameter? + end + + max_position = extract_max_param(6) + set_values = values.values_at(*(1..max_position)) + # If Time bits are not there, then default to 0 + (3..5).each { |i| set_values[i] = set_values[i].presence || 0 } + instantiate_time_object(set_values) + end + + def read_date + return if blank_date_parameter? + set_values = values.values_at(1,2,3) + begin + Date.new(*set_values) + rescue ArgumentError # if Date.new raises an exception on an invalid date + instantiate_time_object(set_values).to_date # we instantiate Time object and convert it back to a date thus using Time's logic in handling invalid dates + end + end + + def read_other(klass) + max_position = extract_max_param + positions = (1..max_position) + validate_required_parameters!(positions) + + set_values = values.values_at(*positions) + klass.new(*set_values) + end + + # Checks whether some blank date parameter exists. Note that this is different + # than the validate_required_parameters! method, since it just checks for blank + # positions instead of missing ones, and does not raise in case one blank position + # exists. The caller is responsible to handle the case of this returning true. + def blank_date_parameter? + (1..3).any? { |position| values[position].blank? } + end + + # If some position is not provided, it errors out a missing parameter exception. + def validate_required_parameters!(positions) + if missing_parameter = positions.detect { |position| !values.key?(position) } + raise ArgumentError.new("Missing Parameter - #{name}(#{missing_parameter})") + end + end + + def extract_max_param(upper_cap = 100) + [values.keys.max, upper_cap].min + end + end +end diff --git a/lib/active_form/too_many_records.rb b/lib/active_form/too_many_records.rb deleted file mode 100644 index 6214a1b..0000000 --- a/lib/active_form/too_many_records.rb +++ /dev/null @@ -1,4 +0,0 @@ -module ActiveForm - class TooManyRecords < RuntimeError - end -end \ No newline at end of file diff --git a/test/dummy/app/forms/project_form.rb b/test/dummy/app/forms/project_form.rb index 6651a2f..b86d81d 100644 --- a/test/dummy/app/forms/project_form.rb +++ b/test/dummy/app/forms/project_form.rb @@ -1,8 +1,8 @@ class ProjectForm < ActiveForm::Base - attributes :name, :description, :owner_id + attributes :name, :description, :owner_id, :published_at association :tasks do - attributes :name, :description, :done + attributes :name, :description, :done, :finished_at association :sub_tasks do attributes :name, :description, :done diff --git a/test/dummy/db/migrate/20141227163700_add_published_at_to_projects.rb b/test/dummy/db/migrate/20141227163700_add_published_at_to_projects.rb new file mode 100644 index 0000000..77b1a4b --- /dev/null +++ b/test/dummy/db/migrate/20141227163700_add_published_at_to_projects.rb @@ -0,0 +1,5 @@ +class AddPublishedAtToProjects < ActiveRecord::Migration + def change + add_column :projects, :published_at, :datetime + end +end diff --git a/test/dummy/db/migrate/20141227165333_add_finished_at_to_tasks.rb b/test/dummy/db/migrate/20141227165333_add_finished_at_to_tasks.rb new file mode 100644 index 0000000..4e0f98c --- /dev/null +++ b/test/dummy/db/migrate/20141227165333_add_finished_at_to_tasks.rb @@ -0,0 +1,5 @@ +class AddFinishedAtToTasks < ActiveRecord::Migration + def change + add_column :tasks, :finished_at, :datetime + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index f3eab85..3e469c7 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20140821074917) do +ActiveRecord::Schema.define(version: 20141227165333) do create_table "answers", force: true do |t| t.text "content" @@ -110,6 +110,7 @@ t.datetime "created_at" t.datetime "updated_at" t.integer "owner_id" + t.datetime "published_at" end create_table "questions", force: true do |t| @@ -169,6 +170,7 @@ t.datetime "created_at" t.datetime "updated_at" t.integer "assignment_id" + t.datetime "finished_at" end add_index "tasks", ["assignment_id"], name: "index_tasks_on_assignment_id" diff --git a/test/forms/project_form_test.rb b/test/forms/project_form_test.rb index 1a10110..f7700e8 100644 --- a/test/forms/project_form_test.rb +++ b/test/forms/project_form_test.rb @@ -138,7 +138,7 @@ def setup test "project form initializes the number of records specified for tasks" do assert_respond_to @tasks_form, :models assert_equal 1, @tasks_form.models.size - + @tasks_form.each do |form| assert_instance_of ActiveForm::Form, form assert_instance_of Task, form.model @@ -228,6 +228,36 @@ def setup assert_nil project_form.model.owner end + test "accepts multiattributes" do + project = Project.new + project_form = ProjectForm.new(project) + + params = { + name: "Add Form Models", + description: "Google Summer of Code 2014", + "published_at(3i)"=>"10", + "published_at(2i)"=>"12", + "published_at(1i)"=>"2014", + "published_at(4i)"=>"02", + "published_at(5i)"=>"29", + owner_attributes: { + name: "", + role: "", + description: "" + } + } + + project_form.submit(params) + + assert_equal Time.zone.local(2014, 12, 10, 02, 29), project_form.published_at + + assert_difference('Project.count') do + project_form.save + end + + assert_nil project_form.model.owner + end + test "reject contributor if attributes are all blank" do project = Project.new project_form = ProjectForm.new(project) @@ -294,7 +324,7 @@ def setup ## FAILS ProjectTag.delete_all Tag.delete_all - + tag = Tag.create(name: "Html Forms") project = Project.new project_form = ProjectForm.new(project) @@ -464,11 +494,11 @@ def setup test "update project with new owner" do project = Project.create(name: "Form Models", description: "GSoC 2014") project_form = ProjectForm.new(project) - + params = { name: "Add Form Models", description: "Nesting models in a single form", - + owner_attributes: { name: "Carlos Silva", role: "RoR Core Team", @@ -495,11 +525,11 @@ def setup owner = Person.create(name: "Carlos Silva", role: "RoR Core Member", description: "Mentoring Peter throughout GSoC") project = Project.create(name: "Form Models", description: "GSoC 2014") project_form = ProjectForm.new(project) - + params = { name: "Add Form Models", description: "Nesting models in a single form", - + owner_id: owner.id } @@ -568,7 +598,16 @@ def setup name: "Add Form Models", tasks_attributes: { - "0" => { name: "Form unit", description: "Form to represent a single model", done: false }, + "0" => { + name: "Form unit", + description: "Form to represent a single model", + done: false, + "finished_at(3i)"=>"10", + "finished_at(2i)"=>"12", + "finished_at(1i)"=>"2014", + "finished_at(4i)"=>"02", + "finished_at(5i)"=>"29", + }, } } @@ -580,6 +619,8 @@ def setup assert_equal "Form to represent a single model", @form.tasks[0].description assert_equal false, @form.tasks[0].done + assert_equal Time.zone.local(2014, 12, 10, 02, 29), @form.tasks[0].finished_at + assert_equal 1, @form.tasks.size end @@ -682,7 +723,7 @@ def setup form = ProjectForm.new(project) params = { name: "Life", - + tasks_attributes: { "0" => { name: "Eat", done: "1", id: tasks(:rake).id }, "1" => { name: "Pray", done: "1", id: tasks(:paint).id }, @@ -698,10 +739,10 @@ def setup assert_equal "Life", form.name assert_equal "Eat", form.tasks[0].name assert_equal true, form.tasks[0].done - + assert_equal "Pray", form.tasks[1].name assert_equal true, form.tasks[1].done - + assert_equal 2, form.tasks.size end @@ -757,5 +798,5 @@ def setup test "project form responds to owner writer method" do assert_respond_to @form, :owner_attributes= end - -end \ No newline at end of file + +end