SO answer to printing tabular report using forall/2 or foreach/2

This is an answer to this question posted on StackOverflow.

The answer is here because Discourse allows for better formatting and a proper discussion.

Note: The correct way to create a formatted tabular report with SWI-Prolog is to use format/2 with ~| , ~t , ~+, but this answer is specific to the means the OP is seeking.


Repost of original question.

I have list of facts as follows.

items(itemId('P01'),prodName('Pots'),stockQty(50),price(8200)).
items(itemId('P02'),prodName('Pans'),stockQty(50),price(400)).
items(itemId('P03'),prodName('Spoons'),stockQty(50),price(200)).
items(itemId('P04'),prodName('Forks'),stockQty(50),price(120)).
items(itemId('P05'),prodName('Kettles'),stockQty(50),price(500)).
items(itemId('P06'),prodName('Plates'),stockQty(50),price(60)).

How to print on the console something like the following when a command like print_all_products. is given.

…

Available Products

…

Name Qty

Pots 60

Pans 50

Spoons 40

…

  • The Name and Qty must be properly formatted in a tabular structure.

I tried using forall and foreach I am unsuccessful in generating what I need.


Answer:

The OP asked for an example using predicates like forall/2 or foreach/2.
This could also be done using DCGs. For something similar using DCGs see this post.

Think of the output as a sequence of Prolog atoms concatenated, then the output would be like

'........................\nAvailable Products\n........................\nName   Qty\nPots   60\nPans   50\nSpoons 40\n........................\n'

The head of the output would be

'\n........................\nAvailable Products\n........................\nName   Qty\n'

The footer of the output be

'........................\n'

The detail of the output would be

'Pots   60\nPans   50\nSpoons 40\n'

Each detail line would be like

'Pots   60'

Being lines of text, you can also think of them as 'Pots 60' followed by '\n'.

Since a fact has more information than is needed, unification can be used to extract the needed term(s), e.g.

Item = items(_,prodName(Name),stockOty(Quantity),_).
?- items(_,prodName(Name),stockOty(Quantity),_).
Name = 'Pots',
Quantity = 50 ;
Name = 'Pans',
Quantity = 50 ;
Name = 'Spoons',
Quantity = 50 ;
Name = 'Forks',
Quantity = 50 ;
Name = 'Kettles',
Quantity = 50 ;
Name = 'Plates',
Quantity = 50.

Now that the code has access to the needed values for the detail line, predicates are needed to convert the detail values into a detail line.

A value will need a variable number of padding spaces on the right.

spaces(Length,Spaces) :-
    length(List,Length),
    maplist([_,0'\s]>>true,List,Codes),
    string_codes(Spaces,Codes).

Example usage:

?- spaces(4,Spaces).
Spaces = "    ".

To determine the max width of a field such as name

aggregate_all(max(Width),Width,(items(_,prodName(Name),_,_),string_length(Name,Width)),Max).

Example usage:

?- aggregate_all(max(Width),Width,(items(_,prodName(Name),_,_),string_length(Name,Width)),Max).
Max = 7.

For the next predicate, something not commonly intuitive is used. In SWI-Prolog, strings are also atomic so goals like this work.

?- atomic("abcd").
true.

?- atomic('abcd').
true.

?- string_length('abcd',Length).
Length = 4.

?- string_length("abcd",Length).
Length = 4.

A predicate is needed to take a value and a field width and right pad the value with spaces.

padded_string(String,Width,Padded_string) :-
    string_length(String,String_length),
    Padding_length is Width - String_length,
    spaces(Padding_length,Padding),
    atom_concat(String,Padding,Padded_string)

Example run:

?- padded_string('Pots',8,Padded_string).
Padded_string = 'Pots    '

A predicate is needed to take an item and convert it into a line.

format_detail_line(item(Name,Quantity),width(Name_width),Formatted_item) :-
    padded_string(Name,Name_width,Padded_name),
    atom_concat(Padded_name,Quantity,Formatted_item).

Example run:

?- format_detail_line(item('Pots',60),width(8),Formatted_item).
Formatted_item = 'Pots    60'.

To build all of the detail lines foldl/4 is used. foldl/4 needs a goal.

add_detail_line(width(Name_Width),Item,Lines0,Lines) :-
    format_detail_line(Item,width(Name_Width),Formatted_item),
    atomic_list_concat([Lines0,Formatted_item,"\n"], Lines).

Example runs:

?- add_detail_line(width(8),item('Pots',60),"",Lines).
Lines = 'Pots    60\n'.

?- add_detail_line(width(8),item('Pans',50),'Pots    60\n',Lines).
Lines = 'Pots    60\nPans    50\n'.

The predicate that converts all the items in the detail using foldl/4. Remember the detail is just a concatenated sequence of atomic values.

items_detail(Detail) :-
    findall(item(Name,Quantity),items(_,prodName(Name),stockOty(Quantity),_),Items),
    aggregate_all(max(Width),Width,(items(_,prodName(Name),_,_),string_length(Name,Width)),Name_Width),
    Name_field_width is Name_Width + 1,
    foldl(add_detail_line(width(Name_field_width)),Items,"",Detail).

Example run:

?- items_detail(Detail).
Detail = 'Pots    50\nPans    50\nSpoons  50\nForks   50\nKettles 50\nPlates  50\n'.

The predicate to generate the report.

print_all_products(Report) :-
    header(Header),
    items_detail(Detail),
    footer(Footer),
    atomic_list_concat([Header,Detail,Footer], Report).

Now for a proper test case.

:- begin_tests(formatted_report).

test(1) :-
    print_all_products(Report),
    with_output_to(atom(Atom),write(Report)),
    assertion( Atom == '\n........................\nAvailable Products\n........................\nName   Qty\nPots    50\nPans    50\nSpoons  50\nForks   50\nKettles 50\nPlates  50\n........................\n' ).

:- end_tests(formatted_report).

Example run:

?- run_tests.
% PL-Unit: formatted_report . done
% test passed
true.

To print just the report.

print_all_products :-
    print_all_products(Report),
    write(Report).

Example run:

?- print_all_products.

........................
Available Products
........................
Name   Qty
Pots    50
Pans    50
Spoons  50
Forks   50
Kettles 50
Plates  50
........................
true.

All the code.

items(itemId('P01'),prodName('Pots'),stockOty(50),price(8200)).
items(itemId('P02'),prodName('Pans'),stockOty(50),price(400)).
items(itemId('P03'),prodName('Spoons'),stockOty(50),price(200)).
items(itemId('P04'),prodName('Forks'),stockOty(50),price(120)).
items(itemId('P05'),prodName('Kettles'),stockOty(50),price(500)).
items(itemId('P06'),prodName('Plates'),stockOty(50),price(60)).

header("\n........................\nAvailable Products\n........................\nName   Qty\n").
footer("........................\n").

spaces(Length,Spaces) :-
    length(List,Length),
    maplist([_,0'\s]>>true,List,Codes),
    string_codes(Spaces,Codes).

padded_string(String,Width,Padded_string) :-
    string_length(String,String_length),
    Padding_length is Width - String_length,
    spaces(Padding_length,Padding),
    atom_concat(String,Padding,Padded_string).

format_detail_line(item(Name,Quantity),width(Name_width),Formatted_item) :-
    padded_string(Name,Name_width,Padded_name),
    atom_concat(Padded_name,Quantity,Formatted_item).

add_detail_line(width(Name_Width),Item,Lines0,Lines) :-
    format_detail_line(Item,width(Name_Width),Formatted_item),
    atomic_list_concat([Lines0,Formatted_item,"\n"], Lines).

items_detail(Detail) :-
    findall(item(Name,Quantity),items(_,prodName(Name),stockOty(Quantity),_),Items),
    aggregate_all(max(Width),Width,(items(_,prodName(Name),_,_),string_length(Name,Width)),Name_Width),
    Name_field_width is Name_Width + 1,
    foldl(add_detail_line(width(Name_field_width)),Items,"",Detail).

print_all_products(Report) :-
    header(Header),
    items_detail(Detail),
    footer(Footer),
    atomic_list_concat([Header,Detail,Footer], Report).

print_all_products :-
    print_all_products(Report),
    write(Report).

:- begin_tests(formatted_report).

test(1) :-
    print_all_products(Report),
    with_output_to(atom(Atom),write(Report)),
    assertion( Atom == '\n........................\nAvailable Products\n........................\nName   Qty\nPots    50\nPans    50\nSpoons  50\nForks   50\nKettles 50\nPlates  50\n........................\n' ).

:- end_tests(formatted_report).

HTH

You can simplify this by using format/2 … for putting things in neat columns, use ~| , ~t , ~+ .

~| sets a tab to β€œhere”, ~t inserts fill characters, ~+ advances the tab beyond the last β€œhere” ( ~| ) and distributes the fill characters. So,

format("(~|~`.t~d~5+)~n", [123])

produces (..123) – the format string right-justifies the number with . s in a width of 5, surrounded by parentheses.

Yes.

The OP is trying to learn about AI and was looking for an answer using predicates like forall/2 or foreach/2. I noted that this was the reason for answering in this way.

This could also be done using DCGs but the OP asked for an example using predicates like forall/2 or foreach/2.

I also noted that your answer is the proper way to do such formmating in the SO answer.

Note: The answer given by Peter is the customary way to do the formatting, but as I noted, that drives me nuts. Even so, that is the way I would do it in a production environment.

:smile:


EDIT

Add this near the top of first post.

Here is a version with forall/2, dcgs, and clpfd.

items(itemId('P01'),prodName('Pots'),stockQty(50),price(8200)).
items(itemId('P02'),prodName('Pans'),stockQty(50),price(400)).
items(itemId('P03'),prodName('Spoons'),stockQty(50),price(200)).
items(itemId('P04'),prodName('Forks'),stockQty(50),price(120)).
items(itemId('P05'),prodName('Kettles'),stockQty(50),price(500)).
items(itemId('P06'),prodName('Plates'),stockQty(50),price(60)).

header("\n........................\nAvailable Products\n........................\nName   Qty\n").
footer("........................\n").

print_all_products :-
   header(Hdr), write(Hdr),
   forall( items(_,prodName(Prod),stockQty(Qty),_),
           print_item(Prod-0,Qty-8)),
   footer(Ftr), write(Ftr).

print_item(Product-Pos1,Qty-Pos) :-
   atom_codes(Product,ProductCodes), atom_codes(Qty,QtyCodes),
   phrase(formatted_line(ProductCodes-Pos1,QtyCodes-Pos),Line),
   format('~s~n',[Line]).

formatted_line(Product-Pos1,Qty-Pos2) -->
   { Pos2 #= Pos1 + LenProduct + BeforeQty },
   char_list(0'\s,_,Pos1),
   list(Product,LenProduct),
   char_list(0'\s,_,BeforeQty),
   Qty.

list([],0)     --> [].
list([L|Ls],Len1) -->
   [L], { Len #>= 0, Len1 #= Len + 1 },
   list(Ls,Len).

char_list(_,[],0)     --> [].
char_list(Char,[Char|Ls],Len1) -->
   [Char], { Len #>= 0, Len1 #= Len + 1 },
   char_list(Char,Ls,Len).

Query:

45 ?- print_all_products.

........................
Available Products
........................
Name   Qty
Pots    50
Pans    50
Spoons  50
Forks   50
Kettles 50
Plates  50
........................
true.

Shameless self-promotion – I wrote library(cli_table), which does this:

?- Data = [[a,bb,ccc], [111,22,3]],
   Head = ['First', 'Second', 'Third'],
   cli_table(Data, [head(Head)]).
╔═══════╀════════╀═══════╗
β•‘ First β”‚ Second β”‚ Third β•‘
β•Ÿβ”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β•’
β•‘   a   β”‚   bb   β”‚  ccc  β•‘
β•‘  111  β”‚   22   β”‚   3   β•‘
β•šβ•β•β•β•β•β•β•β•§β•β•β•β•β•β•β•β•β•§β•β•β•β•β•β•β•β•
4 Likes