Tree hierarchy in external table

Not for 'how-to' coding questions but PHP theory instead, this forum is here for those of us who wish to learn about design aspects of programming with PHP.

Moderator: General Moderators

Post Reply
User avatar
allspiritseve
DevNet Resident
Posts: 1174
Joined: Thu Mar 06, 2008 8:23 am
Location: Ann Arbor, MI (USA)

Tree hierarchy in external table

Post by allspiritseve »

At the moment I have a table of items and a separate table of relationships. Each row in the relationship table has a parent id and a child id, so a whole tree can be created. I'm tossing around the idea of using modified preorder tree traversal instead of parent/child ids... I'm trying to figure out if it's possible to use that method in an external table though. Things are also a little more complicated because I'd like a single item to be able to appear multiple times in the hierarchy.

Let me know if I need to post some images, this is kind of a hard thing to explain.
User avatar
allspiritseve
DevNet Resident
Posts: 1174
Joined: Thu Mar 06, 2008 8:23 am
Location: Ann Arbor, MI (USA)

Re: Tree hierarchy in external table

Post by allspiritseve »

Never mind, I figured it out... :D I just had to think of a (left, right) pair as being the location of a given item, and then it was easy.

If I construct the table like this:

Code: Select all

relations
--------
id
item_id
left_id
right_id
I should have all the benefits of MPTT, but with the option of multiple locations per item.
josh
DevNet Master
Posts: 4872
Joined: Wed Feb 11, 2004 3:23 pm
Location: Palm beach, Florida

Re: Tree hierarchy in external table

Post by josh »

Its definitely possible, heres how an aggregrate function such as counting the products in a tree would be done. You'd have to modify this a bit to do what you want.. This is called a visitation model, theres a class on phpclasses.org but I found it to be limited for my purposes

Code: Select all

 
/**
    * @desc Aggregrate functions in a nested set
    */
    public function aggregrateSet()
    {
        // gets the count of items in another table for whole or sub trees
        $query = sprintf(
            "
           SELECT parent.name, COUNT(product.name)
            FROM nested_category AS node ,
            nested_category AS parent,
            product
            WHERE node.lft BETWEEN parent.lft AND parent.rgt
            AND node.category_id = product.category_id
            GROUP BY parent.name
            ORDER BY node.lft;
            "
        );
        //If you do not wish to show the parent node, change the HAVING depth <= 1 line to HAVING depth = 1.
        return $query;
    }
 
User avatar
allspiritseve
DevNet Resident
Posts: 1174
Joined: Thu Mar 06, 2008 8:23 am
Location: Ann Arbor, MI (USA)

Re: Tree hierarchy in external table

Post by allspiritseve »

Well, there'll be a join in there since I'm keeping all the relations in a separate table, but that looks about right.
User avatar
allspiritseve
DevNet Resident
Posts: 1174
Joined: Thu Mar 06, 2008 8:23 am
Location: Ann Arbor, MI (USA)

Re: Tree hierarchy in external table

Post by allspiritseve »

Any ideas on how to select all immediate children of a root object, but not the whole tree? That's something that is easily done with parent/child ids, but I can't see an easy way to do it in SQL. Maybe that's something that would have to be parsed in PHP?
josh
DevNet Master
Posts: 4872
Joined: Wed Feb 11, 2004 3:23 pm
Location: Palm beach, Florida

Re: Tree hierarchy in external table

Post by josh »

Code: Select all

 
        
class NTreeSQL
{
    
 
    
    
    /**
    * @desc Retrieving a Full Tree
    */
    public function fullTree( $id )
    {
        //We can retrieve the full tree through the use of a self-join that links parents with nodes on the basis that a node's lft value will always appear between its parent's lft and rgt values:
        $query = sprintf(
            "
            SELECT
                node.name
            FROM
                nested_category AS node,
                nested_category AS parent
            WHERE
                node.lft
                    BETWEEN parent.lft AND parent.rgt
            AND
                parent.category_id = %d
            AND
                parent.host = %d
            ORDER BY
                node.lft;
            ",
            (int)$id,
            (int)$this -> _host -> getHost()
        );
        return $query;
    }
    
    /**
    * @desc Finding all the Leaf Nodes
    */
    public function leafNodes()
    {
        // Finding all leaf nodes in the nested set model even simpler than the LEFT JOIN method used in the adjacency list model. If you look at the nested_category table, you may notice that the lft and rgt values for leaf nodes are consecutive numbers. To find the leaf nodes, we look for nodes where rgt = lft + 1:
        $query = sprintf(
            "
            SELECT 
                name
            FROM
                nested_category
            WHERE
                rgt = lft + 1
            AND
                `host` = %d
            ",
            (int)$this -> _host -> getHost()
        );
        return $query;
    }
    
    /**
    * @desc Retrieving a Single Path
    */
    public function getPath($id)
    {
        $query = sprintf(
            "
            SELECT
                parent.name
            FROM
                nested_category AS node,
            nested_category AS parent
            WHERE
                node.lft
                BETWEEN
                    parent.lft AND parent.rgt
            AND
                node.category_id = %d
            AND
                parent.host = %d
            ORDER BY
                node.lft; 
            ",
            (int)$id,
            (int)$this -> _host -> getHost()
        );
        return $query;
    }
    /**
    * @desc Finding the Depth of the Nodes
    */
    public function getDepths()
    {
        // This can be done by adding a COUNT function and a GROUP BY clause to our existing query for showing the entire tree
        $query = sprintf(
            "
           SELECT
                node.name,
                (COUNT(parent.name) - 1)
           AS depth
           FROM
                nested_category AS node,
           nested_category AS parent
           WHERE
                node.lft
                BETWEEN parent.lft AND parent.rgt
           AND
                node.host = %d
           GROUP BY node.name
           ORDER BY node.lft;
            ",
            (int)$this -> _host -> getHost()
        );
        return $query;
    }
    
    /**
    * @desc Depth of a Sub-Tree
    */
     public function subTreeDepth($id)
    {
        // we add a third self-join, along with a sub-query to determine the depth that will be the new starting point for our sub-tree:
        $query = sprintf(
            "
           SELECT node.name, (COUNT(parent.name) - (sub_tree.depth + 1)) AS depth
            FROM nested_category AS node,
                nested_category AS parent,
                nested_category AS sub_parent,
                (
                    SELECT node.category_id, node.name, (COUNT(parent.name) - 1) AS depth
                    FROM nested_category AS node,
                    nested_category AS parent
                    WHERE node.lft BETWEEN parent.lft AND parent.rgt
                    AND node.category_id = %d
                    AND node.host = %d
                    GROUP BY node.name
                    ORDER BY node.lft
                )AS sub_tree
            WHERE node.lft BETWEEN parent.lft AND parent.rgt
                AND node.lft BETWEEN sub_parent.lft AND sub_parent.rgt
                AND sub_parent.category_id = sub_tree.category_id
            GROUP BY node.name
            ORDER BY node.lft;
            ",
            (int)$id,
            (int)$this -> _host -> getHost()
        );
        return $query;
    }
    
    /**
    * @desc Find the Immediate Subordinates of a Node
    */
    public function nodeSubordinates($id)
    {
        // show the products of that category, as well as list its immediate sub-categories, but not the entire tree of categories beneath it.
        // For this, we need to show the node and its immediate sub-nodes, but no further down the tree
        $query = sprintf(
            "
           SELECT node.name, (COUNT(parent.name) - (sub_tree.depth + 1)) AS depth
            FROM nested_category AS node,
                nested_category AS parent,
                nested_category AS sub_parent,
                (
                    SELECT node.category_id, node.name, (COUNT(parent.name) - 1) AS depth
                    FROM nested_category AS node,
                    nested_category AS parent
                    WHERE node.lft BETWEEN parent.lft AND parent.rgt
                    AND node.category_id = %d
                    GROUP BY node.name
                    ORDER BY node.lft
                )AS sub_tree
            WHERE node.lft BETWEEN parent.lft AND parent.rgt
                AND node.lft BETWEEN sub_parent.lft AND sub_parent.rgt
                AND sub_parent.category_id = sub_tree.category_id
            GROUP BY node.name
            HAVING depth <= 1
            ORDER BY node.lft;
            ",
            (int)$id,
            (int)$this -> _host -> getHost()
        );
        //If you do not wish to show the parent node, change the HAVING depth <= 1 line to HAVING depth = 1.
        return $query;
    }
    /**
    * @desc Aggregrate functions in a nested set
    */
    public function aggregrateSet()
    {
        // gets the count of items in another table for whole or sub trees
        $query = sprintf(
            "
           SELECT parent.name, COUNT(product.name)
            FROM nested_category AS node ,
            nested_category AS parent,
            product
            WHERE node.lft BETWEEN parent.lft AND parent.rgt
            AND node.category_id = product.category_id
            GROUP BY parent.name
            ORDER BY node.lft;
            "
        );
        //If you do not wish to show the parent node, change the HAVING depth <= 1 line to HAVING depth = 1.
        return $query;
    }
 
 
    
}
 
 
and the aggregate function does use 2 different tables. My SQL representation just doesn't use a "join" keyword.
User avatar
allspiritseve
DevNet Resident
Posts: 1174
Joined: Thu Mar 06, 2008 8:23 am
Location: Ann Arbor, MI (USA)

Re: Tree hierarchy in external table

Post by allspiritseve »

I think I found your source ;)

All I meant about the joins was that I have my relations in a separate table from my items. With your code, I'd have to join the relations table and the items table in order to return actual items, and not just ids.
User avatar
allspiritseve
DevNet Resident
Posts: 1174
Joined: Thu Mar 06, 2008 8:23 am
Location: Ann Arbor, MI (USA)

Re: Tree hierarchy in external table

Post by allspiritseve »

I finally had time to finish my mapper class for hierarchical data. I saw in some framework's docs (codeigniter maybe?) that they have a left, right, and parent column in any table that needs a hierarchy. I thought that was a cool idea, to combine traditional parent/child hierarchies and MPTT, since they each have their own strengths. It takes a little more maintainence when updating, but isn't that the theory behind MPTT? I decided to normalize things a bit so I can add more than one location per item in the future: MPTT is kept in a table called item_positions and parent/child rows are kept in a table called item_relations. I think the resulting queries ended up pretty tame, compared to some of the ones mysql has on their site.

Comments/criticism more than welcome.

Here's my code:

Code: Select all

<?php
 
class NestedSetMapper   {
 
function __construct ($db)  {
    $this->db = $db;
}
    
function add ($childId, $parentId)  {
    
    if ($childId === $parentId) throw new Exception ('cannot add item as parent of itself');
    if (!is_int ($childId)) throw new Exception ('must provide valid child id');
    if (!is_int ($parentId)) throw new Exception ('must provide valid parent id');
    
    list ($parentX, $parentY) = $this->getPosition ($parentId);
    
    $stmt = $this->db->prepare ('UPDATE item_positions SET x = x + 2 WHERE x > :y');
    $stmt->bindValue (':y', $parentY);
    $stmt->execute();
    
    $stmt = $this->db->prepare ('UPDATE item_positions SET y = y + 2 WHERE y >= :y');
    $stmt->bindValue (':y', $parentY);
    $stmt->execute();   
    
    $stmt = $this->db->prepare ('INSERT INTO item_positions SET item_id = :child_id, x = :x, y = :y');
    $stmt->bindValue (':child_id', $childId);
    $stmt->bindValue (':x', $parentY);
    $stmt->bindValue (':y', $parentY + 1);
    $stmt->execute();
    
    $stmt = $this->db->prepare ('INSERT INTO item_relations SET parent_id = :parent_id, child_id = :child_id');
    $stmt->bindValue (':parent_id', $parentId);
    $stmt->bindValue (':child_id', $childId);
    $stmt->execute();
    
}
 
function remove ($childId)  {
 
    if (!is_int ($childId)) throw new Exception ('must provide valid child id');
    
    $children = array_merge ($this->getChildren ($childId), $this->getDescendants ($childId));
    
    if (!empty ($children)) throw new Exception ('Must delete children first');
 
    list ($childX, $childY) = $this->getPosition ($childId);    
    
    $stmt = $this->db->prepare ('DELETE FROM item_positions WHERE item_id = :item_id');
    $stmt->bindValue (':item_id', $childId);
    $stmt->execute();
    
    $stmt = $this->db->prepare ('DELETE FROM item_relations WHERE child_id = :child_id');
    $stmt->bindValue (':child_id', $childId);
    $stmt->execute();
    
    $stmt = $this->db->prepare ('UPDATE item_positions SET x = x - :width WHERE x > :y');
    $stmt->bindVAlue (':width', $childY - $childX);
    $stmt->bindValue (':y', $childY);
    $stmt->execute();
    
    $stmt = $this->db->prepare ('UPDATE item_positions SET y = y - :width WHERE y > :y');
    $stmt->bindVAlue (':width', $childY - $childX);
    $stmt->bindValue (':y', $childY);
    $stmt->execute();
}
 
function getAncestors ($id) {
    
    $stmt = $this->db->prepare ('SELECT ancestors.item_id FROM item_positions AS descendants INNER JOIN item_positions as ancestors WHERE descendants.x > ancestors.x AND descendants.y < ancestors.y AND descendants.item_id = :item_id');
    $stmt->bindValue (':item_id', $id);
    $stmt->execute();
    
    return $stmt->fetchAll (PDO::FETCH_COLUMN);
    
}
 
function getChildren ($id)  {
    
    $stmt = $this->db->prepare ('SELECT child_id FROM item_relations WHERE parent_id = :parent_id');
    $stmt->bindValue (':parent_id', $id);
    $stmt->execute();
    
    return $stmt->fetchAll (PDO::FETCH_COLUMN);
    
}
 
function getDescendants ($id)   {
    
    list ($ancestorX, $ancestorY) = $this->getPosition ($id);
    
    $stmt = $this->db->prepare ('SELECT descendants.item_id FROM item_positions AS descendants WHERE descendants.x > :ancestor_x AND descendants.y < :ancestor_y');
    $stmt->bindValue (':ancestor_x', $ancestorX);
    $stmt->bindValue (':ancestor_y', $ancestorY);
    $stmt->execute();
    
    return $stmt->fetchAll (PDO::FETCH_COLUMN);
    
}
 
function getParent ($id)    {
    
    $stmt = $this->db->prepare ('SELECT parent_id FROM item_relations WHERE child_id = :child_id');
    $stmt->bindValue (':child_id', $id);
    $stmt->execute();
    
    return $stmt->fetchColumn();
    
}
 
function getPosition ($id)  {
    
    $stmt = $this->db->prepare ('SELECT x, y FROM item_positions WHERE item_id = :item_id');
    $stmt->bindValue (':item_id', $id);
    $stmt->execute();
    $position = $stmt->fetch (PDO::FETCH_ASSOC);
    
    if ($position['x'] == null || $position['y'] == null) throw new Exception ('Item with id ' . $id . ' has no position');
    
    return array ($position['x'], $position['y']);
    
}
 
function isChildOf ($childId, $parentId)    {
 
    $stmt = $this->db->prepare ('SELECT * FROM item_relations WHERE parent_id = :parent_id AND child_id = :child_id');
    $stmt->bindValue (':parent_id', $parentId);
    $stmt->bindValue (':child_id', $childId);
    $stmt->execute();
    
    return $stmt->fetch()?true:false;
    
}
 
function isDescendantOf ($descendantId, $ancestorId)    {
    
    list ($ancestorX, $ancestorY) = $this->getPosition ($ancestorId);
    
    $stmt = $this->db->prepare ('SELECT * FROM item_positions AS ancestors INNER JOIN item_positions AS descendants WHERE descendants.x BETWEEN :ancestor_x AND :ancestor_y AND descendants.item_id = :item_id');
    $stmt->bindValue (':ancestor_x', $ancestorX);
    $stmt->bindValue (':ancestor_y', $ancestorY);
    $stmt->bindValue (':item_id', $descendantId);
    $stmt->execute();
    
    return $stmt->fetch()?true:false;
    
}
 
}
And here's it's test case:

Code: Select all

<?php
 
require_once ('../classes/NestedSetMapper.php');
require_once ('../classes/NestedSet.php');
 
class TestOfNestedSetMapper extends UnitTestCase    {
 
function __construct() {
    $this->UnitTestCase();
    }   
    
function setUp()    {
    $this->db = new LoggedPDO (new PDO ('mysql:host=localhost;dbname=icebox_dev', 'root', 'password'));
    $this->db->setAttribute (PDO::ATTR_EMULATE_PREPARES, true);
    $this->db->setAttribute (PDO::ERRMODE_EXCEPTION, true);
    $this->tearDown();
    $this->db->exec ('INSERT INTO item_positions SET x = 1, y = 2, item_id = 1');
    $this->mapper = new NestedSetMapper ($this->db);
    $this->mapper->add (2, 1);
    $this->mapper->add (3, 1);
    $this->mapper->add (4, 2);
}
 
function tearDown() {
    $this->db->exec ('TRUNCATE TABLE `item_positions`');
    $this->db->exec ('TRUNCATE TABLE `item_relations`');
}
 
function testGetItemPositions() {
    $this->assertEqual ($this->mapper->getPosition (1), array (1, 8));
    $this->assertEqual ($this->mapper->getPosition (2), array (2, 5));
}
 
function testRemoveItemsWithChildrenThrowsException()   {
    $this->expectException ('Exception');
    $this->mapper->remove (2);
}
 
function testRemoveItemsWithoutChildren()   {
    $this->assertEqual ($this->mapper->getDescendants (1), array (2, 3, 4));
    $this->mapper->remove (4);
    $this->assertEqual ($this->mapper->getDescendants (1), array (2, 3));
}
 
function testAddItemAsParentOfItselfReturnsException()  {
    $this->expectException ('Exception');
    $this->mapper->add (1,1);
}
 
function testGetParent()    {
    $this->assertEqual ($this->mapper->getParent (2), 1);
}
 
function testGetChildren()  {
    $this->assertEqual ($this->mapper->getChildren (1), array (2, 3));
}
 
function testIsChildOf()    {
    $this->assertTrue ($this->mapper->isChildOf (2, 1));
    $this->assertFalse ($this->mapper->isChildOf (1, 2));
}
 
function testIsDescendantOf()   {
    $this->assertTrue ($this->mapper->isDescendantOf (3, 1));
    $this->assertFalse ($this->mapper->isDescendantOf (1, 3));
}
 
function testGetDescendants()   {
    $this->assertEqual ($this->mapper->getDescendants (1), array (2, 3, 4));
}
 
function testGetAncestors() {
 
    $this->assertEqual ($this->mapper->getAncestors (4), array (1, 2));
}
 
}
josh
DevNet Master
Posts: 4872
Joined: Wed Feb 11, 2004 3:23 pm
Location: Palm beach, Florida

Re: Tree hierarchy in external table

Post by josh »

I like doctrine's implementation ( http://www.doctrine-project.org/documen ... hical-data ), they include lft, right, and level fields. The level field tells you how many parents sit between the current node and it's root node so you can calculate how to display the data ( for instance indent things per their level ). Storing both lft, rgt, and the adjacency list model ( the parent_id system ) seems redundant and pointless to me.
User avatar
allspiritseve
DevNet Resident
Posts: 1174
Joined: Thu Mar 06, 2008 8:23 am
Location: Ann Arbor, MI (USA)

Re: Tree hierarchy in external table

Post by allspiritseve »

jshpro2 wrote:Storing both lft, rgt, and the adjacency list model ( the parent_id system ) seems redundant and pointless to me.
Well, for example, here's mysql's query for retrieving "immediate subordinates of a node":

Code: Select all

SELECT node.name, (COUNT(parent.name) - (sub_tree.depth + 1)) AS depth
FROM nested_category AS node,
    nested_category AS parent,
    nested_category AS sub_parent,
    (
        SELECT node.name, (COUNT(parent.name) - 1) AS depth
        FROM nested_category AS node,
        nested_category AS parent
        WHERE node.lft BETWEEN parent.lft AND parent.rgt
        AND node.name = 'PORTABLE ELECTRONICS'
        GROUP BY node.name
        ORDER BY node.lft
    )AS sub_tree
WHERE node.lft BETWEEN parent.lft AND parent.rgt
    AND node.lft BETWEEN sub_parent.lft AND sub_parent.rgt
    AND sub_parent.name = sub_tree.name
GROUP BY node.name
HAVING depth <= 1
ORDER BY node.lft;
 
Here's mine:

Code: Select all

SELECT child_id FROM item_relations WHERE parent_id = :parent_id
Now, your/Doctrine's solution might fall somewhere in between those two, but I don't see much difference between storing the level and storing the parent_id, at least not compared to the difference between MPTT and the adjacency list.
josh
DevNet Master
Posts: 4872
Joined: Wed Feb 11, 2004 3:23 pm
Location: Palm beach, Florida

Re: Tree hierarchy in external table

Post by josh »

I'd rather have the first query and a normalized database, that's just me. If performance is of concern it makes sense, but I argue thats what a cache is for. I say normalized because you are duplicating data which allows for inconsistencies. Just saying its not as "pure", plus in practice your queries aren't going to be limited to immediate subordinates. You then get into a possibility where its harder to spot code duplication ( because you're mixing two tree models )
User avatar
allspiritseve
DevNet Resident
Posts: 1174
Joined: Thu Mar 06, 2008 8:23 am
Location: Ann Arbor, MI (USA)

Re: Tree hierarchy in external table

Post by allspiritseve »

jshpro2 wrote:I'd rather have the first query and a normalized database, that's just me. If performance is of concern it makes sense, but I argue thats what a cache is for. I say normalized because you are duplicating data which allows for inconsistencies. Just saying its not as "pure", plus in practice your queries aren't going to be limited to immediate subordinates. You then get into a possibility where its harder to spot code duplication ( because you're mixing two tree models )
Aren't your levels duplicating code as well? They can be calculated using MPTT. It's ok to have a little denormalization in the database, if it makes things simpler. As I showed above, it definitely does that.

Sure, my queries aren't going to be limited to immediate subordinates, but they aren't limited to getting whole trees either. The way I look at it, I now I have two methods of traversing the tree at my disposal, and they don't have to be used separately.

Also: considering I have full test coverage, should I ever have data inconsistencies, I can just modify the tests and refactor. That's the beauty of TDD. Not to mention I'd feel much safer having the adjacency list as a backup, should anything go wrong with my tree model. Problems in one section of the tree won't affect the rest of the tree, unlike MPTT and levels, which require all rows to be updated when a single row is modified.

All in all, I see a lot of benefits with very little drawbacks. Does anyone else have an opinion?
josh
DevNet Master
Posts: 4872
Joined: Wed Feb 11, 2004 3:23 pm
Location: Palm beach, Florida

Re: Tree hierarchy in external table

Post by josh »

I agree that its not as clean to store the `level` in the db, I guess like you said they chose to de-normalize to increase to increase functionality. What worries me about code duplication in your method vs just using the level is youre using 2 different methods to traverse, where as the level is just an indicator of depth used in the view, I wouldn't depend on that level field for actual tree traversal ( finding parents, children ). Both the level and child_id could by hydrated into your models during traversal.

Storing the `level` in doctrine probably does make doctrine itself more prone to code duplication then if they didn't, and yeah you're right both methods are storing duplicate data. I don't mean to be a doctrine zealot I was just using it as an example. I'm just careful of doing programming "tricks" to increase performance, its arguable whether you'd consider your method a trick or not. Doctrines way of doing would be a "trick" as well and preferably they would hydrate that level at runtime. The advantage of the child_id is you optimize queries for nodes of depth N +/- 1, I'm not even sure if doctrine uses that level field during traversal.

The issue I see with having your view or models see the child_id is you're passing an int that represents another node, instead of passing an actual node object. It's easier to get yourself into a situation where behavior isn't being handled by the object that should be responsible for that behavior, used properly though the child_id could be beneficial. The level field ( as far as I know ) isn't being used for business logic in the actual traversal process, and certainly not in my models

I'd be interested in seeing if some of the more seasoned pros agree/disagree with me as well
User avatar
allspiritseve
DevNet Resident
Posts: 1174
Joined: Thu Mar 06, 2008 8:23 am
Location: Ann Arbor, MI (USA)

Re: Tree hierarchy in external table

Post by allspiritseve »

jshpro2 wrote: I'm just careful of doing programming "tricks" to increase performance, its arguable whether you'd consider your method a trick or not.
Well, I'm not worried about performance... I like the simplicity of adjacency queries, and simpler queries lead to less mistakes, in my experience.
jshpro2 wrote:The issue I see with having your view or models see the child_id is you're passing an int that represents another node, instead of passing an actual node object. It's easier to get yourself into a situation where behavior isn't being handled by the object that should be responsible for that behavior, used properly though the child_id could be beneficial. The level field ( as far as I know ) isn't being used for business logic in the actual traversal process, and certainly not in my models
The child_ids can easily be translated into object relations. My models and views don't really need to know about what specific tree model I'm using, as long as they get the right data. My method just makes it easier for the mappers to select that data behind the scenes.
jshpro2 wrote:I'd be interested in seeing if some of the more seasoned pros agree/disagree with me as well
I would be interested to hear others' opinions as well.
Post Reply