2

I have a table like this:

id     parent_id     name
1          1         Root
2          1         Car
3          1         Plane
4          2         BMW
5          4         CLK

How can I dynamically create popup menu with all subitems in Delphi?

This is how it should look like:

image

Glorfindel
  • 21,988
  • 13
  • 81
  • 109
  • I think I should you recursion, but I don't know how – Maxat Utepbergenov Jan 16 '13 at 10:41
  • What part do you have problem with - quering the table or creating the menuitems? – ain Jan 16 '13 at 10:43
  • Ain, I don't know how to create menuitems, cause there could be a lot subitems – Maxat Utepbergenov Jan 16 '13 at 10:48
  • What version of Delphi? And is this thing supposed to look like a TREE (based on the "parent_id" field), or is it supposed to be a flat list? – Cosmin Prund Jan 16 '13 at 10:55
  • 1
    Are the ID's ordered? Is the parent_id always grater then (or equal) to the ID? Because if they're not, it gets a little complicated. – Cosmin Prund Jan 16 '13 at 11:01
  • Cosmin, Delphi XE2. It should be like a tree. ID ordered. Parent_id could be equal to ID – Maxat Utepbergenov Jan 16 '13 at 11:16
  • @maxat use tags to specify details, really. How can anyone know which Delphi do you have, which SQL server uses, which DB access library do you use, which SQL query?... Use *tags* where appropriate and specify all the details. http://www.catb.org/esr/faqs/smart-questions.html – Arioch 'The Jan 16 '13 at 11:22
  • The backing SQL server is irrelevant. The Delphi version is relevant: Since you need to generate a tree, one needs to remember the TMenuItem generated, say, for ID=1 so it'll be used as the parent of the menu item generated for ID=2; Delphi XE2 has Generics so one can use a TDictionary<> – Cosmin Prund Jan 16 '13 at 11:24
  • 4
    `1 1 Root` - very wrong row. It is called "infinite recursion' Root elements should have no parent! and than means values like NULL or ZERO – Arioch 'The Jan 16 '13 at 11:26
  • 1
    @Arioch'The convention on how root-less nodes are to be represented is just a convention. Not a big deal. – Cosmin Prund Jan 16 '13 at 11:30
  • @CosminPrund Everything is just convention. But some conventions makes subsequent operations like traversing up simplier or harder, safer or more error prone. `Select ID, Parent_ID, Name from menu order by Parent_ID nulls first, ID` - doing this with `ID = Parent_ID` idea would be not so easy and readable. – Arioch 'The Jan 16 '13 at 11:36
  • @CosminPrund SQL server may support or not `NULLS FIRST` option, may support or not tree unwrapping statements. And topicstarters usually have very distant ideawhat may be releavant or not - otherwise they would not ask such vague questions. With not-tree SQL operations there are at least two approaches. Recursive to select child of given parent at each iteration, or ust scanning after the request i put above - searchign for parent at each iteration. – Arioch 'The Jan 16 '13 at 11:40
  • Comsin_Prund, Parent_Id equal ID only at Root. other records don't have equal ids. – Maxat Utepbergenov Jan 16 '13 at 11:43
  • @Maxat then add to the table a computed column that would be null for roots and parent_id for the rest. That convention "i am my own parent" is VERY fragile. It would make any code such a mess that no one would be able to read it and find bugs. – Arioch 'The Jan 16 '13 at 12:17
  • I'm with @Arioch'The on the Root ID needs to be NULL. – kobik Jan 16 '13 at 12:36
  • This question involves reading from a database, interpreting the results, and creating TMenuItem descendants. Which part of the task are you having trouble with? Please [edit] your question to clarify. – Rob Kennedy Jan 16 '13 at 15:38

5 Answers5

3

Too many solutions for such a simple problem. Too bad you got ordered ID's because without ordered ID's things would have been more fun. Here's my own solution. On an empty form drop a button, a TClientDataSet and a TPopupMenu. Make the form's PopupMenu = PopupMenu1 so you can see the result. Add this to Button1.OnClick:

Note: I'm intentionally using TClientDataSet and not a real Query. This question is not about the query and this solution works with whatever TDataSet descendant you throw at it. Just make sure the result set is ordered on id, or else you could see the child nodes before the parents. Also note, half the code is used to fill up the ClientDataSet with the sample data in the question!

procedure TForm16.Button1Click(Sender: TObject);
var Prev: TDictionary<Integer, TMenuItem>; // We will use this to keep track of previously generated nodes so we do not need to search for them
    CurrentItem, ParentItem: TMenuItem;
begin
  if not ClientDataSet1.Active then
  begin
    // Prepare the ClientDataSet1 structure
    ClientDataSet1.FieldDefs.Add('id', ftInteger);
    ClientDataSet1.FieldDefs.Add('parent_id', ftInteger);
    ClientDataSet1.FieldDefs.Add('name', ftString, 100);

    ClientDataSet1.CreateDataSet;

    // Fill the dataset
    ClientDataSet1.AppendRecord([1, 1, 'Root']);
    ClientDataSet1.AppendRecord([2, 1, 'Car']);
    ClientDataSet1.AppendRecord([3, 1, 'Plane']);
    ClientDataSet1.AppendRecord([4, 2, 'BMW']);
    ClientDataSet1.AppendRecord([5, 4, 'CLK']);
  end;

  // Clear the existing menu
  PopupMenu1.Items.Clear;

  // Prepare the loop
  Prev := TDictionary<Integer, TMenuItem>.Create;
  try
    ClientDataSet1.First; // Not required for a true SQL Query, only required here for re-entry
    while not ClientDataSet1.Eof do
    begin
      CurrentItem := TMenuItem.Create(Self);
      CurrentItem.Caption := ClientDataSet1['name'];

      if (not ClientDataSet1.FieldByName('parent_id').IsNull) and Prev.TryGetValue(ClientDataSet1['parent_id'], ParentItem) then
        ParentItem.Add(CurrentItem)
      else
        PopupMenu1.Items.Add(CurrentItem);

      // Put the current Item in the dictionary for future reference
      Prev.Add(ClientDataSet1['id'], CurrentItem);

      ClientDataSet1.Next;
    end;
  finally Prev.Free;
  end;
end;
Cosmin Prund
  • 25,498
  • 2
  • 60
  • 104
  • For what i know you can make a cloned CDS instead of making TDictionary. And that works really fast to have two independent cursors over the same dataset. BTW, does in-memory CDS build search indices? If it does, then maybe the search for parent could take O(log n) rather than O(n) – Arioch 'The Jan 16 '13 at 12:43
  • `TDictionary`, as implemented in `Genercis.Collections` is a hash table: querying it is an O(1) operation, and that beats O(log n) every time. Our work set is the whole data-set (the table). We know it contains `n` records. Since we need to look at each record at least once, the time will be a minimum of `O(n)`. What we do in the loop gets multiplied with the `n`: My algoritm is `O(n * 1) = O(n)`, while the "fast" `O(log n)` search would net us `O(n*log n)`. – Cosmin Prund Jan 16 '13 at 12:52
  • *We know it contains n records* - perhaps, after you cached all the query into CDS you know it. Vut in your code TDictionary itself hardly knows this. You do not warm it up. Nor do you warranty unique hash values. – Arioch 'The Jan 16 '13 at 12:59
  • @Arioch'The, I'm not going to argue with you on the merits of `TDictionary`, nor on how big-O notation uses `n`. – Cosmin Prund Jan 16 '13 at 13:11
3

Assuming root element has NULL as Parent_ID you can issue the request

 Select ID, Parent_ID, Name from all_my_menus 
   order by Parent_ID nulls first, ID 
   where Menu_ID = :MenuIDParameter

1   <NULL>    Root
8   <NULL>    another root
2        1    Car
4        1    Plane
3        2    BMW
5        4    CLK

You would also cache in-memory created menu items: var MI_by_id: TDictionary<integer, TMenuItem>;

The traversing through the results would look like

var MI: TMenuItem;
    MI_by_id: TDictionary<integer, TMenuItem>;
begin 
  MI_by_id := TDictionary<integer, TMenuItem>.Create;
  try
    While not Query.EOF do begin
        MI := TMenuItem.Create(Self);
        MI.Caption := Query.Fields[2].AsString;
        MI.Tag := Query.Fields[0].AsInteger; // ID, would be helpful for OnClick event
        MI.OnClick := ...some click handler

        if Query.Fields[1].IsNull {no parent}
           then MainMenu.Items.Add(MI)
           else MI_by_id.Items[Query.Fields[1].AsInteger].Add(MI);

        MI_by_id.Add(MI.Tag, MI); //save shortcut to potential parent for future searching
        Query.Next;
    end;
  finally 
    MI_by_id.Free;
  end;
end;

Actually, since we made sort upon Parent_ID on the query, all the children for given parent make single continuous list, so could be better to remove populated parents from the dictionary after we populated last child (i.e. after parent_ID got new value) and caching previously found parent otherwise in another local variable (instead of making yet another search through the dictionary). However reasonable size for human-targeted menu should be much less to worth this. But you have to understand this approach most probably scales as O(n*n) thus would start loose speed very fast as the table grows.

Note: this also requires that for every non-root element ID > ParentID (put CHECK CONSTRAINT on the table)

1   <NULL>    Root
8   <NULL>    another root
7        1    Plane
3        4    BMW
4        7    CLK
5        8    Car

This would lead to BMW tied to create before its parent CLK created. Violation for that conditions can be overcome by few means:

  • recursive load: select <items> where Parent_id is null, then for each of the added menu items do select <items> where Parent_id = :current_memuitem_id and so on that. This is like VirtualTreeView would work
  • ask SQL server to sort and flatten the tree - this is usually called self-recursive SQL selection and is server-dependant.
  • introduce one more collection variable - menu items w/o parent. After each new item added to the menu this collection should be searched if there are pending children to extract from it and move into the newly created parent.
Arioch 'The
  • 15,799
  • 35
  • 62
  • +1; This algorithm (the same algorithm as in my example) is O(n), not O(n*n): Querying the dictionary is O(1) and you're only going over each record in the resulting dataset exactly once. – Cosmin Prund Jan 16 '13 at 12:26
  • I allready asked the OP if `ID <= PARENT_ID` and he said yes. Order by `ID` and if it has a parent, you've already seen it, guaranteed. If that condition is dropped then it get's a little messy because there's no `order by` that guarantees you'll always see the PARENTS before the CHILDS. Without that condition there's also the chance of circular loops. – Cosmin Prund Jan 16 '13 at 12:35
  • @Cosmin that is dependent on TDictionary implementation. If it is sorted - then new item insertion would take O(n). If it is not sorted, then the search would take O(N). And Delphi does not have FiongerTrees for what i know :-) – Arioch 'The Jan 16 '13 at 12:39
  • @Cosmin i prefer to have all children for every given parent go in one single section, that provides for optimization "only ask Dictionary for parent if the Parent_ID field changed", however that is left as a homework for the topic starter :-) – Arioch 'The Jan 16 '13 at 12:41
  • 2
    Delphi's TDictionary, as any dictionary in any language that I know of, is a HASH TABLE. It's not sorted, by design! Or, if you want, it's sorted on the hash-code of the items you add to it. That's why I like it and that's why it's O(1) – Cosmin Prund Jan 16 '13 at 12:42
  • @Cosmin 1st of all, hash is made over looong data types. For integers the most proper hash would be the value itself :-) Or perhaps proper hash would be might be some byte of that integer, LSB or #2 or any other. Then well, do you really think that dictionary over integer would take 4GB of virtual memory ? so that any access to `internal_array[hash_value]` would not result in AV ? Surely not. Then if the dictionary is sorted, the access would be O(log n) (i was wrong about O(1)), but then also account for data insertion into sorted list, which also would not be O(1) – Arioch 'The Jan 16 '13 at 12:48
  • If you rely on IDs go one by one incrementing - then just use TList and drop hashing at all ;-) But here we have the dictionary where potentially there would be much more inserts than searches. – Arioch 'The Jan 16 '13 at 12:57
  • there are lots of smart people out there working on very smart hash functions to be used for hash tables. Of course, given a hash function you might be able to design a series of input values that would degrade the dictionary performance to a sorted array (or worst, depending on the collision-resolution algorithm). But you're unlikely to manage that. Please take a look at the TDictionary implementation in Delphi then read up a bit on hash functions for hash tables and collision resolution. – Cosmin Prund Jan 16 '13 at 12:58
  • Glad to hear. But you'd if soem menu in your table would grow too large - you would have to think about optimizing this. Also http://meta.stackexchange.com/questions/5234 – Arioch 'The Jan 17 '13 at 07:28
2

Try this

procedure TForm1.MyPopup(Sender: TObject);
begin
  with Sender as TMenuItem do ShowMessage(Caption);
end;

procedure TForm1.Button1Click(Sender: TObject);
var 
  MyItem,MySubItem1: TMenuItem;
begin
  Inc(Num);
  MyItem:=TMenuItem.Create(Self);
  MySubItem1:=TMenuItem.Create(Self);

  MyItem.Caption:='Hello'+IntToStr(Num);
  MySubItem1.Caption:='Good Bye'+IntToStr(Num);

  MainMenu1.Items.Add(MyItem);
  MainMenu1.Items[0].Insert(num-1,MySubItem1);

  MyItem.OnClick:=MyPopUp;
  MySubItem1.OnClick:=MyPopUp;
end;

Taken from http://www.greatis.com/delphicb/tips/lib/components-addmenuitem.html

onedevteam.com
  • 3,838
  • 10
  • 41
  • 74
1

This solution requires parent_id of root to be 0, tested with

Select 1 as ID,          0 as Parent_ID,         'Root' as Name
union
Select 2,          1,        ' Car'
union
Select 3 ,         1,         'Plane'
union
Select 4,          2,        'BMW'
union
Select 5,          4,         'CLK'

should by optimized, have just a lack of time ...

Function GetMenu(pop:TPopupmenu;ID:Integer):TMenuItem;
var
 i:Integer;
 Function CheckItem(mi:TMenuItem):TMenuItem;
    var
     i:Integer;
    begin
      Result := nil;
      if mi.Name = 'DYN_' + INtToStr(ID) then Result := mi
      else  for i := 0 to mi.Count-1 do
        if not Assigned(Result) then Result := CheckItem(mi[i]);
    end;
begin
  Result := nil;
  for i := 0 to pop.Items.Count-1 do
    begin
      if not Assigned(Result) then Result := CheckItem(pop.Items[i]);
      if Assigned(Result) then Break;
    end;
end;


Function InsertMenuItem(pop:TPopupMenu;mi:TMenuItem;ID:Integer;Const caption:String):TMenuItem;
begin
    Result := TMenuItem.Create(pop);
    Result.Caption := caption;
    Result.Name := 'DYN_' + INtToStr(ID) ;
    if not Assigned(mi) then pop.Items.Add(Result) else mi.Add(Result);

end;

Function AddMenuItem(pop:TPopupmenu;ID:Integer;Ads:TDataset):TMenuItem;
begin
  Ads.Locate('ID',ID,[]);
  Result := GetMenu(pop,id);
  if (not Assigned(Result))   then
    begin
     if  (Ads.FieldByName('parent_ID').AsInteger<>0) then
       begin
        result := AddMenuItem(pop,Ads.FieldByName('parent_ID').AsInteger,Ads);
        Ads.Locate('ID',ID,[]);
       end;
     Result := InsertMenuItem(pop,Result,ID,Ads.FieldByName('Name').AsString);
    end;
  Ads.Locate('ID',ID,[]);
end;

procedure TForm1.Button1Click(Sender: TObject);

begin
   while not ADS.Eof do
      begin
        AddMenuItem(Popupmenu1,ads.FieldByName('ID').AsInteger,Ads);
        Ads.Next
      end;
end;
bummi
  • 27,123
  • 14
  • 62
  • 101
1

Interesting conundrum ...another late night thought, a practical answer for re-use :)

Make a derived component:

type
  TCascadeMenuItem = class(TMenuItem)
  private
    Id: Integer;
  public
    function AddItem(const ToId, WithId: Integer; AName: string): Boolean;
  end;

with code

function TCascadeMenuItem.AddItem(const ToId, WithId: Integer; AName: string): Boolean;
var
  i: Integer;
  cmi: TCascadeMenuItem;
begin
  if ToId = Id then
  begin
    cmi := TCascadeMenuItem.Create(Owner);
    cmi.Caption := AName;
    cmi.Id := WithId;
    Add(cmi);
    Result := True;
  end
  else begin
    i := 0;
    Result := False;
    while (i < Count) and (not Result) do
    begin
      Result := TCascadeMenuItem(Items[i]).AddItem(ToId,WithId, ANAme);
      inc(i);
    end;
  end;

end;

Main form, Assumes your data:

procedure TForm4.Button2Click(Sender: TObject);
var
  mi: TCascadeMenuItem;
  i: Integer;
  Added: Boolean;
begin
    cds1.First;
    while not cds1.Eof do
    begin
      i := 0;
      Added := False;
      while (i < pup.Items.Count) and (not Added) do
      begin
        Added := TCascadeMenuItem(pup.Items[i]).AddItem(cds1Parent_Id.AsInteger, cds1id.AsInteger, cds1name.AsString);
        inc(i);
      end;
      if not Added then
      begin  // new root
        mi := TCasCadeMenuItem.Create(Self);
        mi.Caption := cds1name.AsString;
        mi.id := cds1Parent_Id.AsInteger;
        pup.Items.Add(mi);
      end;
      cds1.Next;
    end;
end;

You could derive a TCascasePopupMenu and put it on the palette :)

Despatcher
  • 1,745
  • 12
  • 18
  • nice approach, but you should consider a situation where root parent_id=0 or null – kobik Jan 17 '13 at 11:42
  • @kobik Yes but the OP said "this is my Data" and this answer uses it - rather than impose a new condition. changing it to use a new root if Parent is zero is almost trivial. – Despatcher Jan 17 '13 at 20:21