Разработчики приложений для работы с базами данных (БД) наверняка не раз сталкивались с некоторыми неудобствами при кодировании. Одно из неудобств – это обращение к полю набора данных (НД) по его имени. Неудобство заключается в том, что если к полю обращаться по имени DataSet['FieldName'], то компилятор никаким образом не может проверить, правильно ли написано 'FieldName' и есть ли это поле в НД. А при спешке, кодер может запросто допустить опечатку в имени поля, и, как следствие, это может привести к ошибке во время исполнения приложения.
Я хочу описать технологию, которая может научить компилятор знать, какие имена в таблице могут быть. Это нужно для того, чтобы возможные опечатки исключить на стадии компиляции. А в качестве бонуса, мы можем увидеть список полей в выпадающем списке CodeInsight после имени таблицы и точки. Ну т.е. в конечном итоге, я получаю возможность прямо в коде писать так: TableName.FieldName := 10; вместо привычного нам TableName['FieldName'].AsInteger := 10;
Лирическое отсутпление.
В своей практике я давно не использую стандартные компоненты доступа к БД и компоненты-наследники от TDataSet. Используются полностью самописные библиотеки. В качестве набора данных – используется специальный класс (грубо говоря наследник от TObject). В качестве грида – компонент VirtualTreeView. Вместо DataSource – используется класс-обёртка, которая визуализирует набор данных в гриде: позволяет делать сортировку (по нескольким столбцам), группировку (по нескольким столбцам) , фильтрацию (локальную), подсвечивать строки и ячейки таблицы разными цветами (в зависимости от условия), подсчитывать итоги (агрегатные функции).
О некоторых причинах, которые привели меня и моих коллег уйти от использования стандартных механизмов, я упоминал в предыдущем посте.
В этом посте я не буду писать о том, как создаются компоненты для выборки данных и как они связываются с визуальными компонентами. Для демонстрации проблемы (обращение к полю НД по имени) это не важно.
Часть 1.
Чтобы было легче ориентироваться в коде ниже, возьмём для примера: таблица CLIENTS – информация о клиентах, в ней поля: INN – ИНН клиента, KPP - КПП клиента. И таблица CUSTOMERS.
Описанная проблема усугубляется, когда к одним и тем же полям в коде надо обращаться очень часто. А если у вас есть много таблиц, в которых есть поля с одинаковыми или схожими именами – это может заморочить голову даже опытному программисту. Рассмотрим средства, которые могут нам помочь.
1й способ.
// создать переменную: var INN: TField; .. // на момент, когда набор данных открыт, инициализировать переменную: INN := Clients['INN']; // работать с полем через переменную: INN.AsString := '1234567890';
Способ прост и хорош, когда в одном модуле работаешь с одной таблицей. Однако когда таблиц две и более – возникает путаница, к какой именно таблице относится конкретное поле? В этом случае, переменные можно именовать в виде <TableName><FieldName>:
var ClientsINN: TField; CustomersINN: TField; .. ClientsINN := Clients['INN']; CustomersINN := Customers['INN']; ... if ClientsINN.AsString <> CustomersINN.AsString then ...
Преимущества этого способа:
- обращение к полю по ссылке (через переменную ClientsINN) работает чуточку быстрее, чем обращение через свойство набора данных (Clients['INN']);
- имя поля, заключённое в кавычках, используется только один раз.
Явные недостатки:
- необходимость создавать переменную;
- необходимости инициализировать переменную, причём при инициализации остаётся проблема: компилятор не может проверить, есть ли поле с таким именем.
Остальные недостатки также встречаются и при использовании 2го способа, о них чуть позже.
2й способ.
У компонента DataSet есть свойство Fields, которое можно заполнять в Design-Time. Заполнить можно автоматически (для этого нужно, чтобы из Design-Time можно было подключиться к БД: компонент считает мета-описание таблицы и сам создаст все поля таблицы), либо вручную. Также вручную можно создавать вычисляемые поля и поля подстановки, т.е. те поля, которых нет в БД (или в запросе). При этом среда автоматически создаёт переменные (переменные именуются в виде <TableName><FieldName>: T<FieldType>Field) и автоматически их инициализирует (на самом деле, каждое поле – это наследник от TComponent, поэтому всего его свойства сохраняются в dfm-файле и считываются при создании экземпляра формы/фреймы/датамодуля).
В этом случае, для нашего поля создастся компонент ClientsINN: TStringField; и далее в коде к полю можно обращаться через переменную ClientsINN, как в 1м способе. Основным отличием TStringField от TField является то, что свойство Value в первом случае имеет тип String, а во втором – Variant.
Теперь наш код местами может быть таким:
ClientsINN.Value := '1234567890'; if ClientsINN.Value <> CustomersINN.Value then ...
Преимущества этого способа, по сравнению с первым способом, очевидны. Но и есть недостатки, которые встречаются не часто, но если встречаются – могут доставить массу хлопот.
Неудобства стандартных решений.
Неудобство №1 – длинное имя.
Действительно, если мы работаем всего лишь с одной таблицей, то всё время набирать имя таблицы не очень приятно. Тут плюс в сторону 1го способа, либо для второго способа можно задать имена переменных вручную (однако, если в коде через пару лет вдруг появится вторая таблица…).
Неудобство №2 – много имён.
При работе сразу с двумя и более таблицами обращение к полям разных таблиц в одной строке кода (или в одном блоке кода) замыливают глаза: читающий начинает путаться, где одна таблица, а где другая. Если бы можно было разделить имена полей точкой (именно точкой, а не символом подчёркивания! символ подчёркивания принято использовать в именах таблиц и именах полей), то чтение кода улучшилось бы в разы. Сравните код:
ClientsINN.Value := CustomersINN.Value; if CustomersKPP.IsNull then CustomersKPP.Value := GetCustomerKPP; ClientsKPP.Value := CustomersKPP.Value;
и код:
Clients.INN := Customers.INN; if Customers.KPP = Null then Customers.KPP := GetCustomerKPP; Clients.KPP := Customers.KPP;
Второй вариант читается (и пишется!) проще. Именно к такому написанию кода и будем стремиться.
Неудобство №3 – конфликт имён.
Ситуация редкая, но потенциально возможная, когда сочетание <TableName><FieldName> для двух разных таблиц и полей дают одинаковый идентификатор. Правда в этом случае в редакторе полей можно задать другое имя для поля второй таблицы, однако при чтении кода это может ввести читающего в заблуждение.
Неудобство №4 – разные БД.
Допустим у Вас есть несколько клиентских БД, причём структура БД у разных Ваших заказчиков может незначительно отличаться. Например, в таблице CLIENTS поля KPP может не быть, но при этом приложение должно работать. В этом случае в тексте запроса к БД нельзя перечислять все поля, но запрос может быть таким:
SELECT * FROM clients
и такой запрос будет работать в не зависимости, есть поле KPP, или нет. Однако о создании полей в Design-Time уже речи быть не может. Да и вообще, в этой ситуации придётся скрывать часть полей на форме редактирования записей. Ситуация хоть и довольно частая в моей практике, но не уверен, что решаемая стандартными компонентами. А потому, многие не любят допускать такого и стараются поддерживать структуры БД у своих клиентов. В общем, эта тема отдельного разговора – я её решаю за счёт использования своих библиотек.
Часть 2. Идея.
Идея сама по себе незатейлива. Для того, чтобы в коде можно было писать Clients.INN, очевидно, что Clients – это экземпляр некоторого класса, INN – ссылка на поле. В своих библиотеках, я могу обращаться к произвольной записи в любой момент, поэтому там синтаксис примерно такой:
Clients.INN[Row1] := Customers.INN[Row2]; if Customers.KPP.IsNull[Row2] then ...
Однако я буду приводить пример, для работы с DataSet’ами, здесь синтаксис получится таким:
Clients.INN.Value := Customers.INN.Value; if Customers.KPP.IsNull then ...
Для того, чтобы можно было так писать, нам надо подготовить описательную часть, например такой класс:
type TClients = class(TTableDesc) private FClientID: TIntegerField; FName: TStringField; FINN: TStringField; FKPP: TStringField; .. public property ClientID: TIntegerField read FClientID; property Name: TStringField read FName; property INN: TStringField read FINN; property KPP: TStringField read FKPP; ... end;
Обратите внимание: у таблицы Clients есть поле Name, однако у TDataSet (как наследника от TComponent) уже есть свойство Name. Поэтому, чтобы не было конфликта имён (Clients.Name – это ссылка на поле Name, или свойство компонента?), тип TTableDesc наследован от обычного TObject. Сначала я даже хотел этот тип сделать обычной записью (record), но в целях безопасности (чтобы случайно не подменить ссылку на поле), решил сделать всё-таки классом. Не буду сейчас вдаваться в подробности реализации класса, сейчас это не важно.
Итак, мы объявляем переменную Clients: TClients и запрашиваем экземпляр этого класса. Выглядеть это может быть разными способами, можно сделать через интерфейсы, можно всё сделать в конструкторе TClients, либо можно создать отдельный менеджер, который будет создавать экземпляр по требованию. Я пока выбрал способ, в котором все экземпляры НД создаются при запуске приложения и существуют во время всего его жизненного цикла. Ссылки на все экземпляры классов TTableDesc хранятся в отдельном объекте (назовём его DF).
Теперь можно писать так:
DF.Clients.INN.Value := SomeValue
Можно пойти дальше, и вместо ссылки на экземпляр стандартного T<FieldType>Field, подставлять свой класс, который знает (или умеет определять) есть ли поле в БД – это поможет решить неудобство №4.
Для доступа непосредственно к датасету, класс TTableDesc можно снабдить свойством _DataSet (символ подчёркивания в начале имени предлагается, чтобы обойти возможные конфликты имён с наименованиями полей). Однако писать
DF.Clients._DataSet.Open;
неудобно. Поэтому все экземпляры датасетов я предлагаю также собрать в отдельный класс (назовём его DT), и писать так:
DT.Clients.Open;
Для участков кода, где не нужно работать с экземплярами полей, а достаточно работать только со значениями (причём если тип Variant нас устроит), можно создать ещё один класс (назовём его DV) и собрать в нём экземпляры классов такого вида:
type TClientsV = class(TTableValues) private function GetClientID: Variant; procedure SetClientID(Value: Variant); .. public property ClientID: Varaiant read GetClientID write SetClientID; property Name: Varaiant read GetName write SetName; property INN: Varaiant read GetINN write SetINN; property KPP: Varaiant read GetKPP write SetKPP; ... end;
Это даст возможность писать так:
DV.Clients.INN := DV.Customers.INN; if DV.Customers.KPP = Null then ...
А чтобы постоянно не писать DT, DF, DV, можно использовать with. Оцените как читается такой код:
with DT do begin Clients.Open; Clients.First; Customers.Open; while not Clients.Eof do begin Customers.First; while not Customers.Eof do begin with DV do if Clients.INN = Customers.INN then begin DoSomething; Break; end; Customers.Next; end; Clients.Next; end; end;
В общем, довольно наглядно. А самое главное, в момент компиляции приложения компилятор проверит все имена полей в нашем коде.
Теперь нам надо научиться генерировать код, чтобы описательную часть классов не создавать вручную.
Часть 3. Генерация.
Для кодогенерации, я использую примерно такой механизм.
а) Все таблицы и их поля описываются в виде xml-файла. Для этого у меня есть небольшое приложение, которое подключается к БД и для конкретной таблицы генерит xml-описание. В этом xml-описании уже вручную я добавляю вычисляемые (calc) поля и поля-подстановки (lookup), настраиваю описание полей и их свойства. Описание каждой таблицы сохраняется в отдельный xml-файл (для удобства).
б) Перед генерацией кода, происходит сборка единого xml-файла. Грубо говоря, все xml-файлы собираются в один файл.
в) Запускается ещё одно приложение, которое по xml-описанию создаёт файл TablesDesc.pas, этот файл содержит описание классов для работы с таблицами, и создаёт экземпляры DT, DF и DV.
В Delphi 7, где нельзя в явном виде настроить вызов внешнего скрипта перед сборкой приложения, поэтому я это реализовал с помощью CnPack/Script Master.
Остаётся не забыть включить в uses модуль TablesDesc, и мы можем писать, как в примере выше.
Эти три пункта у нас сложились исторически. Более того, в xml-описании можно хранить некоторую информацию, которую потом можно использовать для формирования SQL-запросов, а править запросы удобнее именно в xml-файле (где видна перед глазами структура таблицы), а не где-то там в коде. Ещё плюс в сторону xml-описания: при большом количестве таблиц искать и редактировать текст в xml-файлах проще, чем глазками искать нужный DataSet в DataModul’е (или в датамодулях, если их несколько) и мышкой прокликивать свойства НД и полей.
Вы же, для простоты, можете написать Wizard для IDE, который будет делать всю описанную работу.
11 коммент.:
А SQL-код БД тоже генерится из этого XML?
Ну, в простых случаях, он просто генерится по описанию дата-полей. В более сложных - xml может содержать части SQL-запроса, либо несколько версий запросов (если НД используется в разных контекстах).
Плюс я добавил такую фичу - при первом обращении к НД (а это может произойти до запроса к БД за данными) происходит проверка на наличие таблицы (вьюхи) в БД и наличие всех полей (т.е. я динамически могу отключать/включать функционал приложения), а заодно я уточняю реальный размер текстовых полей (чтобы можно было ограничить пользовательский ввод автоматически).
Если этот механизм ешё умет "Create table" и "update" запросы генерить. То это почти ORM (Object-relational mapping) , аналогов правда уже много имеется:
1) Bold for Delphi
2) InstantObjects
3) tiOPF
4) DePO - Delphi Persistent Objects
5) DObject
6) g-framework
DML умеет. DDL - сознательно нет. Хотя я согласен, что в некоторых случаях можно было бы и научить клиентские приложения создавать объекты БД.
Аналоги конечно же есть, но Вы же понимаете, что _свой_ исходник роднее :). Тем более, корни этой библиотеки уже имеют 12 летний "стаж".
Это всегда да :)
Но и чужие труды, если они качественно написаны, и не разу не подвели своей работой, со временем становятся почти родными )
Перекочёвывают из проекта в проект, и от них бывает сложно отказаться
По поводу создания полей в design-time.
Недавно после перехода на юникод, при попытке научить программу корректно работать и с ANSI и Unicode версией БД, столкнулся с проблемой из-за которой пришлось удалять все созданные в дизайне поля и пересоздавать их в Runtime. Хорошо что таких полей было мало.
Причина банальна: несоответствие типов. Для ANSI базы VARCHAR поле имеет тип TStringField, а для Unicode базы TWideStringField.
Впрочем, возможно какие-то из библиотек доступа к БД и не имеют такой проблемы.
Теперь по поводу автогенерации классов. Недавно я и сам подумывал о том, чтобы написать свои обёртки для Dataset-ов, чтобы обращаться к полям по полному имени (с проверкой названия поля компилятором). Т.е. по сути заменить обращение:
aDataset.FieldByName('OLOLO')
на
MyDatasetWrapper.FieldOlolo: TField
И уже где-то в MyDatasetWrapper.FieldOlolo обращаться к полю Dataset-а по имени.
> Aleksey Timohin
> Это сообщение было удалено автором.
Вроде бы я не удалял комментарий!?
А второй комментарий попал в спам почему-то, только сяс заметил..
>> Aleksey Timohin
>> Это сообщение было удалено автором.
> Вроде бы я не удалял комментарий!?
Я по ошибке опубликовал здесь комментарий к предыдущему посту. Когда заметил сам его и удалил.
в своей сводной статье я описал свой опыт по автоматической генерации кода http://fk-uran.com.ua/programma-postroitel-klassov-classbuilder/
Мне кстати рассказывали про вашу технику :-)
Отправить комментарий