From 8fd6167cef82ab5000a3ccd823e197189d5d1a48 Mon Sep 17 00:00:00 2001 From: MANOJ PUTHRAN Date: Sat, 15 Nov 2025 18:16:42 +0530 Subject: [PATCH 1/7] add attribute_constraints property for adding attribute rules or constraints --- app/models/generic_object_definition.rb | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/app/models/generic_object_definition.rb b/app/models/generic_object_definition.rb index f87471ca3c4..ff7bcb789fc 100644 --- a/app/models/generic_object_definition.rb +++ b/app/models/generic_object_definition.rb @@ -20,7 +20,7 @@ class GenericObjectDefinition < ApplicationRecord :time => N_('Time') }.freeze - FEATURES = %w[attribute association method].freeze + FEATURES = %w[attribute attribute_constraint association method].freeze REG_ATTRIBUTE_NAME = /\A[a-z][a-zA-Z_0-9]*\z/ REG_METHOD_NAME = /\A[a-z][a-zA-Z_0-9]*[!?]?\z/ ALLOWED_ASSOCIATION_TYPES = (MiqReport.reportable_models + %w[GenericObject]).freeze @@ -103,12 +103,13 @@ def type_cast(attr, value) end def properties=(props) - props.reverse_merge!(:attributes => {}, :associations => {}, :methods => []) + props.reverse_merge!(:attributes => {}, :attribute_constraints => {}, :associations => {}, :methods => []) super end - def add_property_attribute(name, type) + def add_property_attribute(name, type, constraints = {}) properties[:attributes][name.to_s] = type.to_sym + properties[:attribute_constraints][name.to_s] = constraints if constraints.present? save! end @@ -117,10 +118,24 @@ def delete_property_attribute(name) generic_objects.find_each { |o| o.delete_property(name) } properties[:attributes].delete(name.to_s) + properties[:attribute_constraints].delete(name.to_s) save! end end + def add_property_attribute_constraint(name, constraint) + name = attribute_name.to_s + raise "attribute [#{name}] is not defined" unless property_attribute_defined?(name) + + properties[:attribute_constraints][name.to_s] = constraint + save! + end + + def delete_property_attribute_constraint(name) + properties[:attribute_constraints].delete(name.to_s) + save! + end + def add_property_association(name, type) type = type.to_s.classify raise "invalid model for association: [#{type}]" unless type.in?(ALLOWED_ASSOCIATION_TYPES) @@ -230,6 +245,6 @@ def check_not_in_use end def set_default_properties - self.properties = {:attributes => {}, :associations => {}, :methods => []} unless properties.present? + self.properties = {:attributes => {}, :attribute_constraints => {}, :associations => {}, :methods => []} unless properties.present? end end From b9a8ffc07d350cfd9e53c3a69e8f9f83147b55de Mon Sep 17 00:00:00 2001 From: MANOJ PUTHRAN Date: Sat, 15 Nov 2025 18:37:46 +0530 Subject: [PATCH 2/7] Add validation for attribute_constraints in GenericObjectDefinition --- app/models/generic_object_definition.rb | 120 +++++++++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/app/models/generic_object_definition.rb b/app/models/generic_object_definition.rb index ff7bcb789fc..e9e27721b10 100644 --- a/app/models/generic_object_definition.rb +++ b/app/models/generic_object_definition.rb @@ -20,6 +20,16 @@ class GenericObjectDefinition < ApplicationRecord :time => N_('Time') }.freeze + CONSTRAINT_TYPES = { + :required => [:boolean, :datetime, :float, :integer, :string, :time], + :min => [:integer, :float, :datetime, :time], + :max => [:integer, :float, :datetime, :time], + :min_length => [:string], + :max_length => [:string], + :enum => [:integer, :string], + :format => [:string] + }.freeze + FEATURES = %w[attribute attribute_constraint association method].freeze REG_ATTRIBUTE_NAME = /\A[a-z][a-zA-Z_0-9]*\z/ REG_METHOD_NAME = /\A[a-z][a-zA-Z_0-9]*[!?]?\z/ @@ -34,6 +44,7 @@ class GenericObjectDefinition < ApplicationRecord validates :name, :presence => true, :uniqueness_when_changed => true validate :validate_property_attributes, + :validate_property_attribute_constraints, :validate_property_associations, :validate_property_methods, :validate_property_name_unique, @@ -41,6 +52,7 @@ class GenericObjectDefinition < ApplicationRecord before_validation :set_default_properties before_validation :normalize_property_attributes, + :normalize_property_attribute_constraints, :normalize_property_associations, :normalize_property_methods @@ -124,10 +136,10 @@ def delete_property_attribute(name) end def add_property_attribute_constraint(name, constraint) - name = attribute_name.to_s + name = name.to_s raise "attribute [#{name}] is not defined" unless property_attribute_defined?(name) - properties[:attribute_constraints][name.to_s] = constraint + properties[:attribute_constraints][name] = constraint save! end @@ -187,6 +199,14 @@ def normalize_property_attributes end end + def normalize_property_attribute_constraints + props = properties.symbolize_keys + + properties[:attribute_constraints] = props[:attribute_constraints].each_with_object({}) do |(name, constraints), hash| + hash[name.to_s] = constraints + end + end + def normalize_property_associations props = properties.symbolize_keys @@ -207,6 +227,77 @@ def validate_property_attributes end end + def validate_property_attribute_constraints + properties[:attribute_constraints].each do |attr_name, constraints| + # Check if the attribute exists + unless properties[:attributes].key?(attr_name) + errors.add(:properties, "constraint defined for non-existent attribute: [#{attr_name}]") + next + end + + attr_type = properties[:attributes][attr_name].to_sym + + # Validate constraints is a hash + unless constraints.is_a?(Hash) + errors.add(:properties, "constraints for attribute [#{attr_name}] must be a hash") + next + end + + constraints.each do |constraint_type, constraint_value| + constraint_type_sym = constraint_type.to_sym + + # Check if constraint type is valid + unless CONSTRAINT_TYPES.key?(constraint_type_sym) + errors.add(:properties, "invalid constraint type [#{constraint_type}] for attribute [#{attr_name}]") + next + end + + # Check if constraint type is applicable to attribute type + unless CONSTRAINT_TYPES[constraint_type_sym].include?(attr_type) + errors.add(:properties, "constraint [#{constraint_type}] is not applicable to attribute type [#{attr_type}] for attribute [#{attr_name}]") + next + end + + # Validate constraint values + validate_constraint_value(attr_name, attr_type, constraint_type_sym, constraint_value) + end + end + end + + def validate_constraint_value(attr_name, attr_type, constraint_type, value) + case constraint_type + when :required + unless [true, false].include?(value) + errors.add(:properties, "constraint 'required' must be true or false for attribute [#{attr_name}]") + end + when :min, :max + if attr_type == :integer && !value.is_a?(Integer) + errors.add(:properties, "constraint '#{constraint_type}' must be an integer for attribute [#{attr_name}]") + elsif attr_type == :float && !value.is_a?(Numeric) + errors.add(:properties, "constraint '#{constraint_type}' must be a number for attribute [#{attr_name}]") + elsif [:datetime, :time].include?(attr_type) && !value.is_a?(String) && !value.is_a?(Time) && !value.is_a?(DateTime) + errors.add(:properties, "constraint '#{constraint_type}' must be a valid time/datetime for attribute [#{attr_name}]") + end + when :min_length, :max_length + unless value.is_a?(Integer) && value > 0 + errors.add(:properties, "constraint '#{constraint_type}' must be a positive integer for attribute [#{attr_name}]") + end + when :enum + validate_enum_constraint(attr_name, attr_type, value) + when :format + unless value.is_a?(Regexp) || (value.is_a?(String) && valid_regex?(value)) + errors.add(:properties, "constraint 'format' must be a valid regular expression for attribute [#{attr_name}]") + end + end + end + + def valid_regex?(string) + Regexp.new(string) + true + rescue RegexpError + false + end + def validate_property_associations invalid_models = properties[:associations].values - ALLOWED_ASSOCIATION_TYPES errors.add(:properties, "invalid models for association: [#{invalid_models.join(",")}]") unless invalid_models.empty? @@ -247,4 +338,29 @@ def check_not_in_use def set_default_properties self.properties = {:attributes => {}, :attribute_constraints => {}, :associations => {}, :methods => []} unless properties.present? end + + private + + def validate_enum_constraint(attr_name, attr_type, value) + unless value.is_a?(Array) && value.any? + errors.add(:properties, "constraint 'enum' must be a non-empty array for attribute [#{attr_name}]") + return + end + + if value.any?(&:nil?) + errors.add(:properties, "constraint 'enum' must not contain nil values for attribute [#{attr_name}]") + return + end + + if value.uniq.size != value.size + errors.add(:properties, "constraint 'enum' contains duplicate values for attribute [#{attr_name}]") + end + + # Existing type validation continues... + if attr_type == :integer && !value.all? { |v| v.is_a?(Integer) } + errors.add(:properties, "constraint 'enum' values must be integers for attribute [#{attr_name}]") + elsif attr_type == :string && !value.all? { |v| v.is_a?(String) } + errors.add(:properties, "constraint 'enum' values must be strings for attribute [#{attr_name}]") + end + end end From 16c050977126bb0791cbcfca98aef675632f56d7 Mon Sep 17 00:00:00 2001 From: MANOJ PUTHRAN Date: Sat, 15 Nov 2025 21:48:15 +0530 Subject: [PATCH 3/7] add spec for attribute_constraints in GenericObjectDefinition --- .../generic_object_definitions_spec.rb | 2 +- .../generic_object_definitions_spec.rb | 1 + spec/models/generic_object_definition_spec.rb | 370 ++++++++++++++++++ 3 files changed, 372 insertions(+), 1 deletion(-) diff --git a/spec/lib/task_helpers/exports/generic_object_definitions_spec.rb b/spec/lib/task_helpers/exports/generic_object_definitions_spec.rb index ab9966c4f66..88943be4d99 100644 --- a/spec/lib/task_helpers/exports/generic_object_definitions_spec.rb +++ b/spec/lib/task_helpers/exports/generic_object_definitions_spec.rb @@ -28,7 +28,7 @@ expect(god2_yaml.first["GenericObjectDefinition"]["name"]).to eq(@god2.name) expect(god2_yaml.first["GenericObjectDefinition"]["description"]).to eq(nil) expect(god2_yaml.first["GenericObjectDefinition"]["properties"]).to eq( - :attributes => {}, :associations => {}, :methods => [] + :attributes => {}, :attribute_constraints=>{}, :associations => {}, :methods => [] ) end end diff --git a/spec/lib/task_helpers/imports/generic_object_definitions_spec.rb b/spec/lib/task_helpers/imports/generic_object_definitions_spec.rb index 0a4b694af7c..b85e4213dc3 100644 --- a/spec/lib/task_helpers/imports/generic_object_definitions_spec.rb +++ b/spec/lib/task_helpers/imports/generic_object_definitions_spec.rb @@ -18,6 +18,7 @@ 'created' => :datetime, 'retirement' => :datetime }, + :attribute_constraints => {}, :associations => {'cloud_tenant' => 'CloudTenant'}, :methods => ['kick', 'laugh_at', 'punch', 'parseltongue'] } diff --git a/spec/models/generic_object_definition_spec.rb b/spec/models/generic_object_definition_spec.rb index bf6ce93ca87..6e7ec004af3 100644 --- a/spec/models/generic_object_definition_spec.rb +++ b/spec/models/generic_object_definition_spec.rb @@ -475,4 +475,374 @@ expect(subject).to be_valid end end + + describe 'attribute_constraints' do + context 'validation' do + it 'accepts valid constraints for string attributes' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:name => :string}, + :attribute_constraints => {:name => {:required => true, :min_length => 3, :max_length => 50}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts valid constraints for integer attributes' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:age => :integer}, + :attribute_constraints => {:age => {:required => true, :min => 0, :max => 120}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts valid constraints for float attributes' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:price => :float}, + :attribute_constraints => {:price => {:min => 0.0, :max => 999.99}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts valid enum constraint for string attributes' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:status => :string}, + :attribute_constraints => {:status => {:enum => %w[active inactive pending]}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts valid enum constraint for integer attributes' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:priority => :integer}, + :attribute_constraints => {:priority => {:enum => [1, 2, 3, 4, 5]}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts valid format constraint for string attributes' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:email => :string}, + :attribute_constraints => {:email => {:format => /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts format constraint as string regex' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:code => :string}, + :attribute_constraints => {:code => {:format => '\A[A-Z]{3}\d{3}\z'}} + } + ) + expect(testdef).to be_valid + end + + it 'rejects constraint for non-existent attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:name => :string}, + :attribute_constraints => {:age => {:required => true}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint defined for non-existent attribute/) + end + + it 'rejects non-hash constraints' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:name => :string}, + :attribute_constraints => {:name => "invalid"} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraints for attribute .* must be a hash/) + end + + it 'rejects invalid constraint type' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:name => :string}, + :attribute_constraints => {:name => {:invalid_constraint => true}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /invalid constraint type/) + end + + it 'rejects constraint not applicable to attribute type' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:flag => :boolean}, + :attribute_constraints => {:flag => {:min_length => 5}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint .* is not applicable to attribute type/) + end + + it 'rejects non-boolean value for required constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:name => :string}, + :attribute_constraints => {:name => {:required => "yes"}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint 'required' must be true or false/) + end + + it 'rejects non-integer value for min constraint on integer attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:age => :integer}, + :attribute_constraints => {:age => {:min => "zero"}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint 'min' must be an integer/) + end + + it 'rejects non-numeric value for min constraint on float attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:price => :float}, + :attribute_constraints => {:price => {:min => "zero"}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint 'min' must be a number/) + end + + it 'rejects non-positive integer for min_length constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:name => :string}, + :attribute_constraints => {:name => {:min_length => -1}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint 'min_length' must be a positive integer/) + end + + it 'rejects non-array value for enum constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:status => :string}, + :attribute_constraints => {:status => {:enum => "active"}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint 'enum' must be a non-empty array/) + end + + it 'rejects empty array for enum constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:status => :string}, + :attribute_constraints => {:status => {:enum => []}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint 'enum' must be a non-empty array/) + end + + it 'rejects enum constraint with nil values' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:status => :string}, + :attribute_constraints => {:status => {:enum => ['active', nil, 'inactive']}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint 'enum' must not contain nil values/) + end + + it 'rejects enum constraint with duplicate values' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:status => :string}, + :attribute_constraints => {:status => {:enum => ['active', 'inactive', 'active']}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint 'enum' contains duplicate values/) + end + + it 'rejects enum constraint with wrong type values for integer attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:priority => :integer}, + :attribute_constraints => {:priority => {:enum => [1, "2", 3]}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint 'enum' values must be integers/) + end + + it 'rejects enum constraint with wrong type values for string attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:status => :string}, + :attribute_constraints => {:status => {:enum => ['active', 123, 'inactive']}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint 'enum' values must be strings/) + end + + it 'rejects invalid regex for format constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:code => :string}, + :attribute_constraints => {:code => {:format => '[invalid('}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint 'format' must be a valid regular expression/) + end + end + + context 'normalization' do + it 'normalizes attribute constraint keys to strings' do + testdef = described_class.create!( + :name => 'test', + :properties => { + :attributes => {:name => :string}, + :attribute_constraints => {:name => {:required => true}} + } + ) + expect(testdef.properties[:attribute_constraints]).to have_key('name') + end + end + + describe '#add_property_attribute' do + let(:definition) do + FactoryBot.create(:generic_object_definition, + :name => 'test', + :properties => {:attributes => {:status => "string"}}) + end + + it 'adds attribute with constraints' do + definition.add_property_attribute(:priority, "integer", {:min => 1, :max => 5}) + expect(definition.properties[:attributes]).to include("priority" => :integer) + expect(definition.properties[:attribute_constraints]).to include("priority" => {:min => 1, :max => 5}) + end + + it 'adds attribute without constraints when not provided' do + definition.add_property_attribute(:name, "string") + expect(definition.properties[:attributes]).to include("name" => :string) + expect(definition.properties[:attribute_constraints]).not_to have_key("name") + end + + it 'adds attribute without constraints when empty hash provided' do + definition.add_property_attribute(:name, "string", {}) + expect(definition.properties[:attributes]).to include("name" => :string) + expect(definition.properties[:attribute_constraints]).not_to have_key("name") + end + end + + describe '#delete_property_attribute' do + let(:definition) do + FactoryBot.create(:generic_object_definition, + :name => 'test', + :properties => { + :attributes => {:status => "string", :priority => "integer"}, + :attribute_constraints => {:status => {:required => true}, :priority => {:min => 1, :max => 5}} + }) + end + + it 'deletes attribute and its constraints' do + definition.delete_property_attribute("status") + expect(definition.properties[:attributes]).not_to have_key("status") + expect(definition.properties[:attribute_constraints]).not_to have_key("status") + end + + it 'keeps other attributes and their constraints' do + definition.delete_property_attribute("status") + expect(definition.properties[:attributes]).to include("priority" => :integer) + expect(definition.properties[:attribute_constraints]).to include("priority" => {:min => 1, :max => 5}) + end + end + + describe '#add_property_attribute_constraint' do + let(:definition) do + FactoryBot.create(:generic_object_definition, + :name => 'test', + :properties => {:attributes => {:name => "string"}}) + end + + it 'adds constraint to existing attribute' do + definition.add_property_attribute_constraint(:name, {:required => true, :min_length => 3}) + expect(definition.properties[:attribute_constraints]).to include("name" => {:required => true, :min_length => 3}) + end + + it 'raises error for non-existent attribute' do + expect { definition.add_property_attribute_constraint(:age, {:min => 0}) }.to raise_error(/attribute .* is not defined/) + end + end + + describe '#delete_property_attribute_constraint' do + let(:definition) do + FactoryBot.create(:generic_object_definition, + :name => 'test', + :properties => { + :attributes => {:name => "string"}, + :attribute_constraints => {:name => {:required => true, :min_length => 3}} + }) + end + + it 'deletes constraint for attribute' do + definition.delete_property_attribute_constraint("name") + expect(definition.properties[:attribute_constraints]).not_to have_key("name") + end + + it 'does nothing for non-existent constraint' do + definition.delete_property_attribute_constraint("age") + expect(definition.properties[:attribute_constraints]).to include("name" => {:required => true, :min_length => 3}) + end + end + + describe '#property_attribute_constraints' do + let(:definition) do + FactoryBot.create(:generic_object_definition, + :name => 'test', + :properties => { + :attributes => {:name => "string", :age => "integer"}, + :attribute_constraints => {:name => {:required => true}, :age => {:min => 0, :max => 120}} + }) + end + + it 'returns attribute constraints hash' do + expect(definition.property_attribute_constraints).to be_a(Hash) + expect(definition.property_attribute_constraints).to include("name" => {:required => true}) + expect(definition.property_attribute_constraints).to include("age" => {:min => 0, :max => 120}) + end + end + + describe 'default properties' do + it 'initializes attribute_constraints as empty hash' do + testdef = described_class.create!(:name => 'test') + expect(testdef.properties[:attribute_constraints]).to eq({}) + end + end + end end From d4db2619e0518d0e97af2d957e23492ac88b1bbc Mon Sep 17 00:00:00 2001 From: MANOJ PUTHRAN Date: Sun, 16 Nov 2025 02:15:26 +0530 Subject: [PATCH 4/7] add spec for import & export of GenericObjectDefinitions with attribute_constraints --- .../generic_object_definitions_spec.rb | 49 ++++++++++++++ .../god_with_attr_constraints.yaml | 66 +++++++++++++++++++ .../generic_object_definitions_spec.rb | 62 ++++++++++++++++- 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 spec/lib/task_helpers/imports/data/generic_object_definitions/god_with_attr_constraints.yaml diff --git a/spec/lib/task_helpers/exports/generic_object_definitions_spec.rb b/spec/lib/task_helpers/exports/generic_object_definitions_spec.rb index 88943be4d99..693bf5647c4 100644 --- a/spec/lib/task_helpers/exports/generic_object_definitions_spec.rb +++ b/spec/lib/task_helpers/exports/generic_object_definitions_spec.rb @@ -39,4 +39,53 @@ expect(Dir[File.join(export_dir, '**', '*')].count { |file| File.file?(file) }).to eq(0) end end + + context 'when exporting definitions with attribute constraints' do + let(:god_filename) { "#{export_dir}/#{@god.name}.yaml" } + + before do + @god = FactoryBot.create( + :generic_object_definition, + :name => 'ProductDefinition', + :description => 'Product with constraints', + :properties => { + :attributes => { + 'name' => :string, + 'sku' => :string, + 'priority' => :integer, + 'price' => :float, + 'active' => :boolean + }, + :attribute_constraints => { + 'name' => {:required => true, :min_length => 3, :max_length => 100}, + 'sku' => {:required => true, :format => /\A[A-Z]{3}-\d{6}\z/}, + 'priority' => {:enum => [1, 2, 3, 4, 5]}, + 'price' => {:min => 0.0, :max => 999999.99}, + 'active' => {:required => true} + }, + :associations => {}, + :methods => [] + } + ) + end + + it 'exports definition with attribute constraints' do + TaskHelpers::Exports::GenericObjectDefinitions.new.export(:directory => export_dir) + expect(Dir[File.join(export_dir, '**', '*')].count { |file| File.file?(file) }).to eq(1) + + god_yaml = YAML.load_file(god_filename) + expect(god_yaml.first["GenericObjectDefinition"]["name"]).to eq(@god.name) + expect(god_yaml.first["GenericObjectDefinition"]["description"]).to eq(@god.description) + expect(god_yaml.first["GenericObjectDefinition"]["properties"]).to eq(@god.properties) + + # Verify attribute constraints are exported + exported_constraints = god_yaml.first["GenericObjectDefinition"]["properties"][:attribute_constraints] + expect(exported_constraints).to be_present + expect(exported_constraints['name']).to include(:required => true, :min_length => 3, :max_length => 100) + expect(exported_constraints['sku']).to include(:required => true, :format => /\A[A-Z]{3}-\d{6}\z/) + expect(exported_constraints['priority']).to include(:enum => [1, 2, 3, 4, 5]) + expect(exported_constraints['price']).to include(:min => 0.0, :max => 999999.99) + expect(exported_constraints['active']).to include(:required => true) + end + end end diff --git a/spec/lib/task_helpers/imports/data/generic_object_definitions/god_with_attr_constraints.yaml b/spec/lib/task_helpers/imports/data/generic_object_definitions/god_with_attr_constraints.yaml new file mode 100644 index 00000000000..6bcd884e4b0 --- /dev/null +++ b/spec/lib/task_helpers/imports/data/generic_object_definitions/god_with_attr_constraints.yaml @@ -0,0 +1,66 @@ +--- +- GenericObjectDefinition: + name: ProductDefinition + description: Product definition with comprehensive attribute constraints + properties: + :attributes: + name: :string + description: :string + sku: :string + email: :string + status: :string + priority: :integer + quantity: :integer + price: :float + discount: :float + is_active: :boolean + created_at: :datetime + updated_at: :datetime + scheduled_time: :time + :attribute_constraints: + name: + :required: true + :min_length: 3 + :max_length: 100 + description: + :min_length: 10 + :max_length: 500 + sku: + :required: true + :format: !ruby/regexp '/\A[A-Z]{3}-\d{6}\z/' + email: + :format: !ruby/regexp '/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i' + status: + :required: true + :enum: + - active + - inactive + - pending + - archived + priority: + :required: true + :enum: + - 1 + - 2 + - 3 + - 4 + - 5 + quantity: + :min: 0 + :max: 10000 + price: + :required: true + :min: 0.0 + :max: 999999.99 + discount: + :min: 0.0 + :max: 100.0 + is_active: + :required: true + :associations: + vms: Vm + cloud_tenant: CloudTenant + :methods: + - calculate_total + - apply_discount + - validate_stock diff --git a/spec/lib/task_helpers/imports/generic_object_definitions_spec.rb b/spec/lib/task_helpers/imports/generic_object_definitions_spec.rb index b85e4213dc3..c7787e90518 100644 --- a/spec/lib/task_helpers/imports/generic_object_definitions_spec.rb +++ b/spec/lib/task_helpers/imports/generic_object_definitions_spec.rb @@ -32,7 +32,7 @@ expect do TaskHelpers::Imports::GenericObjectDefinitions.new.import(options) end.to_not output.to_stderr - expect(GenericObjectDefinition.all.count).to eq(2) + expect(GenericObjectDefinition.all.count).to eq(3) assert_test_god_one_present assert_test_god_two_present end @@ -105,6 +105,66 @@ end end end + + describe "when the source file has attribute constraints" do + let(:source) { "#{data_dir}/god_with_attr_constraints.yaml" } + let(:overwrite) { true } + + it 'imports generic object definition with attribute constraints' do + expect do + TaskHelpers::Imports::GenericObjectDefinitions.new.import(options) + end.to_not output.to_stderr + + god = GenericObjectDefinition.find_by(:name => 'ProductDefinition') + expect(god).to be_present + expect(god.description).to eq('Product definition with comprehensive attribute constraints') + + # Verify attributes + expect(god.properties[:attributes]).to include( + 'name' => :string, + 'sku' => :string, + 'email' => :string, + 'status' => :string, + 'priority' => :integer, + 'quantity' => :integer, + 'price' => :float, + 'discount' => :float, + 'is_active' => :boolean + ) + + # Verify attribute constraints + expect(god.properties[:attribute_constraints]).to be_present + expect(god.properties[:attribute_constraints]['name']).to include( + :required => true, + :min_length => 3, + :max_length => 100 + ) + expect(god.properties[:attribute_constraints]['sku']).to include( + :required => true, + :format => /\A[A-Z]{3}-\d{6}\z/ + ) + expect(god.properties[:attribute_constraints]['status']).to include( + :required => true, + :enum => ['active', 'inactive', 'pending', 'archived'] + ) + expect(god.properties[:attribute_constraints]['priority']).to include( + :required => true, + :enum => [1, 2, 3, 4, 5] + ) + expect(god.properties[:attribute_constraints]['quantity']).to include( + :min => 0, + :max => 10000 + ) + expect(god.properties[:attribute_constraints]['price']).to include( + :required => true, + :min => 0.0, + :max => 999999.99 + ) + expect(god.properties[:attribute_constraints]['is_active']).to include( + :required => true + ) + end + end end def assert_test_god_one_present From a5326b357985d687d2779f53ffb14861f7e9cee4 Mon Sep 17 00:00:00 2001 From: MANOJ PUTHRAN Date: Sun, 16 Nov 2025 03:14:04 +0530 Subject: [PATCH 5/7] add attribute constraint validation in GenericObject --- app/models/generic_object.rb | 55 ++++ spec/models/generic_object_spec.rb | 402 +++++++++++++++++++++++++++++ 2 files changed, 457 insertions(+) diff --git a/app/models/generic_object.rb b/app/models/generic_object.rb index dc862e99356..02e13863172 100644 --- a/app/models/generic_object.rb +++ b/app/models/generic_object.rb @@ -11,6 +11,7 @@ class GenericObject < ApplicationRecord has_many :custom_button_events, :foreign_key => :target_id, :dependent => :destroy validates :name, :presence => true + validate :validate_property_attribute_constraints delegate :property_attribute_defined?, :property_defined?, @@ -212,4 +213,58 @@ def remove_go_from_all_related_services remove_from_service(resource.service) if resource.service end end + + def validate_property_attribute_constraints + return unless generic_object_definition + + constraints = generic_object_definition.properties[:attribute_constraints] || {} + + constraints.each do |attr_name, attr_constraints| + # Get value from properties hash (where custom attributes are stored) + value = properties[attr_name] + + # Skip validation if attribute is not set (nil) unless it's required + next if value.nil? && !attr_constraints[:required] + + attr_constraints.each do |constraint_type, constraint_value| + validate_constraint(attr_name, value, constraint_type, constraint_value) + end + end + end + + def validate_constraint(attr_name, value, constraint_type, constraint_value) + case constraint_type + when :required + if constraint_value && (value.nil? || (value.is_a?(String) && value.strip.empty?)) + errors.add(:properties, "attribute '#{attr_name}' is required") + end + when :min + if value.present? && value < constraint_value + errors.add(:properties, "attribute '#{attr_name}' must be greater than or equal to #{constraint_value}") + end + when :max + if value.present? && value > constraint_value + errors.add(:properties, "attribute '#{attr_name}' must be less than or equal to #{constraint_value}") + end + when :min_length + if value.present? && value.to_s.length < constraint_value + errors.add(:properties, "attribute '#{attr_name}' must be at least #{constraint_value} characters long") + end + when :max_length + if value.present? && value.to_s.length > constraint_value + errors.add(:properties, "attribute '#{attr_name}' must be at most #{constraint_value} characters long") + end + when :enum + if value.present? && !constraint_value.include?(value) + errors.add(:properties, "attribute '#{attr_name}' must be one of: #{constraint_value.join(', ')}") + end + when :format + if value.present? + regex = constraint_value.is_a?(Regexp) ? constraint_value : Regexp.new(constraint_value) + unless regex.match?(value.to_s) + errors.add(:properties, "attribute '#{attr_name}' format is invalid") + end + end + end + end end diff --git a/spec/models/generic_object_spec.rb b/spec/models/generic_object_spec.rb index 156bec70323..3fd7a5253ba 100644 --- a/spec/models/generic_object_spec.rb +++ b/spec/models/generic_object_spec.rb @@ -385,4 +385,406 @@ end end end + + describe 'attribute constraint validation' do + let(:definition_with_constraints) do + FactoryBot.create( + :generic_object_definition, + :name => 'product_definition', + :properties => { + :attributes => { + :product_name => :string, + :sku => :string, + :email => :string, + :status => :string, + :priority => :integer, + :quantity => :integer, + :price => :float, + :discount => :float, + :is_active => :boolean, + :description => :string + }, + :attribute_constraints => { + :product_name => {:required => true, :min_length => 3, :max_length => 50}, + :sku => {:required => true, :format => /\A[A-Z]{3}-\d{6}\z/}, + :email => {:format => /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i}, + :status => {:required => true, :enum => ['active', 'inactive', 'pending']}, + :priority => {:enum => [1, 2, 3, 4, 5]}, + :quantity => {:min => 0, :max => 1000}, + :price => {:required => true, :min => 0.0, :max => 99999.99}, + :discount => {:min => 0.0, :max => 100.0}, + :is_active => {:required => true}, + :description => {:max_length => 500} + }, + :associations => {}, + :methods => [] + } + ) + end + + context 'required constraint' do + it 'validates required string attribute is present' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true + ) + expect(obj).to be_valid + end + + it 'fails validation when required string attribute is nil' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => nil, + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'product_name' is required") + end + + it 'fails validation when required string attribute is empty' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => ' ', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'product_name' is required") + end + + it 'fails validation when required boolean attribute is nil' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => nil + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'is_active' is required") + end + end + + context 'min/max constraint' do + it 'validates integer within range' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true, + :quantity => 500 + ) + expect(obj).to be_valid + end + + it 'fails validation when integer is below minimum' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true, + :quantity => -1 + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'quantity' must be greater than or equal to 0") + end + + it 'fails validation when integer is above maximum' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true, + :quantity => 1001 + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'quantity' must be less than or equal to 1000") + end + + it 'validates float within range' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true, + :discount => 50.0 + ) + expect(obj).to be_valid + end + + it 'fails validation when float is below minimum' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => -1.0, + :is_active => true + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'price' must be greater than or equal to 0.0") + end + + it 'fails validation when float is above maximum' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100000.0, + :is_active => true + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'price' must be less than or equal to 99999.99") + end + end + + context 'min_length/max_length constraint' do + it 'validates string within length range' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true + ) + expect(obj).to be_valid + end + + it 'fails validation when string is too short' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'AB', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'product_name' must be at least 3 characters long") + end + + it 'fails validation when string is too long' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'A' * 51, + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'product_name' must be at most 50 characters long") + end + + it 'validates max_length only constraint' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true, + :description => 'A' * 500 + ) + expect(obj).to be_valid + end + + it 'fails validation when string exceeds max_length' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true, + :description => 'A' * 501 + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'description' must be at most 500 characters long") + end + end + + context 'enum constraint' do + it 'validates string value in enum' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true + ) + expect(obj).to be_valid + end + + it 'fails validation when string value not in enum' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'deleted', + :price => 100.0, + :is_active => true + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'status' must be one of: active, inactive, pending") + end + + it 'validates integer value in enum' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true, + :priority => 3 + ) + expect(obj).to be_valid + end + + it 'fails validation when integer value not in enum' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true, + :priority => 10 + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'priority' must be one of: 1, 2, 3, 4, 5") + end + end + + context 'format constraint' do + it 'validates string matching format' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true, + :email => 'test@example.com' + ) + expect(obj).to be_valid + end + + it 'fails validation when string does not match format' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'invalid-sku', + :status => 'active', + :price => 100.0, + :is_active => true + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'sku' format is invalid") + end + + it 'fails validation when email format is invalid' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true, + :email => 'invalid-email' + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'email' format is invalid") + end + end + + context 'multiple constraint violations' do + it 'reports all validation errors' do + obj = GenericObject.new( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'AB', + :sku => 'invalid', + :status => 'deleted', + :price => -10.0, + :is_active => nil + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties].size).to be >= 5 + end + end + + context 'updating existing object' do + it 'validates constraints on update' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_constraints, + :name => 'TestObject', + :product_name => 'Product1', + :sku => 'ABC-123456', + :status => 'active', + :price => 100.0, + :is_active => true + ) + + obj.price = -50.0 + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'price' must be greater than or equal to 0.0") + end + end + + context 'without constraints' do + it 'does not validate when no constraints are defined' do + obj = GenericObject.new( + :generic_object_definition => definition, + :name => 'Test', + :max_number => -100 + ) + expect(obj).to be_valid + end + end + end end From 48f4a09e1b2473ef0df9ab84ac02c5d613601b31 Mon Sep 17 00:00:00 2001 From: MANOJ PUTHRAN Date: Sun, 16 Nov 2025 10:52:06 +0530 Subject: [PATCH 6/7] add 'default' value constraint, for non-require attributes --- app/models/generic_object.rb | 20 ++++ app/models/generic_object_definition.rb | 33 ++++++ .../god_with_attr_constraints.yaml | 6 + spec/models/generic_object_definition_spec.rb | 101 ++++++++++++++++ spec/models/generic_object_spec.rb | 109 ++++++++++++++++++ 5 files changed, 269 insertions(+) diff --git a/app/models/generic_object.rb b/app/models/generic_object.rb index 02e13863172..8e16b38bdb5 100644 --- a/app/models/generic_object.rb +++ b/app/models/generic_object.rb @@ -11,6 +11,7 @@ class GenericObject < ApplicationRecord has_many :custom_button_events, :foreign_key => :target_id, :dependent => :destroy validates :name, :presence => true + before_validation :apply_default_values validate :validate_property_attribute_constraints delegate :property_attribute_defined?, @@ -214,6 +215,23 @@ def remove_go_from_all_related_services end end + def apply_default_values + return unless generic_object_definition + + constraints = generic_object_definition.properties[:attribute_constraints] || {} + + constraints.each do |attr_name, attr_constraints| + # Only apply default if attribute is not set and has a default value + if properties[attr_name].nil? && attr_constraints.key?(:default) + default_value = attr_constraints[:default] + # Type cast the default value + if property_attribute_defined?(attr_name) + properties[attr_name] = type_cast(attr_name, default_value) + end + end + end + end + def validate_property_attribute_constraints return unless generic_object_definition @@ -227,6 +245,8 @@ def validate_property_attribute_constraints next if value.nil? && !attr_constraints[:required] attr_constraints.each do |constraint_type, constraint_value| + # Skip default constraint in validation (it's applied in before_validation) + next if constraint_type == :default validate_constraint(attr_name, value, constraint_type, constraint_value) end end diff --git a/app/models/generic_object_definition.rb b/app/models/generic_object_definition.rb index e9e27721b10..d4a8fccd3a2 100644 --- a/app/models/generic_object_definition.rb +++ b/app/models/generic_object_definition.rb @@ -22,6 +22,7 @@ class GenericObjectDefinition < ApplicationRecord CONSTRAINT_TYPES = { :required => [:boolean, :datetime, :float, :integer, :string, :time], + :default => [:boolean, :datetime, :float, :integer, :string, :time], :min => [:integer, :float, :datetime, :time], :max => [:integer, :float, :datetime, :time], :min_length => [:string], @@ -270,6 +271,8 @@ def validate_constraint_value(attr_name, attr_type, constraint_type, value) unless [true, false].include?(value) errors.add(:properties, "constraint 'required' must be true or false for attribute [#{attr_name}]") end + when :default + validate_default_value(attr_name, attr_type, value) when :min, :max if attr_type == :integer && !value.is_a?(Integer) errors.add(:properties, "constraint '#{constraint_type}' must be an integer for attribute [#{attr_name}]") @@ -341,6 +344,36 @@ def set_default_properties private + def validate_default_value(attr_name, attr_type, value) + # Type check based on attribute type + case attr_type + when :boolean + unless [true, false].include?(value) + errors.add(:properties, "default value for attribute [#{attr_name}] must be a boolean") + end + when :integer + unless value.is_a?(Integer) + errors.add(:properties, "default value for attribute [#{attr_name}] must be an integer") + end + when :float + unless value.is_a?(Numeric) + errors.add(:properties, "default value for attribute [#{attr_name}] must be a number") + end + when :string + unless value.is_a?(String) + errors.add(:properties, "default value for attribute [#{attr_name}] must be a string") + end + when :datetime + unless value.is_a?(String) || value.is_a?(Time) || value.is_a?(DateTime) + errors.add(:properties, "default value for attribute [#{attr_name}] must be a valid datetime") + end + when :time + unless value.is_a?(String) || value.is_a?(Time) + errors.add(:properties, "default value for attribute [#{attr_name}] must be a valid time") + end + end + end + def validate_enum_constraint(attr_name, attr_type, value) unless value.is_a?(Array) && value.any? errors.add(:properties, "constraint 'enum' must be a non-empty array for attribute [#{attr_name}]") diff --git a/spec/lib/task_helpers/imports/data/generic_object_definitions/god_with_attr_constraints.yaml b/spec/lib/task_helpers/imports/data/generic_object_definitions/god_with_attr_constraints.yaml index 6bcd884e4b0..7b721d69839 100644 --- a/spec/lib/task_helpers/imports/data/generic_object_definitions/god_with_attr_constraints.yaml +++ b/spec/lib/task_helpers/imports/data/generic_object_definitions/god_with_attr_constraints.yaml @@ -25,6 +25,7 @@ description: :min_length: 10 :max_length: 500 + :default: "No description provided" sku: :required: true :format: !ruby/regexp '/\A[A-Z]{3}-\d{6}\z/' @@ -37,6 +38,7 @@ - inactive - pending - archived + :default: pending priority: :required: true :enum: @@ -45,9 +47,11 @@ - 3 - 4 - 5 + :default: 3 quantity: :min: 0 :max: 10000 + :default: 0 price: :required: true :min: 0.0 @@ -55,8 +59,10 @@ discount: :min: 0.0 :max: 100.0 + :default: 0.0 is_active: :required: true + :default: true :associations: vms: Vm cloud_tenant: CloudTenant diff --git a/spec/models/generic_object_definition_spec.rb b/spec/models/generic_object_definition_spec.rb index 6e7ec004af3..7125846864c 100644 --- a/spec/models/generic_object_definition_spec.rb +++ b/spec/models/generic_object_definition_spec.rb @@ -844,5 +844,106 @@ expect(testdef.properties[:attribute_constraints]).to eq({}) end end + + context 'default value constraint' do + it 'accepts default value for string attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:status => :string}, + :attribute_constraints => {:status => {:default => 'pending'}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts default value for integer attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:priority => :integer}, + :attribute_constraints => {:priority => {:default => 1}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts default value for float attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:price => :float}, + :attribute_constraints => {:price => {:default => 0.0}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts default value for boolean attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:active => :boolean}, + :attribute_constraints => {:active => {:default => false}} + } + ) + expect(testdef).to be_valid + end + + it 'rejects non-string default value for string attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:status => :string}, + :attribute_constraints => {:status => {:default => 123}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /default value for attribute .* must be a string/) + end + + it 'rejects non-integer default value for integer attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:priority => :integer}, + :attribute_constraints => {:priority => {:default => "one"}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /default value for attribute .* must be an integer/) + end + + it 'rejects non-numeric default value for float attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:price => :float}, + :attribute_constraints => {:price => {:default => "zero"}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /default value for attribute .* must be a number/) + end + + it 'rejects non-boolean default value for boolean attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:active => :boolean}, + :attribute_constraints => {:active => {:default => "yes"}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /default value for attribute .* must be a boolean/) + end + + it 'accepts default value with other constraints' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:status => :string}, + :attribute_constraints => {:status => {:default => 'pending', :enum => ['pending', 'active', 'inactive']}} + } + ) + expect(testdef).to be_valid + end + end end end diff --git a/spec/models/generic_object_spec.rb b/spec/models/generic_object_spec.rb index 3fd7a5253ba..f4967193826 100644 --- a/spec/models/generic_object_spec.rb +++ b/spec/models/generic_object_spec.rb @@ -786,5 +786,114 @@ expect(obj).to be_valid end end + + context 'default value constraint' do + let(:definition_with_defaults) do + FactoryBot.create( + :generic_object_definition, + :name => 'product_with_defaults', + :properties => { + :attributes => { + :status => :string, + :priority => :integer, + :discount => :float, + :is_active => :boolean, + :description => :string + }, + :attribute_constraints => { + :status => {:default => 'pending', :enum => ['pending', 'active', 'inactive']}, + :priority => {:default => 3}, + :discount => {:default => 0.0}, + :is_active => {:default => true}, + :description => {:default => 'No description'} + }, + :associations => {}, + :methods => [] + } + ) + end + + it 'applies default value for string attribute when not provided' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_defaults, + :name => 'TestObject' + ) + expect(obj.status).to eq('pending') + end + + it 'applies default value for integer attribute when not provided' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_defaults, + :name => 'TestObject' + ) + expect(obj.priority).to eq(3) + end + + it 'applies default value for float attribute when not provided' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_defaults, + :name => 'TestObject' + ) + expect(obj.discount).to eq(0.0) + end + + it 'applies default value for boolean attribute when not provided' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_defaults, + :name => 'TestObject' + ) + expect(obj.is_active).to eq(true) + end + + it 'does not override explicitly provided value with default' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_defaults, + :name => 'TestObject', + :status => 'active', + :priority => 5, + :discount => 10.0, + :is_active => false + ) + expect(obj.status).to eq('active') + expect(obj.priority).to eq(5) + expect(obj.discount).to eq(10.0) + expect(obj.is_active).to eq(false) + end + + it 'applies multiple default values at once' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_defaults, + :name => 'TestObject' + ) + expect(obj.status).to eq('pending') + expect(obj.priority).to eq(3) + expect(obj.discount).to eq(0.0) + expect(obj.is_active).to eq(true) + expect(obj.description).to eq('No description') + end + + it 'applies default value on update when attribute is nil' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_defaults, + :name => 'TestObject', + :status => 'active' + ) + expect(obj.status).to eq('active') + + obj.status = nil + obj.save! + expect(obj.reload.status).to eq('pending') + end + + it 'validates default value against other constraints' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_defaults, + :name => 'TestObject' + ) + # Default status is 'pending' which is in the enum + expect(obj).to be_valid + expect(obj.status).to eq('pending') + end + end end end From 0439acd46d97f2903d7b3fc7b5197131df636481 Mon Sep 17 00:00:00 2001 From: MANOJ PUTHRAN Date: Sun, 16 Nov 2025 13:17:15 +0530 Subject: [PATCH 7/7] add two new attributes types: Hash and Array --- app/models/generic_object.rb | 39 +++ app/models/generic_object_definition.rb | 49 ++- .../god_with_attr_constraints.yaml | 32 ++ spec/models/generic_object_definition_spec.rb | 251 +++++++++++++++ spec/models/generic_object_spec.rb | 304 ++++++++++++++++++ 5 files changed, 665 insertions(+), 10 deletions(-) diff --git a/app/models/generic_object.rb b/app/models/generic_object.rb index 8e16b38bdb5..f3858ff46c0 100644 --- a/app/models/generic_object.rb +++ b/app/models/generic_object.rb @@ -236,14 +236,27 @@ def validate_property_attribute_constraints return unless generic_object_definition constraints = generic_object_definition.properties[:attribute_constraints] || {} + attributes = generic_object_definition.properties[:attributes] || {} constraints.each do |attr_name, attr_constraints| # Get value from properties hash (where custom attributes are stored) value = properties[attr_name] + attr_type = attributes[attr_name] # Skip validation if attribute is not set (nil) unless it's required next if value.nil? && !attr_constraints[:required] + # Type checking for Hash and Array + if attr_type == :hash && value.present? && !value.is_a?(Hash) + errors.add(:properties, "attribute '#{attr_name}' must be a Hash") + next + end + + if attr_type == :array && value.present? && !value.is_a?(Array) + errors.add(:properties, "attribute '#{attr_name}' must be an Array") + next + end + attr_constraints.each do |constraint_type, constraint_value| # Skip default constraint in validation (it's applied in before_validation) next if constraint_type == :default @@ -285,6 +298,32 @@ def validate_constraint(attr_name, value, constraint_type, constraint_value) errors.add(:properties, "attribute '#{attr_name}' format is invalid") end end + when :min_items + if value.is_a?(Array) && value.size < constraint_value + errors.add(:properties, "attribute '#{attr_name}' must have at least #{constraint_value} items") + end + when :max_items + if value.is_a?(Array) && value.size > constraint_value + errors.add(:properties, "attribute '#{attr_name}' must have at most #{constraint_value} items") + end + when :unique_items + if constraint_value && value.is_a?(Array) && value.uniq.size != value.size + errors.add(:properties, "attribute '#{attr_name}' must have unique items") + end + when :required_keys + if value.is_a?(Hash) + missing_keys = constraint_value.map(&:to_s) - value.keys.map(&:to_s) + if missing_keys.any? + errors.add(:properties, "attribute '#{attr_name}' is missing required keys: #{missing_keys.join(', ')}") + end + end + when :allowed_keys + if value.is_a?(Hash) + extra_keys = value.keys.map(&:to_s) - constraint_value.map(&:to_s) + if extra_keys.any? + errors.add(:properties, "attribute '#{attr_name}' has disallowed keys: #{extra_keys.join(', ')}") + end + end end end end diff --git a/app/models/generic_object_definition.rb b/app/models/generic_object_definition.rb index d4a8fccd3a2..d50b5ea53b3 100644 --- a/app/models/generic_object_definition.rb +++ b/app/models/generic_object_definition.rb @@ -8,7 +8,9 @@ class GenericObjectDefinition < ApplicationRecord :float => ActiveModel::Type::Float.new, :integer => ActiveModel::Type::Integer.new, :string => ActiveModel::Type::String.new, - :time => ActiveModel::Type::Time.new + :time => ActiveModel::Type::Time.new, + :hash => ActiveRecord::Type::Json.new, + :array => ActiveRecord::Type::Json.new }.freeze TYPE_NAMES = { @@ -17,18 +19,25 @@ class GenericObjectDefinition < ApplicationRecord :float => N_('Float'), :integer => N_('Integer'), :string => N_('String'), - :time => N_('Time') + :time => N_('Time'), + :hash => N_('Hash'), + :array => N_('Array') }.freeze CONSTRAINT_TYPES = { - :required => [:boolean, :datetime, :float, :integer, :string, :time], - :default => [:boolean, :datetime, :float, :integer, :string, :time], - :min => [:integer, :float, :datetime, :time], - :max => [:integer, :float, :datetime, :time], - :min_length => [:string], - :max_length => [:string], - :enum => [:integer, :string], - :format => [:string] + :required => [:boolean, :datetime, :float, :integer, :string, :time, :hash, :array], + :default => [:boolean, :datetime, :float, :integer, :string, :time, :hash, :array], + :min => [:integer, :float, :datetime, :time], + :max => [:integer, :float, :datetime, :time], + :min_length => [:string], + :max_length => [:string], + :enum => [:integer, :string], + :format => [:string], + :min_items => [:array], + :max_items => [:array], + :unique_items => [:array], + :required_keys => [:hash], + :allowed_keys => [:hash] }.freeze FEATURES = %w[attribute attribute_constraint association method].freeze @@ -291,6 +300,18 @@ def validate_constraint_value(attr_name, attr_type, constraint_type, value) unless value.is_a?(Regexp) || (value.is_a?(String) && valid_regex?(value)) errors.add(:properties, "constraint 'format' must be a valid regular expression for attribute [#{attr_name}]") end + when :min_items, :max_items + unless value.is_a?(Integer) && value >= 0 + errors.add(:properties, "constraint '#{constraint_type}' must be a non-negative integer for attribute [#{attr_name}]") + end + when :unique_items + unless [true, false].include?(value) + errors.add(:properties, "constraint 'unique_items' must be true or false for attribute [#{attr_name}]") + end + when :required_keys, :allowed_keys + unless value.is_a?(Array) && value.all? { |k| k.is_a?(String) || k.is_a?(Symbol) } + errors.add(:properties, "constraint '#{constraint_type}' must be an array of strings/symbols for attribute [#{attr_name}]") + end end end @@ -371,6 +392,14 @@ def validate_default_value(attr_name, attr_type, value) unless value.is_a?(String) || value.is_a?(Time) errors.add(:properties, "default value for attribute [#{attr_name}] must be a valid time") end + when :hash + unless value.is_a?(Hash) + errors.add(:properties, "default value for attribute [#{attr_name}] must be a Hash") + end + when :array + unless value.is_a?(Array) + errors.add(:properties, "default value for attribute [#{attr_name}] must be an Array") + end end end diff --git a/spec/lib/task_helpers/imports/data/generic_object_definitions/god_with_attr_constraints.yaml b/spec/lib/task_helpers/imports/data/generic_object_definitions/god_with_attr_constraints.yaml index 7b721d69839..68c0950c650 100644 --- a/spec/lib/task_helpers/imports/data/generic_object_definitions/god_with_attr_constraints.yaml +++ b/spec/lib/task_helpers/imports/data/generic_object_definitions/god_with_attr_constraints.yaml @@ -17,6 +17,10 @@ created_at: :datetime updated_at: :datetime scheduled_time: :time + settings: :hash + metadata: :hash + labels: :array + categories: :array :attribute_constraints: name: :required: true @@ -63,6 +67,32 @@ is_active: :required: true :default: true + settings: + :required: true + :required_keys: + - theme + - language + :allowed_keys: + - theme + - language + - timezone + - notifications + :default: + theme: light + language: en + metadata: + :default: {} + labels: + :required: true + :min_items: 1 + :max_items: 10 + :unique_items: true + :default: + - untagged + categories: + :min_items: 0 + :max_items: 5 + :default: [] :associations: vms: Vm cloud_tenant: CloudTenant @@ -70,3 +100,5 @@ - calculate_total - apply_discount - validate_stock + +# Made with Bob diff --git a/spec/models/generic_object_definition_spec.rb b/spec/models/generic_object_definition_spec.rb index 7125846864c..a836f6f67a5 100644 --- a/spec/models/generic_object_definition_spec.rb +++ b/spec/models/generic_object_definition_spec.rb @@ -945,5 +945,256 @@ expect(testdef).to be_valid end end + + context 'Hash attribute type' do + it 'accepts hash attribute type' do + testdef = described_class.new( + :name => 'test', + :properties => {:attributes => {:config => :hash}} + ) + expect(testdef).to be_valid + end + + it 'accepts hash with required_keys constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:config => :hash}, + :attribute_constraints => {:config => {:required_keys => ['theme', 'language']}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts hash with allowed_keys constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:config => :hash}, + :attribute_constraints => {:config => {:allowed_keys => ['theme', 'language', 'timezone']}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts hash with both required_keys and allowed_keys constraints' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:config => :hash}, + :attribute_constraints => {:config => {:required_keys => ['theme'], :allowed_keys => ['theme', 'language', 'timezone']}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts hash default value' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:config => :hash}, + :attribute_constraints => {:config => {:default => {'theme' => 'light'}}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts empty hash as default value' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:metadata => :hash}, + :attribute_constraints => {:metadata => {:default => {}}} + } + ) + expect(testdef).to be_valid + end + + it 'rejects non-hash default value' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:config => :hash}, + :attribute_constraints => {:config => {:default => ['not', 'a', 'hash']}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /must be a Hash/) + end + + it 'rejects non-array value for required_keys constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:config => :hash}, + :attribute_constraints => {:config => {:required_keys => 'theme'}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /must be an array of strings/) + end + + it 'rejects non-array value for allowed_keys constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:config => :hash}, + :attribute_constraints => {:config => {:allowed_keys => 'theme'}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /must be an array of strings/) + end + + it 'rejects min constraint on hash attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:config => :hash}, + :attribute_constraints => {:config => {:min => 1}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /is not applicable to attribute type/) + end + end + + context 'Array attribute type' do + it 'accepts array attribute type' do + testdef = described_class.new( + :name => 'test', + :properties => {:attributes => {:tags => :array}} + ) + expect(testdef).to be_valid + end + + it 'accepts array with min_items constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:tags => :array}, + :attribute_constraints => {:tags => {:min_items => 1}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts array with max_items constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:tags => :array}, + :attribute_constraints => {:tags => {:max_items => 10}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts array with unique_items constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:tags => :array}, + :attribute_constraints => {:tags => {:unique_items => true}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts array with all constraints' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:tags => :array}, + :attribute_constraints => {:tags => {:min_items => 1, :max_items => 5, :unique_items => true}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts array default value' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:tags => :array}, + :attribute_constraints => {:tags => {:default => ['untagged']}} + } + ) + expect(testdef).to be_valid + end + + it 'accepts empty array as default value' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:categories => :array}, + :attribute_constraints => {:categories => {:default => []}} + } + ) + expect(testdef).to be_valid + end + + it 'rejects non-array default value' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:tags => :array}, + :attribute_constraints => {:tags => {:default => 'not an array'}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /must be an Array/) + end + + it 'rejects non-integer value for min_items constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:tags => :array}, + :attribute_constraints => {:tags => {:min_items => 'one'}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /must be a non-negative integer/) + end + + it 'rejects negative value for min_items constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:tags => :array}, + :attribute_constraints => {:tags => {:min_items => -1}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /must be a non-negative integer/) + end + + it 'rejects non-integer value for max_items constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:tags => :array}, + :attribute_constraints => {:tags => {:max_items => 'ten'}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /must be a non-negative integer/) + end + + it 'rejects non-boolean value for unique_items constraint' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:tags => :array}, + :attribute_constraints => {:tags => {:unique_items => 'yes'}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /must be true or false/) + end + + it 'rejects min_length constraint on array attribute' do + testdef = described_class.new( + :name => 'test', + :properties => { + :attributes => {:tags => :array}, + :attribute_constraints => {:tags => {:min_length => 3}} + } + ) + expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /is not applicable to attribute type/) + end + end end end diff --git a/spec/models/generic_object_spec.rb b/spec/models/generic_object_spec.rb index f4967193826..c09edc95eb0 100644 --- a/spec/models/generic_object_spec.rb +++ b/spec/models/generic_object_spec.rb @@ -895,5 +895,309 @@ expect(obj.status).to eq('pending') end end + + context 'Hash attribute validation' do + let(:definition_with_hash) do + FactoryBot.create( + :generic_object_definition, + :name => 'app_definition', + :properties => { + :attributes => { + :settings => :hash, + :metadata => :hash + }, + :attribute_constraints => { + :settings => { + :required => true, + :required_keys => ['theme', 'language'], + :allowed_keys => ['theme', 'language', 'timezone'], + :default => {'theme' => 'light', 'language' => 'en'} + }, + :metadata => { + :default => {} + } + } + } + ) + end + + it 'accepts valid hash' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_hash, + :name => 'TestApp', + :settings => {'theme' => 'dark', 'language' => 'en'} + ) + expect(obj.settings).to eq({'theme' => 'dark', 'language' => 'en'}) + end + + it 'applies hash default value' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_hash, + :name => 'TestApp' + ) + expect(obj.settings).to eq({'theme' => 'light', 'language' => 'en'}) + end + + it 'applies empty hash default value' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_hash, + :name => 'TestApp' + ) + expect(obj.metadata).to eq({}) + end + + it 'validates required keys' do + obj = GenericObject.new( + :generic_object_definition => definition_with_hash, + :name => 'TestApp', + :settings => {'theme' => 'dark'} + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include(/missing required keys: language/) + end + + it 'validates multiple missing required keys' do + obj = GenericObject.new( + :generic_object_definition => definition_with_hash, + :name => 'TestApp', + :settings => {} + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include(/missing required keys:/) + end + + it 'validates allowed keys' do + obj = GenericObject.new( + :generic_object_definition => definition_with_hash, + :name => 'TestApp', + :settings => {'theme' => 'dark', 'language' => 'en', 'invalid_key' => 'value'} + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include(/disallowed keys: invalid_key/) + end + + it 'validates multiple disallowed keys' do + obj = GenericObject.new( + :generic_object_definition => definition_with_hash, + :name => 'TestApp', + :settings => {'theme' => 'dark', 'language' => 'en', 'key1' => 'v1', 'key2' => 'v2'} + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include(/disallowed keys:/) + end + + it 'accepts hash with all allowed keys' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_hash, + :name => 'TestApp', + :settings => {'theme' => 'dark', 'language' => 'en', 'timezone' => 'UTC'} + ) + expect(obj).to be_valid + end + + it 'fails validation when hash attribute receives non-hash value' do + obj = GenericObject.new( + :generic_object_definition => definition_with_hash, + :name => 'TestApp', + :settings => ['not', 'a', 'hash'] + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'settings' must be a Hash") + end + + it 'accepts hash with symbol keys' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_hash, + :name => 'TestApp', + :settings => {:theme => 'dark', :language => 'en'} + ) + expect(obj).to be_valid + end + end + + context 'Array attribute validation' do + let(:definition_with_array) do + FactoryBot.create( + :generic_object_definition, + :name => 'task_definition', + :properties => { + :attributes => { + :labels => :array, + :categories => :array + }, + :attribute_constraints => { + :labels => { + :required => true, + :min_items => 1, + :max_items => 5, + :unique_items => true, + :default => ['untagged'] + }, + :categories => { + :default => [] + } + } + } + ) + end + + it 'accepts valid array' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_array, + :name => 'TestTask', + :labels => ['urgent', 'customer'] + ) + expect(obj.labels).to eq(['urgent', 'customer']) + end + + it 'applies array default value' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_array, + :name => 'TestTask' + ) + expect(obj.labels).to eq(['untagged']) + end + + it 'applies empty array default value' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_array, + :name => 'TestTask' + ) + expect(obj.categories).to eq([]) + end + + it 'validates min_items' do + obj = GenericObject.new( + :generic_object_definition => definition_with_array, + :name => 'TestTask', + :labels => [] + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'labels' must have at least 1 items") + end + + it 'validates max_items' do + obj = GenericObject.new( + :generic_object_definition => definition_with_array, + :name => 'TestTask', + :labels => ['a', 'b', 'c', 'd', 'e', 'f'] + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'labels' must have at most 5 items") + end + + it 'validates unique_items' do + obj = GenericObject.new( + :generic_object_definition => definition_with_array, + :name => 'TestTask', + :labels => ['urgent', 'urgent', 'customer'] + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'labels' must have unique items") + end + + it 'accepts array with unique items' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_array, + :name => 'TestTask', + :labels => ['urgent', 'customer', 'high-priority'] + ) + expect(obj).to be_valid + end + + it 'accepts array at min_items boundary' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_array, + :name => 'TestTask', + :labels => ['single'] + ) + expect(obj).to be_valid + end + + it 'accepts array at max_items boundary' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_array, + :name => 'TestTask', + :labels => ['a', 'b', 'c', 'd', 'e'] + ) + expect(obj).to be_valid + end + + it 'fails validation when array attribute receives non-array value' do + obj = GenericObject.new( + :generic_object_definition => definition_with_array, + :name => 'TestTask', + :labels => 'not an array' + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties]).to include("attribute 'labels' must be an Array") + end + + it 'accepts array with mixed types' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_array, + :name => 'TestTask', + :labels => ['string', 123, true] + ) + expect(obj).to be_valid + end + end + + context 'Hash and Array combined' do + let(:definition_with_both) do + FactoryBot.create( + :generic_object_definition, + :name => 'complex_definition', + :properties => { + :attributes => { + :config => :hash, + :labels => :array + }, + :attribute_constraints => { + :config => { + :required_keys => ['name'], + :default => {'name' => 'default'} + }, + :labels => { + :min_items => 1, + :default => ['default'] + } + } + } + ) + end + + it 'validates both hash and array constraints' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_both, + :name => 'TestObject', + :config => {'name' => 'test', 'value' => 123}, + :labels => ['tag1', 'tag2'] + ) + expect(obj).to be_valid + expect(obj.config).to eq({'name' => 'test', 'value' => 123}) + expect(obj.labels).to eq(['tag1', 'tag2']) + end + + it 'applies defaults for both hash and array' do + obj = GenericObject.create!( + :generic_object_definition => definition_with_both, + :name => 'TestObject' + ) + expect(obj.config).to eq({'name' => 'default'}) + expect(obj.labels).to eq(['default']) + end + + it 'reports errors for both hash and array violations' do + obj = GenericObject.new( + :generic_object_definition => definition_with_both, + :name => 'TestObject', + :config => {}, + :labels => [] + ) + expect(obj).not_to be_valid + expect(obj.errors[:properties].size).to be >= 2 + end + end end end