package Class::DBI::Plugin::DeepAbstractSearch;

our $VERSION = '0.08';

use strict;
use warnings;
use base 'Class::DBI::Plugin';
use SQL::Abstract;

sub init {
	my $class = shift;
	$class->set_sql( deeply_and_broadly => qq{%s} );
}

sub deep_search_where : Plugged
{
    my $class = shift;
    
    my ($what, $from, $where, $bind) = $class->get_deep_where(@_);
    
	my $sql = <<"";
SELECT DISTINCT $what
FROM $from
WHERE $where

	return $class->sth_to_objects($class->sql_deeply_and_broadly($sql), $bind);
}

sub count_deep_search_where : Plugged
{
    my $class = shift;
    
    my ($what, $from, $where, $bind) = $class->get_deep_where(@_);
    
	my $sql = <<"";
SELECT COUNT(*)
FROM $from
WHERE $where

	return $class->sql_deeply_and_broadly($sql)->select_val(@$bind);
}

# my ($what, $from, $where, $bind) = CDBI->get_deep_where($where, $attr);
sub get_deep_where : Plugged
{
    my $class = shift;
    my $where = (ref $_[0]) ? $_[0]          : { @_ };
    my $attr  = (ref $_[0]) ? $_[1]          : undef;
    my $order = ($attr)     ? delete($attr->{order_by}) : undef;
	my $joins = {};
	my $order_fields = '';

	## Collect tables
	$where = _transform_where($class, $joins, $where);
	if ($order) {
		my %order_fields;
		$order = join(", ", @$order) if ref $order;
		$order = _transform_order($class, $joins, $order, \%order_fields);
		$order_fields = join(", ", map { /\./ ? $_ : () } keys %order_fields);
		$order_fields = ", $order_fields"
			if $order_fields;
	}

	## Translate to SQL
    my $sql = SQL::Abstract->new(%$attr);
    my($filter, @bind) = $sql->where($where, $order);
    $filter = "WHERE 1=1 $filter"
    	unless $filter =~ /^\s*WHERE/i;
	my $op = (keys(%$joins) > 1) ? 'AND' : '';
    $filter =~ s/^\s*WHERE/$op/i;

	## Build __TABLEs__
	my $tables = join(', ', map { "__TABLE($_->{class}=$_->{alias})__" } values %$joins) || "__TABLE__";

	## Build __JOINs__
	my $join = join(' AND ', map { $_->{fclass} ? "__JOIN($_->{fclass} $_->{alias})__" : () } values %$joins);

	## Build pseudo-query
	my $alias = $joins->{''}->{alias};
	my $essential = defined ($alias) ? "__ESSENTIAL($alias)__" : "__ESSENTIAL__";

	$sql = join("\0", "$essential$order_fields", $tables, "$join $filter");

	## Transform to real SQL
	$sql = $class->transform_sql($sql);

	return (split(/\0/, $sql), \@bind);
}


# Replace field names with fully qualified (table_alias.field) names
sub _transform_where {
    my ($class, $joins, $where, $hint) = @_;
    my $ref  = ref $where || '';
    my $val;

    $hint ||= '';

    if($ref eq 'ARRAY') {
    	my @where = @$where;
        if ($hint ne 'exps' || $where->[0] !~ /^[a-z]/i) {
            ## transforming [ operator, expr1, expr2 ]
            ## or array in  { operator => ['assigned', 'in-progress']}
            $val = [];
            while ($_ = shift @where) {
                push @$val, ((ref $_) ? _transform_where($class, $joins, $_) : $_);
            }
        } else {
            ## transforming [ field1 => expr1, field2 => expr2 ]
            ## or array in  { operator => [ field1 => expr1, field2 => expr2 ]}
            $val = [];
            while ($_ = shift @where) {
                push @$val, _transform_field($class, $joins, $_);
                push @$val, _transform_where($class, $joins, shift @where);
            }
        }
    } elsif ($ref eq 'HASH') {
        $val = {};
        foreach my $key (keys %$where) {
            if($key !~ /^[a-z]/i) {
                ## transforming   { operator => expr }
                ## or operator in   field => { operator => [ values ] }
                if($key =~ /^-?\s*(not[\s_]+)?(in|between)\s*$/i) {
                	## special case for IN and BETWEEN
	                $hint = 'val';
                } else {
	                $hint ||= 'exps';
	            }
                $val->{$key} = _transform_where($class, $joins, $where->{$key}, $hint);
            } else {
                ## transforming { field => expr }
                $val->{_transform_field($class, $joins, $key)} =
                    _transform_where($class, $joins, $where->{$key}, 'val');
            }
        }
    } else {
        ## literal or SQL
        $val = $where;
    }
    $val;
}

# Change "table.field1.field2, table.field3.field4 DESC" into
#     "t_table_field1.field2, t_table_field3.field4 DESC"
sub _transform_order {
	my ($class, $joins, $order, $order_fields) = @_;

	join(", ", map {
 		my @ord = split /\s+/, $_;
		$ord[0] = _transform_field($class, $joins, $ord[0]);
		$order_fields->{$ord[0]} = 1;
		join(" ", @ord);
	} (split /\s*,\s*/, $order));
}

# Change "table.field1.field2" into "t_table_field1.field2"
sub _transform_field {
    my ($class, $joins, $field) = @_;
	my @path = split /\./, $field;

	$field = pop @path;
	my $join = _get_join($class, $joins, @path);
	"$join->{alias}.$field";
}

# Return the join for (table, field1, field2, field3)
sub _get_join {
    my ($class, $joins, @path) = @_;

	my $join_key = lc join('.', @path);
	my $join = $joins->{$join_key};
	if(!$join) {
		if(my $field = pop @path) {
			## Joined table
			my $prev_join = _get_join($class, $joins, @path);
			my $fcl = $prev_join->{class};
			my $falias = $prev_join->{alias};

			my $col = $fcl->find_column($field)
				or $class->_croak("$fcl doesn't contain column '$field'");
			my $has_a = $fcl->meta_info('has_a')
				or $class->_croak("$fcl column '$col' doesn't have a 'has_a' relationship");
			$has_a = $has_a->{$col}
				or $class->_croak("$fcl column '$col' doesn't have a 'has_a' relationship");
			my $cl = $has_a->foreign_class;
			$join = { fclass => $falias, fkey => "$col", class => $cl, alias => "${falias}_$col" }
		} else {
			## Primary table
			$join = { class => $class, alias => "t_" . $class->table }
		}

		## Add join to list of joins
		$joins->{$join_key} = $join;
	}

	$join;
}

1;

__END__

=head1 NAME

Class::DBI::Plugin::DeepAbstractSearch - deep_search_where() for Class::DBI

=head1 SYNOPSIS

	use base 'Class::DBI';
	use Class::DBI::Plugin::DeepAbstractSearch;

	my @cds = Music::CD->deep_search_where(
		{
			'artist.name' => $artist_name
		}
	);

=head1 DESCRIPTION

This plugin provides a L<SQL::Abstract> search method for L<Class::DBI>.
It is similar to L<Class::DBI::AbstractSearch>, but allows you to search
and sort by fields from joined tables.

Note: When searching and sorting by the fields of the current class only,
it is more efficient to use L<Class::DBI::AbstractSearch>.

=head1 METHODS

=head2 deep_search_where

	my @cds = Music::CD->deep_search_where(
		{
			'artist.name' => $artist_name
		}
	);

This method will be exported into the calling class, and allows for searching
of objects using L<SQL::Abstract> format based on fields from the calling
class as well as using fields in classes related through a (chain of) 'has_a'
relationships to the calling class.

When specifying a field in a related class, you separate it with a period
from the corresponding foreign key field in the primary class.

	package Music::Artist;
	use base 'Class::DBI';
	Music::Artist->table('artist');
	Music::Artist->columns(All => qw/artistid name/);
	Music::Artist->has_many(cds => 'Music::CD');

	package Music::CD;
	use base 'Class::DBI';
	Music::CD->table('cd');
	Music::CD->columns(All => qw/cdid artist title year/);
	Music::CD->has_many(tracks => 'Music::Track');
	Music::CD->has_a(artist => 'Music::Artist');

	package Music::Track;
	use base 'Class::DBI';
	Music::Track->table('track');
	Music::Track->columns(All => qw/trackid cd position title/); 

	## Tracks on all CDs with the title "Greatest Hits"
	@tracks = Music::Track->deep_search_where(
		{
			'cd.title' => "Greatest Hits"
		},
		{
			sort_by => 'cd.title'
		}
	);

	## Tracks on CDs by Willie Nelson, sorted by CD Title and Track Position
	@tracks = Music::Track->deep_search_where(
		{
			'cd.artist.name' => "Willie Nelson"
		},
		{
			sort_by => 'cd.title, position'
		}
	);

	## First 3 Tracks on CDs, whose title contains "Outlaw", by Willie Nelson
	@tracks = Music::Track->deep_search_where(
		{
			'cd.artist.name' => "Willie Nelson",
			'cd.title' => { -like => '%Outlaw%' },
			position => { '<=' => 3 }
		},
		{
			sort_by => 'cd.title, position'
		}
	);

=head2 count_deep_search_where

	my $num_cds = Music::CD->count_deep_search_where(
		{
			'artist.name' => $artist_name
		}
	);

This method will be exported into the calling class, and allows for counting
of objects using L<SQL::Abstract> format based on fields from the calling
class as well as using fields in classes related through a (chain of) 'has_a'
relationships to the calling class.

=head2 get_deep_where

    my ($what, $from, $where, $bind) = $class->get_deep_where($where, $attr);

This method will be exported into the calling class, and allows for retrieving
SQL fragments used for creating queries.  The parameters are the same as to
deep_search_where.

=head1 AUTHOR

Stepan Riha, C<sriha@cpan.org>

=head1 COPYRIGHT

Copyright (C) 2005, 2007, 2008 Stepan Riha. All rights reserved.

This module is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=head1 SEE ALSO

L<Class::DBI>, L<SQL::Abstract>, L<Class::DBI::AbstractSearch>

=cut