Models

A common pattern when you're using any kind of datastore is to represent the data contained within it as a domain model. For relational databases, Martin Fowler's “Active Record” pattern has become the de facto standard, and because of its popularity, a few people have tried to apply the same pattern to LDAP. However, LDAP records aren't of fixed dimensionality, and don't need to be grouped together in the directory, so applying the logic of relational sets only works for the simplest cases.

Treequel comes with its own set of tools for constructing domain models, tools that take advantage of Ruby's flexible object model to reflect the flexibility and organic nature of LDAP data.

Modeling ObjectClasses

The principle component is a class called Treequel::Model, which provides the usual attribute accessors for the entry that it wraps, as well as a mechanism for layering functionality onto an object based on what its objectClass attributes are.

The layers are mixin Modules that extend Treequel::Model::ObjectClass, each of which is associated with a particular combination of objectClasses and bases. Here's a fairly simple example that adds a method that expands any labeledUri attributes of entries under ou=people,dc=acme,dc=com that have the inetOrgPerson objectClass:

require 'treequel/model'
require 'treequel/model/objectclass'

module ACME::InetOrgPerson
        extend Treequel::Model::ObjectClass

        model_class Treequel::Model
        model_bases 'ou=people,dc=acme,dc=com'
        model_objectclasses :inetOrgPerson


        ### Return the person's URIs as values in a Hash keyed by either the
        ### associated label (if there is one), or a number if there's no
        ### label.
        def labeled_uris
                counter = 0
                return self.labeled_uri.inject({}) do |hash, luri|
                        uri, label = luri.split( /\s+/, 2 )
                        unless label
                                label = counter
                                counter += 1
                        end
                        hash[ label ] = URI( uri )
                        hash
                end
        end

end # module ACME::InetOrgPerson

The module first extends Treequel::Model::ObjectClass [line 5], and then registers itself with a model class [line 7]. The next two lines set which objectClasses and base DNs the mixin will apply to [lines 8 and 9], and then the code that follows declares the method that's added to applicable model objects.

For example, if the above code was in a file called acme/inetorgperson.rb:

require 'treequel/model'
require 'acme/inetorgperson'

Treequel::Model.directory = Treequel.directory_from_config
jonh = ACME::InetOrgPerson.filter( :uid => 'jonh' ).first
jonh.labeled_uris
# => {"My Homepage"=>#<URI::HTTP:0x00000102841f68 URL:http://example.com/>}

Model Associations

You can use the methods of the mixins to associate entries with one another, as well. For attributes that contain a full DN, fetching the value will automatically return another Treequel::Model instance, but for less-restrictive attributes like memberUid that are just plain strings, you'll need to map them into the corresponding entry yourself:

require 'treequel/model'
require 'treequel/model/objectclass'

Treequel::Model.directory = Treequel.directory_from_config

module ACME::PosixAccount
        extend Treequel::Model::ObjectClass

        model_class Treequel::Model
        model_bases 'ou=people,dc=acme,dc=com'
        model_objectclasses :posixAccount

        ### Return ACME::PosixGroup objects for the groups the account is a member of.
        def groups
                return ACME::PosixGroup.filter( :memberUid => self.uid ).all
        end

end # module ACME::PosixAccount


module ACME::PosixGroup
        extend Treequel::Model::ObjectClass

        model_class Treequel::Model
        model_bases 'ou=groups,dc=acme,dc=com'
        model_objectclasses :posixGroup

        ### Return ACME::PosixAccount objects for the group's members
        def members
                return ACME::PosixAccount.filter( :uid => self.memberUid ).all
        end

end # module ACME::PosixGroup

If you want to make the associations a bit more useful, you can return a Treequel::Branchset from the association methods instead of calling .all on it immediately, which will allow the results to be filtered further by chaining additional filter methods:

require 'treequel/model'
require 'treequel/model/objectclass'

Treequel::Model.directory = Treequel.directory_from_config

# Re-open to modify the association to return a Branchset instead
module ACME::PosixGroup
        def members
                return ACME::PosixAccount.filter( :uid => self.memberUid )
        end
end # module ACME::PosixGroup

sysadmin_group = ACME::PosixGroup.filter( :cn => 'sysadmin' ).first
sysadmin_group.members.filter( ~:userPassword ).all

# => [#<Treequel::Model:0x100b0a2d8 uid=mahlon,ou=People,dc=acme,dc=com @ localhost:389 (dc=acme,dc=com, tls, anonymous) entry=nil>]

Mahlon appears to be violating ACME Company policy [line 17]. He will be flogged in accordance with company handbook section C, paragraph 2.

If the functionality you wish to define requires attributes of two or more different objectClasses , you can put all of them in the model_objectclasses statement, and then the mixin will only apply to entries that have all of them. The model_bases attribute can also take multiple values; entries will be limited to children of any of them.

Saving Model Objects

Another difference between regular Treequel::Branch objects and Treequel::Model is that Model objects defer writing attribute changes to the directory; while changes made to Treequel::Branch are written immediately, Treequel::Model objects keep track of modifications made to them, and only write them to the directory when their Treequel::Model#save method is called.

Model Validation

Before a Model object is saved, it is first checked against its validations. Validations are a way of expressing additional constraints on values contained in an entry, or on the structure of entries themselves, constraints that are not always enforceable in the directory itself because attributes in LDAP are often free-form strings.

You define validations by overriding the #validate method, and then adding to the object's #errors if any problems are detected.

For example, if we wanted to be sure that the company's posixAccount entries followed an established standard, we could add that like so:

require 'treequel/model/objectclass'

# Re-open the PosixAccount class from above to add validations
module ACME::PosixAccount

        def validate( options={} )
                # Enforce consistent home directory location based on first initial and uid
                expected_homedir = "/home/%s/%s" % [ self.uid[0,1], self.uid ]
                self.errors.add( :homeDirectory, "doesn't follow company convention" ) unless
                        self.home_directory == expected_homedir

                # Make sure the account has a GECOS set, effectively treating a MAY attribute
                # as a MUST.
                self.errors.add( :gecos, "isn't set" ) unless self.gecos

                # Be sure to super so other validations run
                super
        end

end

You can check an object's validity at any time using the Treequel::Model#valid? predicate. If it isn't valid, Treequel::Model#errors will contain a Treequel::Model::Errors object that can be used to diagnose the problem, build error messages for display, etc.

require 'acme/posixaccount'

Treequel::Model.directory = Treequel.directory_from_config

# Create a new account
account = ACME::PosixAccount.create( 'uid=jbernam,ou=people,dc=acme,dc=com' )

account.cn = 'James Bernam'
account.uid_number = 511
account.gid_number = 500

unless account.valid?
        $stderr.puts "Couldn't save #{account}:",
                *account.errors.full_messages
end

Since the posixAccount objectClass declares homeDirectory as one of its MUST attributes in the LDAP schema, this outputs:

bc. Couldn't save uid=jbernam,ou=people,dc=acme,dc=com:
homeDirectory MUST have a value
homeDirectory doesn't follow company convention
gecos isn't set

The default validations are provided by the Treequel::Model::SchemaValidations module, and as its name suggests, they check the entry against the schema loaded from the directory. They are intended to prevent sending data to the directory that would result in a obvious error. If you want to skip them, you can pass the :with_schema => false option to #save, #valid?, or #validate, which will still check any additional validations you've defined.

Model Callbacks

Treequel::Model objects also call a number of callbacks during various stages of their lifecycle, letting you take actions by declaring one or more in an ObjectClass mixin or in a Treequel::Model subclass, if you wish them to be run for every instance. The hooks are (in the order they're typically executed):

Typically you'll use a hook to do things like encrypt sensitive data before saving, normalize specially-formatted values, fill in defaults, etc.

You can cause the hooked action to be aborted by returning a false value from any of the before_ hooks. This causes a Treequel::BeforeHookFailed exception to be raised unless you call the action with :raise_on_failure => false.

Using hooks, you could rewrite the previous example like this:

require 'acme/posixaccount'

# Create a new account
account = ACME::PosixAccount.create( 'uid=jbernam,ou=people,dc=acme,dc=com' )

account.cn = 'James Bernam'
account.uid_number = 511
account.gid_number = 500

begin
        account.save
rescue Treequel::ValidationFailed => exception
        $stderr.puts "Couldn't save #{account}:",
                *exception.errors.full_messages
rescue Treequel::BeforeHookFailed => exception
        $stderr.puts "Couldn't save #{account}: the before_#{exception.hook} failed."
end