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

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

Нам понадобится всего два новых поля:

  • cproperty.is_filter – флаг, определяющий, нужно ли использовать данное свойство для построения фильтра
  • class.is_ShowFilterPanel – флаг, включающий/отключающий отображение панели фильтра.

efmCProperty

Для нового поля потребуется компонент на форме редактирования свойства класса. Не забываем добавить его в список компонентов для сохранения данных в настройке кнопки btnSave.

Скрипты

Сначала я хотел переделать процедуру efmObject_OnShow() таким образом, чтобы использовать её как для генерации формы редактирования параметров объекта, так и для генерации панели фильтрации. Очевидно, что у них много общего: запрос на выборку данных, генерация компонентов редактирования. Однако, в перспективе могут возникнуть существенные различия. Например, при фильтрации данных по дате обычно используют не одну дату, а период; для чекбокса нужно учитывать три состояния и так далее. Если поместить всю логику в одну процедуру то она станет очень сложной в отладке. Поэтому я создал PrepareFilterPanel(), которая отвечает только за панель фильтрации.

procedure PrepareFilterPanel(APanel:TdbPanel; AIDClass:integer );
// создание элементов фильтрации
// APanel - панель для размещения элементов
// AIDClass - класс, для которого строится фильтр
var
  tmpForm: TForm;
  tmpSQL: string;
  tmpDataSet: TDataSet;
  tmpLineTop: integer;
  tmpCount: integer;
  tmpLabel: TdbLabel;
  tmpEdit: TdbEdit;
  tmpPanel: TdbPanel;
  tmpImageOff: TdbImage;
  tmpImageOn: TdbImage;
  tmpImageNull: TdbImage;
  tmpButton: TdbButton;
  tmpComboBox: TdbComboBox;
  tmpParentList:string;
  tmpChildList:string;
  tmpParent: TdbPanel;
  i: integer;
  tmpControlID: integer;
  tmpDataEdit : TdbDateTimePicker;
begin
  if APanel.Tag <> AIDClass then
  begin
    APanel.Tag := AIDClass;
  CForm(APanel,tmpForm);
  tmpParent := APanel;
  // скрыть все компоненты
  for i := tmpParent.ControlCount - 1 downto 0 do
  begin
    APanel.Controls[i].Visible := False;
  end;
  tmpCount := 0;
  // создать компоненты, которые нужны для фильтрации
  GetChildAndParent( AIDClass, tmpParentList, tmpChildList ); // список родительских узлов
  tmpSQL := 'SELECT cproperty.is_detail, cproperty.id_ptype, 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 in ('+tmpParentList+') and cproperty.is_filter = 1 ORDER BY orderNum ';
  tmpLineTop := 0;
  SQLQuery(tmpSQL,tmpDataSet);
  while not tmpDataSet.EOF do
  begin
    tmpControlID := tmpDataSet.FieldByName('id_uicontrol').asInteger;
    // метка
    FindC(tmpForm,'labData_'+intToStr(tmpCount),tmpLabel,False);
    if tmpLabel = nil then
      tmpLabel := TdbLabel.Create( tmpForm );
    with tmpLabel do
    begin
      visible := True;
      parent := tmpParent;
      Font.Size := ScaleFontSize;
      top := tmpLineTop;
      left := Scale(8);
      name := 'labData_'+intToStr(tmpCount);
      Caption := tmpDataSet.FieldByName('name').asString;
    end;
    // поле ввода текста
    FindC(tmpForm,'edtData_'+intToStr(tmpCount),tmpEdit,False);
    if tmpEdit = nil then
      tmpEdit := TdbEdit.Create( tmpForm );
    with tmpEdit do // Tag -> cproperty.id ;
    begin
      visible := True;
      name := 'edtData_'+intToStr(tmpCount);
      parent := tmpParent;
      Font.Size := ScaleFontSize;
      top := tmpLineTop + tmpLabel.Height;
      left := Scale(8);
      width := APanel.width - Left*2; // по ширине панели с отступом
      // заполняем данными
      tag := tmpDataSet.FieldByName('id').asInteger; //
      dbTable := tmpDataSet.FieldByName('id_ptype').asString;
      dbField := '';
      tagString := '';
      text := '';
      //
      dbCurrency := False;
      NumbersOnly := False;
      if tmpControlID = 3 then // целое число
      begin
        dbCurrency := True;
        dbAccuracy := 0;
        Alignment := taLeftJustify;
      end;
      if tmpControlID = 4 then // вещественное число
      begin
        NumbersOnly := True;
      end;
      onChange := 'FilterEdit_OnChange';
      Font.Style := 0;
      tmpEdit.ReadOnly := False;
    end;
    // переключатель
    if tmpControlID = 7 then
    begin
      tmpEdit.Width := trunc(tmpEdit.Height * 1.1); // Scale(32);
      FindC(tmpForm,'panData_'+intToStr(tmpCount),tmpPanel,False);
      if tmpPanel = nil then
        tmpPanel := TdbPanel.Create(tmpForm);
      with tmpPanel do
      begin
        parent := tmpParent;
        name := 'panData_'+intToStr(tmpCount);
        caption := '';
        top := tmpEdit.Top;
        left := tmpEdit.Left;
        width := tmpEdit.width;
        height := tmpEdit.height;
        bevelWidth := 0;
        color := tmpParent.color;
        visible := True;
      end;
      //
      FindC(tmpForm,'imgDataOn_'+intToStr(tmpCount),tmpImageOn,False);
      if tmpImageOn = nil then
        tmpImageOn := TdbImage.Create(tmpForm);
      with tmpImageOn do
      begin
        parent := tmpPanel;
        name := 'imgDataOn_'+intToStr(tmpCount);
        align := alClient;
        center := True;
        stretch := True;
        picture.LoadFromFile( ExtractFilePath(Application.ExeName) + 'images\toggle\toggle_on.png' );
        onClick := 'Toggle_Off';
      end;
      //
      FindC(tmpForm,'imgDataOff_'+intToStr(tmpCount),tmpImageOff,False);
      if tmpImageOff = nil then
        tmpImageOff := TdbImage.Create(tmpForm);
      with tmpImageOff do
      begin
        parent := tmpPanel;
        name := 'imgDataOff_'+intToStr(tmpCount);
        align := alClient;
        center := True;
        stretch := True;
        picture.LoadFromFile( ExtractFilePath(Application.ExeName) + 'images\toggle\toggle_off.png' );
        onClick := 'Toggle_Null';
      end;
      //
      FindC(tmpForm,'imgDataNull_'+intToStr(tmpCount),tmpImageNull,False);
      if tmpImageNull = nil then
        tmpImageNull := TdbImage.Create(tmpForm);
      with tmpImageNull do
      begin
        parent := tmpPanel;
        name := 'imgDataNull_'+intToStr(tmpCount);
        align := alClient;
        center := True;
        stretch := True;
        picture.LoadFromFile( ExtractFilePath(Application.ExeName) + 'images\toggle\toggle_indeterminate.png' );
        onClick := 'Toggle_On';
      end;
      // переключатель в нейтральное положение
      tmpImageNull.Visible := True;
      tmpImageOff.Visible := False;
      tmpImageOn.Visible := False;
    end;
    if tmpControlID = 6 then // справочник значений - кнопка
    begin
      FindC(tmpForm,'btnData_'+intToStr(tmpCount),tmpButton,False);
      if tmpButton = nil then
      begin
        tmpButton := TdbButton.Create( tmpForm );
      end;
      with tmpButton do
      begin
        visible := True;
        name := 'btnData_'+intToStr(tmpCount);
        parent := tmpParent;
        Font.Size := Scale(13);
        Caption := chr(8981);
        imageAlignment := iaCenter;
        Top := tmpEdit.Top - 1;
        Height := tmpEdit.Height + 2;
        Width := Height + 2 ;
        Left := tmpEdit.Left + tmpEdit.Width - Width;
        onClick := 'efmObject_btnData_OnClick';
        tag := tmpDataSet.FieldByName('ClassID').asInteger;
        //
        tmpEdit.Width := tmpEdit.Width - Width;
        tmpEdit.ReadOnly := True;
      end;
    end;
    if tmpControlID = 2 then // выпадающий список
    begin
      //
      FindC(tmpForm,'cmbData_'+intToStr(tmpCount),tmpComboBox,False);
      if tmpComboBox = nil then
        tmpComboBox := TdbComboBox.Create( tmpForm );
      with tmpComboBox do
      begin
        visible := True;
        name := 'cmbData_'+intToStr(tmpCount);
        parent := tmpParent;
        Font.Size := tmpEdit.Font.Size;
        top := tmpEdit.Top;
        left := tmpEdit.Left;
        width := tmpEdit.width;
        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.is_name = 1 ) AND object.id_class = '+tmpDataSet.FieldByName('ClassID').asString+' ORDER BY 2';
        dbUpdate;
        enabled := True;
        onChange := 'efmObject_cmbEdit_OnChange';
        Font.Style :=0;
      end;
    end;
    //
    if tmpControlID = 5 then // дата
    begin
      FindC(tmpForm,'dtpData_'+intToStr(tmpCount),tmpDataEdit,False);
      if tmpDataEdit = nil then
      begin
        tmpDataEdit := TdbDateTimePicker.Create( tmpForm );
        AssignEvents(tmpDataEdit);
      end;
      with tmpDataEdit do
      begin
        visible := True;
        name := 'dtpData_'+intToStr(tmpCount);
        parent := tmpParent;
        Font.Size := tmpEdit.Font.Size;
        top := tmpEdit.Top;
        left := tmpEdit.Left;
        width := tmpEdit.width;
        ShowCheckBox := True;
        DateTime := Now();
        Checked := False;
        //
        dbOnChange := 'efmObject_dtpEdit_OnChange';
        Font.Style :=0;
      end;
    end;
    inc(tmpCount);
    tmpLineTop := tmpLineTop + tmpEdit.Height*2;
    tmpDataSet.Next;
  end;
  tmpDataSet.Free;
  APanel.Visible := (tmpCount <> 0); // если ничего нет на панели, скрыть её
  end;
end;

Code language: Delphi (delphi)

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

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

procedure efmObject_dtpEdit_OnChange (Sender: TObject);
// выбор нового значения даты
var
  tmpEdit: TdbEdit;
  tmpForm: TForm;
  tmpDate:TdbDateTimePicker;
begin
  tmpDate:= TdbDateTimePicker(Sender);
  CForm(Sender,tmpForm);
  FindC(tmpForm,'edt'+DeleteClassName(TdbDateTimePicker(Sender).Name),tmpEdit);
  TdbDateTimePicker(Sender).Font.Style := fsBold;
  // установить флаг обновления данных
  tmpEdit.Font.Style := fsBold;
  if tmpDate.Checked then
    tmpEdit.Text := DateToStr( tmpDate.DateTime )
  else
    tmpEdit.Text := '';
end;
Code language: Delphi (delphi)

Главное отличие работы компонентов, находящихся на форме фильтра от тех, что расположены форме редактирования, в том, что при изменении значения сразу вызывается обновление списка объектов. За это отвечает процедура FilterEdit_OnChange().

procedure FilterEdit_OnChange( Sender: TObject);
// обработчик изменний в поле редактирования текста фильтра
var
  tmpForm:TForm;
begin
  // если будет работать быстро, то использовать, нет - отдельная кнопка должна быть
  CForm(Sender,tmpForm);
  Form_UpdateData(tmpForm);
end;
Code language: Delphi (delphi)

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

procedure DTF_btnUpdateOnClick(Sender: TObject; var Cancel: boolean);
// редактирование записи
var
  tmpForm: TForm;
begin
  if (ActiveGrid <> nil) then
  begin
    CForm(ActiveGrid,tmpForm);
    Form_UpdateData(tmpForm);
  end
  else
    ShowHint( TControl(Sender), 'Выберите таблицу' );
end;
Code language: Delphi (delphi)

Волшебство

Напомню, что у формы отображения списка объектов frmObject есть ключевая процедура frmObject_btnUpdate_OnClick() – обработчик нажатия кнопки btnUpdate. В ней мы добавляем вызов построителя фильтра (строка 16). А чтобы фильтр перестраивался только при смене класса, будем сохранять текущий класс в свойстве Tag (см. PrepareFilterPanel() строки 26-28). Затем в строке 19 вызывается процедура btnPrepareOject(), которая готовит отображаемые данные.

procedure frmObject_btnUpdate_OnClick (Sender: TObject; var Cancel: boolean);
// обновить отображение
var
  tmpIDClass: integer;
  tmpButton: TdbButton;
  tmpDataSet: TDataSet;
  tmpSQL: string;
  i: integer;
  tmpTabSheet: TdbTabSheet;
  Splitter:TSplitter;
begin
  frmObject.labIDClass.Caption := 'Change'; // блокируем срабатывание frmObject_tgrMain_OnColumnResize
  // строится запрос на выборку данных - все объекты указанного класса
  // получаем ID класса из дерева класса
  tmpIDClass := Form_GetDataViewer( GetFormByName('dtfClass_Tree') ).dbItemId;
  PrepareFilterPanel( frmObject.panFilter, tmpIDClass ); // настроить панель фильтра
  frmObject.labIDClass.Tag := tmpIDClass; // запоминаем класс
  tmpButton := TdbButton(Sender);
  btnPrepareOject( tmpButton, tmpIDClass);
  // убираем детализацию
  for i:= frmObject.pgcDetails.PageCount - 1 downto 0 do
  begin
    frmObject.pgcDetails.Pages[i].Free;
  end;
  FindC(frmObject,frmObject.panDetails.TagString,Splitter);
  Splitter.Visible := False;
  frmObject.panDetails.Visible := False;
  // определить, есть ли детализация, и создать вкладки-кнопки
  tmpSQL := 'SELECT class.id, class.name FROM cproperty LEFT JOIN class ON class.id = cproperty.id_class WHERE cproperty.is_detail = 1 AND cproperty.id_class1 = '+IntToStr( tmpIDClass );
  SQLQuery(tmpSQL,tmpDataSet);
  while not tmpDataSet.EOF do
  begin
    tmpTabSheet := TdbTabSheet.Create( frmObject );
    with tmpTabSheet do
    begin
      PageControl := frmObject.pgcDetails;
//      Name := '';
      Caption := tmpDataSet.FieldByName('name').asString;
      Tag := tmpDataSet.FieldByName('id').asInteger;
//      TagString := '';
    end;
    tmpDataSet.Next;
  end;
  if frmObject.pgcDetails.PageCount > 0 then
  begin
    frmObject.panDetails.Visible := True;
    Splitter.Visible := True;
    frmObject_Detail.btnUpdate.Click;
  end;
end;

Code language: Delphi (delphi)

Доработок в btnPrepareOject() немного, добавлена логика для формирования условия фильтра по данным, в строках 81-86.

procedure btnPrepareOject( AButton: TdbButton; AIDClass: integer; ACheckBox:Boolean = False; ADetail: boolean = False);
// генерация табличного предстваления класса
// AButton - кнопка для генерации запроса
// AIDClass - класс, для которого делается запрос
// ACheckBox - нужна ли колонка с чеерами
// ADetail - нужна ли фильтрация по мастеру
var
  tmpParentList: string;
  tmpChildList: string;
  tmpDataSet: TDataSet;
  tmpSQL: string;
  tmpFields : string;
  tmpJoins: string;
  tmpCount: integer;
  tmpCaptions: string;
  tmpTableAlias: string;
  tmpOrderBy: string;
  tmpIDMasterClass: integer;
  tmpIDMasterObject: integer;
  tmpFilter: string;
  tmpVisible: boolean;
  tmpForm:TForm;
  tmpPanel:TdbPanel;
  tmpFilterValue: string;
  tmpFiltered : boolean;
begin
  CForm(AButton,tmpForm);
  FindC(tmpForm,'panFilter',tmpPanel,False);
  tmpFiltered := (tmpPanel <> nil) and (tmpPanel.Visible);
  tmpFilter := '';
  if ADetail then
  begin
    tmpIDMasterClass := frmObject.labIDClass.Tag;
    tmpIDMasterObject := frmObject.tgrMain.dbItemID;
  end;
  GetChildAndParent( AIDClass, tmpParentList, tmpChildList); // получить список ID родителей и детей
  tmpSQL := 'SELECT * FROM cproperty WHERE id_class in ('+tmpParentList+') ORDER BY orderNum ';
  SQLQuery(tmpSQL,tmpDataSet);
  tmpFields := 'object.id';
  if ACheckBox then
    tmpFields := tmpFields + ',"$checkbox"';
  tmpJoins := '';
  tmpCaptions := 'delete_col'; // не отображать
  tmpOrderBy := '2';
  if ACheckBox then
  begin
    tmpCaptions := tmpCaptions + ',#';
    tmpOrderBy := '3';
  end;
  tmpCount := 0;
  //
  while not tmpDataSet.EOF do
  begin
    tmpVisible := tmpDataSet.FieldByName('visible').asInteger = 1;
    tmpTableAlias := 'OP_'+IntToStr(tmpCount);
    //
    if tmpVisible then
    begin
      if tmpFields <> '' then
        tmpFields := tmpFields + ', ';
      tmpFields := tmpFields + tmpTableAlias+'.value_s ';
    end;
    //
    tmpJoins := tmpJoins + 'LEFT JOIN oproperty '+tmpTableAlias+' ON '+tmpTableAlias+'.id_object = object.id AND '+tmpTableAlias+'.id_cproperty = '+tmpDataSet.FieldByName('id').asString+' AND '+tmpTableAlias+'.orderNum is NULL '+CR ;
    //
    if tmpVisible then
    begin
      if tmpCaptions <> '' then
        tmpCaptions := tmpCaptions + ', ';
      tmpCaptions := tmpCaptions + tmpDataSet.FieldByName('name').asString;
    end;
    //
    if tmpDataSet.FieldByName('is_name').asInteger = 1 then
      AButton.Tag := tmpCount;
    //
    if ADetail and (tmpDataSet.FieldByName('id_class1').asInteger = tmpIDMasterClass) then
    begin
      tmpFilter := tmpFilter + tmpTableAlias+'.id_object1 = '+IntToStr(tmpIDMasterObject)+' AND ';
    end;
    //
    if tmpFiltered and (tmpDataSet.FieldByName('is_filter').asInteger = 1) then
    begin
      tmpFilterValue := GetTextFilterValue( tmpPanel, tmpDataSet.FieldByName('id').asInteger );
      if tmpFilterValue <> '' then
        tmpFilter := tmpFilter + tmpTableAlias+'.value_s LIKE "%'+tmpFilterValue+'%" AND ';
    end;
    inc(tmpCount);
    tmpDataSet.Next;
  end;
  tmpDataSet.Free;
  if tmpCount = 0 then
    tmpOrderBy := '1';
  tmpSQL := 'SELECT '+tmpFields+CR+' FROM object '+tmpJoins+CR+' WHERE '+tmpFilter+' object.id_class in ('+tmpChildList+') ORDER BY '+tmpOrderBy;
  AButton.dbSQL := tmpSQL;
  AButton.dbListFieldsNames := tmpCaptions;
 end;

Code language: Delphi (delphi)

Для извлечения конкретных значений для фильтра добавлена функция GetTextFilterValue(), принцип поиска основан на том, что в свойстве Tag поля редактирования хранится значение CProperty.ID – идентификатор свойства класса. Как только находится совпадение, текст из поля редактирования возвращается в вызывающую процедуру. Дополнительная проверка видимости связана с механизмом “рециклинга” компонентов на панели во избежание ошибок, связанных с изменением настроек панели фильтрации в процессе работы приложения.

function GetTextFilterValue( APanel:TdbPanel; ACPropertyID:integer ):string;
// извлечение строкового значения фильтра
var
  i:integer;
  tmpEdit:TdbEdit;
begin
  Result := '';
  for i := APanel.ControlCount - 1 downto 0 do
  begin
    if APanel.Controls[i] is TdbEdit then
    begin
      tmpEdit:=TdbEdit(APanel.Controls[i]);
      if tmpEdit.Visible and (tmpEdit.Tag = ACPropertyID) then
      begin
        Result := tmpEdit.Text;
        Break;
      end;
    end;
  end;
end;
Code language: PHP (php)

Результат

Устанавливаем чекер на свойстве класса
В поле фильтра вводим значение и сразу получаем отфильтрованные данные
Для чекбокса по умолчанию устанавливается третье состояние, которое позволяет просматривать все записи.
Для даты фильтрация идет по полному совпадению даты или по всем датам (если чекер не установлен)
Поддерживается список значений для ссылочных полей.
Поддерживается фильтр для ссылочных значений, выбираемый через справочник.

Список ToDo уменьшился на 1 пункт, но всё ещё выглядит внушительным:

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

Строго говоря, не обязательно все это делать в прототипе (на My Visual Database), в какой-то момент нужно будет принять решение о реализации всего этого на Delphi в мультиплатформенном проекте Windows/Android. Но прежде, пожалуй, стоит потестировать производительность, чтобы четко понимать ограничения принятой архитектуры данных.

Ссылки

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

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