An object interface to LDAP entries.
The hooks that are called after an action
The hooks that are called before an action
Defaults for destroy options
Defaults for save options
Defaults for validate options
Hooks the user can override
A prototype Hash that autovivifies its members as Sets, for use in the ::objectclass_registry and the ::base_registry
Unsaved attribute values hash
Return the Treequel::Directory the Model will use for searches, creating it if it hasn't been created already. The default Directory will be created by calling Treequel.directory_from_config.
# File lib/treequel/model.rb, line 90
def self::directory
self.directory = Treequel.directory_from_config unless @directory
return @directory
end
Set the Treequel::Directory that should be
used for searches. The receiving class will also be set as the
results_class of the newdirectory
.
# File lib/treequel/model.rb, line 98
def self::directory=( newdirectory )
@directory = newdirectory
@directory.results_class = self if @directory
end
Never freeze converted values in Model objects.
# File lib/treequel/model.rb, line 192
def self::freeze_converted_values?; false; end
Inheritance callback – add a class-specific objectclass registry to inheriting classes.
# File lib/treequel/model.rb, line 105
def self::inherited( subclass )
super
subclass.instance_variable_set( :@objectclass_registry, SET_HASH.dup )
subclass.instance_variable_set( :@base_registry, SET_HASH.dup )
end
Return the mixins that should be applied to an entry with the given
dn
.
# File lib/treequel/model.rb, line 175
def self::mixins_for_dn( dn )
dn_tuples = dn.downcase.split( /\s*,\s*/ )
dn_keys = dn_tuples.reverse.inject(['']) do |keys, dnpair|
dnpair += ',' + keys.last unless keys.last.empty?
keys << dnpair
end
# Get the union of all of the mixin sets for the DN and all of its parents
union = self.base_registry.
values_at( *dn_keys ).
inject {|set1,set2| set1 | set2 }
return union
end
Return the mixins that should be applied to an entry with the given
objectclasses
.
# File lib/treequel/model.rb, line 157
def self::mixins_for_objectclasses( *objectclasses )
return self.objectclass_registry[:top] if objectclasses.empty?
ocsymbols = objectclasses.flatten.collect {|oc| oc.untaint.to_sym }
# Get the union of all of the mixin sets for the objectclasses in question
mixins = self.objectclass_registry.
values_at( *ocsymbols ).
inject {|set1,set2| set1 | set2 }
# Return the mixins whose objectClass requirements are met by the
# specified objectclasses
return mixins.delete_if do |mixin|
!mixin.model_objectclasses.all? {|oc| ocsymbols.include?(oc) }
end
end
Override the default to extend new instances with applicable mixins if their entry is set.
# File lib/treequel/model.rb, line 215
def initialize( directory, dn, entry=nil, from_directory=false )
if from_directory
super( directory, dn, entry )
@dirty = false
else
super( directory, dn )
@values = symbolify_keys( entry ? entry : self.rdn_attributes )
@dirty = true
end
self.apply_applicable_mixins( @dn, @entry )
self.after_initialize
end
Create a new Treequel::Model object with the given
entry
hash from the specified directory
.
Overrides Treequel::Branch.new_from_entry
to pass the from_directory
flag to mark it as unmodified.
# File lib/treequel/model.rb, line 198
def self::new_from_entry( entry, directory )
entry = Treequel::HashUtilities.stringify_keys( entry )
dnvals = entry.delete( 'dn' ) or
raise ArgumentError, "no 'dn' attribute for entry"
Treequel.logger.debug "Creating %p from entry: %p in directory: %s" %
[ self, dnvals.first, directory ]
return self.new( directory, dnvals.first, entry, true )
end
Register the given mixin
for the specified
objectclasses
. Instances that have all the specified
objectclasses
will be extended with the mixin
,
which should be a Module extended with Treequel::Model::ObjectClass.
# File lib/treequel/model.rb, line 115
def self::register_mixin( mixin )
objectclasses = mixin.model_objectclasses
bases = mixin.model_bases
bases << '' if bases.empty?
Treequel.logger.debug "registering %p [objectClasses: %p, base DNs: %p]" %
[ mixin, objectclasses, bases ]
# Register it with each of its objectClasses
objectclasses.each do |oc|
@objectclass_registry[ oc.to_sym ].add( mixin )
end
# ...and each of its bases
bases.each do |dn|
@base_registry[ dn.downcase ].add( mixin )
end
end
Unregister the given mixin
for the specified
objectclasses
.
# File lib/treequel/model.rb, line 136
def self::unregister_mixin( mixin )
objectclasses = mixin.model_objectclasses
bases = mixin.model_bases
bases << '' if bases.empty?
Treequel.logger.debug "un-registering %p [objectclasses: %p, base DNs: %p]" %
[ mixin, objectclasses, bases ]
# Unregister it from each of its bases
bases.each do |dn|
@base_registry[ dn.downcase ].delete( mixin )
end
# ...and each of its objectClasses
objectclasses.each do |oc|
@objectclass_registry[ oc.to_sym ].delete( mixin )
end
end
Index set operator – set attribute attrname
to a new
value
. Overridden to make Model
objects defer writing changes until #save is called.
# File lib/treequel/model.rb, line 273
def []=( attrname, value )
attrtype = self.find_attribute_type( attrname.to_sym ) or
raise ArgumentError, "unknown attribute %p" % [ attrname ]
value = Array( value ) unless attrtype.single?
self.mark_dirty
if value.nil?
@values.delete( attrtype.name.to_sym )
else
@values[ attrtype.name.to_sym ] = value
end
# If the objectClasses change, we (may) need to re-apply mixins
if attrname.to_s.downcase == 'objectclass'
self.log.debug " objectClass change -- reapplying mixins"
self.apply_applicable_mixins( self.dn )
else
self.log.debug " no objectClass changes -- no need to reapply mixins"
end
return value
end
Delete the specified attributes. Overridden to make Model objects defer writing changes until #save is called.
# File lib/treequel/model.rb, line 310
def delete( *attributes )
return super if attributes.empty?
self.log.debug "Deleting attributes: %p" % [ attributes ]
self.mark_dirty
attributes.flatten.each do |attribute|
# With a hash, delete each value for each key
if attribute.is_a?( Hash )
self.delete_specific_values( attribute )
# With an array of attributes to delete, replace
# MULTIPLE attribute types with an empty array, and SINGLE
# attribute types with nil
elsif attribute.respond_to?( :to_sym )
attrtype = self.find_attribute_type( attribute.to_sym )
if attrtype.single?
@values[ attribute.to_sym ] = nil
else
@values[ attribute.to_sym ] = []
end
else
raise ArgumentError,
"can't convert a %p to a Symbol or a Hash" % [ attribute.class ]
end
end
return true
end
Like delete, but runs destroy hooks before and after deleting.
# File lib/treequel/model.rb, line 495
def destroy( opts={} )
opts = DEFAULT_DESTROY_OPTIONS.merge( opts )
self.before_destroy or raise Treequel::BeforeHookFailed, :destroy
self.delete
self.after_destroy
return true
rescue Treequel::BeforeHookFailed => err
self.log.info( err.message )
raise if opts[:raise_on_failure]
end
Diff the specified values
for the given attribute
against those in the directory entry and return LDAP::Mod objects for any differences.
# File lib/treequel/model.rb, line 435
def diff_with_entry( attribute, values )
mods = []
attribute = attribute.to_s
entry = self.entry || {}
entry_values = entry.key?( attribute ) ? entry[attribute] : []
# Workaround for the fact that Time has a #to_ary, causing it to become an
# Array of integers when cast via Array().
values = [ values ] if values.is_a?( Time )
values = Array( values ).compact.
collect {|val| self.get_converted_attribute(attribute, val) }
self.log.debug " comparing %s values to entry: %p vs. %p" %
[ attribute, values, entry_values ]
# If the attributes on the server are the same as the local ones,
# it's a NOOP.
if values.sort == entry_values.sort
self.log.debug " no change."
return nil
# If the directory doesn't have this attribute, but the local
# object does, it's an ADD
elsif entry_values.empty?
self.log.debug " ADD %s: %p" % [ attribute, values ]
return LDAP::Mod.new( LDAP::LDAP_MOD_ADD, attribute, values )
# ...or if the local value doesn't have anything for this attribute
# but the directory does, it's a DEL
elsif values.empty?
self.log.debug " DELETE %s" % [ attribute ]
return LDAP::Mod.new( LDAP::LDAP_MOD_DELETE, attribute )
# ...otherwise it's a REPLACE
else
self.log.debug " REPLACE %s: %p with %p" %
[ attribute, entry_values, values ]
return LDAP::Mod.new( LDAP::LDAP_MOD_REPLACE, attribute, values )
end
end
Returns the validation errors associated with this object.
# File lib/treequel/model.rb, line 343
def errors
return @errors ||= Treequel::Model::Errors.new
end
Return the Treequel::Model::ObjectClass mixins that have been applied to the receiver.
# File lib/treequel/model.rb, line 530
def extensions
eigenclass = ( class << self; self; end )
return eigenclass.included_modules.find_all do |mod|
(class << mod; self; end).include?(Treequel::Model::ObjectClass)
end
end
Copy initializer – re-apply mixins to duplicates, too.
# File lib/treequel/model.rb, line 231
def initialize_copy( other )
super
self.apply_applicable_mixins( @dn, @entry )
self.after_initialize
end
Return a human-readable representation of the receiving object, suitable for debugging.
# File lib/treequel/model.rb, line 539
def inspect
return "#<%s:0x%x (%s): %s>" % [
self.class.name,
self.object_id * 2,
self.loaded? ?
self.extensions.map( &:name ).join( ', ' ) :
'not yet loaded',
self.dn
]
end
Return the pending modifications for the object as an LDIF string.
# File lib/treequel/model.rb, line 479
def modification_ldif
mods = self.modifications
return LDAP::LDIF.mods_to_ldif( self.dn, mods )
end
Return any pending changes in the model object as an Array of LDAP::Mod objects.
# File lib/treequel/model.rb, line 418
def modifications
return unless self.modified?
self.log.debug "Gathering modifications..."
mods = []
@values.sort_by {|k, _| k.to_s }.each do |attribute, vals|
self.log.debug " finding mods for %s" % [ attribute ]
mod = self.diff_with_entry( attribute, vals ) or next
mods << mod
end
return mods
end
Tests whether the object has been modified since it was loaded from the directory.
# File lib/treequel/model.rb, line 259
def modified?
return @dirty ? true : false
end
Mark the object as unmodified.
# File lib/treequel/model.rb, line 265
def reset_dirty_flag
@dirty = false
end
Returns true
if the receiver responds to the given method.
# File lib/treequel/model.rb, line 520
def respond_to?( sym, include_priv=false )
return super if caller(1).first =~ %r{/r?spec/} &&
caller(1).first !~ /respond_to/ # RSpec workaround
return true if super
plainsym, _ = attribute_from_method( sym )
return self.find_attribute_type( plainsym ) ? true : false
end
Revert to the attributes in the directory, discarding any pending changes.
# File lib/treequel/model.rb, line 486
def revert
self.clear_caches
@dirty = false
return true
end
Write any pending changes in the model object to the directory. The valid
opts
are:
[:raise_on_failure
] raise a Treequel::ValidationFailed or Treequel::BeforeHookFailed if either the
validations or before_{save,create}.
# File lib/treequel/model.rb, line 378
def save( opts={} )
opts = DEFAULT_SAVE_OPTIONS.merge( opts )
self.log.debug "Saving %s..." % [ self.dn ]
if opts[ :validate ]
raise Treequel::ValidationFailed, self.errors unless self.valid?( opts )
self.log.debug " validation succeeded."
end
unless mods = self.modifications
self.log.debug " no modifications... no save necessary."
return false
end
self.log.debug " got %d modifications." % [ mods.length ]
self.before_save( mods ) or
raise Treequel::BeforeHookFailed, :save
if self.exists?
self.log.debug " already exists, so updating."
self.update( mods )
else
self.log.debug " doesn't exist, so creating."
self.create( mods )
end
self.after_save( mods )
return true
rescue Treequel::BeforeHookFailed => err
self.log.info( err.message )
raise if opts[:raise_on_failure]
rescue Treequel::ValidationFailed => err
self.log.error( "Save aborted: validation failed." )
self.log.info( err.errors.full_messages.join(', ') )
raise if opts[:raise_on_failure]
end
Override Branch#search to inject the 'objectClass' attribute to the selected attribute list if there is one.
# File lib/treequel/model.rb, line 511
def search( scope=:subtree, filter='(objectClass=*)', parameters={}, &block )
parameters[:selectattrs] |= ['objectClass'] unless
!parameters.key?( :selectattrs ) || parameters[ :selectattrs ].empty?
super
end
Return true
if the model object passes all of its validations.
# File lib/treequel/model.rb, line 349
def valid?( opts={} )
self.errors.clear
self.validate( opts )
return self.errors.empty? ? true : false
end
Validate the object with the specified options
, appending
validation errors onto the errors
object.
# File lib/treequel/model.rb, line 358
def validate( options={} )
options = DEFAULT_VALIDATION_OPTIONS.merge( options )
self.before_validation or
raise Treequel::BeforeHookFailed, :validation
self.errors.add( :objectClass, 'must have at least one' ) if self.object_classes.empty?
super( options )
self.log.debug "Validations failed:\s %s" % [ self.errors.full_messages.join("\n ") ] if
self.errors.count.nonzero?
self.after_validation
end
Apply mixins that are applicable considering the receiver's DN and the
objectClasses from the given entryhash
merged with any unsaved
values.
# File lib/treequel/model.rb, line 718
def apply_applicable_mixins( dn, entryhash=nil )
objectclasses = @values[:objectClass] ||
(entryhash && entryhash['objectClass'])
return unless objectclasses
# self.log.debug "Applying mixins applicable to %s" % [ dn ]
schema = self.directory.schema
ocs = objectclasses.collect do |oc_oid|
explicit_oc = schema.object_classes[ oc_oid ]
explicit_oc.ancestors.collect {|oc| oc.name }
end.flatten.uniq
# self.log.debug " got %d candidate objectClasses: %p" % [ ocs.length, ocs ]
# The applicable mixins are those in the intersection of the ones
# inferred by its objectclasses and those that apply to its DN
oc_mixins = self.class.mixins_for_objectclasses( *ocs )
dn_mixins = self.class.mixins_for_dn( dn )
mixins = ( oc_mixins & dn_mixins )
# self.log.debug " %d mixins remain after intersection: %p" % [ mixins.length, mixins ]
mixins.each {|mod| self.extend(mod) }
end
Create the entry for the object, using the specified mods
to
set the attributes.
# File lib/treequel/model.rb, line 573
def create( mods )
self.log.debug " entry doesn't exist: creating..."
self.before_create( mods ) or
raise Treequel::BeforeHookFailed, :create
super( mods )
self.after_create( mods )
end
Delete specific key/value pairs
from the entry.
# File lib/treequel/model.rb, line 583
def delete_specific_values( pairs )
self.log.debug " hash-delete..."
# Ensure the value exists, and its values converted and cached, as
# the delete needs Ruby object instead of string comparison
pairs.each do |key, vals|
next unless self[ key ]
self.log.debug " deleting %p: %p" % [ key, vals ]
@values[ key ].delete_if {|val| vals.include?(val) }
end
end
Ensure the entry is loaded and then return a Method object for the method
of the specified name
. Returns nil
if the method
isn't defined.
# File lib/treequel/model.rb, line 612
def entry_method( name )
self.entry
self.log.debug "Looking up entry method %p" % [ name ]
return nil unless self.singleton_class.method_defined?( name )
return self.method( name )
end
Search for the Treequel::Schema::AttributeType
associated with sym
.
# File lib/treequel/model.rb, line 598
def find_attribute_type( name )
attrtype = nil
# Try both the name as-is, and the camelCased version of it
camelcased_sym = name.to_s.gsub( /_(\w)/ ) { $1.upcase }.to_sym
attrtype = self.valid_attribute_type( name ) ||
self.valid_attribute_type( camelcased_sym )
return attrtype
end
Overridden to apply applicable mixins to lazily-loaded objects once their entry has been looked up.
# File lib/treequel/model.rb, line 707
def lookup_entry
if entryhash = super
self.apply_applicable_mixins( self.dn, entryhash )
end
return entryhash
end
Make a predicate method body for the given attrtype
.
# File lib/treequel/model.rb, line 692
def make_predicate( attrtype )
self.log.debug "Generating an attribute predicate for %p" % [ attrtype ]
attrname = attrtype.name
if attrtype.single?
self.log.debug " attribute is SINGLE, so generating a scalar predicate..."
return lambda { self[attrname] ? true : false }
else
self.log.debug " attribute isn't SINGLE, so generating an array predicate..."
return lambda { self[attrname].any? {|val| val} }
end
end
Make a reader method body for the given attrtype
.
# File lib/treequel/model.rb, line 664
def make_reader( attrtype )
self.log.debug "Generating an attribute reader for %p" % [ attrtype ]
attrname = attrtype.name
return lambda do |*args|
if args.empty?
self[ attrname ]
else
self.traverse_branch( attrname, *args )
end
end
end
Make a writer method body for the given attrtype
.
# File lib/treequel/model.rb, line 678
def make_writer( attrtype )
self.log.debug "Generating an attribute writer for %p" % [ attrtype ]
attrname = attrtype.name
if attrtype.single?
self.log.debug " attribute is SINGLE, so generating a scalar writer..."
return lambda {|newvalue| self[attrname] = newvalue }
else
self.log.debug " attribute isn't SINGLE, so generating an array writer..."
return lambda {|*newvalues| self[attrname] = newvalues.flatten }
end
end
Mark the object as having been modified.
# File lib/treequel/model.rb, line 557
def mark_dirty
@dirty = true
end
Proxy method – Handle calls to missing methods by searching for an attribute.
# File lib/treequel/model.rb, line 630
def method_missing( sym, *args )
self.log.debug "Dynamic dispatch to %p with args: %p" % [ sym, args ]
# First, if the entry hasn't yet been loaded, try loading it to make sure the
# object is already extended with any applicable objectClass mixins. If that ends
# up defining the method in question, call it.
if (( meth = self.entry_method(sym) ))
return meth.call( *args )
end
# Next, super to rdn-traversal if it looks like a reader but has arguments
plainsym, methodtype = attribute_from_method( sym )
return super if methodtype == :reader && !args.empty?
# Now make a method body for a new method based on what attributeType it is if
# it's a valid attribute
attrtype = self.find_attribute_type( plainsym ) or return super
methodbody = case methodtype
when :writer
self.make_writer( attrtype )
when :predicate
self.make_predicate( attrtype )
else
self.make_reader( attrtype )
end
# Define the new method and call it by fetching the corresponding Method object
# so we don't loop back through #method_missing if something goes wrong
self.class.send( :define_method, sym, &methodbody )
return self.method( sym ).call( *args )
end
Hook method – return true if the method sym
is handled by method_missing.
# File lib/treequel/model.rb, line 621
def respond_to_missing?( sym, include_all )
return true if self.entry_method( sym )
plainsym, _ = attribute_from_method( sym )
return true if self.find_attribute_type( plainsym )
return super
end
Update the object's entry with the specified mods
.
# File lib/treequel/model.rb, line 563
def update( mods )
self.log.debug " entry already exists: updating..."
self.before_update( mods ) or
raise Treequel::BeforeHookFailed, :update
self.modify( mods )
self.after_update( mods )
end