package Class::Inflate; use 5.006; use strict; use warnings; require Exporter; our @ISA = qw(Exporter); our @EXPORT_OK = qw(inflate commit obliterate); our @EXPORT = ('inflate'); # @EXPORT_OK; our $VERSION = '0.05'; $::OBJECT = undef; use Devel::Messenger qw(note); # Preloaded methods go here. sub import { # allows 'use' syntax my ($class, @args) = @_; @args and $class->make(@args); } sub make { # sets up glue to database based on 'use' statement my ($generator_class, @args) = @_; my $target_class = $generator_class->find_target_class; my %persist = (); while (@args) { my ($table, $config) = splice(@args, 0, 2); $persist{$table} = $config; } # $generator_class->_install_method($target_class, $sub_name, $code); foreach my $export (@EXPORT) { my $generate_export = '_' . $export; $generator_class->$generate_export($target_class, \%persist, $export); } } sub find_target_class { # determines where to export generated methods my $class; my $c = 0; while (1) { $class = (caller($c++))[0]; last unless ($class->isa('Class::Inflate') and &{$class->can('ima_generator')}); } return $class; } sub ima_generator { # a subclass may redefine this to return 0, if it wishes # to allow methods added to itself 1; } sub _install_method { # exports method to target class my $generator_class = shift; my $target_class = shift; my $accessor = shift; my $code = shift; no strict 'refs'; unless (defined *{"$target_class\::$accessor"}{CODE}) { note \7, "building accessor '$target_class\::$accessor'\n"; return *{"$target_class\::$accessor"} = $code; } return; } sub _inflate { my $generator_class = shift; my $target_class = shift; my $persist = shift; my $sub_name = shift; $generator_class->_install_method($target_class, $sub_name, sub { my $self = shift; my $class = ref($self) ? ref($self) : $self; my $dbh = shift; my $filter = shift || $self; my @sql = inflation_sql($class, $filter, $persist); my @records = (); # in object format my @data = (); # in table/field format my %rows_fetched = (); my %awaiting_join = (); foreach my $sql (@sql) { my @r = fetchrows($class, $dbh, $sql->{query}, $sql->{bind}); $rows_fetched{$sql->{table}} = \@r; if (exists($persist->{$sql->{table}}{join}) and keys %{$persist->{$sql->{table}}{join}}) { foreach my $table (keys %{$persist->{$sql->{table}}{join}}) { note \3, "want to join $sql->{table} to $table\n"; if (@data and $rows_fetched{$table}) { join_records($class, $persist, $table, \@data, $sql->{table}, \@r); } else { $awaiting_join{$table} ||= []; push @{$awaiting_join{$table}}, $sql->{table}; note \3, " will wait till we have $table data\n"; } } } else { # this is the master/parent table note \3, "populating dataset from $sql->{table}\n"; $rows_fetched{$sql->{table}} = @r; @data = map { { $sql->{table} => [$_] } } splice @r; note \3, "placed " . scalar(@data) . ' of ' . scalar(@r) . " records in dataset\n"; } if (@data and $awaiting_join{$sql->{table}}) { note \3, "now we have $sql->{table} data\n"; foreach my $table (@{$awaiting_join{$sql->{table}}}) { join_records($class, $persist, $sql->{table}, \@data, $table, $rows_fetched{$table}); } } } # TODO remove Dumper #use Data::Dumper; #note "dataset:\n" . Dumper(\@data) . "\n"; my $inflated_object = inflated_object($class, $persist, \@data, [ref($self) ? $self : ()]); while (my $record = $inflated_object->($dbh)) { push @records, $record; } return @records if wantarray; return \@records; }); } sub inflation_sql { my $class = shift; my $filter = shift; my $persist = shift; die "inflate filter must be a HASH\n" unless (UNIVERSAL::isa($filter, 'HASH')); my @sql = (); my $inflate = []; foreach my $table (keys %$persist) { push @$inflate, keys %{$persist->{$table}{methods}}; } my $method_tables = method_tables($class, $persist); my $inflation_fields = inflation_fields($class, $inflate, $persist, $method_tables); my $filter_values = filter_values($class, $filter, $persist, $method_tables); my %join_fields = (); # fields we must select because we will use them to match records # newer code foreach my $table (keys %$inflation_fields) { note \5, "building query for table $table\n"; my $table_filter = add_filter_defaults($class, $filter_values, $persist, $table); my @tables = (); my @fields = (); my @filter = (); my @bind = (); my $alias = ''; my $has_filter = exists($filter_values->{$table}) ? 1 : 0; my $has_external_filter = scalar(keys %$filter_values) > $has_filter; if ($has_external_filter) { # see if we can join to the external tables my $matched = 0; my $need_join = 0; # add current table and fields $alias = 'a'; my %table_alias = ( $table => $alias ); push @tables, $table . ' ' . $table_alias{$table}; push @fields, map { $table_alias{$table} . '.' . $_ } @{$inflation_fields->{$table}}; foreach my $external (keys %$filter_values) { next if ($table eq $external); # add external table $table_alias{$external} = ++$alias; # see if all the fields in the filter join to fields in our table #foreach my $field (@{$filter_values->{$external}{fields}}) { # if (exists($persist->{$table}{join}{$external}) and exists($persist->{$table}{join}{$external}{$field})) { # my $my_field_name = $persist->{$table}{join}{$external}{$field}; # # TODO get value right now? # $matched++; # } else { # $need_join++; # } #} } # add join (field = field) to filter my @aliases = keys %table_alias; foreach my $external (@aliases) { next if ($external eq $table); note \6, "will need to join from $table to $external\n"; my @pkey = exists($persist->{$table}{key}) ? @{$persist->{$table}{key}} : (); # iterate through tables we know how to join to my $next_path = join_path($persist, $table, $external, exists($persist->{$table}{join}) ? keys %{$persist->{$table}{join}} : ()); my @paths = (); my ($path, $join) = run_path_iterator($next_path, $persist->{$table}, 0); push @paths, $path if @$path; # if @path, but not all primary key fields are used in join, check for additional paths and possibly use multiple paths while (@$path and grep { !$join->{$_} } @pkey) { note \6, "checking for additional join paths\n"; ($path, $join) = run_path_iterator($next_path, $persist->{$table}, 0); push @paths, $path if @$path; } my $static_from = $table; if (!@paths and exists($persist->{$external}{join})) { note \6, "checking for reverse join definition\n"; my $reverse_path = join_path($persist, $external, $table, keys %{$persist->{$external}{join}}); ($path, $join) = run_path_iterator($reverse_path, $persist->{$table}, -1); push @paths, $path if @$path; # if @path, but not all primary key fields are used in join, check for additional paths and possibly use multiple paths while (@$path and grep { !$join->{$_} } @pkey) { note \6, "checking for additional join paths\n"; ($path, $join) = run_path_iterator($reverse_path, $persist->{$table}, -1); push @paths, $path if @$path; } $static_from = $external } # TODO check for a common table both ends know how to join to, if there is no direct join path foreach my $path (@paths) { my $from = $static_from; note \6, "path: " . join(' -> ', $from, @$path) . "\n"; foreach my $step (@$path) { # add external table my $t = ($step eq $table) ? $external : $step; $table_alias{$t} = ++$alias unless (exists($table_alias{$t})); push @tables, $t . ' ' . $table_alias{$t} unless (grep { $_ eq ($t . ' ' . $table_alias{$t}) } @tables); $join_fields{$step} ||= []; foreach my $field (keys %{$persist->{$from}{join}{$step}}) { my $join_from = $table_alias{$from} . '.' . $persist->{$from}{join}{$step}{$field}; my $join_to = $table_alias{$step} . '.' . $field; my $seen = 0; foreach my $filter_item (@filter) { if ($filter_item eq ($join_from . ' = ' . $join_to) or $filter_item eq ($join_to . ' = ' . $join_from)) { $seen = 1; last; } } if ($seen) { note \6, ' skipping ' . $from . '.' . $persist->{$from}{join}{$step}{$field} . ' = ' . $step . '.' . $field . "\n"; next; } note \6, ' joining ' . $from . '.' . $persist->{$from}{join}{$step}{$field} . ' = ' . $step . '.' . $field . "\n"; push @filter, $table_alias{$from} . '.' . $persist->{$from}{join}{$step}{$field} . ' = ' . $table_alias{$step} . '.' . $field; push @{$join_fields{$from}}, $persist->{$from}{join}{$step}{$field}; push @{$join_fields{$step}}, $field; } $from = $step; } } unless (@paths) { note \6, "no path from $table to $external found\n"; delete $table_alias{$external}; } } foreach my $external (keys %table_alias) { # add external table conditions to filter if (exists($filter_values->{$external})) { my $next_filter = expand_bind(sub { $table_alias{$external} . '.' . shift }, $filter_values->{$external}{fields}, $filter_values->{$external}{values}); while (my ($f, $b) = $next_filter->()) { push @filter, $f; push @bind, @$b; } } } #if ($need_join) { # # TODO join in SQL, select from both, remove other single table SQL #} else { # # TODO build SQL using our fieldnames for filter parameters #} } else { # build SQL statement for this table - no joins necessary push @tables, $table; push @fields, @{$inflation_fields->{$table}}; if (exists($filter_values->{$table})) { my $next_filter = expand_bind(sub { shift }, $filter_values->{$table}{fields}, $filter_values->{$table}{values}); while (my ($f, $b) = $next_filter->()) { push @filter, $f; push @bind, @$b; } } } if (keys %$table_filter) { my $prefix = $alias ? 'a.' : ''; my $next_filter = expand_bind(sub { $prefix . shift }, $table_filter->{fields}, $table_filter->{values}); while (my ($f, $b) = $next_filter->()) { push @filter, $f; push @bind, @$b; } } next unless @filter; push @sql, { 'bind' => \@bind, 'table' => $table, 'tables' => \@tables, 'fields' => \@fields, 'filter' => \@filter, }; } foreach my $sql (@sql) { my ($table, $alias) = split(/\s+/, $sql->{tables}[0]); $alias .= $alias ? '.' : ''; if (exists($join_fields{$table})) { my %current = map { $_ => 1 } @{$sql->{fields}}; foreach my $field (@{$join_fields{$table}}) { unless (exists($current{$alias.$field})) { push @{$sql->{fields}}, $alias.$field; $current{$alias.$field}++; note \6, "adding $table.$field to selection\n"; } } } my $query = 'SELECT ' . join(', ', @{$sql->{fields}}); $query .= ' FROM ' . join(', ', @{$sql->{tables}}); $query .= ' WHERE ' . join(' AND ', @{$sql->{filter}}); $sql->{query} = $query; note \5, 'built query: ' . $query . " -> " . join(', ', map { defined($_) ? $_ : '' } @{$sql->{bind}}) . "\n"; } return @sql if wantarray; return \@sql; } sub expand_bind { # returns an iterator which returns a filter "$field = ?" and bind value my $transform = shift; my $fields = shift; my $values = shift; my $c = 0; return sub { return if ($c >= @$fields); my $field = $transform->($fields->[$c]); my $value = $values->[$c++]; my $operator = '='; my $placeholder = '?'; if (UNIVERSAL::isa($value, 'ARRAY')) { if (@$value == 0) { push @$value, undef; } elsif (@$value > 1) { $operator = 'IN'; $placeholder = '(' . join(', ', map { '?' } @$value) . ')'; } } else { $value = [$value]; } return ($field . ' ' . $operator . ' ' . $placeholder, $value); } } sub join_path { # determine possible paths to join two tables together my $persist = shift; my $launch = shift; my $target = shift; my @iterators = (); my $spacing = ''; note \7, "creating iterator from $launch to $target\n"; push @iterators, [member_of($spacing, $target, @_), []]; my $c = 0; return sub { while ($c < @iterators) { my ($element, $match) = $iterators[$c][0]->(); $spacing = ' ' x @{$iterators[$c][1]}; unless (defined($element)) { note \7, $spacing . "iterator $c is exhausted\n"; $c++; next; } if ($match) { note \7, $spacing . "iterator $c found a join path: " . join(' -> ', @{$iterators[$c][1]}, $element) . "\n"; return @{$iterators[$c][1]}, $element; } else { if (grep { /^$element$/ } @{$iterators[$c][1]}) { note \7, $spacing . "search loop detected: " . join(' -> ', @{$iterators[$c][1]}, $element) . "\n"; } else { $spacing .= ' '; note \7, $spacing . "creating iterator from $element to $target\n"; push @iterators, [member_of($spacing, $target, keys %{$persist->{$element}{join}}), [@{$iterators[$c][1]}, $element]]; } } } note \7, "all iterators exhausted\n"; return; }; } sub member_of { # iterator to say if an element matches the target my $spacing = shift || ''; my $target = shift; my @possible = @_; note \7, $spacing . " determining if (" . join('|', @possible) . ") contains: $target\n"; return sub { while (my $element = shift(@possible)) { return ($element, ($element eq $target)); } return; } } sub run_path_iterator { # kicks the iterator to find the next path. Returns the path, and fields it will use for the join my $iterator = shift; my $table_instructions = shift; my $element = shift || 0; # expects 0 for forward, -1 for reverse path my @path = $iterator->(); return (\@path, {}) unless @path; my %join = map { $_ => 1 } (exists($table_instructions->{join}) and exists($table_instructions->{join}{$path[$element]})) ? values %{$table_instructions->{join}{$path[$element]}} : (); return (\@path, \%join); } sub inflation_fields { my $class = shift; my $inflate = shift; my $persist = shift; my $method_tables = shift || method_tables($class, $persist); my %fields = (); foreach my $method (@$inflate) { unless (exists($method_tables->{$method})) { warn "ignoring unknown method '$method' for inflation\n"; next; } my $table = $method_tables->{$method}; next if (exists($fields{$table})); # already did this table my $methods = $persist->{$table}{methods}; my @fields = (); # figure out which fields to select based on method names foreach my $field (values %$methods) { if (ref($field)) { if (ref($field) eq 'HASH' and exists($field->{fields})) { push @fields, ref($field->{fields}) eq 'ARRAY' ? @{$field->{fields}} : $field->{fields}; } } else { push @fields, $field; } } next unless @fields; # select any fields needed for joins if (exists($persist->{$table}{join})) { my %selected = map { $_ => 1 } @fields; foreach my $parent (keys %{$persist->{$table}{join}}) { foreach my $field (values %{$persist->{$table}{join}{$parent}}) { unless (exists($selected{$field})) { push @fields, $field; $selected{$field}++; } } } } note \6, "will select from table $table\n"; note \6, "will select fields " . join(', ', @fields) . "\n"; $fields{$table} = \@fields; } return \%fields; } sub filter_values { # returns the table name, field names and bind values for any method name my $class = shift; my $filter = shift; my $persist = shift; my $method_tables = shift || method_tables($class, $persist); my %values = (); foreach my $method (keys %$filter) { if (UNIVERSAL::can($filter, $method)) { my $value = $filter->$method(); if (!defined($value) or !length($value)) { # undefined values of an object do not count as filter parameters next; } } # TODO skip warning if filter is an object, rather than a HASH unless (exists($method_tables->{$method})) { warn "ignoring unknown filter field '$method'\n"; next; } my $table = $method_tables->{$method}; my $methods = $persist->{$table}{methods}; note \6, "filtering on $method\n"; my $field = $methods->{$method}; my @field = (); my @value = (); local $::OBJECT = $filter; if (ref($field)) { if (ref($field) eq 'HASH' and exists($field->{fields})) { push @field, ref($field->{fields}) eq 'ARRAY' ? @{$field->{fields}} : $field->{fields}; my $deflate = exists($field->{deflate}) ? $field->{deflate} : sub { @_ }; if (UNIVERSAL::can($filter, $method)) { push @value, $deflate->($filter->$method()); } else { push @value, $deflate->($filter->{$method}); } } } else { push @field, $field; if (UNIVERSAL::can($filter, $method)) { push @value, $filter->$method(); } else { push @value, $filter->{$method}; } } unless (@field == @value) { warn "filter for $method specified " . scalar(@field) . " fields, but " . scalar(@value) . " values\n"; } for (my $i = 0; $i < @field; $i++) { note \6, " ($table.$field[$i] = $value[$i])\n"; } $values{$table} ||= { 'fields' => [], 'values' => [] }; push @{$values{$table}{fields}}, @field; push @{$values{$table}{values}}, @value; } return \%values; } sub add_filter_defaults { # add values from table filter hash, for fields which have not been set my $class = shift; my $filter_values = shift; my $persist = shift; my $table = shift; my %new = (); if (exists($persist->{$table}{filter}) and keys(%{$persist->{$table}{filter}})) { note \6, "adding default filter values\n"; my %seen = (); %seen = map { $_ => 1 } @{$filter_values->{$table}{fields}} if (exists($filter_values->{$table})); my $default = $persist->{$table}{filter}; foreach my $field (keys %$default) { unless (exists($seen{$field})) { push @{$new{fields}}, $field; push @{$new{values}}, $default->{$field}; note \6, " ($table.$field = $default->{$field})\n"; } } } return \%new; } sub fetchrows { my $class = shift; my $dbh = shift; my $query = shift; my $bind = shift; my @records = (); if ($dbh and my $sth = $dbh->prepare($query)) { note \2, $sth->{Statement} . ' -> ' . join(', ', map { defined($_) ? $_ : '' } @$bind) . "\n"; if ($sth->execute(@$bind)) { while (my $record = $sth->fetchrow_hashref('NAME_lc')) { push @records, $record; } } } note \2, "fetched " . scalar(@records) . " records\n"; return @records if wantarray; return \@records; } sub join_records { my $class = shift; my $persist = shift; my $parent = shift; # table name my $data = shift; # master dataset my $child = shift; # table name my $records = shift; # records my $join = $persist->{$child}{join}{$parent} || return; # can't join without instructions my %parent = (); # keyed off join identifier my $children = 0; # count matches my %joined = (); note \5, "joining " . scalar(@$records) . " $child to matching $parent records\n"; foreach my $d (@$data) { my @identifier = (); foreach my $field (sort keys %$join) { push @identifier, $field, $d->{$parent}[0]->{lc($field)}; } my $identifier = join(':', @identifier); #note \7, " building parent identifier $identifier\n"; push @{$parent{$identifier}}, $d; } foreach my $r (@$records) { my @identifier = (); foreach my $field (sort keys %$join) { push @identifier, $field, $r->{lc($join->{$field})}; } my $identifier = join(':', map { defined($_) ? $_ : '' } @identifier); #note \7, " building child identifier $identifier\n"; if (exists($parent{$identifier})) { #note \7, " joining on $identifier\n"; foreach my $d (@{$parent{$identifier}}) { # TODO do not push child record onto data record more than once (in multiple join scenario) push @{$d->{$child}}, $r; $children++; $joined{$identifier}++; } } } note \5, "joined $children $child to " . scalar(keys %joined) . " $parent records\n"; return $children; } sub inflated_object { # returns iterator to turn table/field data into an object my $class = shift; my $persist = shift; my $data = shift; # ARRAY ref we shift from my $objects = shift; # should only ever contain zero or one objects my $c = 0; return sub { return unless @$data; my ($dbh) = @_; my $d = shift(@$data); my $object = $objects->[$c++] ||= $class->new(); local $::OBJECT = $object; my @postinflate = (); foreach my $table (keys %$d) { my $records = $d->{$table}; note "[$c] inflating object $class with " . scalar(@$records) . " records from $table\n"; my $methods = $persist->{$table}{methods}; foreach my $method (keys %$methods) { note \6, "inflating method $method\n"; my $field = $methods->{$method}; if (ref($field)) { if (ref($field) eq 'HASH' and exists($field->{fields})) { my @field = (); push @field, ref($field->{fields}) eq 'ARRAY' ? @{$field->{fields}} : $field->{fields}; if (UNIVERSAL::can($object, $method)) { my @values = (); foreach my $record (@$records) { if (exists($field->{inflate})) { push @values, $field->{inflate}->(@{$record}{@field}); } else { push @values, @{$record}{@field}; } foreach my $field (@field) { note \6, " ($field = " . (defined($record->{$field}) ? $record->{$field} : '') . ")\n"; } } if ($field->{forceref} or ($field->{wantref} and @values > 1)) { $object->$method(\@values); } else { $object->$method(@values); } if (exists($field->{postinflate})) { push @postinflate, $field->{postinflate}; } } else { # TODO some warning - can't run method on object } } elsif (ref($field) eq 'HASH') { if (exists($field->{inflate})) { push my @values, $field->{inflate}->(); foreach my $value (@values) { note \6, " ( = $value)\n"; } $object->$method(@values); } if (exists($field->{postinflate})) { push @postinflate, $field->{postinflate}; } } } else { if (UNIVERSAL::can($object, $method)) { my @values = (); foreach my $record (@$records) { push @values, $record->{$field}; note \6, " ($field = " . (defined($record->{$field}) ? $record->{$field} : 'undef') . ")\n"; } $object->$method(@values); } else { # TODO some warning - can't run method on object } } } } note \6, "running postinflate hooks\n" if @postinflate; foreach my $code (@postinflate) { $code->($dbh); } return $object; }; } sub method_tables { my $class = shift; my $persist = shift; my %table = (); foreach my $table (keys %$persist) { foreach my $method (keys %{$persist->{$table}{methods}}) { $table{$method} = $table; } } return \%table; } 1; __END__ =head1 NAME Class::Inflate - Inflate HASH Object from Values in Database =head1 SYNOPSIS # in package package Some::Package::Name; use Class::Inflate ( $table_one => { key => \@primary_key_fields, methods => { $method_one => $field_one, $method_two => { inflate => sub { join('-', @_) }, deflate => sub { split(/-/, shift(), 2) }, fields => [$field_two, $field_three], }, }, }, $table_two => { key => \@primary_key_fields, join => { $table_one => { $field_one => $field_1, }, }, methods => { $method_$three => $field_2, } }, ); # in script use Some::Package::Name; my @objects = Some::Package::Name->inflate({$field_one => $value}); =head1 DESCRIPTION Allows for any blessed HASH object to be populated from a database, by describing table relationships to each method. When specifying a database relationship to a method, there are several hooks you can specify: =over 4 =item inflate Called when converting database values into method values. Receives the values from the database fields specified. The return values are passed to the object accessor for the method. =item postinflate Called after object has been inflated. The variable C<$::OBJECT> is available, and contains the object being populated. The database handle used for inflation is available as the first argument to C. =item deflate Called when converting method values into database values. Receives the values from the object accessor for the method. The return values are passed to the database fields specified. =back The database fields are specified as an ARRAY reference of field names, if any of the hooks are used. =head2 EXPORT Exports C method into caller's namespace. =head1 SEE ALSO Tangram(3), Class::DBI(3), which have similar concepts, but are tied more closely to database structure. =head1 AUTHOR Nathan Gray, Ekolibrie@cpan.orgE =head1 COPYRIGHT AND LICENSE Copyright (C) 2006 by Nathan Gray This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself, either Perl version 5.8.4 or, at your option, any later version of Perl 5 you may have available. =cut