Pull to refresh

Пишем ORM для Delphi

Reading time 9 min
Views 17K
Всем привет!
Сегодня я расcкажу вам о своем опыте написания ORM для Delphi с использованием RTTI под влиянием практик работы с Doctrine и Java EE.

Зачем?


Под мою власть недавно попал старый проект на Delphi7 в котором ведется активная работа с базой данных под Interbase 2009. Код в этом проекте радовал, но ровно до тех пор, пока речь не заходила о самом взаимодействии с бд. Выборка данных, обновление, внесение новых записей, удаление — все это занимало немало строк в логике приложения, отчего разобраться в коде порой становилось довольно сложно (спасение в добросовестном разработчике, который круглосуточно отвечал на мои глупые вопросы). В мои руки проект был передан с целью устранения старых бед и добавления в него нового модуля, задача которого — покрыть новые таблицы БД.

Мне нравится MVC подход и очень хотелось разделить код логики с кодом модели. Да и если уж на чистоту — я не захотел для каждой новой таблицы переписывать по новой все get/set методы. Пару лет назад я познакомился с понятием ORM и мне это понравилось. Мне понравился принцип и я был в восторге, применяя его в своей работе.
В тот же момент я ринулся искать в Delphi7 хоть что-нибудь похожее на Doctrine или может генераторы Entity/Facade классов для таблиц… Ни того ни другого. Зато в поисковой выдаче нашлось несколько готовых решений. Например DORM. В целом, отличная штука и, по сути, то что нужно!

Но, не знаю бывает ли у вас, я отказался от готового решения, так как мне нужен весьма ограниченный функционал, и тащить за собой все содержимое DORM или tiOPF просто незачем. С осознанием того что хочу, с пониманием всех недостатков, я стал на этот скользкий путь и, кажется, дошел до…

Размышления


Отсутствие хоть какого-нибудь подобия на ORM — это головняк. Я вот о чем — в Java из коробки есть возможность по готовой БД создать набор классов-сущностей и фасадов для работы с созданными сущностями. Цель этих классов — предоставить разработчику готовый инструмент для взаимодействия с некоторой бд, очистить основной код логики приложения от текстов запросов и разборов результатов их выполнения. Эти же вещи используются во всех популярных PHP фреймворках, в Qt (если мне не изменяет память) в том или ином виде.

В чем же была сложность реализовать качественную библиотеку для object mapping и включить ее в состав IDE? Задача состоит в необходимости подключиться к бд, спросить у пользователя какие таблицы ему нужны в приложении, прочитать поля таблиц и связи между ними (по внешним ключам), уточнить все ли связи правильно были поняты и сгенерировать по собранным данным классы. Под генерацией я имею в виду — создание классов сущностей, задача которых — быть хранилищем одной записи из какой-то таблицы. Зная имя таблицы, можно узнать все ее поля, типы полей и по этой информации обьявить нужную информацию, сгенерировать раздел published, дописать необходимые сеттеры и геттеры… В целом задача трудоемкая, но реализуемая.
После генерации классов сущностей IDE могла бы приступить к генерации классов-фасадов (или как я их называю — Адаптеров). Адаптер представляет собой прослойку между программистом и базой данных и основная его задача — уметь получать, соответствующую некоему ключу, сущность, сохранять изменения в ней, удалять ее. В общем суть Адаптера — представить разработчику методы для работы с БД, результаты которых будут представлены в виде объектов соответствующих им сущностей.

Этот аспект разработки на Delphi мне не понравился. Опыт работы с ним у меня уже относительно большой, я вижу много плюсов в этом нелегком деле, но чем больше узнаю о новых языках и средах, ощущаю что Delphi — инструмент хотя и подходящий, но несколько не дотягивает до требуемого уровня, когда на сложную и муторную рутину мне не придется тратить столько времени.

Генерацию сущностей я готов переложить на плечи героев. Возможно кто-то даже сможет внедрить это в саму IDE как Castalia. Но я не вижу никакого смысла писать отдельно для каждой сущности методы выборки, обновления, удаления. Я не хочу. Я хочу класс, которому я передам имя сущности, у которого вызову метод findAll и получу все записи из нужной таблицы. Или напишу find(5) и получу запись с числовым ключом 5.

Процесс


Разрабатываем класс TUAdapter.
Что должен уметь делать Adapter в результате:
  1. Создает объект по имени класса
  2. Умеет получать поля класса
  3. Умеет получать значение поля обьекта по имени поля
  4. Умеет совершать выборку всех данных
  5. Умеет доставать сущность по ключу
  6. Умеет обновление данные сущности в бд
  7. Умеет удалять сущность из бд
  8. Умеет добавлять новую сущность из бд.

Мои ограничения:
  1. Нет PDO — разработка под одну БД — Interbase
  2. В Delphi7 еще старая версия RTTI. (в Rad 2010 RTTI было сильно улучшено). Можно достать только published поля
  3. Не будут реализованы связи и доставание сущностей по связям (по каким-то внутренним соображениям).


0. Абстрактный класс TUEntity — родитель всех Entity

Должен наследоваться от TPersistent, иначе мы не сможем применить RTTI в полной мере. В нем мы регламентируем и интерфейс сущностей. Adapter будет по ходу своей работы спрашивать у сущности имя таблицы, которой она соответствует, предоставлять имя ключевого поля, по которому будет происходить поиск, значение этого поля, а так же метод для строкового представления сущности (для Логов, к примеру).
Код. TUEntity
TUEntity = class (TPersistent)
  function getKey():integer;  virtual; abstract;
  function getKeyName() : AnsiString; virtual; abstract;
  function toString(): AnsiString; virtual; abstract;
  function getTableName(): AnsiString; virtual; abstract;
  function getKeyGenerator():AnsiString; virtual; abstract;
end;


1. Создание объекта по его имени

Выше уже было указано, что сущности наследуются от класса TPersistent, но для того что бы сущность можно было создать по имени — необходимо позаботиться о регистрации класса всех необходимых сущностей. Я это делаю в конструкторе TUAdapter.Create() в первой строке.
Код. TUAdapter.Create
constructor TUAdapter.Create(db : TDBase; entityName : AnsiString);
begin
  RegisterClasses([TUObject, TUGroup, TUSource, TUFile]);
  self.db := db;
  self.entityName := 'TU' + entityName;

  uEntityObj := CreateEntity();

  self.tblName := uEntityObj.getTableName;
  self.fieldsSql := getFields();
end;


Сам же метод создания выглядит так. Почему я не передаю имя сущности аргументом? Потому что это в контексте моей задачи я не вижу смысла этого делать, поскольку по ходу работы дополнительно создаются обьекты, а имя сущности всегда остается одним и тем же — переданным при создании Adapter-а
Код. Создание сущности по ее имени
function TUAdapter.CreateEntity(): TUEntity;
begin
  result := TUEntity(GetClass(self.entityName).Create);
end;


2. Получение полей класса

Думаю, это вопрос который разработчиками под Delphi задается не редко. Главная «особенность», что мы не можем достать все поля, как этого бы хотелось, а только property поля из раздела published. На самом деле это очень даже хорошо, потому что properties в нашей задаче использовать очень удобно.
Код. Получение полей класса
procedure TUAdapter.getProps(var list: TStringList);
var
  props : PPropList;
  i: integer;
  propCount : integer;
begin
  if (uEntityObj.ClassInfo = nil) then
  begin
    raise Exception.Create('Not able to get properties!');
  end;
  try
    propCount := GetPropList(uEntityObj.ClassInfo, props);
    for i:=0 to propCount-1 do
    begin
      list.Add(props[i].Name);
    end;
  finally
    FreeMem(props);
  end;
end;


3. Получение значения поля обьекта по имени поля

Для этого можно воспользоваться методом GetPropValue. Остановлюсь на параметре PreferStrings — он влияет на то, каким образом будет возвращаться результат полей типа tkEnumeration и tkSet. Если он стоит как True, то из tkEnumeration вернется enum, а из tkSet вернется SetProp.
(Instance: TObject; const PropName: string; PreferStrings: Boolean): Variant;. 
Код. Использование GetPropValue
VarToStr(GetPropValue(uEntityObj,  props.Strings[i], propName, true)


4,5,6… Работа с БД

Думаю, приводить весь код — дурной тон (и его место в конце статьи). А здесь я лишь приведу часть, на примере формирования запроса на выборку всех данных.
Для выборки данных формируется транзакция на чтение, создается запрос. Мы связываем запрос и транзакцию, после чего запускаем их и получаем все значения в TIbSQL. Используя TIbSQL.EoF и TIbSQL,Next можно перебрать все записи, что мы и делаем — поочередно создавая новую сущность, помещаем ее в массив и заполняем ее поля.
Код. Метод TUAdapter.FindAll
function TUAdapter.FindAll(): TEntityArray;
var
  rTr : TIBTransaction;
  rSQL : TIbSQL;
  props: TStringList;
  i, k: integer;
  rowsCount : integer;
begin
  db.CreateReadTransaction(rTr);
  rSql := TIbSQL.Create(nil);
  props := TStringList.Create();
  try
    rSql.Transaction := rTr;
    rSQL.SQL.Add('SELECT ' + fieldsSql + ' FROM '+ tblName);

    if not rSql.Transaction.Active then
      rSQL.Transaction.StartTransaction;

    rSQL.Prepare;
    rSQl.ExecQuery;

    rowsCount := getRowsCount();
    SetLength(result, rowsCount);
    getProps(props);
    i := 0;
    while not rSQl.Eof do
    begin
      result[i] := CreateEntity();
      for k:=0 to props.Count-1 do
      begin
        if (not VarIsNull(rSql.FieldByName(props.Strings[k]).AsVariant)) then
          SetPropValue(result[i], props.Strings[k], rSql.FieldByName(props.Strings[k]).AsVariant);
      end;
      inc(i);
      rSql.Next;
    end;
  finally
    props.Destroy;
    rTr.Destroy;
    rSQL.Destroy;
  end;
end;


В прочем, я не забуду упомянуть несколько сложностей. Во-первых, кодировка. Если ваша база данных создана с кодировкой WIN1251 и Collation установлен win1251 и вам придется работать с этой бд из Delphi — вы не сможете просто взять и добавить запись с кириллическими символами. В таком случае, прочитайте информацию по ссылке IBase.ru Rus FAQ. Тут вас и научат, и пальцем ткнут во все подводные камни.

Моя агрегация прочитанного выглядит как следующая последовательность действий:
  1. Запустить bdeAdmin.exe из папки Borland Shared\BDE\
  2. В Configuration -> System -> Init выбрать драйвер по умолчанию Paradox и Langdriver = Pdox Ansi Cyrrilic
  3. В Configuration -> Drivers -> Native поставим Langdriver = Pdox Ansi Cyrrilic в драйверах: Microsfot Paradox Driver, Data Direct ODBC to Interbase, Microsoft dBase Driver.
  4. Сохраним изменения, оставаясь на измененных элементах в главном меню Object нажав Apply.

Такая последовательность действий помогает не иметь проблем при запросах на Update или Insert. (а при Select никаких проблем с кириллицей и нет).
В некоторых случаях так же помогает вместо:
UPDATE tablename SET field = 'июнь';
писать:
UPDATE tablename SET field = _win1251'июнь';
Но это не сработает, если использовать запрос с параметрам, так как TIbSQL не знаком с функцией _win1251.
Например, такой код не сработает и спровоцирует исключение.
IbSQL.SQL.Add("UPDATE tablename SET field = _win1251 :field");
IbSQL.Prepare();  //  <- Exception
IbSQL.Params.byName('field').asString := 'июнь';

В прочем, после того как вы проделаете указанные выше 4 шага — вам не нужно использовать _win1251 и вы вольны том как составляете запрос. Я же, сам того не осознавая, выбрал сложный путь и решил самостоятельно формировать запрос. Не учел, что параметризация бы взяла на себя часть тягот с фильтрацией передаваемых параметров. Не понятно о чем я?

Я столкнулся в проблемой, когда в текстовом значении поля есть кавычка или перевод строки. И пришлось написать метод для замены этих символ на допустимые:
Код. TUAdapter.Escape()
function TUAdapter.StringReplaceExt(const S : string; OldPattern, NewPattern:  array of string; Flags: TReplaceFlags):string;
var
 i : integer;
begin
   Assert(Length(OldPattern)=(Length(NewPattern)));
   Result:=S;
   for  i:= Low(OldPattern) to High(OldPattern) do
    Result:=StringReplace(Result,OldPattern[i], NewPattern[i], Flags);
end;

function TUAdapter.escape(const unescaped_string : string ) : string;
begin
  Result:=StringReplaceExt(unescaped_string,
    [ #39, #34, #0, #10, #13, #26], ['`','`','\0','\n','\r','\Z'] ,
    [rfReplaceAll]
  );
end;


Результаты


В целом у нас сложились требования к Enitity классам:
  1. описать поля приватными
  2. описать поля, соответствующие колонкам таблицы как property в разделе published
  3. имена properties должны совпадать с именами соответствующих им колонок
  4. при необходимости реализовать Get/Set методы для полей (для Boolean, TDateTime, для Blob полей)

Итак, допустим, у нас есть следующая БД

Создаем два Entity класса TUser и TPost.
Код. Обьявление TUser
TUsersArray = Array of TUser;

TUser = class(TUEntity)
  private
    f_id: longint;
    f_name : longint;
    f_password : AnsiString;
    f_email : AnsiString;
    f_last_login : TDateTime;
    f_rate: integer;
  published
    property id: integer read f_id write f_id;
    property name : AnsiString read f_name write f_name ;
    property password : AnsiString read f_password write f_password ;
    property email : AnsiString read f_email write f_email ;
    property last_login: AnsiString read getLastLogin write setLastLogin;
    property rate: integer read f_rate write f_rate;
  public
    constructor Create();
    procedure setParams(id, rate: longint; name, password, email: AnsiString);
    procedure setLastLogin(datetime: AnsiString);
    function getLastLogin(): AnsiString;
    function getKey(): integer; override;
    function getKeyName(): AnsiString; override;
    function toString(): AnsiString; override;
    function getTableName(): AnsiString; override;
    function getKeyGenerator():AnsiString; override;
end;

TPost объявляем таким же образом.

А использование в коде в паре с адаптером будет выглядеть так:
var
  Adapter : TUAdapter;
  users: TUsersArray;
   i: integer;
begin
  Adapter := TUAdapter.Create(db, 'User');
  try
    users:= TUsersArray(Adapter.FindAll());
    for i:=0 to Length(users) -1 do
    begin
      Grid.Cells[0, i+1] := VarToStr(users[i].id);
      Grid.Cells[1, i+1] := VarToStr(users[i].name);
      Grid.Cells[2, i+1] := VarToStr(users[i].email);
      Grid.Cells[3, i+1] := VarToStr(users[i].password);
      SetRateStars(i, VarToStr(users[i].rate));
      Grid.Cells[5, i+1] := VarToStr(users[i].last_login);
    end;
  finally
    Adapter.Destroy;
  end;
end;


Выводы


Я хотел бы сделать акцент на скорости работы кода с использованием RTTI. Опыт подсказывает, что частое обращение к RTTI методам замедлит работу приложения, однако в моей реальности скорости разработанного класса хватает. Я считаю что цель достигнута и в результате получилось некоторое подобие ORM с малым функционалом, но честно решающее поставленные на неё задачи.

Проект на BitBucket.

P.S.
Напомню, что читатель у которого есть предрасположенность к извержению негативных мыслей в сторону Delphi не обязан всем об этом рассказывать. Так что, ребята, держите себя в руках.
Извините, я на самом деле вызываю MessageBox при ошибке, вместо того что бы кинуть Exception. Но я исправлюсь, я обещаю.

UPD:
Больше никаких MessageBox в коде.
Tags:
Hubs:
+20
Comments 26
Comments Comments 26

Articles