My Visual Database (MVDB) позволяет создавать приложения для хранения различных данных и в тех объемах, каких это нужно пользователю. Но если количество таблиц для хранения переваливает за десяток, то создание форм отображения табличного представления и форм редактирования становится скучной рутиной. А при числе форм более 30 заметно возрастает время запуска приложения, так как MVDB создает каждую форму при старте программы. При изменении структуры данных также необходимо править соответствующие формы.

My Visual Multibase (MVM) будет свободен от указанных выше недостатков, так как в нем формы пользовательского интерфейса тесно связаны со структурой данных и автоматически меняются при внесении изменений в схему данных. Однако, у меня пока нет четкого понимания, будет ли MVM востребованным на рынке, который переполнен различными инструментами разработки.

Представляю вашему вниманию Data Keeper – программу для организации хранения информации произвольной структуры. Особенности программы:

  • Объектно-ориентированный подход
  • Пользователь сам определяет структуру хранимых данных
  • Форма табличного представления и форма редактирования создается автоматически

Основа проекта

За основу был взят проект “Справочник разработчика“, в котором хорошо себя зарекомендовала технология динамического создания табличных форм (DTF).

Большая часть функционала программы сосредоточена в модулях, редактирование которых приходится осуществлять в стороннем текстовом редакторе (Norepad++), так как MVDB не располагает встроенным многостраничным редактором скриптов.

Структура данных

Идея задействовать реляционную СУБД в качестве объектной не очень хорошая, так как скорей всего возникнут проблемы с производительностью при обработке больших массивов данных, но это проект и будет той самой проверкой скорости работы и удобства использования существующих СУБД в новом качестве.

Вся структура данных основывается на понятии классов, объектов и свойств

Class

Класс – это абстрактное описание объекта. Класс имеет название (name). Классы образуют иерархию (parent_id), в которой от базового класса наследуются все остальные. Так как свойства объектов задаются через классы, то для их редактирования необходимо указать вид компонента (id_uicontrol). Поле nullID служит для реализации механизма переключения отображения дерева в линейный список, а поле description – для хранения расширенного описания класса.

CProperty

Свойство класса – абстрактное описание свойства объекта, принадлежит определенному классу (id_class). Имеет название (name) и описание (description). Свойство, помеченное флагом is_name, служит для хранения отображаемого названия объекта в случаях, когда объект является значением свойства другого объекта (ссылочным значением). Поля orderNum, visible и col_width отвечают за порядок следования, видимость и ширину колонок для табличного представления объектов данного класса. Вычисляемые поля vtype и ptype служат для удобства отображения информации в табличном представлении свойств класса. Свойство класса может быть представлено в объекте как единичным значением, так и множеством. За эту возможность отвечает поле ptype. Тип данных, которые могут находиться в свойстве (id_class1), является ссылкой на класс, поэтому простые типы, такие как текст, числа и др., также необходимо описать в иерархии классов.

Примечание. В версии DataKeeper 1.0 поддерживается только единичные значения.

Object

Объект имеет пока только два поля – идентификатор (id) и ссылку на класс (id_class). Все остальные свойства объекта, включая название, хранятся в таблице oproperty.

OProperty

Свойства объекта – это конкретные значения свойств класса объекта, определяющие качества объекта. Свойство связано как с объектом (id_object), так и со свойством класса (id_cproperty). Данные хранятся в двух форматах: в виде текста (value_s) и ссылки на объект (id_object1). Текст отображается в табличном представлении (в том числе для отображения названия объектов, являющихся значением ссылочного свойства, что повышает производительность системы, но нарушает принципы нормализации), а ссылка используется для поддержания целостности данных.

Примечание. Поле orderNum в дальнейшем будет использоваться как индекс массива для свойств, которые являются множествами.

PType

Служебная таблица, определяющая тип значения свойства. Содержит две записи:

  • Значение (единственное значение)
  • Множество (массив значений)

UIControl

Служебная таблица со списком элементов пользовательского интерфейса, которые должны использоваться для редактирования свойств объектов.

Примечание. В версии DataKeeper 1.0 это редактируемый справочник, однако редактировать его не следует, так как это может привести к нарушению работы программы.

Формы

В проекте имеется несколько статических форм:

  • frmMain – главная форма
  • frmClass – форма-контейнер для отображения классов и объектов – основная форма хранилища данных
  • frmObject – форма для отображения объектов
  • efmClass – форма редактирования класса
  • efmObect – форма редактирования объекта
  • efmCProperty – форма редактирования свойства класса
  • efmUIControl – форма редактирования справочника компонентов интерфейса

И ещё есть несколько динамических форм, создание которых идёт на основании описания в файле dforms.ini:

  • dfmClass_Tree – дерево классов
  • dfmUIControl – список компонентов UI
  • dfmCProperty – список свойств класса

Скрипты

procedure frmObject_btnUpdate_OnClick (Sender: TObject; var Cancel: boolean);
// обновить отображение
var
  tmpIDClass: integer;
  tmpDataSet: TDataSet;
  tmpSQL: string;
  tmpFields : string;
  tmpJoins: string;
  tmpCount: integer;
  tmpCaptions: string;
  tmpTableAlias: string;
  tmpButton: TdbButton;
begin
  frmObject.labIDClass.Caption := 'Change'; // блокируем срабатывание frmObject_tgrMain_OnColumnResize
  // строится запрос на выборку данных - все объекты указанного класса
  // получаем ID класса
  tmpIDClass := Form_GetDataViewer( GetFormByName('dtfClass_Tree') ).dbItemId;
  frmObject.labIDClass.Tag := tmpIDClass; // запоминаем класс
  tmpSQL := 'SELECT * FROM cproperty WHERE id_class = '+IntToStr(tmpIDClass)+' ORDER BY orderNum ';
  SQLQuery(tmpSQL,tmpDataSet);
  tmpFields := 'object.id';
  tmpJoins := '';
  tmpCaptions := 'delete_col'; // не отображать
  tmpCount := 0;
  //
  while not tmpDataSet.EOF do
  begin
    if tmpDataSet.FieldByName('visible').asInteger = 1 then
    begin
      tmpTableAlias := 'OP_'+IntToStr(tmpCount);
      //
      if tmpFields <> '' then
        tmpFields := tmpFields + ', ';
      tmpFields := tmpFields + tmpTableAlias+'.value_s ';
      //
      tmpJoins := tmpJoins + 'LEFT JOIN oproperty '+tmpTableAlias+' ON '+tmpTableAlias+'.id_object = object.id AND '+tmpTableAlias+'.id_cproperty = '+tmpDataSet.FieldByName('id').asString+CR ;
      //
      if tmpCaptions <> '' then
        tmpCaptions := tmpCaptions + ', ';
      tmpCaptions := tmpCaptions + tmpDataSet.FieldByName('name').asString;
      //
      inc(tmpCount);
    end;
    tmpDataSet.Next;
  end;
  tmpDataSet.Free;
  tmpSQL := 'SELECT '+tmpFields+CR+' FROM object '+tmpJoins+CR+' WHERE object.id_class = '+IntToStr(tmpIDClass);
  tmpButton := TdbButton(Sender);
  tmpButton.dbSQL := tmpSQL;
  tmpButton.dbListFieldsNames := tmpCaptions;
end;
Code language: Delphi (delphi)

При нажатии кнопки btnUpdate (нажатие производится с помощью встроенных механизмов DTF), скрипт собирает SQL-запрос, в котором каждая колонка вытаскивается из отдельной выборки, присоединяемой с помощью ключевого слова JOIN. При этом учитывается видимость свойств и порядок их следования, описанный при настройке свойств класса.

Так как количество колонок со свойствами у разных объектов может отличаться, то необходимо настроить их ширину после загрузки данных в компонент:

procedure frmObject_tgrMain_OnChange (Sender: TObject);
// обновление данных в таблице
var
  tmpIDClass: integer;
  tmpDataSet: TDataSet;
  tmpSQL: string;
  tmpColumn: integer;
begin
  // получаем ID
  tmpIDClass := frmObject.labIDClass.Tag;
  // считать ширины колонок из базы
  tmpSQL := 'SELECT COALESCE(col_width,100) as width FROM cproperty WHERE id_class = '+IntToStr(tmpIDClass)+' ORDER BY orderNum ';
  SQLQuery(tmpSQL,tmpDataSet);
  //
  tmpColumn := 0;
  while not tmpDataSet.EOF do
  begin
    frmObject.tgrMain.Columns[tmpColumn].Width := tmpDataSet.FieldByName('width').asInteger;
    inc(tmpColumn);
    tmpDataSet.Next;
  end;
  tmpDataSet.Free;
  frmObject.labIDClass.Caption := ''; // разблокируем frmObject_tgrMain_OnColumnResize
end;
Code language: Delphi (delphi)

Настройку ширины осуществляем непосредственно в табличном представлении объектов, а для сохранения значений в базу используется обработчик события onColumnResize:

procedure frmObject_tgrMain_OnColumnResize (Sender: TObject; ACol: Integer);
// ручное изменение ширины колонок
var
  tmpIDClass: integer;
  tmpSQL : string;
begin
  if frmObject.labIDClass.Caption = '' then
  begin
    // ручное изменение ширины колонок
    // запоминаем ширину колонки в БД
    tmpIDClass := frmObject.labIDClass.Tag; //  класс
    tmpSQL := 'UPDATE cproperty SET col_width = '+IntToStr(frmObject.tgrMain.Columns[ACol].Width)+' WHERE id_class = '+IntToStr(tmpIDClass)+' AND orderNum = '+IntToStr(ACol+1);
    SQLExecute(tmpSQL);
  end;
end;Code language: Delphi (delphi)

Необходимо блокировать запись в БД при изменениях ширины колонки, которые вызываются алгоритмами самого MVDB, чтобы frmObject_tgrMain_OnColumnResize сохранял данные только при ручном редактировании. Для этого в свойство frmObject.labIDClass.Caption записывается специальная текстовая метка.

Подобную технологию можно применять и в других проектах, чтобы настройки ширины хранились не в локальных файлах, а в базе данных, что может быть полезным, если на одном рабочем месте работают несколько пользователей, и для каждого из них нужна своя настройка.

Для построения формы редактирования используется сведения из таблицы oproperty.

Название свойства, которое используется для представления объекта в выпадающих списках для выбора как ссылочного значения, подсвечивается жирным шрифтом (1). Также жирным шрифтом выделяются данные, которые были введены пользователем в текущем сеансе редактирования (2).

procedure efmObject_OnShow (Sender: TObject; Action: string);
// отображение формы редактирования
var
  i: integer;
  tmpSQL: string;
  tmpIDClass: integer;
  tmpCount: integer;
  tmpLabel: TdbLabel;
  tmpEdit: TdbEdit;
  tmpComboBox: TdbComboBox;
  tmpForm: TForm;
  tmpParent: TdbPanel;
  tmpDataSet: TDataSet;
  tmpID: string;
  tmpControlID: integer;
begin
  tmpForm := TForm(Sender);
  tmpParent := efmObject.panEdit;
  if Action = 'NewRecord' then
  begin
    efmObject.cmbClass.dbItemID := Form_GetDataViewer( GetFormByName('dtfClass_Tree') ).dbItemId;
  end;
  // удалить все компоненты
  for i := tmpParent.ControlCount - 1 downto 0 do
  begin
    tmpParent.Controls[i].Free;
  end;
  tmpIDClass := efmObject.cmbClass.dbItemID;
  tmpCount := 0;
  // создать компоненты, которые нужны для редактирования свойств текущего объекта
  tmpSQL := 'SELECT cproperty.id, cproperty.name, cproperty.is_name, class.name as cname, class.id_uicontrol, class.id as ClassID FROM cproperty LEFT JOIN class ON class.id = cproperty.id_class1 WHERE id_class = '+IntToStr(tmpIDClass)+' ORDER BY orderNum ';
  SQLQuery(tmpSQL,tmpDataSet);
  while not tmpDataSet.EOF do
  begin
    tmpControlID := tmpDataSet.FieldByName('id_uicontrol').asInteger;
    // метка
    tmpLabel := TdbLabel.Create( tmpForm );
    with tmpLabel do
    begin
      parent := tmpParent;
      Font.Size := 11;
      top := tmpCount * 50;
      left := 8;
      name := 'labData_'+intToStr(tmpCount);
      Caption := tmpDataSet.FieldByName('name').asString;
      if tmpDataSet.FieldByName('is_name').asInteger = 1 then
      begin
        Font.Style := fsBold;
      end;
    end;
    // поле ввода
    tmpEdit := TdbEdit.Create( tmpForm );
    with tmpEdit do
    begin
      name := 'edtData_'+intToStr(tmpCount);
      parent := tmpParent;
      Font.Size := 11;
      top := tmpCount * 50 + tmpLabel.Height;
      left := 8;
      width := 300;
      tag := tmpDataSet.FieldByName('id').asInteger; //
      tagString := VarToStr( SQLExecute('SELECT id FROM oproperty WHERE id_object = '+IntToStr( efmObject.btnSave.dbGeneralTableId  )+' AND id_cproperty = '+tmpDataSet.FieldByName('id').asString ) );
      text := VarToStr( SQLExecute('SELECT value_s FROM oproperty WHERE id_object = '+IntToStr( efmObject.btnSave.dbGeneralTableId  )+' AND id_cproperty = '+tmpDataSet.FieldByName('id').asString ) );
      if tmpControlID = 3 then // целое число
      begin
        dbCurrency := True;
        dbAccuracy := 0;
        Alignment := taLeftJustify;
      end;
      if tmpControlID = 4 then // вещественное число
      begin
        NumbersOnly := True;
      end;
      onChange := 'efmObject_edtEdit_OnChange';
    end;
    //
    if tmpControlID = 2 then // выпадающий список
    begin
      //
      tmpComboBox := TdbComboBox.Create( tmpForm );
      with tmpComboBox do
      begin
        name := 'cmbData_'+intToStr(tmpCount);
        parent := tmpParent;
        Font.Size := 11;
        top := tmpCount * 50 + tmpLabel.Height;
        left := 8;
        width := 300;
        tagString := VarToStr( SQLExecute('SELECT id_object1 FROM oproperty WHERE id_object = '+IntToStr( efmObject.btnSave.dbGeneralTableId  )+' AND id_cproperty = '+tmpDataSet.FieldByName('id').asString ) );
        dbSQL := 'SELECT object.id, oproperty.value_s FROM object LEFT JOIN oproperty ON oproperty.id_object = object.id WHERE oproperty.id_cproperty = ( SELECT id FROM cproperty WHERE cproperty.id_class = '+tmpDataSet.FieldByName('ClassID').asString+' AND cproperty.is_name = 1 ) AND object.id_class = '+tmpDataSet.FieldByName('ClassID').asString;
        dbUpdate;
        if tagString <> '' then // если есть ссылочное значение, то синхронизировать выпадающий список
          dbItemID := StrToInt(tagString);
        onChange := 'efmObject_cmbEdit_OnChange';
      end;
    end;
    inc(tmpCount);
    tmpDataSet.Next;
  end;
  tmpDataSet.Free;
  tmpForm.ClientHeight := tmpCount * 50 + 48; // скорректировать высоту формы
end;Code language: PHP (php)

Компоненты располагаются сверху-вниз. Если свойство является ссылочным, то поверх поля ввода текста добавляется компонент с выпадающим списком.

Сохранение данных идёт в несколько этапов. Сначала текст из выпадающего списка переносится в поле редактирования:

procedure efmObject_btnSave_OnClick (Sender: TObject; var Cancel: boolean);
// сохраняем данные
var
  i: integer;
  tmpParent: TdbPanel;
  tmpEdit: TdbEdit;
  tmpSQL: string;
  tmpForm: TForm;
  tmpCombo: TdbComboBox;
  tmpName: string;
begin
  CForm( Sender, tmpForm );
  // Прописать данные из комбика в Текст
  tmpParent := efmObject.panEdit;
  for i := 0 to tmpParent.ControlCount - 1 do
  begin
    if tmpParent.Controls[i] is TdbComboBox then
    begin
      tmpCombo := TdbComboBox(tmpParent.Controls[i]);
      tmpName := 'edt'+DeleteClassName(tmpCombo.Name);
      FindC( tmpForm, tmpName, tmpEdit );
      tmpEdit.Text := tmpCombo.Text;
    end;
  end;
end;Code language: JavaScript (javascript)

Затем производится основное сохранение – запись в таблицу object. Это реализовано через настройку кнопки “Сохранить”.

Потом анализируются изменения данных на форме редактирования и в случае необходимости фиксируются в базе данных:

procedure efmObject_btnSave_OnAfterClick (Sender: TObject);
// после основного сохранения
var
  i: integer;
  tmpParent: TdbPanel;
  tmpEdit: TdbEdit;
  tmpSQL: string;
  tmpForm: TForm;
  tmpCombo: TdbComboBox;
  tmpLabel: TdbLabel;
  tmpID: string;
  tmpName: string;
begin
  // значения из каждого компонента сохраняется в отдельной записи в таблице opropety
  CForm( Sender, tmpForm );
  tmpParent := efmObject.panEdit;
  for i := 0 to tmpParent.ControlCount - 1 do
  begin
    if tmpParent.Controls[i] is TdbEdit then
    begin
      tmpEdit := TdbEdit(tmpParent.Controls[i]);
      // в некоторых случаях сохраняется связь с объектом, который является значением свойства
      tmpName := 'cmb'+DeleteClassName(tmpEdit.Name);
      FindC( tmpForm, tmpName, tmpCombo, False );
      if tmpCombo = nil then
        tmpID := 'NULL'
      else
        tmpID := tmpCombo.SQLValue;
      // два варианта: добавление и редактирование
      if tmpEdit.tagString = '' then // если нет ID записи, то добавляем
      begin
        tmpSQL:= 'INSERT INTO oproperty (id_object,id_cproperty,value_s,id_object1 ) VALUES ('+IntToStr( efmObject.btnSave.dbGeneralTableId  )+','+IntToStr( tmpEdit.Tag )+',"'+tmpEdit.Text+'", '+tmpID+') ';
        SQLExecute(tmpSQL);
      end
      else // обновляем
      begin
        if tmpEdit.Font.Style = fsBold then
        begin
          tmpSQL:= 'UPDATE oproperty SET value_s = "'+tmpEdit.Text+'", id_object1 = '+tmpID+' WHERE id = '+tmpEdit.tagString;
          SQLExecute(tmpSQL);
          // так как кроме ссылок в базе хранится текст, то нужно его обновлять, если это - первый парметр, который является заголовком
          tmpName := 'lab'+DeleteClassName(tmpEdit.Name);
          FindC( tmpForm, tmpName, tmpLabel );
          if tmpLabel.Font.Style = fsBold then
          begin
            tmpSQL := 'UPDATE oproperty SET value_s = "'+tmpEdit.Text+'" WHERE id_object1 = '+IntToStr( efmObject.btnSave.dbGeneralTableId  );
            SQLExecute(tmpSQL);
          end;
        end;
      end;
    end;
  end;
  frmObject.btnUpdate.Click; // обновить табличное представление
end;Code language: Delphi (delphi)

Такой алгоритм необходим для сокращения числа обращений к БД, которых и без того получается больше, чем при классическом реляционном подходе. В частности, при редактировании свойства, которое используется в качестве отображаемого названия объекта, необходимо обновить все текстовые значения в зависимых свойствах.

Ещё пара процедур обеспечивает фиксацию факта редактирования данных пользователем. Для этого изменяется стиль отображения данных на жирный.

procedure efmObject_cmbEdit_OnChange (Sender: TObject);
// выбор нового значения в выпадающем списке
var
  tmpEdit: TdbEdit;
  tmpCombo: TdbComboBox;
  tmpForm: TForm;
begin
  CForm(Sender,tmpForm);
  tmpCombo := TdbComboBox(Sender);
  FindC(tmpForm,'edt'+DeleteClassName(tmpCombo.Name),tmpEdit);
  tmpCombo.Font.Style := fsBold;
  // установить флаг обновления данных
  tmpEdit.Font.Style := fsBold;
end;

procedure efmObject_edtEdit_OnChange (Sender: TObject);
// ввод нового значения в текстовом поле
var
  tmpEdit: TdbEdit;
begin
  tmpEdit := TdbEdit(Sender);
  // установить флаг обновления данных
  tmpEdit.Font.Style := fsBold;
end;Code language: Delphi (delphi)

Итоги

Пока реализован только базовый функционал: создание классов, создание и редактирование объектов. В планах хочется реализовать все фишки ООП:

  • Наследование свойств класса при создании класса-наследника
  • Поддержка свойств-множеств
  • Отображение зависимых объектов (объектов, у которых данный объект является значением какого-либо свойства)
  • Отображение дочерних объектов вместе с родительскими (сейчас отображаются только объекты выбранного класса).
  • Создание произвольных представлений (использования аналога SQL для ООП).
  • Поиск (поиск объектов по заданному значению свойства)
  • Фильтрация (автоматическое построение панели фильтрации объектов заданного класса на основании списка свойств)
  • Флаг обязательности заполнения свойства объекта (аналог обязательного поля)
  • Флаг уникальности свойства объекта класса (аналог уникальности значения поля)
  • Флаг глобальной уникальности свойства объекта (или свойство с генерацией GUID)
  • Расширение базовых типов данных (дата/время, да/нет, изображения/файлы и др.)
  • Добавление контроля для типов данных (аналог доменов) – диапазон значений для чисел и дат, длина текста для строк и т.д.

Ссылки

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *