Skip to content

Conversation

@putmanoj
Copy link
Contributor

Summary

This PR implements comprehensive attribute constraint validation for GenericObjectDefinition, enabling developers to define validation rules for property attributes. The implementation supports all existing attribute types (boolean, integer, float, string, datetime, time) plus newly added Hash and Array types.

Changes

Core Features

Constraint Types - 12 validation constraint types:

  • required - Marks attribute as mandatory
  • min/max - Numeric range validation (integer, float, datetime, time)
  • min_length/max_length - String length validation
  • enum - Enumerated value validation (integer, string)
  • format - Regex pattern validation (string)
  • default - Default value assignment (all types)
  • min_items/max_items - Array size validation
  • unique_items - Array uniqueness validation
  • required_keys/allowed_keys - Hash key validation

Hash and Array Types - New attribute types with JSON storage:

  • Stored as JSONB in PostgreSQL
  • Type-specific constraints
  • Full validation support

Default Values - Automatic default value application:

  • Applied in before_validation callback
  • Type-safe casting using TYPE_MAP
  • Works with all attribute types

Import/Export - Full YAML serialization support:

  • Constraints preserved in import/export
  • Comprehensive test coverage
  • Example YAML file included

Usage Example

# Define a GenericObjectDefinition with constraints
definition = GenericObjectDefinition.create!(
  :name => 'ProductDefinition',
  :properties => {
    :attributes => {
      :product_name => :string,
      :price => :float,
      :status => :string,
      :settings => :hash,
      :tags => :array
    },
    :attribute_constraints => {
      :product_name => {
        :required => true,
        :min_length => 3,
        :max_length => 100
      },
      :price => {
        :required => true,
        :min => 0.0,
        :max => 999999.99
      },
      :status => {
        :enum => ['active', 'inactive', 'pending'],
        :default => 'pending'
      },
      :settings => {
        :required_keys => ['theme'],
        :allowed_keys => ['theme', 'language', 'timezone'],
        :default => {'theme' => 'light'}
      },
      :tags => {
        :min_items => 1,
        :max_items => 10,
        :unique_items => true
      }
    }
  }
)

# Create a GenericObject (defaults applied automatically)
product = GenericObject.create!(
  :generic_object_definition => definition,
  :name => 'Product1',
  :product_name => 'Widget',
  :price => 99.99,
  :tags => ['electronics', 'featured']
  # status and settings get default values
)

# Validation is enforced
invalid = GenericObject.new(
  :generic_object_definition => definition,
  :name => 'Product2',
  :product_name => 'AB',  # Fails min_length validation
  :price => -10.0         # Fails min validation
)
invalid.valid? # => false
invalid.errors.full_messages
# => ["Product name is too short (minimum is 3 characters)",
#     "Price must be greater than or equal to 0.0"]

API Changes

GenericObjectDefinition

New Methods:

  • add_property_attribute_constraint(name, constraint_type, value) # Add a constraint
  • delete_property_attribute_constraint(name, constraint_type) # Remove a constraint
  • property_attribute_constraints(name) # Get constraints for an attribute

Enhanced Methods:

  • add_property_attribute(name, type, constraints: {}) # Now accepts constraints parameter

Example:

definition = GenericObjectDefinition.create!(:name => 'MyDefinition')

# Add attribute with constraints
definition.add_property_attribute(
  :product_name,
  :string,
  :constraints => {
    :required => true,
    :min_length => 3,
    :max_length => 100
  }
)

# Add individual constraint
definition.add_property_attribute_constraint(:product_name, :format, /\A[A-Z]/)

# Get constraints
definition.property_attribute_constraints(:product_name)
# => {:required=>true, :min_length=>3, :max_length=>100, :format=>/\A[A-Z]/}

# Delete constraint
definition.delete_property_attribute_constraint(:product_name, :format)

GenericObject

New Validations:

  • Validates all defined constraints on save
  • Applies default values before validation
  • Provides clear error messages for violations

Constraint Reference

Constraint Applicable Types Description Example
required All Attribute must be present :required: true
default All Default value if not provided :default: "pending"
min integer, float, datetime, time Minimum value :min: 0
max integer, float, datetime, time Maximum value :max: 100
min_length string Minimum string length :min_length: 3
max_length string Maximum string length :max_length: 100
enum integer, string Allowed values :enum: [1, 2, 3]
format string Regex pattern :format: /\A[A-Z]+\z/
min_items array Minimum array size :min_items: 1
max_items array Maximum array size :max_items: 10
unique_items array Array values must be unique :unique_items: true
required_keys hash Keys that must be present :required_keys: [theme]
allowed_keys hash Keys that are permitted :allowed_keys: [theme, lang]

Backward Compatibility

✅ Fully backward compatible - Existing GenericObjectDefinitions without constraints continue to work unchanged. Constraints are optional and only enforced when defined.

Benefits

  • Data Integrity - Ensures GenericObject data meets requirements
  • Type Safety - Prevents invalid data types and values
  • Developer Experience - Clear validation errors guide usage
  • Flexibility - Supports wide range of validation scenarios
  • Extensibility - Easy to add new constraint types
  • Import/Export - Full YAML support for constraint definitions

Checklist

  • Code follows project style guidelines
  • All tests pass (246/246)
  • New tests added for all features
  • Backward compatible
  • Documentation updated
  • No breaking changes

Issues : #23625

Comment on lines +213 to +217
props = properties.symbolize_keys

properties[:attribute_constraints] = props[:attribute_constraints].each_with_object({}) do |(name, constraints), hash|
hash[name.to_s] = constraints
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this just properties.deep_symbolize_keys!?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, stringify_keys!, but yes.

end

def validate_property_attribute_constraints
properties[:attribute_constraints].each do |attr_name, constraints|
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible for properties[:attribute_constraints] to be nil here for existing generic_object_definitions unless we're also doing a data migration?

errors.add(:properties, "constraint 'enum' contains duplicate values for attribute [#{attr_name}]")
end

# Existing type validation continues...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not a necessary comment

Comment on lines +46 to +47
before do
@god = FactoryBot.create(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to use let(:generic_object_definition) (or let!(:...) if you need it to be eagerly defined) rather than introducing an instance variable in the spec context.

Comment on lines +21 to 23
:attribute_constraints => {},
:associations => {'cloud_tenant' => 'CloudTenant'},
:methods => ['kick', 'laugh_at', 'punch', 'parseltongue']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going to want to align the => here (assume rubocop will warn about it if it hasn't already)

@agrare
Copy link
Member

agrare commented Nov 17, 2025

This looks great @putmanoj safe to say you can take it out of Draft and let specs run I think unless you are waiting on something else.

@agrare
Copy link
Member

agrare commented Nov 17, 2025

The mixture of strings and symbols looks weird and pretty fragile, but it looks like that is the way the existing code is as well and is required to be that way

end

def apply_default_values
return unless generic_object_definition
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how this line can happen, because you have to create a generic object from a generic object definition. I think we can remove it.

Comment on lines +225 to +231
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if there's a way to dry this up relative to _property_setter.

:min_length => [:string],
:max_length => [:string],
:enum => [:integer, :string],
:format => [:string],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

format is a weird name for a regex type. can we call it regex?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, in context it looks fine, I think. WDYT, @agrare ?

end

def validate_enum_constraint(attr_name, attr_type, value)
unless value.is_a?(Array) && value.any?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor but this is hard to read. Prefer:

Suggested change
unless value.is_a?(Array) && value.any?
if value.is_a?(Array) && value.empty?

- apply_discount
- validate_stock

# Made with Bob
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can remove this :)

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 => []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style nit

Suggested change
:attributes => {}, :attribute_constraints=>{}, :associations => {}, :methods => []
:attributes => {}, :attribute_constraints => {}, :associations => {}, :methods => []

:attribute_constraints => {:name => "invalid"}
}
)
expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraints for attribute .* must be a hash/)
Copy link
Member

@Fryguy Fryguy Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this error message test could be more explicit...I would expect something like

Suggested change
expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraints for attribute .* must be a hash/)
expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraints for attribute "name" must be a hash/)

:attribute_constraints => {:flag => {:min_length => 5}}
}
)
expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /constraint .* is not applicable to attribute type/)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here with the error message.

:attribute_constraints => {:name => {:invalid_constraint => true}}
}
)
expect { testdef.save! }.to raise_error(ActiveRecord::RecordInvalid, /invalid constraint type/)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably list for which property the constraint is invalid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants