treequel 1.2.2 → 1.3.0pre384

Sign up to get free protection for your applications and to get access to all the features.
@@ -26,6 +26,24 @@ module Treequel
26
26
  ### other problem.
27
27
  class ModelError < Treequel::Error; end
28
28
 
29
+ ### Exception class raised when +raise_on_save_failure+ is set and validation fails
30
+ class ValidationFailed < Treequel::ModelError
31
+
32
+ ### Create a new Treequel::ValidationFailed exception with the given +errors+.
33
+ ### @param [Treequel::Model::Errors, String] errors the validaton errors
34
+ def initialize( errors )
35
+ if errors.respond_to?( :full_messages )
36
+ @errors = errors
37
+ super( errors.full_messages.join(', ') )
38
+ else
39
+ super
40
+ end
41
+ end
42
+
43
+ # @return [Treequel::Model::Errors] the validation errors
44
+ attr_reader :errors
45
+ end
46
+
29
47
  end # module Treequel
30
48
 
31
49
 
@@ -153,14 +153,6 @@ module Treequel
153
153
  ### Add logging to a Treequel class. Including classes get #log and #log_debug methods.
154
154
  module Loggable
155
155
 
156
- LEVEL = {
157
- :debug => Logger::DEBUG,
158
- :info => Logger::INFO,
159
- :warn => Logger::WARN,
160
- :error => Logger::ERROR,
161
- :fatal => Logger::FATAL,
162
- }
163
-
164
156
  ### A logging proxy class that wraps calls to the logger into calls that include
165
157
  ### the name of the calling class.
166
158
  ### @private
@@ -172,13 +164,34 @@ module Treequel
172
164
  @force_debug = force_debug
173
165
  end
174
166
 
175
- ### Delegate calls the global logger with the class name as the 'progname'
176
- ### argument.
177
- def method_missing( sym, msg=nil, &block )
178
- return super unless LEVEL.key?( sym )
179
- sym = :debug if @force_debug
180
- Treequel.logger.add( LEVEL[sym], msg, @classname, &block )
167
+ ### Delegate debug messages to the global logger with the appropriate class name.
168
+ def debug( msg=nil, &block )
169
+ Treequel.logger.add( Logger::DEBUG, msg, @classname, &block )
170
+ end
171
+
172
+ ### Delegate info messages to the global logger with the appropriate class name.
173
+ def info( msg=nil, &block )
174
+ return self.debug( msg, &block ) if @force_debug
175
+ Treequel.logger.add( Logger::INFO, msg, @classname, &block )
176
+ end
177
+
178
+ ### Delegate warn messages to the global logger with the appropriate class name.
179
+ def warn( msg=nil, &block )
180
+ return self.debug( msg, &block ) if @force_debug
181
+ Treequel.logger.add( Logger::WARN, msg, @classname, &block )
182
+ end
183
+
184
+ ### Delegate error messages to the global logger with the appropriate class name.
185
+ def error( msg=nil, &block )
186
+ return self.debug( msg, &block ) if @force_debug
187
+ Treequel.logger.add( Logger::ERROR, msg, @classname, &block )
188
+ end
189
+
190
+ ### Delegate fatal messages to the global logger with the appropriate class name.
191
+ def fatal( msg=nil, &block )
192
+ Treequel.logger.add( Logger::FATAL, msg, @classname, &block )
181
193
  end
194
+
182
195
  end # ClassNameProxy
183
196
 
184
197
  #########
@@ -200,6 +213,7 @@ module Treequel
200
213
  def log_debug
201
214
  @log_debug_proxy ||= ClassNameProxy.new( self.class, true )
202
215
  end
216
+
203
217
  end # module Loggable
204
218
 
205
219
 
@@ -271,6 +285,22 @@ module Treequel
271
285
  end
272
286
  end
273
287
 
288
+ ### Normalize the attributes in +hash+ to be of the form expected by the
289
+ ### LDAP library (i.e., keys as Strings, values as Arrays of Strings)
290
+ def normalize_attributes( hash )
291
+ normhash = {}
292
+ hash.each do |key,val|
293
+ val = [ val ] unless val.is_a?( Array )
294
+ val.collect! {|obj| obj.to_s }
295
+
296
+ normhash[ key.to_s ] = val
297
+ end
298
+
299
+ normhash.delete( 'dn' )
300
+
301
+ return normhash
302
+ end
303
+
274
304
  end # HashUtilities
275
305
 
276
306
 
@@ -10,12 +10,15 @@ require 'treequel/branchset'
10
10
 
11
11
  # An object interface to LDAP entries.
12
12
  class Treequel::Model < Treequel::Branch
13
+ require 'treequel/model/objectclass'
14
+ require 'treequel/model/errors'
15
+ require 'treequel/model/schemavalidations'
16
+
13
17
  include Treequel::Loggable,
14
18
  Treequel::Constants,
15
19
  Treequel::Normalization,
16
- Treequel::Constants::Patterns
17
-
18
- require 'treequel/model/objectclass'
20
+ Treequel::Constants::Patterns,
21
+ Treequel::Model::SchemaValidations
19
22
 
20
23
 
21
24
  # A prototype Hash that autovivifies its members as Sets, for use in
@@ -23,6 +26,34 @@ class Treequel::Model < Treequel::Branch
23
26
  SET_HASH = Hash.new {|h,k| h[k] = Set.new }
24
27
 
25
28
 
29
+ # The hooks that are called before an action
30
+ BEFORE_HOOKS = [
31
+ :before_create,
32
+ :before_update,
33
+ :before_save,
34
+ :before_destroy,
35
+ :before_validation,
36
+ ]
37
+
38
+ # The hooks that are called after an action
39
+ AFTER_HOOKS = [
40
+ :after_initialize,
41
+ :after_create,
42
+ :after_update,
43
+ :after_save,
44
+ :after_destroy,
45
+ :after_validation,
46
+ ]
47
+
48
+ # Hooks the user can override
49
+ HOOKS = BEFORE_HOOKS + AFTER_HOOKS
50
+
51
+ # Defaults for #validate options
52
+ DEFAULT_VALIDATION_OPTIONS = {
53
+ :with_schema => true,
54
+ }
55
+
56
+
26
57
  #################################################################
27
58
  ### C L A S S M E T H O D S
28
59
  #################################################################
@@ -131,6 +162,25 @@ class Treequel::Model < Treequel::Branch
131
162
  end
132
163
 
133
164
 
165
+ ### Never freeze converted values in Model objects.
166
+ def self::freeze_converted_values?; false; end
167
+
168
+
169
+ ### Create a new Treequel::Model object with the given +entry+ hash from the
170
+ ### specified +directory+. Overrides Treequel::Branch.new_from_entry to pass the
171
+ ### +from_directory+ flag to mark it as unmodified.
172
+ ###
173
+ ### @see Treequel::Branch.new_from_entry
174
+ def self::new_from_entry( entry, directory )
175
+ entry = Treequel::HashUtilities.stringify_keys( entry )
176
+ dnvals = entry.delete( 'dn' ) or
177
+ raise ArgumentError, "no 'dn' attribute for entry"
178
+
179
+ Treequel.logger.debug "Creating %p from entry: %p in directory: %s" %
180
+ [ self, dnvals.first, directory ]
181
+ return self.new( directory, dnvals.first, entry, true )
182
+ end
183
+
134
184
 
135
185
  #################################################################
136
186
  ### I N S T A N C E M E T H O D S
@@ -138,9 +188,25 @@ class Treequel::Model < Treequel::Branch
138
188
 
139
189
  ### Override the default to extend new instances with applicable mixins if their
140
190
  ### entry is set.
141
- def initialize( *args )
191
+ def initialize( directory, dn, entry=nil, from_directory=false )
192
+ if from_directory
193
+ super( directory, dn, entry )
194
+ else
195
+ super( directory, dn )
196
+ @values = entry ? symbolify_keys( entry ) : {}
197
+ @dirty = true
198
+ end
199
+
200
+ self.apply_applicable_mixins( @dn, @entry )
201
+ self.after_initialize
202
+ end
203
+
204
+
205
+ ### Copy initializer -- re-apply mixins to duplicates, too.
206
+ def initialize_copy( other )
142
207
  super
143
- self.apply_applicable_mixins( @dn, @entry ) if @entry
208
+ self.apply_applicable_mixins( @dn, @entry )
209
+ self.after_initialize
144
210
  end
145
211
 
146
212
 
@@ -148,6 +214,229 @@ class Treequel::Model < Treequel::Branch
148
214
  public
149
215
  ######
150
216
 
217
+ ### Set up the empty hook methods
218
+ HOOKS.each do |hook|
219
+ define_method( hook ) do |*args|
220
+ self.log.debug "#{hook} default hook called."
221
+ end
222
+ end
223
+
224
+
225
+ ### Tests whether the object has been modified since it was loaded from
226
+ ### the directory.
227
+ def modified?
228
+ return @dirty ? true : false
229
+ end
230
+
231
+
232
+ ### Mark the object as unmodified.
233
+ def reset_dirty_flag
234
+ @dirty = false
235
+ end
236
+
237
+
238
+ ### Index set operator -- set attribute +attrname+ to a new +value+.
239
+ ### Overridden to make Model objects defer writing changes until
240
+ ### {Treequel::Model#save} is called.
241
+ ###
242
+ ### @param [Symbol, String] attrname attribute name
243
+ ### @param [Object] value the attribute value
244
+ def []=( attrname, value )
245
+ attrtype = self.find_attribute_type( attrname.to_sym ) or
246
+ raise ArgumentError, "unknown attribute %p" % [ attrname ]
247
+ value = Array( value ) unless attrtype.single?
248
+
249
+ self.mark_dirty
250
+ @values[ attrtype.name.to_sym ] = value
251
+
252
+ # If the objectClasses change, we (may) need to re-apply mixins
253
+ if attrname.to_s.downcase == 'objectclass'
254
+ self.log.debug " objectClass change -- reapplying mixins"
255
+ self.apply_applicable_mixins( self.dn )
256
+ else
257
+ self.log.debug " no objectClass changes -- no need to reapply mixins"
258
+ end
259
+
260
+ return value
261
+ end
262
+
263
+
264
+ ### Make the changes to the object specified by the given +attributes+.
265
+ ### Overridden to make Model objects defer writing changes until
266
+ ### {Treequel::Model#save} is called.
267
+ ###
268
+ ### @param attributes (see Treequel::Directory#modify)
269
+ ### @return [TrueClass] if the merge succeeded
270
+ def merge( attributes )
271
+ attributes.each do |attrname, value|
272
+ self[ attrname ] = value
273
+ end
274
+ end
275
+
276
+
277
+ ### Delete the specified attributes.
278
+ ### Overridden to make Model objects defer writing changes until
279
+ ### {Treequel::Model#save} is called.
280
+ ###
281
+ ### @see Treequel::Branch#delete
282
+ def delete( *attributes )
283
+ return super if attributes.empty?
284
+
285
+ self.log.debug "Deleting attributes: %p" % [ attributes ]
286
+ self.mark_dirty
287
+
288
+ attributes.flatten.each do |attribute|
289
+
290
+ # With a hash, delete each value for each key
291
+ if attribute.is_a?( Hash )
292
+ self.delete_specific_values( attribute )
293
+
294
+ # With an array of attributes to delete, replace
295
+ # MULTIPLE attribute types with an empty array, and SINGLE
296
+ # attribute types with nil
297
+ elsif attribute.respond_to?( :to_sym )
298
+ attrtype = self.find_attribute_type( attribute.to_sym )
299
+ if attrtype.single?
300
+ @values[ attribute.to_sym ] = nil
301
+ else
302
+ @values[ attribute.to_sym ] = []
303
+ end
304
+ else
305
+ raise ArgumentError,
306
+ "can't convert a %p to a Symbol or a Hash" % [ attribute.class ]
307
+ end
308
+ end
309
+
310
+ return true
311
+ end
312
+
313
+
314
+ ### Returns the validation errors associated with this object.
315
+ ### @see Treequel::Model::Errors.
316
+ def errors
317
+ return @errors ||= Treequel::Model::Errors.new
318
+ end
319
+
320
+
321
+ ### Return +true+ if the model object passes all of its validations.
322
+ def valid?( opts={} )
323
+ self.errors.clear
324
+
325
+ return false if self.before_validation == false
326
+ self.validate
327
+ self.after_validation
328
+
329
+ return self.errors.empty? ? true : false
330
+ end
331
+
332
+
333
+ ### Validate the object with the specified +options+.
334
+ ### @param [Hash] options options for validation.
335
+ ### @option options [Boolean] :with_schema whether or not to run the schema validations
336
+ def validate( options={} )
337
+ options = DEFAULT_VALIDATION_OPTIONS.merge( options )
338
+
339
+ self.errors.add( :objectClass, 'must have at least one' ) if self.object_classes.empty?
340
+
341
+ super( options )
342
+ end
343
+
344
+
345
+ ### Write any pending changes in the model object to the directory.
346
+ def save
347
+ self.log.debug "Saving %s..." % [ self.dn ]
348
+ raise Treequel::ValidationFailed, self.errors unless self.valid?
349
+ self.log.debug " validation succeeded."
350
+
351
+ unless mods = self.modifications
352
+ self.log.debug " no modifications... no save necessary."
353
+ return false
354
+ end
355
+
356
+ self.log.debug " got %d modifications." % [ mods.length ]
357
+ self.before_save( mods ) or return nil
358
+
359
+ if self.exists?
360
+ self.log.debug " entry already exists: updating..."
361
+ self.before_update( mods ) or return nil
362
+ self.modify( mods )
363
+ self.after_update( mods )
364
+
365
+ else
366
+ self.log.debug " entry doesn't exist: creating..."
367
+ self.before_create( mods ) or return nil
368
+ self.create( mods )
369
+ self.after_create( mods )
370
+ end
371
+
372
+ self.after_save( mods )
373
+
374
+ return true
375
+ end
376
+
377
+
378
+ ### Return any pending changes in the model object.
379
+ ### @return [Array<LDAP::Mod>] the changes as LDAP modifications
380
+ def modifications
381
+ return unless self.modified?
382
+ self.log.debug "Gathering modifications..."
383
+
384
+ mods = []
385
+ entry = self.entry || {}
386
+ self.log.debug " directory entry is: %p" % [ entry ]
387
+
388
+ @values.sort_by {|k, _| k.to_s }.each do |attribute, vals|
389
+ vals = [ vals ] unless vals.is_a?( Array )
390
+ vals = vals.compact
391
+ vals.collect! {|val| self.get_converted_attribute(attribute, val) }
392
+ self.log.debug " comparing %s values to entry: %p vs. %p" %
393
+ [ attribute, vals, entry[attribute.to_s] ]
394
+
395
+ entryvals = (entry[attribute.to_s] || [])
396
+ attrmods = { :add => [], :delete => [] }
397
+
398
+ Diff::LCS.sdiff( entryvals.sort, vals.sort ) do |change|
399
+ self.log.debug " found a change: %p" % [ change ]
400
+ if change.adding?
401
+ attrmods[:add] << change.new_element
402
+ elsif change.changed?
403
+ attrmods[:add] << change.new_element
404
+ attrmods[:delete] << change.old_element
405
+ elsif change.deleting?
406
+ attrmods[:delete] << change.old_element
407
+ # else
408
+ # self.log.debug " no mod necessary for %p" % [ change.action ]
409
+ end
410
+ end
411
+
412
+ mods << LDAP::Mod.new( LDAP::LDAP_MOD_DELETE, attribute.to_s, attrmods[:delete] ) unless
413
+ attrmods[:delete].empty?
414
+ mods << LDAP::Mod.new( LDAP::LDAP_MOD_ADD, attribute.to_s, attrmods[:add] ) unless
415
+ attrmods[:add].empty?
416
+ end
417
+
418
+ self.log.debug " mods are: %p" % [ mods ]
419
+
420
+ return mods
421
+ end
422
+
423
+
424
+ ### Return the pending modifications for the object as an LDIF string.
425
+ def modification_ldif
426
+ mods = self.modifications
427
+ return LDAP::LDIF.mods_to_ldif( self.dn, mods )
428
+ end
429
+
430
+
431
+ ### Revert to the attributes in the directory, discarding any pending changes.
432
+ def revert
433
+ self.clear_caches
434
+ @dirty = false
435
+
436
+ return true
437
+ end
438
+
439
+
151
440
  ### Override Branch#search to inject the 'objectClass' attribute to the
152
441
  ### selected attribute list if there is one.
153
442
  def search( scope=:subtree, filter='(objectClass=*)', parameters={}, &block )
@@ -197,6 +486,29 @@ class Treequel::Model < Treequel::Branch
197
486
  protected
198
487
  #########
199
488
 
489
+
490
+ ### Mark the object as having been modified.
491
+ def mark_dirty
492
+ @dirty = true
493
+ end
494
+
495
+
496
+ ### Delete specific key/value +pairs+ from the entry.
497
+ ### @param [Hash] pairs key/value pairs to delete from the entry.
498
+ def delete_specific_values( pairs )
499
+ self.log.debug " hash-delete..."
500
+
501
+ # Ensure the value exists, and its values converted and cached, as
502
+ # the delete needs Ruby object instead of string comparison
503
+ pairs.each do |key, vals|
504
+ next unless self[ key ]
505
+ self.log.debug " deleting %p: %p" % [ key, vals ]
506
+
507
+ @values[ key ].delete_if {|val| vals.include?(val) }
508
+ end
509
+ end
510
+
511
+
200
512
  ### Search for the Treequel::Schema::AttributeType associated with +sym+.
201
513
  ###
202
514
  ### @param [Symbol,String] name the name of the attribute to find
@@ -205,7 +517,7 @@ class Treequel::Model < Treequel::Branch
205
517
  def find_attribute_type( name )
206
518
  attrtype = nil
207
519
 
208
- # If the attribute doesn't match as-is, try the camelCased version of it
520
+ # Try both the name as-is, and the camelCased version of it
209
521
  camelcased_sym = name.to_s.gsub( /_(\w)/ ) { $1.upcase }.to_sym
210
522
  attrtype = self.valid_attribute_type( name ) ||
211
523
  self.valid_attribute_type( camelcased_sym )
@@ -268,13 +580,13 @@ class Treequel::Model < Treequel::Branch
268
580
  def make_reader( attrtype )
269
581
  self.log.debug "Generating an attribute reader for %p" % [ attrtype ]
270
582
  attrname = attrtype.name
271
- return lambda {|*args|
583
+ return lambda do |*args|
272
584
  if args.empty?
273
585
  self[ attrname ]
274
586
  else
275
587
  self.traverse_branch( attrname, *args )
276
588
  end
277
- }
589
+ end
278
590
  end
279
591
 
280
592
 
@@ -329,30 +641,35 @@ class Treequel::Model < Treequel::Branch
329
641
 
330
642
 
331
643
  ### Apply mixins that are applicable considering the receiver's DN and the
332
- ### objectClasses from its entry.
333
- def apply_applicable_mixins( dn, entry )
334
- self.log.debug "Applying mixins applicable to %s" % [ dn ]
644
+ ### objectClasses from the given entry merged with any unsaved values.
645
+ def apply_applicable_mixins( dn, entry=nil )
646
+ entry ||= {}
647
+ entry.merge!( stringify_keys(@values) )
648
+ return unless entry['objectClass']
649
+
650
+ # self.log.debug "Applying mixins applicable to %s" % [ dn ]
335
651
  schema = self.directory.schema
336
652
 
653
+ # self.log.debug " entry is: %p" % [ entry ]
337
654
  ocs = entry['objectClass'].collect do |oc_oid|
338
655
  explicit_oc = schema.object_classes[ oc_oid ]
339
656
  explicit_oc.ancestors.collect {|oc| oc.name }
340
657
  end.flatten.uniq
341
- self.log.debug " got %d candidate objectClasses: %p" % [ ocs.length, ocs ]
658
+ # self.log.debug " got %d candidate objectClasses: %p" % [ ocs.length, ocs ]
342
659
 
343
660
  oc_mixins = self.class.mixins_for_objectclasses( *ocs )
344
661
  dn_mixins = self.class.mixins_for_dn( dn )
345
- self.log.debug " found %d mixins by objectclass (%s), and %d by base (%s)" % [
346
- oc_mixins.length,
347
- oc_mixins.map(&:name).join(', '),
348
- dn_mixins.length,
349
- dn_mixins.map(&:name).join(', ')
350
- ]
662
+ # self.log.debug " found %d mixins by objectclass (%s), and %d by base (%s)" % [
663
+ # oc_mixins.length,
664
+ # oc_mixins.map(&:name).join(', '),
665
+ # dn_mixins.length,
666
+ # dn_mixins.map(&:name).join(', ')
667
+ # ]
351
668
 
352
669
  # The applicable mixins are those in the intersection of the ones
353
670
  # inferred by its objectclasses and those that apply to its DN
354
671
  mixins = ( oc_mixins & dn_mixins )
355
- self.log.debug " %d mixins remain after intersection: %p" % [ mixins.length, mixins ]
672
+ # self.log.debug " %d mixins remain after intersection: %p" % [ mixins.length, mixins ]
356
673
 
357
674
  mixins.each {|mod| self.extend(mod) }
358
675
  end
@@ -369,9 +686,9 @@ class Treequel::Model < Treequel::Branch
369
686
  def attribute_from_method( methodname )
370
687
 
371
688
  case methodname.to_s
372
- when /^(?:has_)?([a-z]\w+)\?$/i
689
+ when /^(?:has_)?([a-z]\w*)\?$/i
373
690
  return $1.to_sym, :predicate
374
- when /^([a-z]\w+)(=)?$/i
691
+ when /^([a-z]\w*)(=)?$/i
375
692
  return $1.to_sym, ($2 ? :writer : :reader )
376
693
  end
377
694
  end