New NestedHash module needs home

"Byrd, Brendan" <[email protected]>
Newsgroups gmane.comp.lang.perl.modules.dbi.sybase.devel
Message-ID <[email protected]>
I currently have a working and tested model for a "nested hash to table" conversion.  This will essentially fill the gap for DBD::AnyData to be able to convert nested array/hash objects, instead of merely a single table inside of an array.  It works by a recursive sub that looks through the tree, using hash keys for column/table names, and arrays as grouping tables.  The result is a flatten version of the tree, close to 4NF in most cases, with a naming scheme that should match the subject matter.

This flattened hash is then added into a DBI object, using multiple DBD::AnyData import calls.  This would work for JSON objects, complex XML trees (if compiled into a Perl object), Moose classes, or any Perl object with a "sane" structure.  I have attached a standalone example of the code in action.

My dilemma is: Where exactly should this kind of module go in the CPAN namespace?  There's a number of potential choices that I see:

AnyData::Format::NestedHash - This would provide functionality for non-DBI structures, as well as tie into DBD::AnyData as a new data type.  The problem is that AnyData's entire model is designed around single tables, not multiple tables.  Tying this module into AnyData would require some re-writes or special exceptions in place to be able to look at multiple tables for one object.

DBD::AnyData::Format::NestedHash - Since DBD::AnyData is a bit more familiar with multiple tables, maybe a new section could be created here?  However, DBD::AnyData's namespace is actually rather small, as it uses AnyData for most of its conversions.  Furthermore, the functionality within the dbi:AnyData subs are all tied to single tables again, including the DBI interactions.

DBD::NestedHash - This could also be its own Perl module within CPAN.  However, the hash to table conversion is such a thin wrapper around DBD::AnyData that it just seems to make more sense to actually tie it into that module somehow, so that developers can benefit from the integration.

Those are the pros/cons I see.  Since you guys are the developers for the namespaces mentioned, I figured I should leave the question to you.  Are there any new development directions for any of these modules that would provide a better fit for this one?  Or is a standalone module truly the best route?

--
Brendan Byrd <[email protected]<mailto:[email protected]>>
System Integration Analyst (NOC Web Developer)
json.pl (application/octet-stream, 14.6 KB)
#!/usr/bin/perl

use v5.10;
use LWP::Simple;
use Data::Dumper;
use JSON::Any;
use List::MoreUtils qw(none any uniq firstidx indexes);
use Scalar::Util qw(reftype);
use Lingua::EN::Inflect::Phrase qw(to_PL);
use DBI;

use subs qw(newobj dbiobj process_hash_tree foundin notin col2word print_debug);

#my $debug = 1;

my $json = get 'http://maps.googleapis.com/maps/api/directions/json?origin=Adelaide,SA&destination=Adelaide,SA&waypoints=optimize:true|Barossa+Valley,SA|Clare,SA|Connawarra,SA|McLaren+Vale,SA&sensor=false';
my $obj = JSON::Any->jsonToObj($json);
#my $tbl = newobj 'json', $obj;
#say Data::Dumper->new([$tbl], ['*tbl'])->Indent(2)->Dump;
my $dbh = dbiobj 'json', $obj;

my $cmd = 'SELECT steps_id, html_instructions, points, travel_mode FROM json_steps JOIN json_polylines USING (polyline_id)';
my $rows = $dbh->selectall_hashref($cmd, 'steps_id');
say Data::Dumper->new([$rows], ['*rows'])->Indent(2)->Sortkeys(1)->Dump;

sub newobj ($$) {
   my ($table_name, $tree) = @_;
   &process_hash_tree($table_name, $tree, 0);
   ### TODO: Normalize single row tables to other tables? ###
   ### TODO: Rename 'jsons' to 'json' ###

   my $tref = $tables;
   $tables = undef;  # remove only the first reference
   undef $columns;
   undef $ids;

   return $tref;
}

sub dbiobj ($$) {
   my ($table_name, $tree) = @_;
   my $tref = &newobj($table_name, $tree);

   my $dbh = DBI->connect('dbi:AnyData(RaiseError=>1):');
   foreach my $table (keys %$tref) {
      $dbh->func("json_$table", 'ARRAY', [$tref->{$table}{columns}, @{$tref->{$table}{data}}], 'ad_import');
   }

   return $dbh;
}

our $tables, $columns, $ids;

sub process_hash_tree {
   my ($col, $tree, $depth) = @_;
   my ($col_id, $serialized_tree);

   if ($depth > 100) {
      warn "Too deep down the rabbit hole; crawling back...\n";
      return $col => undef;
   }

   print_debug($depth, "$depth - $col => ".(reftype($tree) || substr($tree, 0, 20)));

   state $id_names = ['group', 'matrix', 'cube', 'hypercube'];  # if you go past here, you've gone too far...

   given (reftype $tree) {
      # Common code for both HASHs and ARRAYs
      when (/HASH|ARRAY/) {
         $col    = col2word($col);
         $col_id = $col.'_id';

         # compare serialized trees for the same IDs
         unless ($depth) {  # no point if this is the first node
            $serialized_tree = Data::Dumper->new([$tree], ['*'.$col_id])->
               # (options for consistency, for exact matches)
               Reset->Sortkeys(1)->
               # (options designed to use the smallest possible footprint, as these can get rather large)
               Indent(0)->Quotekeys(0)->Pair('=')->Dump;

            # already exists, makes this easier
            my $id = $ids->{trees}{$serialized_tree};
            if ($id) {
               print_debug($depth+1, "TREE <=== Dumper match ID ".join(' => ', split('|', $id)));
               return split('|', $id);
            }
         }
         continue;
      }
      # HASHs control the main tables, providing column names and data for the rows
      # Table = $col (plural)
      # ID = $col.'_id'
      when ('HASH') {
         # parse out a table name (with plural form)
         my $table_name = to_PL($col);
         $table_name =~ s/ /_/g;

         # now run through the columns and data (with recursive loop goodness)
         my %data = map {
            my $dat = $$tree{$_};
            # clean up the column names
            $_ = col2word($_);
            s/ /_/g;
            &process_hash_tree($_ => $dat, $depth + 1);
         } keys %$tree;
         ### FIXME: don't forget about undef in keys ###

         # check the new column names to see if we've seen this table before
         my @cols = ($col_id, sort keys %data);  # new ID column first
         my $col_key = join('|', @cols);

         if ($columns->{$col_key}) {               # known table
            $table_name = $columns->{$col_key};

            print_debug($depth+1, "HASH ===> Found known table '$table_name'");
         }
         elsif ($tables->{$table_name}) {  # errr, known table, but not with this column structure
            foreach my $j ('', 2 .. 200) {  # loop through a bunch of potential table names
               my $tname = $table_name.$j;
               my $t     = $tables->{$tname};

               if ($t) {
                  my @tcols = @{$t->{columns}};
                  my @ucols = uniq(@cols, @tcols);
                  # have to be the same ID columns  && need to have at least one field in common
                  # (remove keys while we're at it)
                  if (shift(@tcols) eq shift(@cols) && uniq(@cols, @tcols) < (@tcols + @cols)) {
                     # add all items not in the current column list
                     # (since they are on the end, there is no need to shift data around)
                     push(@{$t->{columns}}, notin(@tcols, @cols));

                     print_debug($depth+1, "HASH ===> Found known table with different columns '$table_name'");
                     last;
                  }

                  # wrong table to use; try next name
                  next;
               }
               else {  # just treat this as as new table, then
                  warn "Found a table with a dupe name, but totally different columns; calling it '$tname'...\n" if ($j);
                  $table_name = $tname;
                  $tables->{$table_name} = {};
                  $tables->{$table_name}->{columns} = [@cols];
                  $tables->{$table_name}->{data}    = [];
                  $columns->{$col_key} = $table_name;

                  print_debug($depth+1, "HASH ===> Creating new table '$table_name' because of conflicting columns");
                  last;
               }
            }

            $col_key = join('|', @{$t->{columns}});
         }
         else {                                    # new table
            $tables->{$table_name} = {};
            $tables->{$table_name}->{columns} = [@cols];
            $tables->{$table_name}->{data}    = [];
            $columns->{$col_key} = $table_name;

            print_debug($depth+1, "HASH ===> Creating new table '$table_name'");
         }

         # Add new row
         my $t = $tables->{$table_name};
         my $id = ++($ids->{table}{$table_name});
         $ids->{trees}{$serialized_tree} = $col_id.'|'.$id;
         push(@{$t->{data}}, [ $id, map { $data{$_} } grep { $_ ne $col_id } @{$t->{columns}} ]);

         # Since we're done with this table, send back the col_id and id#
         print_debug($depth+1, "HASH <=== $col_id => $id");
         return $col_id => $id;
      }
      # ARRAYs provide ID grouping tables, capturing the individual rows in a group
      # These are going to be two-column tables with two different IDs
      # Table = $col.'_groups' (plural)
      # ID = $col.(group|matrix|cube|etc.).'_id'
      when ('ARRAY') {
         # Pass the data on down first (ARRAY of ARRAYS to prevent de-duplication of keys)
         my @data = map { $_ = [ &process_hash_tree($col => $_, $depth + 1) ] } @$tree;

         # Okay, we could end up with several different scenarios:

         # A. All items have the same column name (as a ID)
         # B. All items appear to be some form of data
         # C. A mixture of IDs and data (scary!)

         # Process both groups individually (and hope for the best)
         ### TODO: Trouble shoot this in-depth (possible Perl bug): my @id_cols = indexes { $_->[0] =~ /_id$/; say $_->[0]." -----"; } @data;
         my @id_cols   = grep { $data[$_]->[0] =~ /_id$/; } (0 .. (@data - 1));
         my @data_cols = grep { $data[$_]->[0] !~ /_id$/; } (0 .. (@data - 1));
         @id_cols = () unless ($depth);  # skip any group ID tables if this is the very first node

         warn "Inconsistant sets of data within an array near '$col'; going to process it as best as possible...\n" if (@id_cols && @data_cols);

         # Items of IDs
         my (@max_id, @group_id);
         foreach my $i (@id_cols, @data_cols) {
            my ($icol, $item) = @{$data[$i]};

            # Process group ID names
            my $ncol = $icol;
            my $i;
            if ($ncol =~ s/_id$//i) {
               $i = firstidx { $ncol =~ s/(?<=_)$_$//; } @$id_names;  # that's underscore + $_ + EOL
               if (++$i > 3) {   # start whining here
                  $ncol .= '_hypercube_'.$id_names->[$i -= 4];

                  warn "Seriously?!  We're using ridiculous names like '$ncol"."_id' at this point...\n";
               }
               else { $ncol .= '_'.$id_names->[$i]; }
            }

            # Parse out a table name (with plural form)
            my $table_name = ($icol =~ /_id$/) ? col2word($ncol) : col2word($icol.'_group');
            $table_name = to_PL($table_name);  # like blah_groups
            $table_name =~ s/ /_/g;

            if ($icol =~ /_id$/) {
               $ncol .= '_id';
               $max_id[$i] = $ncol;
            }

            # Create new group table (if it doesn't already exist)
            my $t;
            if ($depth) {  # skip any group ID tables if this is the very first node
               unless ($tables->{$table_name}) {
                  ### FIXME: Assuming that table doesn't exist with the same columns ###
                  print_debug($depth+1, "ARRAY ===> Creating new group table '$table_name'");

                  $tables->{$table_name} = {};
                  $tables->{$table_name}->{columns} = [ ($icol =~ /_id$/) ? ($ncol, $icol) : ($icol.'_group_id', $icol.'_id') ];
                  $tables->{$table_name}->{data}  ||= [];
               }
               $t = $tables->{$table_name};
               my $col_key = join('|', @{$t->{columns}});
               $columns->{$col_key} = $table_name;
            }

            # Add new row
            $group_id[$i] ||= ++($ids->{table}{$table_name});  # only increment once (per group type)
            if ($icol =~ /_id$/) {
               push(@{$t->{data}}, [ $group_id[$i], $item ]);
            }
            # If this is a data column, add the data in a different table
            else {
               my $new_tname = col2word($icol);
               $new_tname = to_PL($new_tname);  # like blahs
               $new_tname =~ s/ /_/g;

               # Create new id table (if it doesn't already exist)
               unless ($tables->{$new_tname}) {
                  print_debug($depth+1, "ARRAY ===> Creating new ID table '$new_tname'");

                  $tables->{$new_tname} = {};
                  $tables->{$new_tname}->{columns} = [ $icol.'_id', $icol ];
                  $tables->{$new_tname}->{data}  ||= [];
               }
               my $n = $tables->{$new_tname};
               $n->{columns} = [ $icol.'_id', $icol ];
               $n->{data}    = [] unless ($n->{data});

               my $col_key = join('|', @{$n->{columns}});
               $columns->{$col_key} = $new_tname;
               $max_id[$i] = $icol.'_id';
               ### FIXME: Assuming that table doesn't exist with the same columns ###

               # First, check serial tree with single value
               my $stree = Data::Dumper->new([$item], ['*'.$icol.'_id'])->Reset->Indent(0)->Dump;
               if ($ids->{trees}{$stree}) {
                  # Add new group row (with proper col_id)
                  my $id = (split('|', $ids->{trees}{$stree}))[1];
                  push(@{$t->{data}}, [ $group_id[$i], $id ] ) if ($depth);

                  # (no need to add into main table; already exists)
               }
               else {
                  # Add new group row (with proper col_id)
                  my $id = ++($ids->{table}{$new_tname});
                  push(@{$t->{data}}, [ $group_id[$i], $id ]) if ($depth);

                  # Add new id row
                  $ids->{trees}{$stree} = $icol.'_id|'.$id;
                  push(@{$n->{data}}, [ $id, $item ]);
               }
            }
         }

         # Pass back an ID
         my ($gid_col, $gid) = (pop(@max_id) || $col, pop(@group_id));  # undef @max_id might happen with an empty array

         print_debug($depth+1, "ARRAY <=== $gid_col => $gid");
         $ids->{trees}{$serialized_tree} = $gid_col.'|'.$gid;
         return $gid_col => $gid;
      }
      # An actual scalar; return back the proper column name and data
      when ('' || undef) {
         return $col => $tree;
      }
      # De-reference
      when (/SCALAR|VSTRING/) {
         return $col => $$tree;
      }
      # Warn and de-reference
      when (/Regexp|LVALUE/i) {
         warn "Found a ".(reftype $tree)."; just going to treat this like a SCALAR...\n";
         return $col => $$tree;
      }
      # Warn and de-reference (for further examination)
      when ('REF') {
         warn "Found a REF; going to dive in the rabbit hole...\n";
         return &process_hash_tree($col => $$tree, $depth + 1);
      }
      # Warn and de-reference (for further examination)
      when ('GLOB') {
         foreach my $t qw(Regexp VSTRING IO FORMAT LVALUE GLOB REF CODE HASH ARRAY SCALAR) {  # scalar last, since a ref is still a scalar
            if (defined *$$tree{$t}) {
               warn "Found a GLOB (which turn out to be a $t); going to dive in the rabbit hole...\n";
               return &process_hash_tree($col => *$$tree{$t}, $depth + 1);
            }
         }
         warn "Found a GLOB, but it didn't point to anything...\n";
         return $col => undef;
      }
      # Warn and throw away
      when ('CODE') {
         ### TODO: Warn immediately, eval block with timer to use as output, then continue ###
         ### Definitely need a switch, though ###
         warn "Found a CODE block; not going to even touch this one...\n";
         return $col => undef;
      }
      default {
         warn "Found a ".(reftype $tree)."; WTF is this?  Can't use this at all...\n";
         return $col => undef;
      }
   }

   die "WTF?!  Perl broke my given/when!  Alert the Pumpking!!!";
}

# Find items in @B that are in @A
sub foundin (\@\@) {
   my ($A, $B) = @_;
   return grep { my $i = $_; any { $i eq $_ } @$A; } @$B;
}

# Find items in @B that are not in @A
sub notin (\@\@) {
   my ($A, $B) = @_;
   return grep { my $i = $_; none { $i eq $_ } @$A; } @$B;
}

sub col2word ($) {
   my $word = $_[0];
   $word = lc($word);
   $word =~ s/\W+/ /g;
   $word =~ s/^\s+|\s+(?:id)?$//g;
   return $word;
}

sub print_debug ($$) {
   my ($depth, $msg) = @_;
   return unless ($debug);

   print ("   " x $depth);
   say $msg;
}
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.