Kerosene Maps: an Entity Framework for your POCO classes


Introduction

We have seen in Kerosene Basics that Kerosene, by default, operates with database-oriented records when dealing with the results obtained from a given command. These records are dynamic objects that can adapt themselves to whatever schema is returned from the database without requiring any configuration or mapping files.

We have also seen how to use the Converter mechanism that all the enumerable commands in Kerosene support in order to convert those records as they are returned from the database in whatever object you need or wish.

While this is enough for a number of scenarios, there are others where having the capability of operate completely at the business entity level is really helpful. We are going to impose that their classes are POCO ones, and that you don't want modify them in any way, not even with attributes because, maybe, you don't even have access to their source code. Of course, in this scenario, you don't wish to reinvent the wheel by creating wrapper classes or by using proxy factories.

Kerosene Maps is an Entity Framework alike capability built specifically for your POCO business classes. It permits you to forget the database details and just focus again on your business needs. Only when you need to persist a given object you will refer to a link instance that identifies the database, using the appropriate method with you business instance as its parameter.

Kerosene Maps is built on top of the Kerosene base library, so keeping all its features and advantages. You can mix both flavors together as you wish as there is no impediment to do so.

The Basics

Continuing with the spirit of simplicity of Kerosene, the Maps mechanism has been built to be extremely easy to use. The idea is to instantiate a "map" that defines a correspondence between your business class and a given primary table in the database, and since then, use your instances in a natural way.

For instance, let's suppose you have a very limited knowledge about the structure of your database, that you basically know that there is a table named "Employees" with the usual suspects as its columns. To create, insert, find it again, update and delete an instance of your business class "Employee" what follows is all the code you will need:

var link = ...; // use your favorite way to instantiate your link object
var map = new KMap<Employee>( link, x => x.Employees ); // creating and registering at once
map.Validate();

var emp = new Employee() { Id = "007", FirstName = "James", LastName = "Smith", CountryId = "uk" };
link.Insert( emp ).Execute();

emp = link.Find<Employee>( x => x.Id == "007" ); // Find doesn't requite Execute()
emp.LastName = "Bond";
link.Update( emp ).Execute();

link.Delete( emp ).Execute();

What is an Entity

An entity is defined as an instance of your business class that will be used with the Kerosene's Maps mechanism.

You don't need to do anything on this instance or with the class definition to make them usable with Maps, not even decorate them with attributes, or create wrapper classes, etc. The only restriction is that Kerosene Maps only supports classes, not structs or value objects.

Behind the scenes the history is quite different. It involves attaching to those instances at run time, when needed, a metadata package. This package will identify them as associated to the Maps mechanism, will permit them to be kept in an internal cache as far as they are used by your application, and discarded when they are no longer needed, and will keep enough information to permit Kerosene to identify the changes in their contents when an update operation is requested.

The internal cache is also used to keep the unicity of your instances: it will assure that two or more references, even if obtained at differente moments, as far as they refer to the same "record", they will point to the same internal instance. So if you modify the contents in one of those, the other references will be "updated" (so to speak) automatically.

You can find more details at Kerosene Internals.

The Map Object

A map is an instance of the KMap<T> class, where "T" is the type of your business class. As we have seen in the previous example, its constructor takes a reference to the link object it will be associated with, and a dynamic lambda expression specifying the primary table in the database to associate with your business type.

A given business type can be registered just once in a given link instance, but can be registered as many times as you wish in different ones. The GetMappedTypes(), GetMap(), RemoveMap(), and ClearMaps() extension methods of the IKLink interface permit you to manipulate the maps registered with a given link.

The primary table is where Kerosene will try by default to find the contents of your business instances. Using reflection it will try to match the names of the fields and properties of your business class with the names of the columns that may exist in the table, discarding those that don't match.

A map needs to be validated, using its Validate() method, in order to become usable for database operations. If you try to use a map before it is validated for database related purposes, an exception will be raised. You can customize how the map instance will operate before it is validated, as we will see later.

An interesting thing to mention is that Kerosene needs that your primary table has at least one key column, or one column with unique values, because it needs to locate uniquely the corresponding record when updating or deleting it. But it does not require that you know in advance or specify what this column is, or columns in case of composite keys: they are found and used as needed on your behalf.

Basic Operations

So the idea behing the Maps mechanism is let you focus on your business logic and pay as less attention as possible to the database-related stuff. In order to do so, once your map object is instantiated, a number or instance-related methods are available. The easiest way to use them is in the form of generic extension methods of your link object.

Note that there are two kind of methods: those that instantiate a command object, as the standard Query, Insert, Update and Delete, and those that don't, as Find and Refresh. The reason is because the former ones can be used to get the SQL command they will execute, for logging purposes, whereas the latter ones are typically used in more direct scenarios.

Queries

Query operations permits to query the database using a set of conditions and enumerate through the results. These results will be instances of the business class mapped, created automatically by Kerosene, as identified by the type specified. If no results are found this enumeration will be empty.

var cmd = link.Query<Region>().Where( x => x.Id >= "3" );
foreach( var obj in cmd ) Console.WriteLine( "\n> Region: {0}", obj );

Query commands accept the standard methods you may expect: Where(), Join(), OrderBy() and Top(). They follow the same convention and rules than their standard counterparts, as explained in Kerosene Basics. You may have noticed that there is no a Select() method, because the specification of the columns to return is obtained from the map definition.

There is also a From() method in case you want to return contents from other tables. This method is rarely used, only in very specialized scenarios and, for those, the Query<T>() method itself accepts an optional argument specifying the alias to use with the primary table. This alias specification can also be used, if needed, when you are using Joins and want to qualify each table involved in the query.

Find

The Find operation will return just one instance, the first match that verifies the search conditions given. It will also try to find it firstly in the internal cache Kerosene Maps maintains, and only if it is not found here, it will try to get the first match from the database. If no match is found, it will return null.

Region reg = link.Find<Region>( x => x.Id == "110" );

Refresh

From time to time you may need to assure that the contents on your business instance are in sync with the database - they might have changed since the last time you obtained them. It is as easy as what follows:

reg = link.Refresh( reg );

The Refresh() method goes to the database, locate the record, and load your business instance with the most updated contents. If the record is found, the internal cache is refreshed with those contents. If it is not found, this method returns null.

Insert

Typically you will create your business instance, operate with it, and later, when needed, you will persist it to the database using an insert operation as follows:

var reg = new Region() { Id = "999", Name = "Solar System" };
var cmd = link.Insert<Region>( reg );
cmd.Execute();

The Execute() method returns either the affected instance, or null if something has failed. In this case the instance is also marked for removal from the internal cache.

Note that you can obtain the actual SQL code the command will execute by using its ToString() method.

Delete

The delete operation works only on instances that have been inserted or retrieved using the Maps mechanism. You can not use it to delete a record from the database if the instance is not a managed one - for this you will be better served with the standard Delete commands of the basic mode of Kerosene.

The Maps' delete command will always return null regardless the success or failure of the operation.

reg = link.Delete( reg );

Update

When you invoke an Update operation Kerosene will find what modifications have been done in your business entity, and will create the update command only for those. If no changes were found, the Update operation will merely return without even try to execute it.

reg.Name = "Milky Way";
reg = link.Update<Region>( reg ).Execute();

This is possible because the metadata package attached to the entity when it became a managed one is used, among other things, to keep the last database-related contents. These contents are then compared against the current ones in your business instance to find the changes, if any.

When the Update's Execute() method is invoked, the former internal contents are replaced with the most accurate ones as obtained from the database. Execute() returns the instance affected if succesful, or null if the operation has failed.

Going Beyond the Defaults

The default Maps mechanism covers, I would say, about two thirds of the possible scenarios were it will be used. Basically all those one where there is a correspondence, even if not perfect, among some columns in the database and some fields or properties in your business class.

But may want to customize this default mechanism when you want to map columns to properties whose names don't match, when you want to perform more complex conversions than this basic one-to-one mechanism, or when your business entity has dependency properties.

For the discussions that follow we will refer to the following class:

public class Region {
   public string Id { get; set; }
   public string Name { get; set; }
   public Region Parent { get; set; }
   public List<Region> Childs { get; private set; }

   public Region() { Childs = new List<Region>(); Clear(); }
   public void Clear() {
      Id = null; Name = null; Parent = null;
      if( Childs != null ) Childs.Clear();
   }
}

Its "Id" and "Name" properties will match with corresponding columns in the database. Its "Parent" property will be fetched from the database using the "ParentId" column associated with the record. And its "Childs" property will keep the list of the regions whose parent is this one, list that will be obtained from a separate query against the database.

Where to place your customizations

You must place your customizations after the map is created but before its Validate() method is called. Once a map is validated it does not accept any more customizations, and any attempt to do it will raise an exception.

But do not forget to call its Validate() method: it makes your map usable for database related operations, and if you try to use a map that has not been validated an exception will be raised as well.

The customizations are related to the specification of what columns are you interested in, whether to let Kerosene to manage those columns, or some of them will be unmanaged ones, and how to react when the Maps mechanism invokes the operations against the database.

Specifying what Columns to Map

Remember that, by default, Kerosene will try to match columns in the database with fields or properties in your business class, and if no correspondence is found, the offending columns will be discarded. Additionally Kerosene will use reflection to get and set those members (even if the setter is a private one) on our behalf. This columns are called "managed columns", and it will be the case of the "Id" and "Name" ones in our example.

For them, we don't need to do anything else... except if we wish to restrict the number of managed columns retrieved for validation purposes. In our example, even if it is not needed, we could something like:

var map = new KMap<Region>( link, x => x.Regions );
map.ManagedColumns( "Id", "Name" );

But often we will want Kerosene to retrieve "unmanaged columns": those that we will take care manually of loading its contents into the business instance, or extracting their values from it when writing into the database. This is what happens with the "ParentId" one in our example, and we can specify which ones are to be unmanaged as follows:

map.UnManagedColumns( "ParentId" );

Remember that among the columns you have specified there should be at least one that is either a primary key column or an unique value one. The Validate() method will check for their existance and, if no one is found, an exception will be raised.

How to customize the Creation and Cleaning of your Entities

When reading from the database Kerosene needs to know how to create instances of your business entities to return those results. By default it will try to find a parameterless constructor in your class definition. If such constructor does not exist it will raise an exception unless you have customized how to create those instances.

How? By setting the map's CreateInstance property with the delegate to invoke to create and return them. In our example, even if it is not needed as we have a parameterless constructor, we could write something like:

map.CreateInstance = () => { return new Region(); };

Let me take the chance to mention some very important and pervasive points:
  • The most common way to customize how a map operates is by setting its properties, like the above's CreateInstance one. These properties are the delegates to invoke when the given operation needs to happen. Each delegate can take or not some arguments depending on its own nature.
  • The default internal value of those properties is null, meaning that a default method will be use instead. When you set a property with your own delegate it will be invoked instead of the default method.
  • Regardless you have set or not a value for the propery its getter will always return a delegate you can invoke to perform the requested operation. It will point to the default method or to your custom delegate, but won't be null in order to avoid exception.
  • The default methods only affect managed columns and properties. The best practice is to them for this purposes inside your custom delegates, and let your code concentrate only on the unmaged columns. Their names always start with "Base" followed by the name of the property, as in "BaseCreateInstance".

Similarly there is a property that will be invoked when it is needed to clear a given business entity. It is named ClearInstance, and the delegate takes as its argument the instance to be cleared. In our case we could have written something like:

map.ClearInstance = obj => { obj.Clear(); };

Customizing the transfer of information

Kerosene will use, as its most inner level, KRecord instances as its carry pigeons for many purposes: in particular to obtain the contents to load into your business instances, and to store the contents obtained from them when writing into the database.

But there is one important thing to mention: not all the columns you have specified may appear in the record - there are some circumstances in which some of them will be missed. The reason is because there are scenarios where these records are used as temporary stores without the need of holding all possible columns, but only the ones related to the current internal state.

Having said that, why do we want to customize this mechanism? Because this will be the only way to manipulate the unmanaged columns. And how? By setting the LoadRecord and WriteRecord properties of your map object.

LoadRecord is used to get the contents from the record passed to the delegate as its first argument, and load the business instance passed to it as its second argument. In our example, we will want to use the "ParentId" column to fetch the parent record, if exists, and load the "Parent" property with it:

map.LoadRecord = ( record, obj ) => {
   map.BaseLoadRecord( record, obj );
   record.OnGet( "ParentId", val => {
      obj.Parent = val == null ? null : map.Find( x => x.Id == val ); } );
};

Note how we have used the OnGet() extension method of the record instance to get the value of the "ParentId" column. Indeed this method will check for the existence of the given column, and only if it exists, its delegate will be executed. In our case this delegate will find the parent record only if the value of the column is not null.

Let me mention that, despite you could do anything you wish inside the LoadRecord delegate, the best practice is to load only those properties closely related to the columns obtained from the database. For instance, it will not be considered a best practice to load here the contents of the Child property.

The reason is quite technical (see Kerosene Internals for more details) but basically involves avoiding infinite recursion scenarios. So, where to load those dependency properties? The right place is by setting a custom delegate into the OnRefresh map's property, that takes the business instance as its argument:

map.OnRefresh = obj => {
   obj = map.BaseOnRefresh( obj );
   obj.Childs.Clear();
   obj.Childs.AddRange( link.Query<Region>().Where( x => x.ParentId == obj.Id ).ToList() );
};

Here we have cleaned the "Childs" property for simplicity, and we have load it again using the results of an inner query.

Similarly we will need to write into a record instance the contents we will obtain from our business entity. We will customize this operation setting the map's WriteRecord property with a custom delegate that takes the business instance as its first argument, and the record one as its second argument:

map.WriteRecord = ( obj, record ) => {
   map.BaseWriteRecord( obj, record );
   record.OnSet( "ParentId", () => { return obj.Parent == null ? null : obj.Parent.Id; } );
};

The OnSet() extension method will invoke its inner delegate only if the column specified exists, and if so will use whatever value is returned from this delegate to set the value of this column.

Customizing the Insert operation

So now that we know how to transfer information back and forth the database, we would like to customize the insert operation to deal with those dependency properties. As you can now expect, it is achieved by setting with a custom delegate the OnInsert property of your map.

But dealing with dependencies involves a lot of logic. If you don't want to take care of this logic by yourself, the easiest way to deal with them is by using the Sync<K>() extension method:

map.OnInsert = obj => {
   obj.Parent = map.Sync( obj.Parent, KMaps.SyncReason.Insert );
   obj = map.BaseOnInsert( obj );
   map.Sync( obj.Childs, KMaps.SyncReason.Insert );
   return obj;
};

The Sync method is designed to deal with members of your host instance. Those members will be typically of mapped types associated to their respetive maps, and can be either single references (as the "obj.Parent" one in the example above), or list members (as the "obj.Childs" one in the example above). The second argument specifies why the Sync method is invoked.

Sync will return null if the single instance is not found, or a reference that can be used to set the member. It will insert the record in the database if needed. If the member is a list, it manipulates the list accordingly to the rules specified by the reason.

Customizing the Update operation

Now it is very straightforward the way we will customize our update operations. In our example it will take the following form:

map.OnUpdate = obj => {
   obj.Parent = map.Sync( obj.Parent, KMaps.SyncReason.Update );
   obj = map.BaseOnUpdate( obj );
   map.Sync( obj.Childs, KMaps.SyncReason.Update );
   return obj;
};


Customizing the Delete operation

And similarly, it is also very easy now to customize how the delete operation will behave:

map.OnDelete = obj => {
   map.Sync( obj.Childs, KMaps.SyncReason.Delete );
   obj = map.BaseOnDelete( obj );
   return obj;
};

The only thing to note here is that in this case we have not synchronized its "Parent" property, because conceptually deleting a child does not imply to delete its parent.

What else?

Well, actually there are a lot of things we have not covered in this tutorial. We have only scratched the surface of the metadata extension mechanism and its possibilities. We have not even discussed the "Transient" mechanism Kerosene uses to avoid as many trips as possible to the database, and how this mechanism interacts with the internal cache. We have not discussed the built-in internal collector that, in some scenarios, can be helpful to avoid the internal cache to grow disproportionately.

All those discussion are addressed in the Kerosene Internals documentation.

Last edited Sep 24, 2012 at 6:45 PM by mbarbac, version 9

Comments

No comments yet.