From dad0dae15cd8b62dbc91125e2d0e4078909aab73 Mon Sep 17 00:00:00 2001
From: Thomas Steur <tsteur@users.noreply.github.com>
Date: Wed, 13 Jul 2016 15:11:06 +1200
Subject: [PATCH] Improved join generation for segments (#10264)

* better join generation for segments
* do not update visit if there are no values to be updated
---
 core/DataAccess/LogQueryBuilder.php           | 211 ++------------
 .../LogQueryBuilder/JoinGenerator.php         | 264 ++++++++++++++++++
 .../DataAccess/LogQueryBuilder/JoinTables.php | 114 ++++++++
 core/Plugin/LogTablesProvider.php             |  65 +++++
 core/Tracker/LogTable.php                     |  75 +++++
 core/Tracker/Visit.php                        |   5 +
 plugins/CoreHome/Tracker/LogTable/Action.php  |  30 ++
 .../CoreHome/Tracker/LogTable/Conversion.php  |  24 ++
 .../Tracker/LogTable/ConversionItem.php       |  25 ++
 .../Tracker/LogTable/LinkVisitAction.php      |  29 ++
 plugins/CoreHome/Tracker/LogTable/Visit.php   |  30 ++
 .../SegmentEditor/SegmentQueryDecorator.php   |   4 +-
 .../tests/Unit/SegmentQueryDecoratorTest.php  |   5 +-
 .../Mock/Plugin/LogTablesProvider.php         |  28 ++
 tests/PHPUnit/Integration/SegmentTest.php     |  18 +-
 .../LogQueryBuilder/JoinGeneratorTest.php     | 171 ++++++++++++
 .../LogQueryBuilder/JoinTablesTest.php        | 146 ++++++++++
 17 files changed, 1046 insertions(+), 198 deletions(-)
 create mode 100644 core/DataAccess/LogQueryBuilder/JoinGenerator.php
 create mode 100644 core/DataAccess/LogQueryBuilder/JoinTables.php
 create mode 100644 core/Plugin/LogTablesProvider.php
 create mode 100644 core/Tracker/LogTable.php
 create mode 100644 plugins/CoreHome/Tracker/LogTable/Action.php
 create mode 100644 plugins/CoreHome/Tracker/LogTable/Conversion.php
 create mode 100644 plugins/CoreHome/Tracker/LogTable/ConversionItem.php
 create mode 100644 plugins/CoreHome/Tracker/LogTable/LinkVisitAction.php
 create mode 100644 plugins/CoreHome/Tracker/LogTable/Visit.php
 create mode 100644 tests/PHPUnit/Framework/Mock/Plugin/LogTablesProvider.php
 create mode 100644 tests/PHPUnit/Unit/DataAccess/LogQueryBuilder/JoinGeneratorTest.php
 create mode 100644 tests/PHPUnit/Unit/DataAccess/LogQueryBuilder/JoinTablesTest.php

diff --git a/core/DataAccess/LogQueryBuilder.php b/core/DataAccess/LogQueryBuilder.php
index c65f36bf10..186ed0d96e 100644
--- a/core/DataAccess/LogQueryBuilder.php
+++ b/core/DataAccess/LogQueryBuilder.php
@@ -10,11 +10,23 @@
 namespace Piwik\DataAccess;
 
 use Exception;
-use Piwik\Common;
+use Piwik\DataAccess\LogQueryBuilder\JoinGenerator;
+use Piwik\DataAccess\LogQueryBuilder\JoinTables;
+use Piwik\Plugin\LogTablesProvider;
 use Piwik\Segment\SegmentExpression;
 
 class LogQueryBuilder
 {
+    /**
+     * @var LogTablesProvider
+     */
+    private $logTableProvider;
+
+    public function __construct(LogTablesProvider $logTablesProvider)
+    {
+        $this->logTableProvider = $logTablesProvider;
+    }
+
     public function getSelectQueryString(SegmentExpression $segmentExpression, $select, $from, $where, $bind, $groupBy,
                                          $orderBy, $limitAndOffset)
     {
@@ -31,9 +43,11 @@ class LogQueryBuilder
             $bind = array_merge($bind, $segmentSql['bind']);
         }
 
-        $joins = $this->generateJoinsString($from);
-        $joinWithSubSelect = $joins['joinWithSubSelect'];
-        $from = $joins['sql'];
+        $tables = new JoinTables($this->logTableProvider, $from);
+        $join = new JoinGenerator($tables);
+        $join->generate();
+        $from = $join->getJoinString();
+        $joinWithSubSelect = $join->shouldJoinWithSelect();
 
         // hack for https://github.com/piwik/piwik/issues/9194#issuecomment-164321612
         $useSpecialConversionGroupBy = (!empty($segmentSql)
@@ -55,192 +69,15 @@ class LogQueryBuilder
         );
     }
 
-    private function hasJoinedTableAlreadyManually($tableToFind, $joinToFind, $tables)
+    private function getKnownTables()
     {
-        foreach ($tables as $index => $table) {
-            if (is_array($table)
-                && !empty($table['table'])
-                && $table['table'] === $tableToFind
-                && (!isset($table['tableAlias']) || $table['tableAlias'] === $tableToFind)
-                && isset($table['joinOn']) && $table['joinOn'] === $joinToFind) {
-                return true;
-            }
+        $names = array();
+        foreach ($this->logTableProvider->getAllLogTables() as $logTable) {
+            $names[] = $logTable->getName();
         }
-
-        return false;
+        return $names;
     }
 
-    private function findIndexOfManuallyAddedTable($tableToFind, $tables)
-    {
-        foreach ($tables as $index => $table) {
-            if (is_array($table)
-                && !empty($table['table'])
-                && $table['table'] === $tableToFind
-                && (!isset($table['tableAlias']) || $table['tableAlias'] === $tableToFind)) {
-                return $index;
-            }
-        }
-    }
-
-    private function hasTableAddedManually($tableToFind, $tables)
-    {
-        $table = $this->findIndexOfManuallyAddedTable($tableToFind, $tables);
-
-        return isset($table);
-    }
-
-    /**
-     * Generate the join sql based on the needed tables
-     * @param array $tables tables to join
-     * @throws Exception if tables can't be joined
-     * @return array
-     */
-    private function generateJoinsString(&$tables)
-    {
-        $knownTables = array("log_action", "log_visit", "log_link_visit_action", "log_conversion", "log_conversion_item");
-        $visitsAvailable = $linkVisitActionsTableAvailable = $conversionsAvailable = $conversionItemAvailable = $actionsTableAvailable = false;
-        $defaultLogActionJoin = "log_link_visit_action.idaction_url = log_action.idaction";
-
-        $joinWithSubSelect = false;
-        $sql = '';
-
-        // make sure the tables are joined in the right order
-        // base table first, then action before conversion
-        // this way, conversions can be left joined on idvisit
-        $actionIndex = array_search("log_link_visit_action", $tables);
-        $conversionIndex = array_search("log_conversion", $tables);
-        if ($actionIndex > 0 && $conversionIndex > 0 && $actionIndex > $conversionIndex) {
-            $tables[$actionIndex] = "log_conversion";
-            $tables[$conversionIndex] = "log_link_visit_action";
-        }
-        // same as above: action before visit
-        $actionIndex = array_search("log_link_visit_action", $tables);
-        $visitIndex = array_search("log_visit", $tables);
-        if ($actionIndex > 0 && $visitIndex > 0 && $actionIndex > $visitIndex) {
-            $tables[$actionIndex] = "log_visit";
-            $tables[$visitIndex] = "log_link_visit_action";
-        }
-
-        // we need to add log_link_visit_action dynamically to join eg visit with action
-        $linkVisitAction = array_search("log_link_visit_action", $tables);
-        $actionIndex     = array_search("log_action", $tables);
-        if ($linkVisitAction === false && $actionIndex > 0) {
-            $tables[] = "log_link_visit_action";
-        }
-
-        if ($actionIndex > 0
-            && $this->hasTableAddedManually('log_action', $tables)
-            && !$this->hasJoinedTableAlreadyManually('log_action', $defaultLogActionJoin, $tables)) {
-            // we cannot join the same table with same alias twice, therefore we need to combine the join via AND
-            $tableIndex = $this->findIndexOfManuallyAddedTable('log_action', $tables);
-            $defaultLogActionJoin = '(' . $tables[$tableIndex]['joinOn'] . ' AND ' . $defaultLogActionJoin . ')';
-            unset($tables[$tableIndex]);
-        }
-
-        $linkVisitAction = array_search("log_link_visit_action", $tables);
-        $actionIndex     = array_search("log_action", $tables);
-        if ($linkVisitAction > 0 && $actionIndex > 0 && $linkVisitAction > $actionIndex) {
-            $tables[$actionIndex] = "log_link_visit_action";
-            $tables[$linkVisitAction] = "log_action";
-        }
-
-        foreach ($tables as $i => $table) {
-            if (is_array($table)) {
-                // join condition provided
-                $alias = isset($table['tableAlias']) ? $table['tableAlias'] : $table['table'];
-                $sql .= "
-				LEFT JOIN " . Common::prefixTable($table['table']) . " AS " . $alias
-                    . " ON " . $table['joinOn'];
-                continue;
-            }
-
-            if (!in_array($table, $knownTables)) {
-                throw new Exception("Table '$table' can't be used for segmentation");
-            }
-
-            $tableSql = Common::prefixTable($table) . " AS $table";
-
-            if ($i == 0) {
-                // first table
-                $sql .= $tableSql;
-            } else {
-
-                if ($linkVisitActionsTableAvailable && $table === 'log_action') {
-                    $join = $defaultLogActionJoin;
-
-                    if ($this->hasJoinedTableAlreadyManually($table, $join, $tables)) {
-                        $actionsTableAvailable = true;
-                        continue;
-                    }
-
-                } elseif ($linkVisitActionsTableAvailable && $table == "log_conversion") {
-                    // have actions, need conversions => join on idvisit
-                    $join = "log_conversion.idvisit = log_link_visit_action.idvisit";
-                } elseif ($linkVisitActionsTableAvailable && $table == "log_visit") {
-                    // have actions, need visits => join on idvisit
-                    $join = "log_visit.idvisit = log_link_visit_action.idvisit";
-
-                    if ($this->hasJoinedTableAlreadyManually($table, $join, $tables)) {
-                        $visitsAvailable = true;
-                        continue;
-                    }
-
-                } elseif ($visitsAvailable && $table == "log_link_visit_action") {
-                    // have visits, need actions => we have to use a more complex join
-                    // we don't hande this here, we just return joinWithSubSelect=true in this case
-                    $joinWithSubSelect = true;
-                    $join = "log_link_visit_action.idvisit = log_visit.idvisit";
-
-                    if ($this->hasJoinedTableAlreadyManually($table, $join, $tables)) {
-                        $linkVisitActionsTableAvailable = true;
-                        continue;
-                    }
-
-                } elseif ($conversionsAvailable && $table == "log_link_visit_action") {
-                    // have conversions, need actions => join on idvisit
-                    $join = "log_conversion.idvisit = log_link_visit_action.idvisit";
-                } elseif (($visitsAvailable && $table == "log_conversion")
-                    || ($conversionsAvailable && $table == "log_visit")
-                ) {
-                    // have visits, need conversion (or vice versa) => join on idvisit
-                    // notice that joining conversions on visits has lower priority than joining it on actions
-                    $join = "log_conversion.idvisit = log_visit.idvisit";
-
-                    // if conversions are joined on visits, we need a complex join
-                    if ($table == "log_conversion") {
-                        $joinWithSubSelect = true;
-                    }
-                } elseif ($conversionItemAvailable && $table === 'log_visit') {
-                    $join = "log_conversion_item.idvisit = log_visit.idvisit";
-                } elseif ($conversionItemAvailable && $table === 'log_link_visit_action') {
-                    $join = "log_conversion_item.idvisit = log_link_visit_action.idvisit";
-                } elseif ($conversionItemAvailable && $table === 'log_conversion') {
-                    $join = "log_conversion_item.idvisit = log_conversion.idvisit";
-                } else {
-                    throw new Exception("Table '$table' can't be joined for segmentation");
-                }
-
-                // the join sql the default way
-                $sql .= "
-				LEFT JOIN $tableSql ON $join";
-            }
-
-            // remember which tables are available
-            $visitsAvailable = ($visitsAvailable || $table == "log_visit");
-            $linkVisitActionsTableAvailable = ($linkVisitActionsTableAvailable || $table == "log_link_visit_action");
-            $actionsTableAvailable = ($actionsTableAvailable || $table == "log_action");
-            $conversionsAvailable = ($conversionsAvailable || $table == "log_conversion");
-            $conversionItemAvailable = ($conversionItemAvailable || $table == "log_conversion_item");
-        }
-
-        $return = array(
-            'sql'               => $sql,
-            'joinWithSubSelect' => $joinWithSubSelect
-        );
-        return $return;
-    }
-
-
     /**
      * Build a select query where actions have to be joined on visits (or conversions)
      * In this case, the query gets wrapped in another query so that grouping by visit is possible
@@ -256,7 +93,7 @@ class LogQueryBuilder
      */
     private function buildWrappedSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, $innerGroupBy = null)
     {
-        $matchTables = "(log_visit|log_conversion_item|log_conversion|log_action)";
+        $matchTables = '(' . implode('|', $this->getKnownTables()) . ')';
         preg_match_all("/". $matchTables ."\.[a-z0-9_\*]+/", $select, $matches);
         $neededFields = array_unique($matches[0]);
 
diff --git a/core/DataAccess/LogQueryBuilder/JoinGenerator.php b/core/DataAccess/LogQueryBuilder/JoinGenerator.php
new file mode 100644
index 0000000000..1bd3ab3912
--- /dev/null
+++ b/core/DataAccess/LogQueryBuilder/JoinGenerator.php
@@ -0,0 +1,264 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+
+namespace Piwik\DataAccess\LogQueryBuilder;
+
+use Exception;
+use Piwik\Common;
+use Piwik\Tracker\LogTable;
+
+class JoinGenerator
+{
+    /**
+     * @var JoinTables
+     */
+    protected $tables;
+
+    /**
+     * @var bool
+     */
+    private $joinWithSubSelect = false;
+
+    /**
+     * @var string
+     */
+    private $joinString = '';
+
+    /**
+     * @var array
+     */
+    private $nonVisitJoins = array();
+
+    public function __construct(JoinTables $tables)
+    {
+        $this->tables = $tables;
+        $this->addMissingTablesNeededForJoins();
+    }
+    
+    private function addMissingTablesNeededForJoins()
+    {
+        foreach ($this->tables as $index => $table) {
+            if (is_array($table)) {
+                continue;
+            }
+
+            $logTable = $this->tables->getLogTable($table);
+
+            if (!$logTable->getColumnToJoinOnIdVisit()) {
+                $tableNameToJoin = $logTable->getLinkTableToBeAbleToJoinOnVisit();
+
+                if ($index > 0 && !$this->tables->hasJoinedTable($tableNameToJoin)) {
+                    $this->tables->addTableToJoin($tableNameToJoin);
+                }
+
+                if ($this->tables->hasJoinedTable($tableNameToJoin)) {
+                    $this->generateNonVisitJoins($table, $tableNameToJoin, $index);
+                }
+            }
+        }
+    }
+
+    /**
+     * Generate the join sql based on the needed tables
+     * @throws Exception if tables can't be joined
+     * @return array
+     */
+    public function generate()
+    {
+        /** @var LogTable[] $availableLogTables */
+        $availableLogTables = array();
+
+        $this->tables->sort(array($this, 'sortTablesForJoin'));
+
+        foreach ($this->tables as $i => $table) {
+            if (is_array($table)) {
+
+                // join condition provided
+                $alias = isset($table['tableAlias']) ? $table['tableAlias'] : $table['table'];
+                $this->joinString .= " LEFT JOIN " . Common::prefixTable($table['table']) . " AS " . $alias
+                                   . " ON " . $table['joinOn'];
+                continue;
+            }
+
+            $tableSql = Common::prefixTable($table) . " AS $table";
+
+            $logTable = $this->tables->getLogTable($table);
+
+            if ($i == 0) {
+                // first table
+                $this->joinString .= $tableSql;
+            } else {
+
+                $join = $this->findJoinCriteriasForTables($logTable, $availableLogTables);
+
+                if ($join === null) {
+                    $availableLogTables[$table] = $logTable;
+                    continue;
+                }
+
+                // the join sql the default way
+                $this->joinString .= " LEFT JOIN $tableSql ON " . $join;
+            }
+
+            $availableLogTables[$table] = $logTable;
+        }
+    }
+
+    public function getJoinString()
+    {
+        return $this->joinString;
+    }
+
+    public function shouldJoinWithSelect()
+    {
+        return $this->joinWithSubSelect;
+    }
+
+    /**
+     * @param LogTable $logTable
+     * @param LogTable[] $availableLogTables
+     * @return string|null   returns null in case the table is already joined, or the join string if the table needs
+     *                       to be joined
+     * @throws Exception if table cannot be joined for segmentation
+     */
+    protected function findJoinCriteriasForTables(LogTable $logTable, $availableLogTables)
+    {
+        $join = null;
+        $alternativeJoin = null;
+        $table = $logTable->getName();
+
+        foreach ($availableLogTables as $availableLogTable) {
+            if ($logTable->getColumnToJoinOnIdVisit() && $availableLogTable->getColumnToJoinOnIdVisit()) {
+
+                $join = sprintf("%s.%s = %s.%s", $table, $logTable->getColumnToJoinOnIdVisit(),
+                                                 $availableLogTable->getName(), $availableLogTable->getColumnToJoinOnIdVisit());
+                $alternativeJoin = sprintf("%s.%s = %s.%s", $availableLogTable->getName(), $availableLogTable->getColumnToJoinOnIdVisit(),
+                                                            $table, $logTable->getColumnToJoinOnIdVisit());
+
+                if ($availableLogTable->shouldJoinWithSubSelect()) {
+                    $this->joinWithSubSelect = true;
+                }
+
+                break;
+            }
+
+            if ($logTable->getColumnToJoinOnIdAction() && $availableLogTable->getColumnToJoinOnIdAction()) {
+                if (isset($this->nonVisitJoins[$logTable->getName()][$availableLogTable->getName()])) {
+                    $join = $this->nonVisitJoins[$logTable->getName()][$availableLogTable->getName()];
+                }
+
+                break;
+            }
+        }
+
+        if (!isset($join)) {
+            throw new Exception("Table '$table' can't be joined for segmentation");
+        }
+
+        if ($this->tables->hasJoinedTableManually($table, $join)
+            || $this->tables->hasJoinedTableManually($table, $alternativeJoin)) {
+            // already joined, no need to join it again
+            return null;
+        }
+
+        return $join;
+    }
+
+    /**
+     * This code is a bit tricky. We have to execute this right at the beginning before actually iterating over all the
+     * tables and generating the join string as we may have to delete a table from the tables. If we did not delete
+     * this table upfront, we would have maybe already added a joinString for that table, even though it will be later
+     * removed by another table. This means if we wouldn't delete/unset that table upfront, we would need to alter
+     * an already generated join string which would not be really nice code as well.
+     *
+     * Next problem is, because we are deleting a table, we have to remember the "joinOn" string for that table in a
+     * property "nonVisitJoins". Otherwise we would not be able to generate the correct "joinOn" string when actually
+     * iterating over all the tables to generate that string.
+     *
+     * @param $tableName
+     * @param $tableNameToJoin
+     * @param $index
+     */
+    protected function generateNonVisitJoins($tableName, $tableNameToJoin, $index)
+    {
+        $logTable = $this->tables->getLogTable($tableName);
+        $logTableToJoin = $this->tables->getLogTable($tableNameToJoin);
+
+        $nonVisitJoin = sprintf("%s.%s = %s.%s", $logTableToJoin->getName(), $logTableToJoin->getColumnToJoinOnIdAction(),
+                                                 $tableName, $logTable->getColumnToJoinOnIdAction());
+
+        $altNonVisitJoin = sprintf("%s.%s = %s.%s", $tableName, $logTable->getColumnToJoinOnIdAction(),
+                                                    $logTableToJoin->getName(), $logTableToJoin->getColumnToJoinOnIdAction());
+
+        if ($index > 0
+            && $this->tables->hasAddedTableManually($tableName)
+            && !$this->tables->hasJoinedTableManually($tableName, $nonVisitJoin)
+            && !$this->tables->hasJoinedTableManually($tableName, $altNonVisitJoin)) {
+            $tableIndex = $this->tables->findIndexOfManuallyAddedTable($tableName);
+            $nonVisitJoin = '(' . $this->tables[$tableIndex]['joinOn'] . ' AND ' . $nonVisitJoin . ')';
+            unset($this->tables[$tableIndex]);
+        }
+
+        if (!isset($this->nonVisitJoins[$tableName])) {
+            $this->nonVisitJoins[$tableName] = array();
+        }
+
+        if (!isset($this->nonVisitJoins[$tableNameToJoin])) {
+            $this->nonVisitJoins[$tableNameToJoin] = array();
+        }
+
+        $this->nonVisitJoins[$tableName][$tableNameToJoin] = $nonVisitJoin;
+        $this->nonVisitJoins[$tableNameToJoin][$tableName] = $nonVisitJoin;
+    }
+
+    public function sortTablesForJoin($tA, $tB)
+    {
+        $coreSort = array(
+            'log_link_visit_action' => 0,
+            'log_action' => 1,
+            'log_visit' => 2,
+            'log_conversion' => 3,
+            'log_conversion_item' => 4
+        );
+
+        if (is_array($tA) && is_array($tB)) {
+            return 0;
+        }
+
+        if (is_array($tA)) {
+            return -1;
+        }
+
+        if (is_array($tB)) {
+            return 1;
+        }
+
+        if (isset($coreSort[$tA])) {
+            $weightA = $coreSort[$tA];
+        } else {
+            $weightA = 999;
+        }
+        if (isset($coreSort[$tB])) {
+            $weightB = $coreSort[$tB];
+        } else {
+            $weightB = 999;
+        }
+
+        if ($weightA === $weightB) {
+            return 0;
+        }
+
+        if ($weightA > $weightB) {
+            return 1;
+        }
+
+        return -1;
+    }
+    
+}
diff --git a/core/DataAccess/LogQueryBuilder/JoinTables.php b/core/DataAccess/LogQueryBuilder/JoinTables.php
new file mode 100644
index 0000000000..d840cfba3f
--- /dev/null
+++ b/core/DataAccess/LogQueryBuilder/JoinTables.php
@@ -0,0 +1,114 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+
+namespace Piwik\DataAccess\LogQueryBuilder;
+
+use Exception;
+use Piwik\Plugin\LogTablesProvider;
+
+class JoinTables extends \ArrayObject
+{
+    /**
+     * @var LogTablesProvider
+     */
+    private $logTableProvider;
+
+    /**
+     * Tables constructor.
+     * @param LogTablesProvider $logTablesProvider
+     * @param array $tables
+     */
+    public function __construct(LogTablesProvider $logTablesProvider, $tables)
+    {
+        $this->logTableProvider = $logTablesProvider;
+
+        foreach ($tables as $table) {
+            $this->checkTableCanBeUsedForSegmentation($table);
+        }
+
+        $this->exchangeArray(array_values($tables));
+    }
+
+    public function getTables()
+    {
+        return $this->getArrayCopy();
+    }
+
+    public function addTableToJoin($tableName)
+    {
+        $this->checkTableCanBeUsedForSegmentation($tableName);
+        $this->append($tableName);
+    }
+
+    public function hasJoinedTable($tableName)
+    {
+        return in_array($tableName, $this->getTables());
+    }
+
+    public function hasJoinedTableManually($tableToFind, $joinToFind)
+    {
+        foreach ($this as $table) {
+            if (is_array($table)
+                && !empty($table['table'])
+                && $table['table'] === $tableToFind
+                && (!isset($table['tableAlias']) || $table['tableAlias'] === $tableToFind)
+                && isset($table['joinOn']) && $table['joinOn'] === $joinToFind) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public function getLogTable($tableName)
+    {
+        return $this->logTableProvider->getLogTable($tableName);
+    }
+
+    public function findIndexOfManuallyAddedTable($tableNameToFind)
+    {
+        foreach ($this as $index => $table) {
+            if (is_array($table)
+                && !empty($table['table'])
+                && $table['table'] === $tableNameToFind
+                && (!isset($table['tableAlias']) || $table['tableAlias'] === $tableNameToFind)) {
+                return $index;
+            }
+        }
+    }
+
+    public function hasAddedTableManually($tableToFind)
+    {
+        $table = $this->findIndexOfManuallyAddedTable($tableToFind);
+
+        return isset($table);
+    }
+
+    public function sort($cmpFunction)
+    {
+        // we do not use $this->uasort as we do not want to maintain keys
+        $tables = $this->getTables();
+
+        // we need to make sure first table always comes first, only sort tables after the first table
+        $firstTable = array_shift($tables);
+        usort($tables, $cmpFunction);
+        array_unshift($tables, $firstTable);
+
+        $this->exchangeArray($tables);
+    }
+
+    private function checkTableCanBeUsedForSegmentation($tableName)
+    {
+        if (!is_array($tableName) && !$this->getLogTable($tableName)) {
+            throw new Exception("Table '$tableName' can't be used for segmentation");
+        }
+    }
+
+    
+}
diff --git a/core/Plugin/LogTablesProvider.php b/core/Plugin/LogTablesProvider.php
new file mode 100644
index 0000000000..92afca14c7
--- /dev/null
+++ b/core/Plugin/LogTablesProvider.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugin;
+
+use Piwik\Container\StaticContainer;
+use Piwik\Tracker\LogTable;
+
+class LogTablesProvider {
+
+    /**
+     * @var Manager
+     */
+    private $pluginManager;
+
+    /**
+     * @var LogTable[]
+     */
+    private $tablesCache;
+
+    public function __construct(Manager $pluginManager)
+    {
+        $this->pluginManager = $pluginManager;
+    }
+
+    /**
+     * Get an instance of a specific log table if such a log table exists.
+     *
+     * @param string $tableNameWithoutPrefix  eg "log_visit"
+     * @return LogTable|null
+     */
+    public function getLogTable($tableNameWithoutPrefix)
+    {
+        foreach ($this->getAllLogTables() as $table) {
+            if ($table->getName() === $tableNameWithoutPrefix) {
+                return $table;
+            }
+        }
+    }
+
+    /**
+     * Get all log table instances defined by any activated and loaded plugin. The returned tables are not sorted in
+     * any order.
+     * @return LogTable[]
+     */
+    public function getAllLogTables()
+    {
+        if (!isset($this->tablesCache)) {
+            $tables = $this->pluginManager->findMultipleComponents('Tracker', 'Piwik\\Tracker\\LogTable');
+
+            $this->tablesCache = array();
+            foreach ($tables as $table) {
+                $this->tablesCache[] = StaticContainer::get($table);
+            }
+        }
+
+        return $this->tablesCache;
+    }
+
+}
diff --git a/core/Tracker/LogTable.php b/core/Tracker/LogTable.php
new file mode 100644
index 0000000000..8e66f43463
--- /dev/null
+++ b/core/Tracker/LogTable.php
@@ -0,0 +1,75 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Tracker;
+
+/**
+ * Base class for LogTables. You need to create a log table eg if you want to be able to create a segment for a custom
+ * log table.
+ */
+abstract class LogTable {
+
+    /**
+     * Get the unprefixed database table name. For example 'log_visit' or 'log_action'.
+     * @return string
+     */
+    abstract public function getName();
+
+    /**
+     * Get the name of the column that can be used to join a visit with another table. This is the name of the column
+     * that represents the "idvisit".
+     * @return string
+     */
+    public function getColumnToJoinOnIdVisit()
+    {
+        return '';
+    }
+
+    /**
+     * Get the name of the column that can be used to join an action with another table. This is the name of the column
+     * that represents the "idaction".
+     *
+     * This could be more generic eg by specifiying "$this->joinableOn = array('action' => 'idaction') and this
+     * would allow to also add more complex structures in the future but not needed for now I'd say. Let's go with
+     * simpler, more clean and expressive solution for now until needed.
+     *
+     * @return string
+     */
+    public function getColumnToJoinOnIdAction()
+    {
+        return '';
+    }
+
+    /**
+     * Defines whether this table should be joined via a subselect. Return true if a complex join is needed. (eg when
+     * having visits and needing actions, or when having visits and needing conversions, or vice versa).
+     * @return bool
+     */
+    public function shouldJoinWithSubSelect()
+    {
+        return false;
+    }
+    
+    /**
+     * Returns the name of a log table that allows to join on a visit. Eg if there is a table "action", and it is not
+     * joinable with "visit" table, it can return "log_link_visit_action" to be able to join the action table on visit
+     * via this link table.
+     *
+     * In theory there could be case where it may be needed to join via two tables, so it could be needed at some
+     * point to return an array of tables here. not sure if we should handle this case just yet. Alternatively,
+     * once needed eg in LogQueryBuilder, we should maybe better call instead ->getLinkTableToBeAbleToJoinOnVisit()
+     * again on the returned table until we have found a table that can be joined with visit.
+     *
+     * @return string
+     */
+    public function getLinkTableToBeAbleToJoinOnVisit()
+    {
+        return;
+    }
+
+}
diff --git a/core/Tracker/Visit.php b/core/Tracker/Visit.php
index f4485f52fb..9de1490424 100644
--- a/core/Tracker/Visit.php
+++ b/core/Tracker/Visit.php
@@ -397,6 +397,11 @@ class Visit implements VisitInterface
      */
     protected function updateExistingVisit($valuesToUpdate)
     {
+        if (empty($valuesToUpdate)) {
+            Common::printDebug('There are no values to be updated for this visit');
+            return;
+        }
+
         $idSite = $this->request->getIdSite();
         $idVisit = (int)$this->visitProperties->getProperty('idvisit');
 
diff --git a/plugins/CoreHome/Tracker/LogTable/Action.php b/plugins/CoreHome/Tracker/LogTable/Action.php
new file mode 100644
index 0000000000..fdb69e5ec6
--- /dev/null
+++ b/plugins/CoreHome/Tracker/LogTable/Action.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\CoreHome\Tracker\LogTable;
+
+use Piwik\Tracker\LogTable;
+
+class Action extends LogTable
+{
+    public function getName()
+    {
+        return 'log_action';
+    }
+
+    public function getColumnToJoinOnIdAction()
+    {
+        return 'idaction';
+    }
+
+    public function getLinkTableToBeAbleToJoinOnVisit()
+    {
+        return 'log_link_visit_action';
+    }
+
+}
diff --git a/plugins/CoreHome/Tracker/LogTable/Conversion.php b/plugins/CoreHome/Tracker/LogTable/Conversion.php
new file mode 100644
index 0000000000..e920864088
--- /dev/null
+++ b/plugins/CoreHome/Tracker/LogTable/Conversion.php
@@ -0,0 +1,24 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\CoreHome\Tracker\LogTable;
+
+use Piwik\Tracker\LogTable;
+
+class Conversion extends LogTable
+{
+    public function getName()
+    {
+        return 'log_conversion';
+    }
+
+    public function getColumnToJoinOnIdVisit()
+    {
+        return 'idvisit';
+    }
+}
\ No newline at end of file
diff --git a/plugins/CoreHome/Tracker/LogTable/ConversionItem.php b/plugins/CoreHome/Tracker/LogTable/ConversionItem.php
new file mode 100644
index 0000000000..566841b4fa
--- /dev/null
+++ b/plugins/CoreHome/Tracker/LogTable/ConversionItem.php
@@ -0,0 +1,25 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\CoreHome\Tracker\LogTable;
+
+use Piwik\Tracker\LogTable;
+
+class ConversionItem extends LogTable
+{
+    public function getName()
+    {
+        return 'log_conversion_item';
+    }
+
+    public function getColumnToJoinOnIdVisit()
+    {
+        return 'idvisit';
+    }
+    
+}
diff --git a/plugins/CoreHome/Tracker/LogTable/LinkVisitAction.php b/plugins/CoreHome/Tracker/LogTable/LinkVisitAction.php
new file mode 100644
index 0000000000..cb102d407c
--- /dev/null
+++ b/plugins/CoreHome/Tracker/LogTable/LinkVisitAction.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\CoreHome\Tracker\LogTable;
+
+use Piwik\Tracker\LogTable;
+
+class LinkVisitAction extends LogTable
+{
+    public function getName()
+    {
+        return 'log_link_visit_action';
+    }
+
+    public function getColumnToJoinOnIdAction()
+    {
+        return 'idaction_url';
+    }
+
+    public function getColumnToJoinOnIdVisit()
+    {
+        return 'idvisit';
+    }
+}
diff --git a/plugins/CoreHome/Tracker/LogTable/Visit.php b/plugins/CoreHome/Tracker/LogTable/Visit.php
new file mode 100644
index 0000000000..f403c37697
--- /dev/null
+++ b/plugins/CoreHome/Tracker/LogTable/Visit.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\CoreHome\Tracker\LogTable;
+
+use Piwik\Tracker\LogTable;
+
+class Visit extends LogTable
+{
+    public function getName()
+    {
+        return 'log_visit';
+    }
+
+    public function getColumnToJoinOnIdVisit()
+    {
+        return 'idvisit';
+    }
+
+    public function shouldJoinWithSubSelect()
+    {
+        return true;
+    }
+
+}
\ No newline at end of file
diff --git a/plugins/SegmentEditor/SegmentQueryDecorator.php b/plugins/SegmentEditor/SegmentQueryDecorator.php
index d501f6f9b1..130cc59bd5 100644
--- a/plugins/SegmentEditor/SegmentQueryDecorator.php
+++ b/plugins/SegmentEditor/SegmentQueryDecorator.php
@@ -9,6 +9,7 @@
 namespace Piwik\Plugins\SegmentEditor;
 
 use Piwik\DataAccess\LogQueryBuilder;
+use Piwik\Plugin\LogTablesProvider;
 use Piwik\Plugins\SegmentEditor\Services\StoredSegmentService;
 use Piwik\Segment\SegmentExpression;
 use Piwik\SettingsServer;
@@ -26,9 +27,10 @@ class SegmentQueryDecorator extends LogQueryBuilder
      */
     private $storedSegmentService;
 
-    public function __construct(StoredSegmentService $storedSegmentService)
+    public function __construct(StoredSegmentService $storedSegmentService, LogTablesProvider $logTablesProvider)
     {
         $this->storedSegmentService = $storedSegmentService;
+        parent::__construct($logTablesProvider);
     }
 
     public function getSelectQueryString(SegmentExpression $segmentExpression, $select, $from, $where, $bind, $groupBy,
diff --git a/plugins/SegmentEditor/tests/Unit/SegmentQueryDecoratorTest.php b/plugins/SegmentEditor/tests/Unit/SegmentQueryDecoratorTest.php
index 6ecad82f2f..7e38188966 100644
--- a/plugins/SegmentEditor/tests/Unit/SegmentQueryDecoratorTest.php
+++ b/plugins/SegmentEditor/tests/Unit/SegmentQueryDecoratorTest.php
@@ -11,6 +11,7 @@ namespace Piwik\Plugins\SegmentEditor\tests\Unit;
 use Piwik\Plugins\SegmentEditor\SegmentQueryDecorator;
 use Piwik\Plugins\SegmentEditor\Services\StoredSegmentService;
 use Piwik\Segment\SegmentExpression;
+use Piwik\Tests\Framework\Mock\Plugin\LogTablesProvider;
 
 /**
  * @group SegmentEditor
@@ -36,7 +37,8 @@ class SegmentQueryDecoratorTest extends \PHPUnit_Framework_TestCase
 
         /** @var StoredSegmentService $service */
         $service = $this->getMockSegmentEditorService();
-        $this->decorator = new SegmentQueryDecorator($service);
+        $logTables = new LogTablesProvider();
+        $this->decorator = new SegmentQueryDecorator($service, $logTables);
     }
 
     public function test_getSelectQueryString_DoesNotDecorateSql_WhenNoSegmentUsed()
@@ -86,4 +88,5 @@ class SegmentQueryDecoratorTest extends \PHPUnit_Framework_TestCase
         $mock->expects($this->any())->method('getAllSegmentsAndIgnoreVisibility')->willReturn(self::$storedSegments);
         return $mock;
     }
+
 }
diff --git a/tests/PHPUnit/Framework/Mock/Plugin/LogTablesProvider.php b/tests/PHPUnit/Framework/Mock/Plugin/LogTablesProvider.php
new file mode 100644
index 0000000000..c563e250cd
--- /dev/null
+++ b/tests/PHPUnit/Framework/Mock/Plugin/LogTablesProvider.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Piwik\Tests\Framework\Mock\Plugin;
+
+use Piwik\Plugins\CoreHome\Tracker\LogTable\Action;
+use Piwik\Plugins\CoreHome\Tracker\LogTable\Conversion;
+use Piwik\Plugins\CoreHome\Tracker\LogTable\ConversionItem;
+use Piwik\Plugins\CoreHome\Tracker\LogTable\LinkVisitAction;
+use Piwik\Plugins\CoreHome\Tracker\LogTable\Visit;
+
+class LogTablesProvider extends \Piwik\Plugin\LogTablesProvider
+{
+    public function __construct()
+    {
+    }
+
+    public function getAllLogTables()
+    {
+        return array(
+            new Visit(),
+            new Action(),
+            new LinkVisitAction(),
+            new ConversionItem(),
+            new Conversion()
+        );
+    }
+
+}
diff --git a/tests/PHPUnit/Integration/SegmentTest.php b/tests/PHPUnit/Integration/SegmentTest.php
index 9f626e7969..37d13cc2e7 100644
--- a/tests/PHPUnit/Integration/SegmentTest.php
+++ b/tests/PHPUnit/Integration/SegmentTest.php
@@ -271,7 +271,7 @@ class SegmentTest extends IntegrationTestCase
                     *
                 FROM
                     " . Common::prefixTable('log_conversion') . " AS log_conversion
-                    LEFT JOIN " . Common::prefixTable('log_link_visit_action') . " AS log_link_visit_action ON log_conversion.idvisit = log_link_visit_action.idvisit
+                    LEFT JOIN " . Common::prefixTable('log_link_visit_action') . " AS log_link_visit_action ON log_link_visit_action.idvisit = log_conversion.idvisit
                 WHERE
                     ( log_conversion.idvisit = ? )
                     AND
@@ -361,7 +361,7 @@ class SegmentTest extends IntegrationTestCase
                     *
                 FROM
                     " . Common::prefixTable('log_conversion') . " AS log_conversion
-                    LEFT JOIN " . Common::prefixTable('log_visit') . " AS log_visit ON log_conversion.idvisit = log_visit.idvisit
+                    LEFT JOIN " . Common::prefixTable('log_visit') . " AS log_visit ON log_visit.idvisit = log_conversion.idvisit
                 WHERE
                     ( log_conversion.idvisit = ? )
                     AND
@@ -524,7 +524,7 @@ class SegmentTest extends IntegrationTestCase
                 FROM
                     " . Common::prefixTable('log_visit') . " AS log_visit
                     LEFT JOIN " . Common::prefixTable('log_link_visit_action') . " AS log_link_visit_action ON log_link_visit_action.idvisit = log_visit.idvisit
-                    LEFT JOIN " . Common::prefixTable('log_conversion') . " AS log_conversion ON log_conversion.idvisit = log_link_visit_action.idvisit
+                    LEFT JOIN " . Common::prefixTable('log_conversion') . " AS log_conversion ON log_conversion.idvisit = log_visit.idvisit
                 WHERE
                      log_conversion.idgoal = ? AND HOUR(log_visit.visit_last_action_time) = ? AND log_link_visit_action.custom_var_k1 = ?
                       AND (
@@ -693,7 +693,7 @@ class SegmentTest extends IntegrationTestCase
              SELECT log_conversion.idgoal AS `idgoal`, log_conversion.custom_dimension_1 AS `custom_dimension_1`, count(*) AS `1`, count(distinct log_conversion.idvisit) AS `3`,
              FROM $logConversionsTable AS log_conversion
                   LEFT JOIN $logLinkVisitActionTable AS log_link_visit_action
-                       ON log_conversion.idvisit = log_link_visit_action.idvisit
+                       ON log_link_visit_action.idvisit = log_conversion.idvisit
                   LEFT JOIN $logActionTable AS log_action
                        ON log_link_visit_action.idaction_url = log_action.idaction
              WHERE ( log_conversion.server_time >= ?
@@ -761,7 +761,7 @@ class SegmentTest extends IntegrationTestCase
                     SUM(log_conversion.items) AS `8`
                 FROM
                     " . Common::prefixTable('log_conversion') . " AS log_conversion
-                    LEFT JOIN " . Common::prefixTable('log_link_visit_action') . " AS log_link_visit_action ON log_conversion.idvisit = log_link_visit_action.idvisit
+                    LEFT JOIN " . Common::prefixTable('log_link_visit_action') . " AS log_link_visit_action ON log_link_visit_action.idvisit = log_conversion.idvisit
                 WHERE
                     ( log_conversion.idsite IN (?) )
                     AND
@@ -1289,7 +1289,7 @@ class SegmentTest extends IntegrationTestCase
                 FROM (
                       SELECT log_conversion.idgoal, log_conversion.idvisit, log_conversion.revenue, log_conversion.items
                       FROM $logConversionTable AS log_conversion
-                        LEFT JOIN $logLinkVisitActionTable AS log_link_visit_action ON log_conversion.idvisit = log_link_visit_action.idvisit
+                        LEFT JOIN $logLinkVisitActionTable AS log_link_visit_action ON log_link_visit_action.idvisit = log_conversion.idvisit
                       WHERE ( log_conversion.server_time >= ?
                           AND log_conversion.server_time <= ?
                           AND log_conversion.idsite IN (?) )
@@ -1340,7 +1340,7 @@ class SegmentTest extends IntegrationTestCase
                 FROM (
                       SELECT log_conversion.idgoal, log_conversion.referer_type, log_conversion.referer_name, log_conversion.referer_keyword, log_conversion.idvisit, log_conversion.revenue
                       FROM $logConversionTable AS log_conversion
-                        LEFT JOIN $logLinkVisitActionTable AS log_link_visit_action ON log_conversion.idvisit = log_link_visit_action.idvisit
+                        LEFT JOIN $logLinkVisitActionTable AS log_link_visit_action ON log_link_visit_action.idvisit = log_conversion.idvisit
                       WHERE ( log_conversion.server_time >= ?
                           AND log_conversion.server_time <= ?
                           AND log_conversion.idsite IN (?) )
@@ -1385,8 +1385,8 @@ class SegmentTest extends IntegrationTestCase
                 FROM (
                     SELECT log_conversion.idgoal, log_conversion.idvisit, log_conversion.revenue
                     FROM $logConversionTable AS log_conversion
-                       LEFT JOIN $logLinkVisitActionTable AS log_link_visit_action ON log_conversion.idvisit = log_link_visit_action.idvisit
-                       LEFT JOIN $logVisitTable AS log_visit ON log_visit.idvisit = log_link_visit_action.idvisit
+                       LEFT JOIN $logLinkVisitActionTable AS log_link_visit_action ON log_link_visit_action.idvisit = log_conversion.idvisit
+                       LEFT JOIN $logVisitTable AS log_visit ON log_visit.idvisit = log_conversion.idvisit
                     WHERE ( log_conversion.server_time >= ?
                         AND log_conversion.server_time <= ?
                         AND log_conversion.idsite IN (?) )
diff --git a/tests/PHPUnit/Unit/DataAccess/LogQueryBuilder/JoinGeneratorTest.php b/tests/PHPUnit/Unit/DataAccess/LogQueryBuilder/JoinGeneratorTest.php
new file mode 100644
index 0000000000..cd572e4849
--- /dev/null
+++ b/tests/PHPUnit/Unit/DataAccess/LogQueryBuilder/JoinGeneratorTest.php
@@ -0,0 +1,171 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Tests\Unit\DataAccess;
+
+use Piwik\DataAccess\LogQueryBuilder\JoinGenerator;
+use Piwik\DataAccess\LogQueryBuilder\JoinTables;
+use Piwik\Tests\Framework\Mock\Plugin\LogTablesProvider;
+use Piwik\Tracker\Visit;
+
+/**
+ * @group Core
+ */
+class JoinGeneratorTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @var JoinGenerator
+     */
+    private $generator;
+
+    public function setUp()
+    {
+        $this->generator = $this->makeTables(array(
+            'log_visit',
+            array('table' => 'log_conversion', 'joinOn' => 'log_conversion.idvisit = log_visit.idvisit'),
+            'log_action'));
+    }
+
+    public function test_constructor_shouldAddTablesIfNeeded()
+    {
+        $tables = $this->makeTables(array('log_visit', 'log_action'));
+        $this->makeGenerator($tables);
+
+        $this->assertEquals(array('log_visit', 'log_action', 'log_link_visit_action'), $tables->getTables());
+    }
+
+    public function test_generate_shouldJoinWithSubselect_IfBaseTableIsLogVisit()
+    {
+        $generator = $this->generate(array('log_visit', 'log_action'));
+        $this->assertTrue($generator->shouldJoinWithSelect());
+    }
+
+    public function test_generate_shouldNotJoinWithSubselect_IfBaseTableIsLogVisitButNoTableToJoin()
+    {
+        $generator = $this->generate(array('log_visit'));
+        $this->assertFalse($generator->shouldJoinWithSelect());
+    }
+
+    public function test_generate_shouldNotJoinWithSubselect_IfLogVisitIsGivenButItIsNotBaseTable()
+    {
+        $generator = $this->generate(array('log_conversion', 'log_visit'));
+        $this->assertFalse($generator->shouldJoinWithSelect());
+    }
+
+    public function test_generate_getJoinString()
+    {
+        $generator = $this->generate(array('log_action', 'log_link_visit_action', 'log_visit'));
+
+        $expected  = 'log_action AS log_action ';
+        $expected .= 'LEFT JOIN log_link_visit_action AS log_link_visit_action ON log_link_visit_action.idaction_url = log_action.idaction ';
+        $expected .= 'LEFT JOIN log_visit AS log_visit ON log_visit.idvisit = log_link_visit_action.idvisit';
+        $this->assertEquals($expected, $generator->getJoinString());
+    }
+
+    public function test_generate_getJoinString_OnlyOneTable()
+    {
+        $generator = $this->generate(array('log_visit'));
+        $this->assertEquals('log_visit AS log_visit', $generator->getJoinString());
+    }
+
+    public function test_generate_getJoinString_OnlyOneActionTable()
+    {
+        $generator = $this->generate(array('log_action'));
+        $this->assertEquals('log_action AS log_action', $generator->getJoinString());
+    }
+
+    public function test_generate_getJoinString_OnlyActionTables()
+    {
+        $generator = $this->generate(array('log_link_visit_action', 'log_action'));
+        $expected  = 'log_link_visit_action AS log_link_visit_action';
+        $expected .= ' LEFT JOIN log_action AS log_action ON log_link_visit_action.idaction_url = log_action.idaction';
+        $this->assertEquals($expected, $generator->getJoinString());
+    }
+
+    public function test_generate_getJoinString_manuallyJoinedAlready()
+    {
+        $generator = $this->generate(array(
+            'log_link_visit_action',
+            array('table' => 'log_visit', 'joinOn' => 'log_visit.idvisit = log_link_visit_action.idvisit'),
+            array('table' => 'log_action', 'joinOn' => 'log_link_visit_action.idaction_name = log_action.idaction'),
+            'log_action'
+        ));
+
+        $expected  = 'log_link_visit_action AS log_link_visit_action ';
+        $expected .= 'LEFT JOIN log_visit AS log_visit ON log_visit.idvisit = log_link_visit_action.idvisit ';
+        $expected .= 'LEFT JOIN log_action AS log_action ON (log_link_visit_action.idaction_name = log_action.idaction AND log_link_visit_action.idaction_url = log_action.idaction)';
+        $this->assertEquals($expected, $generator->getJoinString());
+    }
+
+    public function test_generate_getJoinString_allTables()
+    {
+        $generator = $this->generate(array(
+            'log_action',
+            'log_conversion_item',
+            'log_link_visit_action',
+            'log_conversion',
+            'log_visit',
+        ));
+
+        $expected  = 'log_action AS log_action ';
+        $expected .= 'LEFT JOIN log_link_visit_action AS log_link_visit_action ON log_link_visit_action.idaction_url = log_action.idaction ';
+        $expected .= 'LEFT JOIN log_visit AS log_visit ON log_visit.idvisit = log_link_visit_action.idvisit ';
+        $expected .= 'LEFT JOIN log_conversion AS log_conversion ON log_conversion.idvisit = log_link_visit_action.idvisit ';
+        $expected .= 'LEFT JOIN log_conversion_item AS log_conversion_item ON log_conversion_item.idvisit = log_link_visit_action.idvisit';
+        $this->assertEquals($expected, $generator->getJoinString());
+    }
+
+    public function test_sortTablesForJoin_shouldSortTablesAsSpecified()
+    {
+        $tables = array(
+            'log_action',
+            array('table' => 'log_conversion', 'joinOn' => 'log_conversion.idvisit = log_visit.idvisit'),
+            'log_conversion_item',
+            'log_link_visit_action',
+            'log_conversion',
+            'log_visit',
+        );
+
+        $generator = $this->makeGenerator($tables);
+        $tables[] = 'log_foo_bar';
+        usort($tables, array($generator, 'sortTablesForJoin'));
+
+        $expected = array(
+            array('table' => 'log_conversion', 'joinOn' => 'log_conversion.idvisit = log_visit.idvisit'),
+            'log_link_visit_action',
+            'log_action',
+            'log_visit',
+            'log_conversion',
+            'log_conversion_item',
+            'log_foo_bar'
+        );
+
+        $this->assertEquals($expected, $tables);
+    }
+
+    private function generate($tables)
+    {
+        $generator = $this->makeGenerator($tables);
+        $generator->generate();
+        return $generator;
+    }
+
+    private function makeGenerator($tables)
+    {
+        if (is_array($tables)) {
+            $tables = $this->makeTables($tables);
+        }
+
+        return new JoinGenerator($tables);
+    }
+
+    private function makeTables($tables)
+    {
+        return new JoinTables(new LogTablesProvider(), $tables);
+    }
+}
\ No newline at end of file
diff --git a/tests/PHPUnit/Unit/DataAccess/LogQueryBuilder/JoinTablesTest.php b/tests/PHPUnit/Unit/DataAccess/LogQueryBuilder/JoinTablesTest.php
new file mode 100644
index 0000000000..8f5a52fd0b
--- /dev/null
+++ b/tests/PHPUnit/Unit/DataAccess/LogQueryBuilder/JoinTablesTest.php
@@ -0,0 +1,146 @@
+<?php
+/**
+ * Piwik - free/libre analytics platform
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Tests\Unit\DataAccess;
+
+use Piwik\DataAccess\LogQueryBuilder\JoinTables;
+use Piwik\Tests\Framework\Mock\Plugin\LogTablesProvider;
+use Piwik\Tracker\Visit;
+
+/**
+ * @group Core
+ */
+class JoinTablesTest extends \PHPUnit_Framework_TestCase
+{
+    /**
+     * @var JoinTables
+     */
+    private $tables;
+
+    public function setUp()
+    {
+        $this->tables = $this->makeTables(array(
+            'log_visit',
+            array('table' => 'log_conversion', 'joinOn' => 'log_conversion.idvisit = log_visit.idvisit'),
+            'log_action'));
+    }
+
+    /**
+     * @expectedException \Exception
+     * @expectedExceptionMessage Table 'log_foo_bar_baz' can't be used for segmentation
+     */
+    public function test_construct_shouldThrowException_IfTableIsNotPossibleToJoin()
+    {
+        $this->makeTables(array('log_visit', 'log_foo_bar_baz'));
+    }
+
+    public function test_hasJoinedTable_shouldDetectIfTableIsAlreadyAdded()
+    {
+        $this->assertTrue($this->tables->hasJoinedTable('log_visit'));
+        $this->assertTrue($this->tables->hasJoinedTable('log_action'));
+
+        $this->assertFalse($this->tables->hasJoinedTable('log_foo_bar_baz'));
+        $this->assertFalse($this->tables->hasJoinedTable('log_conversion')); // we do not check for manually joined tables
+    }
+
+    public function test_addTableToJoin_shouldAddGivenTable()
+    {
+        $table = 'log_conversion_item';
+        $this->assertFalse($this->tables->hasJoinedTable($table));
+
+        $this->tables->addTableToJoin($table);
+
+        $this->assertTrue($this->tables->hasJoinedTable($table));
+    }
+
+    /**
+     * @expectedException \Exception
+     * @expectedExceptionMessage Table 'log_foo_bar_baz' can't be used for segmentation
+     */
+    public function test_addTableToJoin_shouldCheckIfTableCanBeUsedForSegmentation()
+    {
+        $table = 'log_foo_bar_baz';
+        $this->assertFalse($this->tables->hasJoinedTable($table));
+
+        $this->tables->addTableToJoin($table);
+
+        $this->assertTrue($this->tables->hasJoinedTable($table));
+    }
+
+    public function test_hasJoinedTableManually_shouldReturnTrue_IfTableJoinExistsExactlyAsGiven()
+    {
+        $result = $this->tables->hasJoinedTableManually('log_conversion', 'log_conversion.idvisit = log_visit.idvisit');
+
+        $this->assertTrue($result);
+    }
+
+    public function test_hasJoinedTableManually_shouldReturnFalse_IfTableOrJoinDoesNotMatch()
+    {
+        $result = $this->tables->hasJoinedTableManually('log_foo_bar_baz', 'log_conversion.idvisit = log_visit.idvisit');
+        $this->assertFalse($result);
+
+        $result = $this->tables->hasJoinedTableManually('log_conversion', 'log_foo_bar_baz.idvisit = log_visit.idvisit');
+        $this->assertFalse($result);
+    }
+
+    public function test_hasAddedTableManually_shouldReturnTrue_IfTableWasAddedManually()
+    {
+        $result = $this->tables->hasAddedTableManually('log_conversion');
+
+        $this->assertTrue($result);
+    }
+
+    public function test_hasAddedTableManually_shouldReturnFalse_IfTableWasNotAddedManually()
+    {
+        $result = $this->tables->hasAddedTableManually('log_foo_bar_baz');
+        $this->assertFalse($result);
+
+        $result = $this->tables->hasAddedTableManually('log_conversion_item');
+        $this->assertFalse($result);
+    }
+
+    public function test_getLogTable_shouldReturnInstanceOfLogTable_IfTableExists()
+    {
+        $visit = $this->tables->getLogTable('log_visit');
+        $this->assertFalse($visit instanceof Visit);
+    }
+
+    public function test_getLogTable_shouldReturnNull_IfLogTableDoesNotExist()
+    {
+        $visit = $this->tables->getLogTable('log_foo_bar_baz');
+        $this->assertNull($visit);
+    }
+
+    public function test_findIndexOfManuallyAddedTable_shouldReturnTheIndex_IfTableWasAddedManually()
+    {
+        $this->assertSame(1, $this->tables->findIndexOfManuallyAddedTable('log_conversion'));
+    }
+
+    public function test_findIndexOfManuallyAddedTable_shouldReturnNull_IfTableWasNotAddedManually()
+    {
+        $this->assertNull($this->tables->findIndexOfManuallyAddedTable('log_visit'));
+        $this->assertNull($this->tables->findIndexOfManuallyAddedTable('log_action'));
+        $this->assertNull($this->tables->findIndexOfManuallyAddedTable('log_foo_bar_baz'));
+    }
+
+    public function test_sort_shouldNeverSortFirstEntry_AndNotMaintainKeys()
+    {
+        $tables = $this->makeTables(array('log_conversion', 'log_visit', 'log_action', 'log_conversion_item'));
+        $tables->sort(function($a, $b) {
+            return strcmp($a, $b);
+        });
+
+        $expected = array('log_conversion', 'log_action', 'log_conversion_item', 'log_visit');
+        $this->assertEquals($expected, $tables->getTables());
+    }
+
+    private function makeTables($tables)
+    {
+        return new JoinTables(new LogTablesProvider(), $tables);
+    }
+}
\ No newline at end of file
-- 
GitLab