treequel 1.2.2 → 1.3.0pre384

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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