Consider unselected order_by during complex subqueried prefetch
Peter Rabbitson [Sat, 9 Mar 2013 10:57:39 +0000 (11:57 +0100)]
Augment _resolve_aliastypes_from_select_args to collect the column names
it sees, allowing it to replace _extract_condition_columns() entirely.

In the process fix a number of *incorrect* limit_dialect tests

Changes
lib/DBIx/Class/SQLMaker.pm
lib/DBIx/Class/Storage/DBIHacks.pm
t/prefetch/incomplete.t
t/sqlmaker/limit_dialects/fetch_first.t
t/sqlmaker/limit_dialects/toplimit.t

diff --git a/Changes b/Changes
index 99bd20d..995dc25 100644 (file)
--- a/Changes
+++ b/Changes
@@ -11,6 +11,10 @@ Revision history for DBIx::Class
         - Warn in case of iterative collapse being upgraded to an eager
           cursor slurp
 
+    * Fixes
+        - Properly consider unselected order_by criteria during complex
+          subqueried prefetch
+
 0.08209 2013-03-01 12:56 (UTC)
     * New Features / Changes
         - Debugging aid - warn on invalid result objects created by what
index 1162280..c0a464c 100644 (file)
@@ -351,7 +351,6 @@ sub _generate_join_clause {
 
 sub _recurse_from {
   my $self = shift;
-
   return join (' ', $self->_gen_from_blocks(@_) );
 }
 
index eaa41c4..e5a0d83 100644 (file)
@@ -24,7 +24,7 @@ use namespace::clean;
 #
 sub _prune_unused_joins {
   my $self = shift;
-  my ($from, $select, $where, $attrs) = @_;
+  my ($from, $select, $where, $attrs, $ignore_multiplication) = @_;
 
   return $from unless $self->_use_join_optimizer;
 
@@ -34,20 +34,26 @@ sub _prune_unused_joins {
 
   my $aliastypes = $self->_resolve_aliastypes_from_select_args(@_);
 
+  # don't care
+  delete $aliastypes->{joining};
+
   # a grouped set will not be affected by amount of rows. Thus any
   # {multiplying} joins can go
-  delete $aliastypes->{multiplying} if $attrs->{group_by};
+  delete $aliastypes->{multiplying}
+    if $ignore_multiplication or $attrs->{group_by};
 
   my @newfrom = $from->[0]; # FROM head is always present
 
   my %need_joins;
+
   for (values %$aliastypes) {
     # add all requested aliases
     $need_joins{$_} = 1 for keys %$_;
 
     # add all their parents (as per joinpath which is an AoH { table => alias })
-    $need_joins{$_} = 1 for map { values %$_ } map { @$_ } values %$_;
+    $need_joins{$_} = 1 for map { values %$_ } map { @{$_->{-parents}} } values %$_;
   }
+
   for my $j (@{$from}[1..$#$from]) {
     push @newfrom, $j if (
       (! $j->[0]{-alias}) # legacy crap
@@ -86,9 +92,9 @@ sub _adjust_select_args_for_complex_prefetch {
   # for inside we consider only stuff *not* brought in by the prefetch
   # on the outside we substitute any function for its alias
   my $outer_select = [ @$select ];
-  my $inner_select = [];
+  my $inner_select;
 
-  my ($root_source, $root_source_offset);
+  my ($root_node, $root_node_offset);
 
   for my $i (0 .. $#$from) {
     my $node = $from->[$i];
@@ -97,14 +103,15 @@ sub _adjust_select_args_for_complex_prefetch {
           : next
     ;
 
-    if ( ($h->{-alias}||'') eq $attrs->{alias} and $root_source = $h->{-rsrc} ) {
-      $root_source_offset = $i;
+    if ( ($h->{-alias}||'') eq $attrs->{alias} and $h->{-rsrc} ) {
+      $root_node = $h;
+      $root_node_offset = $i;
       last;
     }
   }
 
   $self->throw_exception ('Complex prefetches are not supported on resultsets with a custom from attribute')
-    unless $root_source;
+    unless $root_node;
 
   # use the heavy duty resolver to take care of aliased/nonaliased naming
   my $colinfo = $self->_resolve_column_info($from);
@@ -127,48 +134,42 @@ sub _adjust_select_args_for_complex_prefetch {
     push @{$inner_attrs->{as}}, $attrs->{as}[$i];
   }
 
-  # We will need to fetch all native columns in the inner subquery, which may be a part
-  # of an *outer* join condition. We can not just fetch everything because a potential
-  # has_many restricting join collapse *will not work* on heavy data types.
-  # Time for more horrible SQL parsing, aughhhh
-
-  # MASSIVE FIXME - in fact when we are fully transitioned to DQ and the support is
-  # is sane - we will need to trim the select list to *only* fetch stuff that is
-  # necessary to build joins. In the current implementation if I am selecting a blob
-  # and the group_by kicks in - we are fucked, and all the user can do is not select
-  # that column. This is silly!
-
-  my $retardo_sqla_cache = {};
-  for my $cond ( map { $_->[1] } @{$from}[$root_source_offset + 1 .. $#$from] ) {
-    for my $col (@{$self->_extract_condition_columns($cond, $retardo_sqla_cache)}) {
-      my $ci = $colinfo->{$col};
-      if (
-        $ci
-          and
-        $ci->{-source_alias} eq $attrs->{alias}
-          and
-        ! $selected_root_columns->{$ci->{-colname}}++
-      ) {
-        # adding it to both to keep limits not supporting dark selectors happy
-        push @$inner_select, $ci->{-fq_colname};
-        push @{$inner_attrs->{as}}, $ci->{-fq_colname};
-      }
+  # We will need to fetch all native columns in the inner subquery, which may
+  # be a part of an *outer* join condition, or an order_by (which needs to be
+  # preserved outside)
+  # We can not just fetch everything because a potential has_many restricting
+  # join collapse *will not work* on heavy data types.
+  my $connecting_aliastypes = $self->_resolve_aliastypes_from_select_args(
+    [grep { ref($_) eq 'ARRAY' or ref($_) eq 'HASH' } @{$from}[$root_node_offset .. $#$from]],
+    [],
+    $where,
+    $inner_attrs
+  );
+
+  for (sort map { keys %{$_->{-seen_columns}||{}} } map { values %$_ } values %$connecting_aliastypes) {
+    my $ci = $colinfo->{$_} or next;
+    if (
+      $ci->{-source_alias} eq $attrs->{alias}
+        and
+      ! $selected_root_columns->{$ci->{-colname}}++
+    ) {
+      # adding it to both to keep limits not supporting dark selectors happy
+      push @$inner_select, $ci->{-fq_colname};
+      push @{$inner_attrs->{as}}, $ci->{-fq_colname};
     }
   }
 
   # construct the inner $from and lock it in a subquery
   # we need to prune first, because this will determine if we need a group_by below
-  # the fake group_by is so that the pruner throws away all non-selecting, non-restricting
-  # multijoins (since we def. do not care about those inside the subquery)
-
+  # throw away all non-selecting, non-restricting multijoins
+  # (since we def. do not care about multiplication those inside the subquery)
   my $inner_subq = do {
 
     # must use it here regardless of user requests
     local $self->{_use_join_optimizer} = 1;
 
-    my $inner_from = $self->_prune_unused_joins ($from, $inner_select, $where, {
-      group_by => ['dummy'], %$inner_attrs,
-    });
+    # throw away multijoins since we def. do not care about those inside the subquery
+    my $inner_from = $self->_prune_unused_joins ($from, $inner_select, $where, $inner_attrs, 'ignore_multiplication');
 
     my $inner_aliastypes =
       $self->_resolve_aliastypes_from_select_args( $inner_from, $inner_select, $where, $inner_attrs );
@@ -197,7 +198,8 @@ sub _adjust_select_args_for_complex_prefetch {
     }
 
     # we already optimized $inner_from above
-    local $self->{_use_join_optimizer} = 0;
+    # and already local()ized
+    $self->{_use_join_optimizer} = 0;
 
     # generate the subquery
     $self->_select_args_to_query (
@@ -224,24 +226,20 @@ sub _adjust_select_args_for_complex_prefetch {
   my @outer_from;
 
   # we may not be the head
-  if ($root_source_offset) {
+  if ($root_node_offset) {
     # first generate the outer_from, up to the substitution point
-    @outer_from = splice @$from, 0, $root_source_offset;
-
-    my $root_node = shift @$from;
+    @outer_from = splice @$from, 0, $root_node_offset;
 
     push @outer_from, [
       {
         -alias => $attrs->{alias},
-        -rsrc => $root_node->[0]{-rsrc},
+        -rsrc => $root_node->{-rsrc},
         $attrs->{alias} => $inner_subq,
       },
-      @{$root_node}[1 .. $#$root_node],
+      @{$from->[0]}[1 .. $#{$from->[0]}],
     ];
   }
   else {
-    my $root_node = shift @$from;
-
     @outer_from = {
       -alias => $attrs->{alias},
       -rsrc => $root_node->{-rsrc},
@@ -249,6 +247,8 @@ sub _adjust_select_args_for_complex_prefetch {
     };
   }
 
+  shift @$from; # it's replaced in @outer_from already
+
   # scan the *remaining* from spec against different attributes, and see which joins are needed
   # in what role
   my $outer_aliastypes =
@@ -256,7 +256,7 @@ sub _adjust_select_args_for_complex_prefetch {
 
   # unroll parents
   my ($outer_select_chain, $outer_restrict_chain) = map { +{
-    map { $_ => 1 } map { values %$_} map { @$_ } values %{ $outer_aliastypes->{$_} || {} }
+    map { $_ => 1 } map { values %$_} map { @{$_->{-parents}} } values %{ $outer_aliastypes->{$_} }
   } } qw/selecting restricting/;
 
   # see what's left - throw away if not selecting/restricting
@@ -331,7 +331,7 @@ sub _resolve_aliastypes_from_select_args {
       or next;
 
     $alias_list->{$al} = $j;
-    $aliases_by_type->{multiplying}{$al} ||= $j->{-join_path}||[] if (
+    $aliases_by_type->{multiplying}{$al} ||= { -parents => $j->{-join_path}||[] } if (
       # not array == {from} head == can't be multiplying
       ( ref($_) eq 'ARRAY' and ! $j->{-is_single} )
         or
@@ -351,6 +351,7 @@ sub _resolve_aliastypes_from_select_args {
   local $sql_maker->{where_bind};
   local $sql_maker->{group_bind};
   local $sql_maker->{having_bind};
+  local $sql_maker->{from_bind};
 
   # we can't scan properly without any quoting (\b doesn't cut it
   # everywhere), so unless there is proper quoting set - use our
@@ -378,6 +379,12 @@ sub _resolve_aliastypes_from_select_args {
         map { $_ => $attrs->{$_} } (qw/group_by having/)
       }),
     ],
+    joining => [
+      $sql_maker->_recurse_from (
+        ref $from->[0] eq 'ARRAY' ? $from->[0][0] : $from->[0],
+        @{$from}[1 .. $#$from],
+      ),
+    ],
     selecting => [
       $sql_maker->_recurse_fields ($select),
       ( map { $_->[0] } $self->_extract_order_criteria ($attrs->{order_by}, $sql_maker) ),
@@ -391,15 +398,18 @@ sub _resolve_aliastypes_from_select_args {
   # alias (should work even if they are in scalarrefs)
   for my $alias (keys %$alias_list) {
     my $al_re = qr/
-      $lquote $alias $rquote $sep
+      $lquote $alias $rquote $sep (?: $lquote ([^$rquote]+) $rquote )?
         |
-      \b $alias \.
+      \b $alias \. ([^\s\)\($rquote]+)?
     /x;
 
     for my $type (keys %$to_scan) {
       for my $piece (@{$to_scan->{$type}}) {
-        $aliases_by_type->{$type}{$alias} ||= $alias_list->{$alias}{-join_path}||[]
-          if ($piece =~ $al_re);
+        if (my @matches = $piece =~ /$al_re/g) {
+          $aliases_by_type->{$type}{$alias} ||= { -parents => $alias_list->{$alias}{-join_path}||[] };
+          $aliases_by_type->{$type}{$alias}{-seen_columns}{"$alias.$_"} = 1
+            for grep { defined $_ } @matches;
+        }
       }
     }
   }
@@ -409,13 +419,15 @@ sub _resolve_aliastypes_from_select_args {
   for my $col (keys %$colinfo) {
     next if $col =~ / \. /x;   # if column is qualified it was caught by the above
 
-    my $col_re = qr/ $lquote $col $rquote /x;
+    my $col_re = qr/ $lquote ($col) $rquote /x;
 
     for my $type (keys %$to_scan) {
       for my $piece (@{$to_scan->{$type}}) {
-        if ($piece =~ $col_re) {
+        if (my @matches = $piece =~ /$col_re/g) {
           my $alias = $colinfo->{$col}{-source_alias};
-          $aliases_by_type->{$type}{$alias} ||= $alias_list->{$alias}{-join_path}||[];
+          $aliases_by_type->{$type}{$alias} ||= { -parents => $alias_list->{$alias}{-join_path}||[] };
+          $aliases_by_type->{$type}{$alias}{-seen_columns}{"$alias.$_"} = 1
+            for grep { defined $_ } @matches;
         }
       }
     }
@@ -424,7 +436,7 @@ sub _resolve_aliastypes_from_select_args {
   # Add any non-left joins to the restriction list (such joins are indeed restrictions)
   for my $j (values %$alias_list) {
     my $alias = $j->{-alias} or next;
-    $aliases_by_type->{restricting}{$alias} ||= $j->{-join_path}||[] if (
+    $aliases_by_type->{restricting}{$alias} ||= { -parents => $j->{-join_path}||[] } if (
       (not $j->{-join_type})
         or
       ($j->{-join_type} !~ /^left (?: \s+ outer)? $/xi)
@@ -646,61 +658,6 @@ sub _inner_join_to_node {
   return \@new_from;
 }
 
-# yet another atrocity: attempt to extract all columns from a
-# where condition by hooking _quote
-sub _extract_condition_columns {
-  my ($self, $cond, $sql_maker_cache) = @_;
-
-  return [] unless $cond;
-
-  my $sm = $sql_maker_cache->{condparser} ||= $self->{_sql_ident_capturer} ||= do {
-    # FIXME - replace with a Moo trait
-    my $orig_sm_class = ref $self->sql_maker;
-    my $smic_class = "${orig_sm_class}::_IdentCapture_";
-
-    unless ($smic_class->isa('SQL::Abstract')) {
-
-      no strict 'refs';
-      *{"${smic_class}::_quote"} = subname "${smic_class}::_quote" => sub {
-        my ($self, $ident) = @_;
-        if (ref $ident eq 'SCALAR') {
-          $ident = $$ident;
-          my $storage_quotes = $self->sql_quote_char || '"';
-          my ($ql, $qr) = map
-            { quotemeta $_ }
-            (ref $storage_quotes eq 'ARRAY' ? @$storage_quotes : ($storage_quotes) x 2 )
-          ;
-
-          while ($ident =~ /
-            $ql (\w+) $qr
-              |
-            ([\w\.]+)
-          /xg) {
-            $self->{_captured_idents}{$1||$2}++;
-          }
-        }
-        else {
-          $self->{_captured_idents}{$ident}++;
-        }
-        return $ident;
-      };
-
-      *{"${smic_class}::_get_captured_idents"} = subname "${smic_class}::_get_captures" => sub {
-        (delete shift->{_captured_idents}) || {};
-      };
-
-      $self->inject_base ($smic_class, $orig_sm_class);
-
-    }
-
-    $smic_class->new();
-  };
-
-  $sm->_recurse_where($cond);
-
-  return [ sort keys %{$sm->_get_captured_idents} ];
-}
-
 sub _extract_order_criteria {
   my ($self, $order_by, $sql_maker) = @_;
 
index 8840c1d..a710fbb 100644 (file)
@@ -6,6 +6,7 @@ use Test::Deep;
 use Test::Exception;
 use lib qw(t/lib);
 use DBICTest;
+use DBIC::SqlMakerTest;
 
 my $schema = DBICTest->init_schema();
 
@@ -119,9 +120,105 @@ throws_ok(
     prefetch => 'books',
   });
 
-  lives_ok {
-    is ($pref_rs->all, 1, 'Expected count of objects on limtied prefetch')
-  } "Complex limited prefetch works with non-selected join condition";
+  is_same_sql_bind(
+    $pref_rs->as_query,
+    '(
+      SELECT me.name, books.id, books.source, books.owner, books.title, books.price
+        FROM (
+          SELECT me.name, me.id
+            FROM owners me
+          LIMIT ?
+          OFFSET ?
+        ) me
+        LEFT JOIN books books
+          ON books.owner = me.id
+    )',
+    [ [ { sqlt_datatype => "integer" } => 3 ], [ { sqlt_datatype => "integer" } => 1 ] ],
+    'Expected SQL on complex limited prefetch with non-selected join condition',
+  );
+
+  is_deeply (
+    $pref_rs->all_hri,
+    [ {
+      name => "Waltham",
+      books => [ {
+        id => 3,
+        owner => 2,
+        price => 65,
+        source => "Library",
+        title => "Best Recipe Cookbook",
+      } ],
+    } ],
+    'Expected result on complex limited prefetch with non-selected join condition'
+  );
+
+  my $empty_ordered_pref_rs = $pref_rs->search({}, {
+    columns => [],  # nothing, we only prefetch the book data
+    order_by => 'me.name',
+  });
+  my $empty_ordered_pref_hri = [ {
+    books => [ {
+      id => 3,
+      owner => 2,
+      price => 65,
+      source => "Library",
+      title => "Best Recipe Cookbook",
+    } ],
+  } ];
+
+  is_same_sql_bind(
+    $empty_ordered_pref_rs->as_query,
+    '(
+      SELECT books.id, books.source, books.owner, books.title, books.price
+        FROM (
+          SELECT me.id, me.name
+            FROM owners me
+          ORDER BY me.name
+          LIMIT ?
+          OFFSET ?
+        ) me
+        LEFT JOIN books books
+          ON books.owner = me.id
+      ORDER BY me.name
+    )',
+    [ [ { sqlt_datatype => "integer" } => 3 ], [ { sqlt_datatype => "integer" } => 1 ] ],
+    'Expected SQL on *ordered* complex limited prefetch with non-selected root data',
+  );
+
+  is_deeply (
+    $empty_ordered_pref_rs->all_hri,
+    $empty_ordered_pref_hri,
+    'Expected result on *ordered* complex limited prefetch with non-selected root data'
+  );
+
+  $empty_ordered_pref_rs = $empty_ordered_pref_rs->search({}, {
+    order_by => [ \ 'LENGTH(me.name)', \ 'RANDOM()' ],
+  });
+
+  is_same_sql_bind(
+    $empty_ordered_pref_rs->as_query,
+    '(
+      SELECT books.id, books.source, books.owner, books.title, books.price
+        FROM (
+          SELECT me.id, me.name
+            FROM owners me
+          ORDER BY LENGTH(me.name), RANDOM()
+          LIMIT ?
+          OFFSET ?
+        ) me
+        LEFT JOIN books books
+          ON books.owner = me.id
+      ORDER BY LENGTH(me.name), RANDOM()
+    )',
+    [ [ { sqlt_datatype => "integer" } => 3 ], [ { sqlt_datatype => "integer" } => 1 ] ],
+    'Expected SQL on *function-ordered* complex limited prefetch with non-selected root data',
+  );
+
+  is_deeply (
+    $empty_ordered_pref_rs->all_hri,
+    $empty_ordered_pref_hri,
+    'Expected result on *function-ordered* complex limited prefetch with non-selected root data'
+  );
 }
 
 
index 302201c..10d3e60 100644 (file)
@@ -152,11 +152,11 @@ is_same_sql_bind (
   $books_45_and_owners->search ({}, { group_by => 'title', order_by => 'title' })->as_query,
   '(SELECT me.id, me.source, me.owner, me.price, owner.id, owner.name
       FROM (
-        SELECT me.id, me.source, me.owner, me.price
+        SELECT me.id, me.source, me.owner, me.price, me.title
           FROM (
-            SELECT me.id, me.source, me.owner, me.price, ORDER__BY__001
+            SELECT me.id, me.source, me.owner, me.price, me.title
               FROM (
-                SELECT me.id, me.source, me.owner, me.price, title AS ORDER__BY__001
+                SELECT me.id, me.source, me.owner, me.price, me.title
                   FROM books me
                   JOIN owners owner ON owner.id = me.owner
                 WHERE ( source = ? )
@@ -164,10 +164,10 @@ is_same_sql_bind (
                 ORDER BY title
                 FETCH FIRST 5 ROWS ONLY
               ) me
-            ORDER BY ORDER__BY__001 DESC
+            ORDER BY title DESC
             FETCH FIRST 2 ROWS ONLY
           ) me
-        ORDER BY ORDER__BY__001
+        ORDER BY title
       ) me
       JOIN owners owner ON owner.id = me.owner
     WHERE ( source = ? )
index 11f4c08..88c99a6 100644 (file)
@@ -192,22 +192,22 @@ is_same_sql_bind (
   $books_45_and_owners->search ({}, { group_by => 'title', order_by => 'title' })->as_query,
   '(SELECT me.id, me.source, me.owner, me.price, owner.id, owner.name
       FROM (
-        SELECT me.id, me.source, me.owner, me.price
+        SELECT me.id, me.source, me.owner, me.price, me.title
           FROM (
             SELECT TOP 2
-                me.id, me.source, me.owner, me.price, ORDER__BY__001
+                me.id, me.source, me.owner, me.price, me.title
               FROM (
                 SELECT TOP 5
-                    me.id, me.source, me.owner, me.price, title AS ORDER__BY__001
+                    me.id, me.source, me.owner, me.price, me.title
                   FROM books me
                   JOIN owners owner ON owner.id = me.owner
                 WHERE ( source = ? )
                 GROUP BY title
                 ORDER BY title
               ) me
-            ORDER BY ORDER__BY__001 DESC
+            ORDER BY title DESC
           ) me
-        ORDER BY ORDER__BY__001
+        ORDER BY title
       ) me
       JOIN owners owner ON owner.id = me.owner
     WHERE ( source = ? )