Аналог .Net Entity Framework в Delphi за допомогою RTTI. Частина перша, вступна

Після того, як в Embarcadero оживили Delhi, я повернувся з розробки на C# до більш звичного інструменту. Серйозно порадувало, що більшість синтаксичних можливостей, класів і різних витребеньок» чарівним чином переїхало з шарпа. На жаль, така приємна можливість, як відображення вибірки з бази даних колекції класів залишилася за дужками.

У своїх проектах ми часто стикаємося з необхідністю алгоритмічної обробки різних вибірок, реалізація яких неможлива засобами SQL. Для кожної вибірки створювався клас і кожен раз, коли потрібно створити нову вибірку, доводилося проводити абсолютно однакові рухи, з тією різницею, що заповнювати поля класів доводилося ручками.

Розкинувши мозком і оцінивши можливості RTTI, трудовитрати і готівковий запас бубнов, у нас вийшов список «хотілок» для роботи з БД, яких не вистачає в нашій нудного життя:

  1. Автоматична генерація класів за структурою таблиць розроблюваної БД.
  2. Заповнення списків класів даними з таблиць.
  3. Для реалізації створення класів буде не зайвим зчитувати структуру таблиць БД.
  4. Маючи на руках структуру БД можна автоматизувати:
  • Порівняння структури існуючої БД з еталонною для попередження помилок при оновленні розроблюваного ПЗ у кінцевого користувача;
  • Формування «контракту БД», що містить у собі константи назв таблиць, полів, збережених процедур і функцій;
  • Створення класів з пп. 1. з урахуванням зв'язків між таблицями.
  • Створення «обгорток» для виклику збережених процедур і функцій.
І при правильній реалізації і акуратній роботі далеко починає маячити можливість кроссплатформної роботи між різними типами SQL серверів.

Почнемо з простого
Перевіримо саму можливість відображення даних з DataSet-ів на класи. Оновлений RTTI дозволяє перераховувати імена властивостей класу, а також, отримувати та встановлювати значення властивостей.

Створимо приклад вибірки з простої таблиці і заповнення списку класів, що містять публічні властивості, що збігаються за назвою з полями таблиці. Працювати будемо MS SQL сервером.

Створимо БД, в ній таблицю з фіз. особами і парою записів:

USE [master]
GO

CREATE DATABASE [TestRtti]
GO

USE [TestRtti]
GO

CREATE TABLE [dbo].[Users_Persons](
[Guid] [uniqueidentifier] ROWGUIDCOL NOT NULL CONSTRAINT [DF_Users_Persons_Guid] DEFAULT (newid()),
[Created] [datetime2](7) NOT NULL CONSTRAINT [DF_Users_Persons_Created] DEFAULT (getutcdate()),
[Written] [datetime2](7) NOT NULL CONSTRAINT [DF_Users_Persons_Written] DEFAULT (getutcdate()),
[First_Name] [nvarchar](30) NOT NULL,
[Middle_Name] [nvarchar](30) NOT NULL,
[Last_Name] [nvarchar](30) NOT NULL,
[Sex] [bit] NOT NULL,
[Born] [date] NULL
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[Users_Persons] ADD CONSTRAINT [PK_Users_Persons] PRIMARY KEY NONCLUSTERED 
(
[Guid] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, IGNORE_DUP_KEY = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
GO

INSERT [dbo].[Users_Persons] ([Guid], [Created], [Written], [First_Name], [Middle_Name], [Last_Name], [Sex], [Born]) 
VALUES ('291fefb5-2d4e-4ccf-8ca0-25e97fabefff', CAST(N'2016-07-21 10:56:16.6630000' AS DateTime2), CAST(N'2016-12-09 16:22:01.8670000' AS DateTime2), 
'Петро', 'Миколайович', 'Іванов', 1, CAST(N'1970-01-01' AS Date))
GO
INSERT [dbo].[Users_Persons] ([Guid], [Created], [Written], [First_Name], [Middle_Name], [Last_Name], [Sex], [Born]) 
VALUES ('11ad8670-158c-4777-a099-172acd61cbd3', CAST(N'2016-07-21 10:59:02.2030000' AS DateTime2), CAST(N'2016-12-09 16:22:10.4730000' AS DateTime2), 
'Андрій', 'Юрійович', 'Смирнов', 1, CAST(N'1970-01-01' AS Date))
GO

Ручками в модулі UsersPersonsEntity.pas створимо клас TUsersPersonsEntity і, забігаючи вперед, оголосимо його список і створимо для нього тип класу-читача:

unit UsersPersonsEntity;

interface

uses
Узагальнення.Collections, DataSetReader;

type
TUsersPersonsEntity = class(TBaseDataRecord)
private
FGuid: TGUID;
FCreated: TDateTime;
FWritten: TDateTime;
FFirstName: String;
FMiddleName: String;
FLastName: String;
FSex: Boolean;
FBorn: TDate;
public
property Guid: TGUID read FGuid write FGuid;
property Created: TDateTime read FCreated write FCreated;
property Written: TDateTime read FWritten write FWritten;
property First_Name: String read FFirstName write FFirstName;
property Middle_Name: String read FMiddleName write FMiddleName;
property Last_Name: String read FLastName write FLastName;
property Sex: Boolean read FSex write FSex;
property Born: TDate read FBorn write FBorn;
end;

TUsersPersonsList = TDataRecordsList<TUsersPersonsEntity>;
TUsersPersonsReader = TDataReader<TUsersPersonsEntity>;

implementation

end.

У поточній ситуації нам навіть не знадобиться конструктор класу. Тепер саме веселе — треба відобразити рядок з DataSet на екземпляр класу. Весь код читання винесено в окремий модуль і займає без малого півтори сотні рядків.

unit DataSetReader;

interface

uses
System.TypInfo, System.Rtti, SysUtils, DB, Узагальнення.Collections, Узагальнення.Defaults;

type
TBaseDataRecord = class
public
constructor Create; overload; virtual;
procedure SetPropertyValueByField(ClassProperty: TRttiProperty;
Field: TField; FieldValue: Variant);
procedure SetRowValuesByFieldName(DataSet: TDataSet);
procedure AfterRead; virtual;
end;

TBaseDataRecordClass = class of TBaseDataRecord;

TDataRecordsList<T: TBaseDataRecord> = class(TObjectList<T>);

TDataReader<T: TBaseDataRecord, constructor> = class
public
function Read(DataSet: TDataSet; ListInstance: TDataRecordsList<T> = nil;
EntityClass: TBaseDataRecordClass = nil): TDataRecordsList<T>;
end;

implementation

var
Context: TRttiContext;

{ TBaseDataRecord }

constructor TBaseDataRecord.Create;
begin
end;

procedure TBaseDataRecord.AfterRead;
begin
end;

procedure TBaseDataRecord.SetPropertyValueByField(ClassProperty: TRttiProperty; Field: TField;
FieldValue: Variant);

function GetValueGuidFromMsSql: TValue;
var
Guid: TGUID;
begin
if Field.IsNull then
Guid := TGUID.Empty
else
Guid := StringToGUID(Field.AsString);
Result := TValue.From(Guid);
end;

var
Value: TValue;
GuidTypeInfo: PTypeInfo;
begin
if Field = nil then
Exit;
GuidTypeInfo := TypeInfo(TGUID);
Value := ClassProperty.GetValue(Self);
case Field.DataType of
ftGuid: begin
if Value.TypeInfo = GuidTypeInfo then
ClassProperty.SetValue(Self, GetValueGuidFromMsSql)
else
ClassProperty.SetValue(Self, TValue.FromVariant(FieldValue));
end;
else
ClassProperty.SetValue(Self, TValue.FromVariant(FieldValue));
end;

end;

procedure TBaseDataRecord.SetRowValuesByFieldName(DataSet: TDataSet);
var
Field: TField;
FieldName: String;
FieldValue: Variant;
ClassName: String;
ClassType: TRttiType;
ClassProperty: TRttiProperty;
begin
ClassName := Self.ClassName;
ClassType := Context.GetType(Self.ClassType.ClassInfo);
for ClassProperty in ClassType.GetProperties do
begin
Field := DataSet.FindField(ClassProperty.Name);
if Field <> nil then
begin
FieldName := Field.FieldName;
FieldValue := Field.Value;
SetPropertyValueByField(ClassProperty, Field, FieldValue);
end;
end;
end;

{ TDataReader<T> }

function TDataReader<T>.Read(DataSet: TDataSet; ListInstance: TDataRecordsList<T>;
EntityClass: TBaseDataRecordClass): TDataRecordsList<T>;
var
Row: T;
begin
if ListInstance = nil then
Result := TDataRecordsList<T>.Create
else begin
Result := ListInstance;
Result.OwnsObjects := True;
Result.Clear;
end;

DataSet.DisableControls;
Result.Capacity := DataSet.RecordCount;
while not DataSet.Eof do
begin
if EntityClass = nil then
Row := T. Create()
else
Row := EntityClass.Create() as T;

Row.SetRowValuesByFieldName(DataSet);

Row.AfterRead;
Result.Add(Row);
DataSet.Next;
end;
end;

initialization

Context := TRttiContext.Create;

end.

Для зручності оперування generic класами бажано створити базовий клас сутності рядка таблиці з віртуальним конструктором TBaseDataRecord і породжувати від нього реальні сутності рядків таблиць (див. оголошення TUsersPersonsEntity). Крім базового класу, в модулі присутня generic клас «читач». Його завдання пробегаться за DataSet-у, створювати екземпляри рядків і підсовувати поточний рядок вибірки створеного екземпляра спадкоємця TBaseDataRecord і складувати його в результуючий список.

Функціонал відображення даних з вибірки на клас винесено TBaseDataRecord. При переборі властивостей класу проводиться пошук в DataSet полів з таким же ім'ям. Якщо поле знайдено, то після легкого шаманства з варіантними типами і TValue, властивості виявляється необхідне значення.

На жаль, «не все так однозначно». У методі SetPropertyValueByField доводиться перевіряти, що поточне властивість має тип TGUID. MSSQL віддає GUID у вигляді рядка і пряме привласнення дасть помилку. Доводиться явно перетворювати рядок до GUID. Більше того, подальше застосування показало необхідність додаткових присідань для:

  • MSSQL, OLEDB та DATE DATETIME
  • Обробка BLOB-ів
  • Firebird і GUID при зберіганні в CHAR(16) CHARACTER SET OCTETS
  • Firebird і TIMESTAMP
Список постійно поповнюється по мірі виявлення. Але головне — воно працює. І працює наступним чином (власне текст програми):

program TestRtti;
{$APPTYPE CONSOLE}
{$R *.res}
uses
DB, ADODB, System.SysUtils, ActiveX,
DataSetReader in 'DataSetReader.pas',
UsersPersonsEntity in 'UsersPersonsEntity.pas';
var
Connection: TADOConnection;
Query: TADOQuery;
UsersPersons: TUsersPersonsList;
UserPerson: TUsersPersonsEntity;
Reader: TUsersPersonsReader;
i: Integer;

begin
ReportMemoryLeaksOnShutdown := True;
UsersPersons := nil;
try
CoInitialize(nil);
Connection := TADOConnection.Create(nil);
try
Connection.ConnectionString :=
'Provider=SQLNCLI11.1;Integrated Security=SSPI;Persist Security Info=False;User ID="";' +
'Initial Catalog="TestRtti";Data Source=localhost;Initial File Name="";Server SPN=""';
Connection.Connected := True;

Query := TADOQuery.Create(nil);
Reader := TUsersPersonsReader.Create;
try
Query.Connection := Connection;
Query.SQL.Text := 'SELECT * FROM Users_Persons';
Query.Open;

UsersPersons := Reader.Read(Query);

Writeln('Прочитано записів: ', UsersPersons.Count);
for i := 0 to UsersPersons.Count - 1 do begin
UserPerson := UsersPersons[i];
Writeln(Format('%d. %s %s %s %s', [i + 1, UserPerson.First_Name, UserPerson.Middle_Name,
UserPerson.Last_Name, FormatDateTime('dd.mm.yyyy', UserPerson.Born)]));
end;
Writeln('Натисніть Enter для завершення...');
Readln;
finally
Query.Free;
Reader.Free;
end;
finally
Connection.Free;
if UsersPersons <> nil then
FreeAndNil(UsersPersons);
end;
except
on E: Exception do
Writeln(E. ClassName, ': ', E. Message);
end;
end.

Головне в коді це рядок UsersPersons := Reader.Read(Query);. І все. Компактненько, однак. А ось і висновок додатки:

image

далі
Це тільки перевірка можливостей. Хоча для «плоских» простих запитів наведений механізм цілком працездатний.

А далі — автоматичне створення контракту БД і сутностей таблиць, створення еталонної схеми БД, зв'язування списків сутностей, оновлення даних, сериализация списків і багатоплатформовий читання.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.