iT邦幫忙

0

[PoEAA] Data Source Architectural Pattern - Data Mapper

本篇同步發布於個人Blog: [PoEAA] Data Source Architectural Pattern - Data Mapper

1. What is Data Mapper

According to [PoEAA], this definition is "A layer of Mappers that moves data between objects and a database while keeping them independent of each other and the mapper itself."

Figure 1. Data Mapper (From PoEAA Page)

1.1 How it works

Separating Domain from Data Source is the main ability. Figure 2 shows that a client uses a person mapper's Find function to get a person instance from database. This mapper uses an Identity Map to check if the person instance is loaded or not. If it's not loaded, then loads it to the map.

Figure 3 shows that a client uses mapper's Update function and mapper reads the data from person instance to write data into database.

Figure 2. Retrieving data from database

Figure 3. Updating Data

For finding function, there are 3 topics to discuss:

1.1.1 Handling Finders

Usually presentation layer uses mapper's finding function to load objects from database. But sometimes domain layer needs to use mapper's finding function. This situation can be avoided by using Lazy Load pattern.

Separating finding interfaces to domain namespace(package) for mapper's implementation is a decoupling method.

1.1.2 Mapping Data to Domain Fields

Mapper creating a rich constructor to create a domain object is a recommend method. Avoid directly setting fields/properties of domain object. If two objects are referenced each other when mapper creates them by rich constructor, using Lazy Load is a method to solve it.

1.1.3 Metadata-Based Mappings

Metadata-Mapping pattern is a method that transfers domain object's fields into database's records. The metadata is saved in a class or a independent file. Using it can have no more source code/code generation/reflection programs.

1.2 When to use it

  1. Domain Model don't understand database existence. Modifying domain objects or mappers is more efficient.
  2. For more complex business logic.
  3. If Domain Model is not used, Data Mapper is not required.

2. Pattern Practice: The Person Management

This problem is introduced in the previous article [PoEAA] Data Source Architectural Pattern - Table Data Gateway. This article uses Data Mapper to build the data source architectural layer.

2.1 Implementation by C# & SQLite

This pattern is implemented by C# based on the content of Chapter 10 Data Source Architectural Patterns - Data Mapper of PoEAA. The database is SQLite.

By Martin's implementation, it doesn't contain a Delete function in a data mapper. So this article also has no Delete function.

2.1.1 Domain/Mapper layers

Create Domain and Mapper these 2 layers to separate responsibilities. Figure 4 shows:

Figure 4. Domain/Mapper layers

Following sections explain what they work for.

2.1.2 AbstractMapper/PersonMapper classes

AbstractMapper class has common behaviors for concrete child Mapper classes. AbstractMapper has a Identity Map: Dictionary<int, DomainObject> LoadedMap. When loading is processing, mapper uses this map to check whether this domain object has been loaded or not. If not, then mapper loads it from database.

AbstractMapper's DoLoad/DoInsert are abstract methods. They are override by concrete child Mapper classes. PersonMapper implements these abstract methods and access Person domain objects from database.

For Update function, this is implemented by concrete Mapper instead of abstract method.

PersonMapper has implemented IPersonFinder interface. This interface is defined in Domain layer for responsibility.

public abstract class AbstractMapper
{
	protected Dictionary<int, DomainObject> LoadedMap = new Dictionary<int, DomainObject>();
	protected abstract string FindStatement();
	protected abstract string InsertStatement();

	protected abstract int FindNextDatabaseId();

	protected DomainObject AbstractFind(int id)
	{
		bool findResult = LoadedMap.TryGetValue(id, out DomainObject result);
		if (findResult)
		{
			return result;
		}

		try
		{
			using var conn = DbManager.CreateConnection();
			conn.Open();
			using IDbCommand comm = new SQLiteCommand(FindStatement(), conn);
			comm.Parameters.Add(new SQLiteParameter("$id", id));
			using IDataReader reader = comm.ExecuteReader();
			reader.Read();
			result = Load(reader);
			return result;
		}
		catch (Exception ex)
		{
			throw new ApplicationException(ex.Message);
		}
	}

	protected DomainObject Load(IDataReader reader)
	{
		object[] resultSet = new object[reader.FieldCount];
		reader.GetValues(resultSet);

		int id = (int)resultSet[0];
		if (LoadedMap.ContainsKey(id))
		{
			return LoadedMap[id];
		}

		DomainObject result = DoLoad(id, reader);
		LoadedMap.Add(id, result);
		return result;
	}

	protected List<DomainObject> LoadAll(IDataReader reader)
	{
		List<DomainObject> result = new List<DomainObject>();
		while (reader.Read())
		{
			result.Add(Load(reader));
		}

		return result;
	}

	protected abstract DomainObject DoLoad(int id, IDataReader reader);

	public int Insert(DomainObject subject)
	{
		try
		{
			using var conn = DbManager.CreateConnection();
			conn.Open();
			using IDbCommand comm = new SQLiteCommand(InsertStatement(), conn);
			subject.Id = FindNextDatabaseId();
			var parameter = comm.CreateParameter();
			parameter.DbType = DbType.Int32;
			parameter.Value = subject.Id;
			comm.Parameters.Add(parameter);
			DoInsert(subject, comm);
			comm.ExecuteNonQuery();
			LoadedMap.Add(subject.Id, subject);
			return subject.Id;
		}
		catch (Exception ex)
		{
			throw new ApplicationException(ex.Message);
		}

	}

	protected abstract void DoInsert(DomainObject subject, IDbCommand insertStatement);

	public List<DomainObject> FindMany(IStatementSource source)
	{
		try
		{
			using var conn = DbManager.CreateConnection();
			conn.Open();
			using IDbCommand comm = new SQLiteCommand(source.Sql, conn);
			foreach (var p in source.Parameters)
			{
				var parameter = comm.CreateParameter();
				parameter.DbType = DbType.Object;
				parameter.Value = p;
				comm.Parameters.Add(parameter);
			}
			using IDataReader reader = comm.ExecuteReader();
			return LoadAll(reader);
		}
		catch (Exception ex)
		{
			throw new ApplicationException(ex.Message);
		}
	}

}

public class PersonMapper : AbstractMapper, IPersonFinder
{
	private const string Columns = " id, lastname, firstname, numberOfDependents ";

	private const string UpdateStatementString =
		"UPDATE person SET lastname = ?, firstname = ?, numberOfDependents = ? WHERE id = ?";
	protected override string FindStatement()
	{
		return "SELECT " + Columns + " FROM person WHERE id = $id";
	}

	protected override string InsertStatement()
	{
		return "INSERT INTO person VALUES (?, ?, ?, ?)";
	}

	protected override int FindNextDatabaseId()
	{
		string sql = "SELECT max(id) as curId from person";
		using var conn = DbManager.CreateConnection();
		conn.Open();
		using IDbCommand comm = new SQLiteCommand(sql, conn);
		using IDataReader reader = comm.ExecuteReader();
		bool hasResult = reader.Read();
		if (hasResult)
		{
			return ((int)((long)reader["curId"] + 1));
		}
		else
		{
			return 1;
		}
	}

	protected override DomainObject DoLoad(int id, IDataReader reader)
	{
		object[] resultSet = new object[reader.FieldCount];
		reader.GetValues(resultSet);
		string lastName = resultSet[1].ToString();
		string firstName = resultSet[2].ToString();
		int numberOfDependents = (int)resultSet[3];
		return new Person(id, lastName, firstName, numberOfDependents);
	}

	protected override void DoInsert(DomainObject subject, IDbCommand insertStatement)
	{
		Person person = (Person) subject;
		var p1 = insertStatement.CreateParameter();
		p1.DbType = DbType.String;
		p1.Value = person.LastName;

		var p2 = insertStatement.CreateParameter();
		p2.DbType = DbType.String;
		p2.Value = person.FirstName;

		var p3 = insertStatement.CreateParameter();
		p3.DbType = DbType.Int32;
		p3.Value = person.NumberOfDependents;
		insertStatement.Parameters.Add(p1);
		insertStatement.Parameters.Add(p2);
		insertStatement.Parameters.Add(p3);
	}

	public Person Find(int id)
	{
		return (Person) AbstractFind(id);
	}

	public IList<Person> FindByLastName2(string pattern)
	{
		return FindMany(new FindByLastName(pattern))
			.Cast<Person>().ToList();
	}

	public IList<Person> FinAll()
	{
		return FindMany(new FindAllStatement())
			.Cast<Person>().ToList();
	}

	public void Update(Person subject)
	{
		try
		{
			using var conn = DbManager.CreateConnection();
			conn.Open();
			using IDbCommand comm = new SQLiteCommand(UpdateStatementString, conn);
			var p1 = comm.CreateParameter();
			p1.DbType = DbType.String;
			p1.Value = subject.LastName;

			var p2 = comm.CreateParameter();
			p2.DbType = DbType.String;
			p2.Value = subject.FirstName;

			var p3 = comm.CreateParameter();
			p3.DbType = DbType.Int32;
			p3.Value = subject.NumberOfDependents;

			var p4 = comm.CreateParameter();
			p4.DbType = DbType.Int32;
			p4.Value = subject.Id;

			comm.Parameters.Add(p1);
			comm.Parameters.Add(p2);
			comm.Parameters.Add(p3);
			comm.Parameters.Add(p4);

			comm.ExecuteNonQuery();
		}
		catch (Exception ex)
		{
			throw new ApplicationException(ex.Message);
		}
	}

	private class FindByLastName : IStatementSource
	{
		private readonly string _lastName;

		public string Sql { get; } =
			"SELECT " + Columns + " FROM person WHERE UPPER(lastname) like UPPER(?) ORDER BY lastName";

		public object[] Parameters
		{
			get
			{
				return new object[] {_lastName};
			}
		}

		public FindByLastName(string lastName)
		{
			_lastName = lastName;
		}
	}

	private class FindAllStatement : IStatementSource
	{
		private readonly string _lastName;

		public string Sql { get; } =
			"SELECT * FROM person";

		public object[] Parameters
		{
			get
			{
				return new object[] {};
			}
		}
	}
}

2.1.3 IStatementSource interface

This interface packs SQL string and SQL parameters for FindMany method.

public interface IStatementSource
{
    string Sql { get; }
    object[] Parameters { get; }
}

2.1.4 DomainObject/Person classes and IPersonFinder interface

Domain Model's super class is DomainObject. For this person management case, Person class is the main Domain Model to perform business logic.

IPersonFinder interface associates with Person class and provides Find methods.

public abstract class DomainObject
{
	public int Id { get; set; }
}

public class Person : DomainObject
{
	public string LastName { get; }
	public string FirstName { get; }
	public int NumberOfDependents { get; }
	public Person(int id, string lastName, string firstName, int numberOfDependents)
	{
		Id = id;
		LastName = lastName;
		FirstName = firstName;
		NumberOfDependents = numberOfDependents;
	}

	public Money GetExemption()
	{
		Money baseExemption = Money.Dollars(1500d);
		Money dependentExemption = Money.Dollars(750d);
		return baseExemption.Add(dependentExemption.Multiply((double)NumberOfDependents));
	}
}

public interface IPersonFinder
{
	IList<Person> FinAll();
	Person Find(int id);
	IList<Person> FindByLastName2(string pattern);
}

2.2 Demo

Create a console program and create 3 Persons in SQLite as the following code:

class Program
{
	static void Main(string[] args)
	{
		InitializeData();

		Console.WriteLine("Get persons");
		PersonMapper mapper = new PersonMapper();
		// get all persons
		var people = mapper.FinAll();
		PrintPerson(people);

		Console.WriteLine("Insert a new person");
		mapper.Insert(new Person(0, "Rose", "Jackson", 60));
		people = mapper.FinAll();
		PrintPerson(people);

		Console.WriteLine("Update a person's first name");
		var firstPerson = mapper.Find(1);
		firstPerson.FirstName = "Jack";
		mapper.Update(firstPerson);

		Console.WriteLine("Update a person's number of dependents");
		var secondPerson = mapper.Find(2);
		secondPerson.NumberOfDependents = 0;
		mapper.Update(secondPerson);


		Console.WriteLine("Get persons again");
		people = mapper.FinAll();
		PrintPerson(people);

		Console.WriteLine("Get persons with lastname containing n");
		people = mapper.FindByLastName2("%n%");
		PrintPerson(people);
	}

	private static void PrintPerson(IEnumerable<Person> people)
	{
		foreach (var person in people)
		{
			Console.WriteLine($"ID: {person.Id}, " +
							  $"last name: {person.LastName}, " +
							  $"first name: {person.FirstName}, " +
							  $"number of dependents: {person.NumberOfDependents}, " +
							  $"exemption: {person.GetExemption().Amount}");
		}
	}

	private static void InitializeData()
	{
		using (var connection = DbManager.CreateConnection())
		{
			connection.Open();

			using (var command = connection.CreateCommand())
			{
				command.CommandText =
					@"
					DROP TABLE IF EXISTS person;
				";
				command.ExecuteNonQuery();


				command.CommandText =
					@"
					CREATE TABLE person (Id int primary key, lastname TEXT, firstname TEXT, numberOfDependents int);
				";
				command.ExecuteNonQuery();

				command.CommandText =
					@"
				   
				INSERT INTO person
					VALUES (1, 'Sean', 'Reid', 5);

				INSERT INTO person
					VALUES (2, 'Madeleine', 'Lyman', 13);

				INSERT INTO person
					VALUES (3, 'Oliver', 'Wright', 66);
				";
				command.ExecuteNonQuery();
			}

		}
	}
}

The console shows:

3. Conclusions

I very like "Data Mapper" pattern because of decoupling between Domain Model and Data Source. For testings and maintenance, these works are easier than other data source patterns. The above sample code is uploaded to this Github Repository.

For next article I will write Unit of Work pattern according to Chapter 11 Object-Relational Behavioral Patterns - Unit of Work of PoEAA.

4. References

Patterns of Enterprise Application Architecture Book(Amazon)


尚未有邦友留言

立即登入留言