Skip to content

Commit

Permalink
Add type enforcement options to Schema#validate.
Browse files Browse the repository at this point in the history
`Schema#validate` can now enforce the types of values on an object. Additionally, properties can now define `nilable` (default: false), which will allow nil values to pass through type validations.

Validations are still not being used by the system.
  • Loading branch information
faultyserver committed Aug 22, 2016
1 parent 65fde3e commit 09380e1
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 36 deletions.
6 changes: 3 additions & 3 deletions objects/route.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
module Shark
class Route < Object
# [Integer] The identifying code for this route
attribute :code, type: Integer
attribute :code, type: Integer, nilable: true
# [String] The (often) humanized name for this route
attribute :name, type: String
# [String] The name of this route used on maps and signs to quickly
# identify it
attribute :short_name, type: String
# [String] The quick summary of what/where this route services
attribute :description, type: String
attribute :description, type: String, nilable: true
# [String] The hexadecimal color used to shade this route on maps. Includes
# the leading hash character.
attribute :color, type: String
attribute :color, type: String, nilable: true
# [Array[Float, Float]] The geo-spatial path that this route takes, stored
# as [lat, lon] pairs
attribute :path, type: Array[[Float, Float]], default: []
Expand Down
4 changes: 2 additions & 2 deletions objects/station.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
module Shark
class Station < Object
# [Integer] The identifying code for this station
attribute :code, type: Integer
attribute :code, type: Integer, nilable: true
# [String] The (often) humanized name for this station
attribute :name, type: String
# [String] The name of this route used on maps and signs to quickly
# identify it
attribute :stop_code, type: String
# [String] The quick summary of what/where this station services
attribute :description, type: String
attribute :description, type: String, nilable: true
# [Float] The latitudinal position of this station
attribute :latitude, type: Float
# [Float] The longitudinal position of this station
Expand Down
20 changes: 10 additions & 10 deletions objects/vehicle.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module Shark
class Vehicle < Object
# [Integer] The identifying code for this vehicle
attribute :code, type: Integer
attribute :code, type: Integer, nilable: true
# [String] The (often) humanized name for this vehicle
attribute :name, type: String
# [Float] The latitudinal position of this vehicle
Expand All @@ -10,33 +10,33 @@ class Vehicle < Object
attribute :longitude, type: Float
# [Integer] The number of passengers that this vehicle can carry at any
# given time
attribute :capacity, type: Integer
attribute :capacity, type: Integer, nilable: true
# [Integer] The number of passengers currently onboard this vehicle
attribute :onboard, type: Integer
attribute :onboard, type: Integer, nilable: true
# [Float] The fullness of the vehicle expressed as a percentage in the
# range [0-1]
attribute :saturation, type: Float
attribute :saturation, type: Float, nilable: true
# [String] The last stop that this vehicle departed from
# NOTE: Only the identifier will be stored. If more information is needed,
# a lookup must be performed on the storage adapter.
attribute :last_station, type: String
attribute :last_station, type: String, nilable: true
# [String] The next stop that this vehicle will arrive at
# NOTE: Only the identifier will be stored. If more information is needed,
# a lookup must be performed on the storage adapter.
attribute :next_station, type: String
attribute :next_station, type: String, nilable: true
# [String] The route that this vehicle is currently traveling on
# NOTE: Only the identifier will be stored. If more information is needed,
# a lookup must be performed on the storage adapter.
attribute :route, type: String
attribute :route, type: String, nilable: true
# [Integer] The amount of time by which this vehicle currently differs from
# the schedule it is following (determined by `route`), stored as an
# integral number of seconds
attribute :schedule_delta, type: Integer
attribute :schedule_delta, type: Integer, nilable: true
# [Float] The directional heading of this vehicle in the range [0-360)
attribute :heading, type: Float
attribute :heading, type: Float, nilable: true
# [Float] The speed that the vehicle is currently travelling at
# TODO: determine unit of speed (mph, mps, kph, etc)
attribute :speed, type: Float
attribute :speed, type: Float, nilable: true

# Vehicles should be uniquely indentifiable by their name.
primary_attribute :name
Expand Down
13 changes: 9 additions & 4 deletions schemable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ module Shark
module Schemable
# Return the current Schema object. If a `block` is given, it will first be
# executed with the Schema object as the receiver before returning.
def schema &block
@schema ||= Schema.new
def schema **options, &block
@schema ||= Schema.new(**options)
# Apply new options for the schema
@schema.options.merge!(options)
# Evaluate any schema definition that is given
@schema.instance_eval(&block) if block_given?
@schema
end
Expand All @@ -25,13 +28,15 @@ def attribute arg, **options, &block

# The primary attribute of an Object is the attribute that can be used to
# uniquely identify the object. By that nature, it will also be made a
# required property in the schema.
# required, non-nilable property in the schema.
attr_accessor :identifying_attribute
def primary_attribute name
@identifying_attribute = name
# Find the property with the given name in the schema. If it exists,
# ensure that it is required.
schema.properties.find{ |prop| prop.name == name }&.required = true
prop = schema.properties.find{ |prop| prop.name == name }
prop&.required = true
prop&.nilable = false
end
end
end
9 changes: 6 additions & 3 deletions schemable/property.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,23 @@ class Property
# The default value that this property will take on. If not set, this
# will default to nil.
attr_accessor :default
# Whether this property is required to be explicitly written in a
# configuration.
# Whether this property is required to be explicitly set on an object.
attr_accessor :required
alias_method :required?, :required
# Whether this property can be set to nil.
attr_accessor :nilable
alias_method :nilable?, :nilable
# The set of value aliases that this property understands and can
# transform.
attr_accessor :value_aliases

# Create a new property with the given name and default value.
def initialize name, type: BasicObject, default: nil, required: false
def initialize name, type: BasicObject, default: nil, required: false, nilable: false
@name = name
@type = type
@default = default
@required = required
@nilable = nilable
@value_aliases = Hash.new
end

Expand Down
85 changes: 71 additions & 14 deletions schemable/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,54 @@ module Schemable
# It includes methods for specifying optional and required properties,
# each with potential type requirements and aliases for custom values.
class Schema
# The set of `Property`s that have been defined for the context.
# The set of `Property`s that have been defined for this schema.
attr_accessor :properties
# A simple set of options for this schema.
attr_accessor :options


DEFAULT_OPTIONS = {
# Enforce property types when validating an object
enforce_types: true
}.freeze


# Create a new Schema instance, encapsulating a set of properties and
# expectations for an Object.
def initialize
# `options` is a hash of options that change the behavior of this schema.
def initialize **options
@properties = []
@options = DEFAULT_OPTIONS.merge(options)
end


# Define a generic new property. `default` sets the default value of the
# property, and `required` sets whether the property is required to be
# present in the context. If given, `&block` will be instance-evaluated
# on the new Property, such that any other parameters can be applied.
def property name, type: BasicObject, default: nil, required: false, &block
new_property = Property.new(name, type: type, default: default, required: required)
def property name, **options, &block
new_property = Property.new(name, **options)
new_property.instance_eval(&block) if block_given?
properties << new_property
end

# Define an optional property for the configuration.
def optional name, default: nil, &block
property(name, default: default, required: false, &block)
def optional name, **options, &block
property(name, **options, &block)
end

# Define a required property for the configuration.
def required name, default: nil, &block
property(name, default: default, required: true, &block)
def required name, **options, &block
property(name, **options, &block)
end


# Determine whether the given object meets the expectations of this
# schema by validating each property that has been defined.
def validate object
def validate object, transform: true
# Transform the object to resolve any aliases that could affect the
# validity of this object.
self.transform(object) if transform
properties.each do |prop|
# True if the property is available on the object
prop_exists = object.respond_to?(prop.name)
Expand All @@ -47,28 +62,70 @@ def validate object
if prop.required?
return false unless prop_exists and prop_is_defined
end

# If `enforce_types` is set, ensure the type of the property matches
# what is expected.
if options[:enforce_types]
given_value = prop_is_defined ? object.send(prop.name) : prop.default
# Only enforce the type if the property exists and/or is required.
if prop_is_defined || prop.required?
# Ensure that the property's type matches what is expected.
return false unless type_matches?(given_value, prop.type, nilable: prop.nilable?)
end
end
end
# If the requirements of all of the properties were met, then the
# configuration is valid.
self
end


# Perform any transformations that properties of this schema define. This
# method assumes that the given object has already been validated against
# this schema.
# Perform any transformations that properties of this schema define.
def transform object
properties.each do |prop|
# Determine if the property currently has a value on the object
prop_is_defined = object.instance_variable_defined?("@#{prop.name}")
# If the property is required, but was not given, skip the property,
# as transforming it may mask validation errors.
next if prop.required? and !prop_is_defined
# Fetch the current value of the property from the object.
# If it was not defined, assume the default value for the property.
prop_is_defined = object.instance_variable_defined?("@#{prop.name}")
given_value = prop_is_defined ? object.send(prop.name) : prop.default
# Transform the value based on the property's definition
transformed_value = prop.transform_value(given_value, context: object)
object.send("#{prop.name}=", transformed_value)
end
self
end

private
# Return whether the given value fits the given type expectation.
def type_matches? value, type, nilable: false
return true if nilable and value.nil?
case type
# Array | Array[] -> array with values of any type
# Array[String] -> array of Strings
# Array[Float, String] -> two-element array of Float and String
# Array[[Float, Float]] -> array of [Float, Float] pairs
when Array
if value.is_a? Array
if value.size > 0
case type.size
when 0 then true
when 1
value.all?{ |val| type_matches?(val, type.first) }
else
value.zip(type).all?{ |val, type| type_matches?(val, type) }
end
else
true
end
else
false
end
else
value.is_a? type
end
end
end
end
end

0 comments on commit 09380e1

Please sign in to comment.