Search code examples
phpmysqlsqlmulti-tier

Determining "Number of Users below" in a multi-tier member database


I've programmed a membership site for a client within which members join below other users. e.g.

userid | name  | subof
1      | John  | 0
2      | Joe   | 1
3      | Jill  | 0
4      | Janet | 2
5      | Juan  | 1
6      | George| 2

John and Jill are at the top, Joe and Juan are below John, and Janet and George are below Joe. The tier-ing is used for passing up commission. My client wants to be able to see How many users are below any given user, (at least it's restricted to 8 tiers out)

For now I have added the additional field `num_below` to the user table, and that field is incremented or decremented whenever someone joins or leaves below the user.

The first problem with this is that it feels like it violates good Database Normalization practices~ cuz it's storing data that's already in the DB

The second is that it get's hairy when my client comes and says "Oh, George meant to join under Juan, please move him"

I considered just dynamically calculating the number below every time it's asked for, but the db queries would seem to grow exponentially.

I've written a rectifySubs() function that can go through and fix all of the `num_below` fields, but as there are more members it will become more and more intensive to run~

function rectifySubs(){
    $NumBelow=array();//UID=>NUM_BELOW
    $SubOf=array();//UID=>IS_A_SUB_OF_UID
    $Uids=array();//UID
    $r=mysql_query("SELECT uid,subof FROM user");
    if(!$r || mysql_num_rows($r)==0){return 'Invalid';}
    while(list($uid,$subof)=mysql_fetch_row($r)){
        $NumBelow[$uid]=0;
        $SubOf[$uid]=$subof;
        $Uids[]=$uid;
    }
    mysql_free_result($r);

    $RungsUp=8;
    foreach($Uids as $uid){
        $r=1;
        $parent=$SubOf[$uid];
        while($parent>0 && $r<=$RungsUp){
            $NumBelow[$parent]+=1;
            $parent=$SubOf[$parent];
            $r++;
        }
    }
    $QueryByNum=array();
    foreach($NumBelow as $uid=>$num){
        if(!isset($QueryByNum[$num])){$QueryByNum[$num]=array();}
        $QueryByNum[$num][]=$uid;
    }
    unset($QueryByNum[0]);
    mysql_query("UPDATE user SET below=0");
    foreach($QueryByNum as $num=>$uids){
        $where=$or='';
        foreach($uids as $uid){
            $where.=$or."`uid`=".$uid;
            $or=" OR ";
        }
        mysql_query("UPDATE user SET below=".$num." WHERE ".$where);
    }
}

Any recommendations? I don't want to put too much redundant data in the DB, but going 8 tiers out every time seems way too processor intensive.

-- EDIT --

I wasn't clear enough about how the tiers worked so I made the table bigger. The key issue I'm addressing with the edit is that any person can have multiple people in a tier directly below them. Hope that makes sense.

-- SOLUTION -- (Implementation of Kakao's solution as a method of the 'Member' Class)

protected function getNumBelowAtLevel($i=1,$force=false){
    $i=abs((int)$i);
    if($i<=1){return 0;}//Level 1 is just the member themselves
    if($force || !isset($this->numBelow[$i])){
        $Us='';
        $Sels='';
        $Lefts='';
        $Groups='';
        $comma='';
        $nl='';
        for($k=1;$k<=$i-1;$k++){
            $j=$k==1?'0':$k-1;
            $Us.=$comma.'u'.$k;
            $Sels.=$comma.$nl.'m'.$k.'.mid as u'.$k;
            $Lefts.=$nl.'left join members as m'.$k.' on m'.$k.'.subof = m'.$j.'.mid';
            $Groups.=$comma.'u'.$k;

            $nl="\n\t\t\t\t\t";
            $comma=', ';
        }
        $sql="select count(*) - 1 as users_below
from (
    select distinct {$Us}
        from (
            select 
                {$Sels}
            from members as m0
                {$Lefts}
            where m0.mid = {$this->id}
                group by {$Groups} with rollup
            ) d
    ) a";
        if(DEBUG){var_dump($sql);}
        $r=mysql_query($sql);
        list($this->numBelow[$i])=mysql_fetch_row($r);
    }
    return $this->numBelow[$i];
}

Solution

  • select (case 
       when m1.userid is null then 0
       when m2.userid is null then 1
       when m3.userid is null then 2
       when m4.userid is null then 3
       when m5.userid is null then 4
       when m6.userid is null then 5
       when m7.userid is null then 6
       when m8.userid is null then 7
       else 8 end
       ) as users_below
    
    from members as m0
    left join members as m1 on m1.subof = m0.userid
    left join members as m2 on m2.subof = m1.userid
    left join members as m3 on m3.subof = m2.userid
    left join members as m4 on m4.subof = m3.userid
    left join members as m5 on m5.subof = m4.userid
    left join members as m6 on m6.subof = m5.userid
    left join members as m7 on m7.subof = m6.userid
    left join members as m8 on m8.subof = m7.userid
    
    where m0.userid = 1
    

    Update

    Multiple members below version:

    select count(*) - 1 as users_below
    from (
       select distinct u1, u2, u3, u4, u5, u6, u7
       from (
          select 
             m1.userid as u1, 
             m2.userid as u2, 
             m3.userid as u3,
             m4.userid as u4,
             m5.userid as u5,
             m6.userid as u6,
             m7.userid as u7
    
          from members as m0
          left join members as m1 on m1.subof = m0.userid
          left join members as m2 on m2.subof = m1.userid
          left join members as m3 on m3.subof = m2.userid
          left join members as m4 on m4.subof = m3.userid
          left join members as m5 on m5.subof = m4.userid
          left join members as m6 on m6.subof = m5.userid
          left join members as m7 on m7.subof = m6.userid
    
          where m0.userid = 1
          group by u1, u2, u3, u4, u5, u6, u7 with rollup
       ) d
    ) a