From 98bc277a08af76e7f498b37b13dada76be727e8f Mon Sep 17 00:00:00 2001
From: Thomas Steur <thomas.steur@googlemail.com>
Date: Thu, 3 Apr 2014 01:11:18 +0200
Subject: [PATCH] started to work on command to set number of available custom
 variables

---
 core/Db.php                                   |  13 ++
 core/Db/Schema/Mysql.php                      |  31 +--
 .../Commands/SetNumberOfCustomVariables.php   | 190 ++++++++++++++++++
 plugins/CustomVariables/CustomVariables.php   |  11 +
 plugins/CustomVariables/Model.php             | 124 ++++++++++++
 plugins/CustomVariables/tests/ModelTest.php   | 113 +++++++++++
 tests/PHPUnit/Integration/Core/DbTest.php     |  32 +++
 7 files changed, 484 insertions(+), 30 deletions(-)
 create mode 100644 plugins/CustomVariables/Commands/SetNumberOfCustomVariables.php
 create mode 100644 plugins/CustomVariables/Model.php
 create mode 100644 plugins/CustomVariables/tests/ModelTest.php
 create mode 100644 tests/PHPUnit/Integration/Core/DbTest.php

diff --git a/core/Db.php b/core/Db.php
index f099d01409..3647a5878f 100644
--- a/core/Db.php
+++ b/core/Db.php
@@ -330,6 +330,19 @@ class Db
         return self::query("DROP TABLE " . implode(',', $tables));
     }
 
+    /**
+     * Get columns information from table
+     *
+     * @param string|array $table The name of the table you want to get the columns definition for.
+     * @return \Zend_Db_Statement
+     */
+    static public function getColumnNamesFromTable($table)
+    {
+        $columns = self::fetchAssoc("SHOW COLUMNS FROM " . $table);
+
+        return array_keys($columns);
+    }
+
     /**
      * Locks the supplied table or tables.
      * 
diff --git a/core/Db/Schema/Mysql.php b/core/Db/Schema/Mysql.php
index fa326a9dc4..34c3764f2f 100644
--- a/core/Db/Schema/Mysql.php
+++ b/core/Db/Schema/Mysql.php
@@ -192,16 +192,6 @@ class Mysql implements SchemaInterface
 							  location_city varchar(255) DEFAULT NULL,
 							  location_latitude float(10, 6) DEFAULT NULL,
 							  location_longitude float(10, 6) DEFAULT NULL,
-							  custom_var_k1 VARCHAR(200) DEFAULT NULL,
-							  custom_var_v1 VARCHAR(200) DEFAULT NULL,
-							  custom_var_k2 VARCHAR(200) DEFAULT NULL,
-							  custom_var_v2 VARCHAR(200) DEFAULT NULL,
-							  custom_var_k3 VARCHAR(200) DEFAULT NULL,
-							  custom_var_v3 VARCHAR(200) DEFAULT NULL,
-							  custom_var_k4 VARCHAR(200) DEFAULT NULL,
-							  custom_var_v4 VARCHAR(200) DEFAULT NULL,
-							  custom_var_k5 VARCHAR(200) DEFAULT NULL,
-							  custom_var_v5 VARCHAR(200) DEFAULT NULL,
 							  PRIMARY KEY(idvisit),
 							  INDEX index_idsite_config_datetime (idsite, config_id, visit_last_action_time),
 							  INDEX index_idsite_datetime (idsite, visit_last_action_time),
@@ -264,16 +254,6 @@ class Mysql implements SchemaInterface
 									  revenue_shipping float default NULL,
 									  revenue_discount float default NULL,
 
-									  custom_var_k1 VARCHAR(200) DEFAULT NULL,
-        							  custom_var_v1 VARCHAR(200) DEFAULT NULL,
-        							  custom_var_k2 VARCHAR(200) DEFAULT NULL,
-        							  custom_var_v2 VARCHAR(200) DEFAULT NULL,
-        							  custom_var_k3 VARCHAR(200) DEFAULT NULL,
-        							  custom_var_v3 VARCHAR(200) DEFAULT NULL,
-        							  custom_var_k4 VARCHAR(200) DEFAULT NULL,
-        							  custom_var_v4 VARCHAR(200) DEFAULT NULL,
-        							  custom_var_k5 VARCHAR(200) DEFAULT NULL,
-        							  custom_var_v5 VARCHAR(200) DEFAULT NULL,
 									  PRIMARY KEY (idvisit, idgoal, buster),
 									  UNIQUE KEY unique_idsite_idorder (idsite, idorder),
 									  INDEX index_idsite_datetime ( idsite, server_time )
@@ -293,16 +273,7 @@ class Mysql implements SchemaInterface
 											  idaction_event_category INTEGER(10) UNSIGNED DEFAULT NULL,
 											  idaction_event_action INTEGER(10) UNSIGNED DEFAULT NULL,
 											  time_spent_ref_action INTEGER(10) UNSIGNED NOT NULL,
-											  custom_var_k1 VARCHAR(200) DEFAULT NULL,
-											  custom_var_v1 VARCHAR(200) DEFAULT NULL,
-											  custom_var_k2 VARCHAR(200) DEFAULT NULL,
-											  custom_var_v2 VARCHAR(200) DEFAULT NULL,
-											  custom_var_k3 VARCHAR(200) DEFAULT NULL,
-											  custom_var_v3 VARCHAR(200) DEFAULT NULL,
-											  custom_var_k4 VARCHAR(200) DEFAULT NULL,
-											  custom_var_v4 VARCHAR(200) DEFAULT NULL,
-											  custom_var_k5 VARCHAR(200) DEFAULT NULL,
-											  custom_var_v5 VARCHAR(200) DEFAULT NULL,
+
 											  custom_float FLOAT NULL DEFAULT NULL,
 											  PRIMARY KEY(idlink_va),
 											  INDEX index_idvisit(idvisit),
diff --git a/plugins/CustomVariables/Commands/SetNumberOfCustomVariables.php b/plugins/CustomVariables/Commands/SetNumberOfCustomVariables.php
new file mode 100644
index 0000000000..57fa3f174d
--- /dev/null
+++ b/plugins/CustomVariables/Commands/SetNumberOfCustomVariables.php
@@ -0,0 +1,190 @@
+<?php
+/**
+ * Piwik - Open source web analytics
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+
+namespace Piwik\Plugins\CustomVariables\Commands;
+
+use Piwik\Plugin\ConsoleCommand;
+use Piwik\Plugins\CustomVariables\Model;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ */
+class SetNumberOfCustomVariables extends ConsoleCommand
+{
+    /**
+     * @var \Symfony\Component\Console\Helper\ProgressHelper
+     */
+    private $progress;
+
+    protected function configure()
+    {
+        $this->setName('customvariables:set-number-available-custom-variables');
+        $this->setDescription('Change the number of available custom variables');
+        $this->setHelp("Example:
+./console customvariables:set-number-available-custom-variables 10
+=> 10 custom variables will be available in total
+");
+        $this->addArgument('maxCustomVars', InputArgument::REQUIRED, 'The number of available custom variables');
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $numVarsToSet = $this->getNumVariablesToSet($input);
+        $numChangesToPerform = $this->getNumberOfChangesToPerform($numVarsToSet);
+
+        if (0 === $numChangesToPerform) {
+            $this->writeSuccessMessage($output, array(
+                'Your Piwik is already configured for ' . $numVarsToSet . ' custom variables.'
+            ));
+            return;
+        }
+
+        foreach (Model::getScopes() as $scope) {
+            $this->printChanges($scope, $numVarsToSet, $output);
+        }
+
+        if (!$this->confirmChange($output)) {
+            return;
+        }
+
+        $output->writeln('');
+        $output->writeln('Starting to apply changes');
+        $output->writeln('');
+
+        $this->progress = $this->initProgress($numChangesToPerform, $output);
+
+        foreach (Model::getScopes() as $scope) {
+            $this->performChange($scope, $numVarsToSet, $output);
+        }
+
+        $this->progress->finish();
+
+        $this->writeSuccessMessage($output, array(
+            'Your Piwik is now configured for ' . $numVarsToSet . ' custom variables.'
+        ));
+    }
+
+    private function initProgress($numChangesToPerform, OutputInterface $output)
+    {
+        /** @var \Symfony\Component\Console\Helper\ProgressHelper $progress */
+        $progress = $this->getHelperSet()->get('progress');
+        $progress->start($output, $numChangesToPerform);
+
+        return $progress;
+    }
+
+    private function performChange($scope, $numVarsToSet, OutputInterface $output)
+    {
+        $model = new Model($scope);
+        $numCurrentVars = $model->getCurrentNumCustomVars();
+        $numDifference  = $this->getAbsoluteDifference($numCurrentVars, $numVarsToSet);
+
+        if ($numVarsToSet > $numCurrentVars) {
+            $this->addCustomVariables($model, $numDifference, $output);
+            return;
+        }
+
+        $this->removeCustomVariables($model, $numDifference, $output);
+    }
+
+    private function getNumVariablesToSet(InputInterface $input)
+    {
+        $maxCustomVars = $input->getArgument('maxCustomVars');
+
+        if (!is_numeric($maxCustomVars)) {
+            throw new \Exception('The number of available custom variables has to be number');
+        }
+
+        $maxCustomVars = (int) $maxCustomVars;
+
+        if ($maxCustomVars <= 0) {
+            throw new \Exception('There has to be at least 1 custom variable');
+        }
+
+        return $maxCustomVars;
+    }
+
+    private function confirmChange(OutputInterface $output)
+    {
+        $output->writeln('');
+
+        $dialog = $this->getHelperSet()->get('dialog');
+        return $dialog->askConfirmation(
+            $output,
+            '<question>Are you sure you want to perform these actions? (y/N)</question>',
+            false
+        );
+    }
+
+    private function printChanges($scope, $numVarsToSet, OutputInterface $output)
+    {
+        $model                = new Model($scope);
+        $scopeName            = $model->getScopeName();
+        $highestIndex         = $model->getHighestCustomVarIndex();
+        $numCurrentCustomVars = $model->getCurrentNumCustomVars();
+        $numVarsDifference    = $this->getAbsoluteDifference($numCurrentCustomVars, $numVarsToSet);
+
+        $output->writeln('');
+        $output->writeln(sprintf('Scope "%s"', $scopeName));
+
+        if ($numVarsToSet > $numCurrentCustomVars) {
+
+            $indexes = implode(',', range($highestIndex + 1, $highestIndex + $numVarsDifference));
+            $output->writeln(
+                sprintf('%s new custom variables having the index(es) %s will be ADDED', $numVarsDifference, $indexes)
+            );
+
+        } elseif ($numVarsToSet < $numCurrentCustomVars) {
+
+            $indexes = implode(',', range($highestIndex - $numVarsDifference + 1, $highestIndex));
+            $output->writeln(
+                sprintf("%s existing custom variables having the index(es) %s will be REMOVED.", $numVarsDifference, $indexes)
+            );
+            $output->writeln('<comment>This is an irreversible change</comment>');
+        }
+    }
+
+    private function getAbsoluteDifference($currentNumber, $numberToSet)
+    {
+        return abs($numberToSet - $currentNumber);
+    }
+
+    private function removeCustomVariables(Model $model, $numberOfVarsToRemove, OutputInterface $output)
+    {
+        for ($index = 0; $index < $numberOfVarsToRemove; $index++) {
+            $indexRemoved = $model->removeCustomVariable();
+            $this->progress->advance();
+            $output->writeln('  <info>Removed a variable in scope "' . $model->getScopeName() .  '" having the index ' . $indexRemoved . '</info>');
+        }
+    }
+
+    private function addCustomVariables(Model $model, $numberOfVarsToAdd, OutputInterface $output)
+    {
+        for ($index = 0; $index < $numberOfVarsToAdd; $index++) {
+            $indexAdded = $model->addCustomVariable();
+            $this->progress->advance();
+            $output->writeln('  <info>Added a variable in scope "' . $model->getScopeName() .  '" having the index ' . $indexAdded . '</info>');
+        }
+    }
+
+    private function getNumberOfChangesToPerform($numVarsToSet)
+    {
+        $numChangesToPerform = 0;
+
+        foreach (Model::getScopes() as $scope) {
+            $model = new Model($scope);
+            $numCurrentCustomVars = $model->getCurrentNumCustomVars();
+            $numChangesToPerform += $this->getAbsoluteDifference($numCurrentCustomVars, $numVarsToSet);
+        }
+        
+        return $numChangesToPerform;
+    }
+}
diff --git a/plugins/CustomVariables/CustomVariables.php b/plugins/CustomVariables/CustomVariables.php
index 12bc5b007b..c7e47f08e1 100644
--- a/plugins/CustomVariables/CustomVariables.php
+++ b/plugins/CustomVariables/CustomVariables.php
@@ -38,6 +38,7 @@ class CustomVariables extends \Piwik\Plugin
             'API.getReportMetadata'           => 'getReportMetadata',
             'API.getSegmentDimensionMetadata' => 'getSegmentsMetadata',
             'ViewDataTable.configure'         => 'configureViewDataTable',
+            'Console.addCommands'             => 'addConsoleCommands'
         );
         return $hooks;
     }
@@ -52,6 +53,16 @@ class CustomVariables extends \Piwik\Plugin
         MenuMain::getInstance()->add('General_Visitors', 'CustomVariables_CustomVariables', array('module' => 'CustomVariables', 'action' => 'index'), $display = true, $order = 50);
     }
 
+    public function install()
+    {
+        Model::install();
+    }
+
+    public function addConsoleCommands(&$commands)
+    {
+        $commands[] = __NAMESPACE__ . '\\Commands\\SetNumberOfCustomVariables';
+    }
+
     /**
      * Returns metadata for available reports
      */
diff --git a/plugins/CustomVariables/Model.php b/plugins/CustomVariables/Model.php
new file mode 100644
index 0000000000..992be895e5
--- /dev/null
+++ b/plugins/CustomVariables/Model.php
@@ -0,0 +1,124 @@
+<?php
+/**
+ * Piwik - Open source web analytics
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ *
+ */
+namespace Piwik\Plugins\CustomVariables;
+
+use Piwik\Common;
+use Piwik\DataTable;
+use Piwik\Db;
+
+class Model
+{
+    const SCOPE_PAGE = 'log_link_visit_action';
+    const SCOPE_VISIT = 'log_visit';
+    const SCOPE_CONVERSION = 'log_conversion';
+
+    private $scope = null;
+
+    public function __construct($scope)
+    {
+        if (empty($scope) || !in_array($scope, $this->getScopes())) {
+            throw new \Exception('Invalid custom variable scope');
+        }
+
+        $this->scope = $scope;
+    }
+
+    public function getScopeName()
+    {
+        // actually we should have a class for each scope but don't want to overengineer it for now
+        switch ($this->scope) {
+            case self::SCOPE_PAGE:
+                return 'Page';
+            case self::SCOPE_VISIT:
+                return 'Visit';
+            case self::SCOPE_CONVERSION:
+                return 'Conversion';
+        }
+    }
+
+    public function getCurrentNumCustomVars()
+    {
+        $customVarColumns = $this->getCustomVarColumnNames();
+
+        $currentNumCustomVars = count($customVarColumns) / 2;
+
+        return (int) $currentNumCustomVars;
+    }
+
+    public function getHighestCustomVarIndex()
+    {
+        $columns = $this->getCustomVarColumnNames();
+
+        if (empty($columns)) {
+            return 0;
+        }
+
+        $indexes = array_map(function ($column) {
+            $onlyNumber = str_replace(array('custom_var_k', 'custom_var_v'), '', $column);
+            return (int) $onlyNumber;
+        }, $columns);
+
+        return max($indexes);
+    }
+
+    private function getCustomVarColumnNames()
+    {
+        $dbTable = Common::prefixTable($this->scope);
+        $columns = Db::getColumnNamesFromTable($dbTable);
+
+        $customVarColumns = array_filter($columns, function ($column) {
+            return false !== strpos($column, 'custom_var_');
+        });
+
+        return $customVarColumns;
+    }
+
+    public function removeCustomVariable()
+    {
+        $dbTable = Common::prefixTable($this->scope);
+        $index   = $this->getHighestCustomVarIndex();
+
+        if ($index < 1) {
+            return null;
+        }
+
+        Db::exec(sprintf('ALTER TABLE %s DROP COLUMN custom_var_k%d', $dbTable, $index));
+        Db::exec(sprintf('ALTER TABLE %s DROP COLUMN custom_var_v%d', $dbTable, $index));
+
+        return $index;
+    }
+
+    public function addCustomVariable()
+    {
+        $dbTable = Common::prefixTable($this->scope);
+        $index   = $this->getHighestCustomVarIndex() + 1;
+
+        Db::exec(sprintf('ALTER TABLE %s ADD COLUMN custom_var_k%d VARCHAR(200) DEFAULT NULL', $dbTable, $index));
+        Db::exec(sprintf('ALTER TABLE %s ADD COLUMN custom_var_v%d VARCHAR(200) DEFAULT NULL', $dbTable, $index));
+
+        return $index;
+    }
+
+    public static function getScopes()
+    {
+        return array(self::SCOPE_PAGE, self::SCOPE_VISIT, self::SCOPE_CONVERSION);
+    }
+
+    public static function install()
+    {
+        foreach (self::getScopes() as $scope) {
+            $model = new Model($scope);
+            for ($index = 0; $index < 5; $index++) {
+                $model->addCustomVariable();
+            }
+        }
+    }
+
+}
+
diff --git a/plugins/CustomVariables/tests/ModelTest.php b/plugins/CustomVariables/tests/ModelTest.php
new file mode 100644
index 0000000000..b141cb4f5d
--- /dev/null
+++ b/plugins/CustomVariables/tests/ModelTest.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * Piwik - Open source web analytics
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+
+namespace Piwik\Plugins\CustomVariables\tests;
+use Piwik\Db;
+use Piwik\Plugins\CustomVariables\Model;
+
+/**
+ * @group CustomVariables
+ * @group ModelTest
+ * @group Database
+ */
+class ModelTest extends \DatabaseTestCase
+{
+    public function testGetAllScopes()
+    {
+        $this->assertEquals(array('log_link_visit_action', 'log_visit', 'log_conversion'), Model::getScopes());
+    }
+
+    public function testGetScopeName()
+    {
+        $this->assertEquals('Page', $this->getPageScope()->getScopeName());
+        $this->assertEquals('Visit', $this->getVisitScope()->getScopeName());
+        $this->assertEquals('Conversion', $this->getConversionScope()->getScopeName());
+    }
+
+    public function test_getCurrentNumCustomVars()
+    {
+        $this->assertEquals(5, $this->getPageScope()->getCurrentNumCustomVars());
+        $this->assertEquals(5, $this->getVisitScope()->getCurrentNumCustomVars());
+        $this->assertEquals(5, $this->getConversionScope()->getCurrentNumCustomVars());
+
+        $this->getPageScope()->addCustomVariable();
+        $this->getConversionScope()->removeCustomVariable();
+
+        $this->assertEquals(6, $this->getPageScope()->getCurrentNumCustomVars());
+        $this->assertEquals(5, $this->getVisitScope()->getCurrentNumCustomVars());
+        $this->assertEquals(4, $this->getConversionScope()->getCurrentNumCustomVars());
+    }
+
+    public function test_getHighestCustomVarIndex_addCustomVariable_removeCustomVariable()
+    {
+        $this->assertEquals(5, $this->getPageScope()->getHighestCustomVarIndex());
+        $this->assertEquals(5, $this->getVisitScope()->getHighestCustomVarIndex());
+        $this->assertEquals(5, $this->getConversionScope()->getHighestCustomVarIndex());
+
+        $this->getPageScope()->addCustomVariable();
+        $this->getConversionScope()->removeCustomVariable();
+
+        $this->assertEquals(6, $this->getPageScope()->getHighestCustomVarIndex());
+        $this->assertEquals(5, $this->getVisitScope()->getHighestCustomVarIndex());
+        $this->assertEquals(4, $this->getConversionScope()->getHighestCustomVarIndex());
+
+        $this->getConversionScope()->removeCustomVariable();
+        $this->getPageScope()->addCustomVariable();
+        $this->getVisitScope()->addCustomVariable();
+        $this->getPageScope()->addCustomVariable();
+        $this->getConversionScope()->removeCustomVariable();
+
+        $this->assertEquals(8, $this->getPageScope()->getHighestCustomVarIndex());
+        $this->assertEquals(6, $this->getVisitScope()->getHighestCustomVarIndex());
+        $this->assertEquals(2, $this->getConversionScope()->getHighestCustomVarIndex());
+    }
+
+    public function test_removeCustomVariable_shouldNotFailIfRemovesMoreThanExist()
+    {
+        $scope = $this->getPageScope();
+
+        $this->assertEquals(5, $scope->getHighestCustomVarIndex());
+
+        for ($index = 0; $index < 5; $index++) {
+            $scope->removeCustomVariable();
+            $this->assertEquals(4 - $index, $scope->getHighestCustomVarIndex());
+        }
+
+        $this->assertNull($scope->removeCustomVariable());
+        $this->assertEquals(0, $scope->getHighestCustomVarIndex());
+        $this->assertEquals(0, $scope->getCurrentNumCustomVars());
+    }
+
+    public function test_removeCustomVariable_addCustomVariable_ReturnsIndex()
+    {
+        $scopeToAdd = $this->getPageScope();
+        $scopeToRemove = $this->getVisitScope();
+
+        for ($index = 0; $index < 5; $index++) {
+            $this->assertEquals(5 - $index, $scopeToRemove->removeCustomVariable());
+            $this->assertEquals(6 + $index, $scopeToAdd->addCustomVariable());
+        }
+    }
+
+    private function getPageScope()
+    {
+        return new Model(Model::SCOPE_PAGE);
+    }
+
+    private function getVisitScope()
+    {
+        return new Model(Model::SCOPE_VISIT);
+    }
+
+    private function getConversionScope()
+    {
+        return new Model(Model::SCOPE_CONVERSION);
+    }
+
+
+}
diff --git a/tests/PHPUnit/Integration/Core/DbTest.php b/tests/PHPUnit/Integration/Core/DbTest.php
new file mode 100644
index 0000000000..3a8c31e7e8
--- /dev/null
+++ b/tests/PHPUnit/Integration/Core/DbTest.php
@@ -0,0 +1,32 @@
+<?php
+/**
+ * Piwik - Open source web analytics
+ *
+ * @link http://piwik.org
+ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
+ */
+use Piwik\Db;
+use Piwik\Common;
+
+/**
+ * Class Core_DbTest
+ *
+ * @group Core
+ */
+class Core_DbTest extends DatabaseTestCase
+{
+
+    public function test_getColumnNamesFromTable()
+    {
+        $this->assertColumnNames('access', array('login', 'idsite', 'access'));
+        $this->assertColumnNames('option', array('option_name', 'option_value', 'autoload'));
+    }
+
+    private function assertColumnNames($tableName, $expectedColumnNames)
+    {
+        $colmuns = Db::getColumnNamesFromTable(Common::prefixTable($tableName));
+
+        $this->assertEquals($expectedColumnNames, $colmuns);
+    }
+
+}
\ No newline at end of file
-- 
GitLab