From 91d4da85e1f6b791bc7b4c0b7fc666bfaf8f7e68 Mon Sep 17 00:00:00 2001 From: Benjamin Dalsass Date: Fri, 14 Jan 2022 11:12:30 +0100 Subject: [PATCH] =?UTF-8?q?N=C2=B04454=20-=20Measuring=20the=20use=20of=20?= =?UTF-8?q?the=20queryphrase=20book?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/query.class.inc.php | 131 ++++++++++++++++- core/dbobject.class.php | 48 ++++++ dictionaries/en.dictionary.itop.ui.php | 6 + dictionaries/fr.dictionary.itop.ui.php | 6 + test/application/query/QueryTest.php | 194 +++++++++++++++++++++++++ test/core/DBObjectTest.php | 118 +++++++++++++++ webservices/export-v2.php | 6 + webservices/export.php | 5 + 8 files changed, 509 insertions(+), 5 deletions(-) create mode 100644 test/application/query/QueryTest.php diff --git a/application/query.class.inc.php b/application/query.class.inc.php index c4d8a42f4..0d92c001c 100644 --- a/application/query.class.inc.php +++ b/application/query.class.inc.php @@ -18,8 +18,7 @@ */ use Combodo\iTop\Application\UI\Base\Component\Alert\AlertUIBlockFactory; -use Combodo\iTop\Application\UI\Base\Component\Field\Field; -use Combodo\iTop\Application\UI\Base\Component\Field\FieldUIBlockFactory; +use Combodo\iTop\Application\UI\Base\Component\FieldSet\FieldSetUIBlockFactory; use Combodo\iTop\Application\UI\Base\Component\Html\Html; use Combodo\iTop\Application\UI\Base\Component\Input\TextArea; @@ -51,6 +50,7 @@ abstract class Query extends cmdbAbstractObject "is_null_allowed" => false, "depends_on" => array(), ))); + MetaModel::Init_AddAttribute(new AttributeText("description", array( "allowed_values" => null, "sql" => "description", @@ -68,6 +68,41 @@ abstract class Query extends cmdbAbstractObject 'display_style' => 'radio_horizontal', ))); + MetaModel::Init_AddAttribute(new AttributeInteger("export_count", array( + "allowed_values" => null, + "sql" => "export_count", + "default_value" => 0, + "is_null_allowed" => false, + "depends_on" => array(), + ))); + + MetaModel::Init_AddAttribute(new AttributeDateTime("export_last_date", array( + "allowed_values" => null, + "sql" => "export_last_date", + "default_value" => null, + "is_null_allowed" => true, + "depends_on" => array(), + ))); + + MetaModel::Init_AddAttribute(new AttributeExternalKey("export_last_user_id", + array( + "targetclass"=>'User', + "allowed_values"=>null, + "sql"=>'user_id', + "is_null_allowed"=>true, + "depends_on"=>array(), + "display_style"=>'select', + "always_load_in_tables"=>false, + "on_target_delete"=>DEL_SILENT + ))); + + MetaModel::Init_AddAttribute(new AttributeExternalField("export_last_user_contact", + array( + "allowed_values"=>null, + "extkey_attcode"=> "export_last_user_id", + "target_attcode"=>"contactid" + ))); + // Display lists MetaModel::Init_SetZListItems('details', array('name', 'is_template', 'description')); // Attributes to be displayed for the complete details @@ -78,6 +113,51 @@ abstract class Query extends cmdbAbstractObject array('name', 'description', 'is_template')); // Criteria of the default search form // MetaModel::Init_SetZListItems('advanced_search', array('name')); // Criteria of the advanced search form } + + + /** @inheritdoc */ + public function GetAttributeFlags($sAttCode, &$aReasons = array(), $sTargetState = '') + { + // read only attribute + if (in_array($sAttCode, ['export_count', 'export_last_date', 'export_last_user_id'])){ + return(OPT_ATT_READONLY); + } + + return parent::GetAttributeFlags($sAttCode, $aReasons, $sTargetState); + } + + + /** + * Return export url. + * + * @param array|null $aValues optional values for the query + * + * @return string|null + */ + public abstract function GetExportUrl(array $aValues = null) : ?string; + + /** + * Update last export information. + * + * @todo validation with Pierre + * + * @return void + * @throws \ArchivedObjectException + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \MySQLException + * @since 3.1.0 + */ + public function UpdateLastExportInformation() : void + { + // last export information + $this->Set('export_last_date', date(AttributeDateTime::GetSQLFormat())); + $this->Set('export_last_user_id', UserRights::GetUserObject()); + $this->DBUpdate(); + + // increment usage counter + $this->DBIncrement('export_count'); + } } class QueryOQL extends Query @@ -116,13 +196,51 @@ class QueryOQL extends Query // Display lists MetaModel::Init_SetZListItems('details', - array('name', 'is_template', 'description', 'oql', 'fields')); // Attributes to be displayed for the complete details + array( + 'col:col1' => array('fieldset:Query:baseinfo' => array('name', 'is_template', 'description', 'oql', 'fields')), + 'col:col2' => array('fieldset:Query:exportInfo' => array('export_count', 'export_last_date', 'export_last_user_id', 'export_last_user_contact')) + ) + ); // Attributes to be displayed for the complete details MetaModel::Init_SetZListItems('list', array('description')); // Attributes to be displayed for a list // Search criteria MetaModel::Init_SetZListItems('standard_search', array('name', 'description', 'is_template', 'fields', 'oql')); // Criteria of the std search form } + /** @inheritdoc */ + public function GetExportUrl(array $aValues = null) : ?string + { + try{ + // retrieve attributes + $sFields = trim($this->Get('fields')); + $sOql = $this->Get('oql'); + + // construct base url depending on version + $bExportV1Recommended = ($sFields == ''); + if ($bExportV1Recommended) { + $sUrl = utils::GetAbsoluteUrlAppRoot().'webservices/export.php?format=spreadsheet&login_mode=basic&query='.$this->GetKey(); + } + else{ + $sUrl = utils::GetAbsoluteUrlAppRoot().'webservices/export-v2.php?format=spreadsheet&login_mode=basic&date_format='.urlencode((string)AttributeDateTime::GetFormat()).'&query='.$this->GetKey(); + } + + // search object from OQL + $oSearch = DBObjectSearch::FromOQL($sOql); + + // inject parameters + $aParameters = $oSearch->GetQueryParams(); + foreach ($aParameters as $sParam => $val) { + ($aValues === null || $aValues[$sParam] === null) ? $paramValue = $sParam : $paramValue = $aValues[$sParam]; + $sUrl .= '&arg_' . $sParam . '=' . $paramValue; + } + + return $sUrl; + } + catch(Exception $e){ + return null; + } + } + function DisplayBareProperties(WebPage $oPage, $bEditMode = false, $sPrefix = '', $aExtraParams = array()) { $aFieldsMap = parent::DisplayBareProperties($oPage, $bEditMode, $sPrefix, $aExtraParams); @@ -152,9 +270,11 @@ class QueryOQL extends Query $sUrl .= '&arg_'.$sParam.'=["'.$sParam.'"]'; } + // add text area inside field set + $oFieldSet = FieldSetUIBlockFactory::MakeStandard(Dict::S('UI:Query:UrlForExcel')); $oTextArea = new TextArea("", $sUrl, null, 80, 3); - $oFieldUrl = FieldUIBlockFactory::MakeFromObject(Dict::S('UI:Query:UrlForExcel'), $oTextArea, Field::ENUM_FIELD_LAYOUT_LARGE); - $oPage->AddSubBlock($oFieldUrl); + $oFieldSet->AddSubBlock($oTextArea); + $oPage->AddSubBlock($oFieldSet); if (count($aParameters) == 0) { $oBlock = new DisplayBlock($oSearch, 'list'); @@ -178,6 +298,7 @@ class QueryOQL extends Query return $aFieldsMap; } + // Rolled back until 'fields' can be properly managed by AttributeQueryAttCodeSet // // public function ComputeValues() diff --git a/core/dbobject.class.php b/core/dbobject.class.php index b3bbc59c8..ae3b78452 100644 --- a/core/dbobject.class.php +++ b/core/dbobject.class.php @@ -3453,6 +3453,54 @@ abstract class DBObject implements iDisplay return $this->m_iKey; } + + /** + * Increment attribute with specified value. + * This function is only applicable with AttributeInteger. + * + * @api + * + * @param string $sAttCode attribute code + * @param int $iValue value to increment (default value 1) + * + * @return int incremented value + * + * @throws \ArchivedObjectException + * @throws \CoreException + * @throws \MySQLException + * @throws \MySQLHasGoneAwayException + */ + public function DBIncrement(string $sAttCode, int $iValue = 1) + { + // retrieve instance class + $sClass = get_class($this); + + // dirty object not allowed + if($this->m_bDirty){ + throw new CoreException("Invalid DBIncrement usage, dirty objects are not allowed. Call DBUpdate before calling DBIncrement."); + } + + // ensure attribute type is AttributeInteger + $oAttr = MetaModel::GetAttributeDef($sClass, $sAttCode); + if(!$oAttr instanceof AttributeInteger){ + throw new CoreException(sprintf("Invalid DBIncrement usage, attribute type of {$sAttCode} is %s. Only AttributeInteger are compatibles with DBIncrement.", get_class($oAttr))); + } + + // prepare SQL statement + $sTable = MetaModel::DBGetTable($sClass, $sAttCode); + $sPKField = '`'.MetaModel::DBGetKey($sClass).'`'; + $sKey = CMDBSource::Quote($this->m_iKey); + $sUpdateSQL = "UPDATE `{$sTable}` SET `{$sAttCode}` = `{$sAttCode}`+{$iValue} WHERE {$sPKField} = {$sKey}"; + + // execute SQL query + CMDBSource::Query($sUpdateSQL); + + // reload instance with new value + $this->Reload(); + + return $this->Get($sAttCode); + } + /** * @internal * Save updated fields previous values for {@see DBObject::DBUpdate()} callbacks diff --git a/dictionaries/en.dictionary.itop.ui.php b/dictionaries/en.dictionary.itop.ui.php index bef34d8d4..6ff3a57cc 100644 --- a/dictionaries/en.dictionary.itop.ui.php +++ b/dictionaries/en.dictionary.itop.ui.php @@ -75,6 +75,12 @@ Dict::Add('EN US', 'English', 'English', array( 'Class:Query/Attribute:is_template+' => 'Usable as source for recipient OQL in Notifications', 'Class:Query/Attribute:is_template/Value:yes' => 'Yes', 'Class:Query/Attribute:is_template/Value:no' => 'No', + 'Class:Query/Attribute:export_count' => 'Export counter', + 'Class:Query/Attribute:export_last_date' => 'Last export', + 'Class:Query/Attribute:export_last_user_id' => 'User', + 'Class:Query/Attribute:export_last_user_contact' => 'Contact', + 'Query:baseinfo' => 'General information', + 'Query:exportInfo' => 'Export information', 'Class:QueryOQL/Attribute:fields' => 'Fields', 'Class:QueryOQL/Attribute:fields+' => 'Comma separated list of attributes (or alias.attribute) to export', 'Class:QueryOQL' => 'OQL Query', diff --git a/dictionaries/fr.dictionary.itop.ui.php b/dictionaries/fr.dictionary.itop.ui.php index a67b7f1ae..22d0cc8ea 100644 --- a/dictionaries/fr.dictionary.itop.ui.php +++ b/dictionaries/fr.dictionary.itop.ui.php @@ -59,6 +59,12 @@ Dict::Add('FR FR', 'French', 'Français', array( 'Class:Query/Attribute:is_template+' => 'Utilisable comme base pour les destinataires des Notifications', 'Class:Query/Attribute:is_template/Value:yes' => 'Oui', 'Class:Query/Attribute:is_template/Value:no' => 'Non', + 'Class:Query/Attribute:export_count' => 'Nombre d\'exports', + 'Class:Query/Attribute:export_last_date' => 'Dernier export', + 'Class:Query/Attribute:export_last_user_id' => 'Utilisateur', + 'Class:Query/Attribute:export_last_user_contact' => 'Contact', + 'Query:baseinfo' => 'Informations générales', + 'Query:exportInfo' => 'Informations sur les exports', 'Class:QueryOQL/Attribute:fields' => 'Champs', 'Class:QueryOQL/Attribute:fields+' => 'Liste CSV des attributs (ou alias.attribut) à exporter', 'Class:QueryOQL' => 'Requête OQL', diff --git a/test/application/query/QueryTest.php b/test/application/query/QueryTest.php new file mode 100644 index 000000000..d4b9b6085 --- /dev/null +++ b/test/application/query/QueryTest.php @@ -0,0 +1,194 @@ + + * + */ + +use Combodo\iTop\Test\UnitTest\ItopDataTestCase; + +/** + * This test creates call export on requests and check request usage counter. + * + * Transaction are disabled to avoid data inconsistency between test and call to export (outside test scope) + * All objects created in this test will be deleted by the test. + * + * @group iTopQuery + * @runTestsInSeparateProcesses + * @preserveGlobalState disabled + * @backupGlobals disabled + */ +class QueryTest extends ItopDataTestCase +{ + // disable transaction to avoid data inconsistency between test and call to export (outside test scope) + const USE_TRANSACTION = false; + + // user for exportation process + const USER = 'dani2'; + const PASSWORD = '1TopCombodo+'; + private $oUser; + + /** @inheritDoc */ + public function setUp() + { + parent::setUp(); + + // create export user + $this->CreateExportUser(); + } + + /** + * Create new user for export authentication purpose. + */ + private function CreateExportUser() + { + $oAdminProfile = MetaModel::GetObjectFromOQL("SELECT URP_Profiles WHERE name = :name", array('name' => 'Administrator'), true); + $this->oUser = $this->CreateUser(self::USER, $oAdminProfile->GetKey(), self::PASSWORD); + } + + /** + * Create an OQL query to list Person objects. + * + * @param string $sName query name + * @param string $sDescription query description + * @param string $sOql query oql phrase + * @param string|null $sFields fields to export + */ + private function CreateQueryOQL(string $sName, string $sDescription, string $sOql, string $sFields = null) : QueryOQL + { + $oQuery = new QueryOQL(); + $oQuery->Set('name', $sName); + $oQuery->Set('description', $sDescription); + $oQuery->Set('oql', $sOql); + + if($sFields != null){ + $oQuery->Set('fields', $sFields); + } + + $oQuery->DBInsert(); + + return $oQuery; + } + + /** + * Test query export V1 usage. + * + * @param string $sDescription query description + * @param string $sOql query oql phrase + * + * @dataProvider getQueryProvider + */ + public function testQueryExportV1Usage(string $sDescription, string $sOql) + { + // create query OQL + $oQuery = $this->CreateQueryOQL($this->dataName(), $sDescription, $sOql); + + // call export service + $this->CallExportService($oQuery); + + // reload to update counter (done by export process) + $oQuery->Reload(); + + // extract counter + $iResult = $oQuery->Get('export_count'); + + // delete the query + $oQuery->DBDelete(); + + // test + $this->assertEquals(1, $iResult); + } + + /** + * Test query export V2 usage. + * + * @param string $sDescription query description + * @param string $sOql query oql phrase + * + * @dataProvider getQueryProvider + */ + public function testQueryExportV2Usage(string $sDescription, string $sOql) + { + // create query OQL + $oQuery = $this->CreateQueryOQL($this->dataName(), $sDescription, $sOql, 'first_name'); + + // call export service + $this->CallExportService($oQuery); + + // reload to update counter (done by export process) + $oQuery->Reload(); + + // extract counter + $iResult = $oQuery->Get('export_count'); + + // delete the query + $oQuery->DBDelete(); + + // test + $this->assertEquals(1, $iResult); + } + + /** + * Data provide for test. + * + * @return array + */ + public function getQueryProvider() + { + return array( + 'Export #1' => array('query without params', 'SELECT Person'), + 'Export #2' => array('query with params', "SELECT Person WHERE first_name LIKE 'B%'") + ); + } + + /** + * Call export for given query object. + * + * @param \Query $oQuery + * + * @return bool|string + */ + private function CallExportService(Query $oQuery) + { + // compute request url + $url = $oQuery->GetExportUrl(); + + // open curl + $curl = curl_init(); + + // curl options + curl_setopt($curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + curl_setopt($curl, CURLOPT_USERPWD, self::USER . ':' . self::PASSWORD); + curl_setopt($curl, CURLOPT_URL, $url); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + + // execute curl + $result = curl_exec($curl); + + // close curl + curl_close($curl); + + return $result; + } + + /** @inheritDoc */ + protected function tearDown() + { + $this->oUser->DBDelete(); + } + +} diff --git a/test/core/DBObjectTest.php b/test/core/DBObjectTest.php index c13b3009d..27faf4878 100644 --- a/test/core/DBObjectTest.php +++ b/test/core/DBObjectTest.php @@ -27,6 +27,7 @@ namespace Combodo\iTop\Test\UnitTest\Core; use Combodo\iTop\Test\UnitTest\ItopDataTestCase; +use CoreException; use DBObject; @@ -250,4 +251,121 @@ class DBObjectTest extends ItopDataTestCase } } } + + /** + * Test attribute integer incrementation. + * + * @covers DBObject::DBIncrement + * + * @dataProvider getAttributeIntegerDBIncrementProvider + * + */ + public function testAttributeIntegerDBIncrement(string $sAttrCode, array $aValues, $expectedResult) + { + // create query object + $oQueryOQL = \MetaModel::NewObject('QueryOQL', [ + 'name' => 'Test Query', + 'description' => 'Test Query', + 'oql' => 'SELECT Person' + ]); + $oQueryOQL->DBInsert(); + + // iterate throw increments... + foreach ($aValues as $aValue) { + $oQueryOQL->DBIncrement($sAttrCode, $aValue); + } + + // retrieve counter current value + $iCounter = $oQueryOQL->Get($sAttrCode); + + // assert equals + $this->assertEquals($expectedResult, $iCounter); + } + + /** + * Data provider for test attribute integer incrementation. + * + * @return array data + */ + public function getAttributeIntegerDBIncrementProvider() + { + return array( + 'Incrementation #1' => array('export_count', [5], 5), + 'Incrementation #2' => array('export_count', [5, 10], 15), + 'Incrementation #3' => array('export_count', [50, 20, 10, 100], 180), + 'Incrementation #4' => array('export_count', [50, 20, -10, 1000], 1060) + ); + } + + /** + * Test attribute integer increment with AttributeText. + * + * @covers DBObject::DBIncrement + * + */ + public function testAttributeTextDBIncrement() + { + // create query object + $oQueryOQL = \MetaModel::NewObject('QueryOQL', [ + 'name' => 'Test Query', + 'description' => 'Test Query', + 'oql' => 'SELECT Person' + ]); + $oQueryOQL->DBInsert(); + + // assert exception + $this->expectException(CoreException::class); + + // try incrementation + $oQueryOQL->DBIncrement('description'); + } + + /** + * Test attribute integer increment when object dirty. + * + * @covers DBObject::DBIncrement + * + */ + public function testAttributeIntegerDBIncrementDirty() + { + // create query object + $oQueryOQL = \MetaModel::NewObject('QueryOQL', [ + 'name' => 'Test Query', + 'description' => 'Test Query', + 'oql' => 'SELECT Person' + ]); + $oQueryOQL->DBInsert(); + + // change description + $oQueryOQL->Set('description', 'new name'); + + // assert exception + $this->expectException(CoreException::class); + + // try incrementation + $oQueryOQL->DBIncrement('export_count'); + } + + /** + * Test query count with attribute integer increment. + * + * @covers DBObject::DBIncrement + * + */ + public function testAttributeIntegerDBIncrementQueryCount() + { + // create query object + $oQueryOQL = \MetaModel::NewObject('QueryOQL', [ + 'name' => 'Test Query', + 'description' => 'Test Query', + 'oql' => 'SELECT Person' + ]); + $oQueryOQL->DBInsert(); + + // assert query count + static::assertDBQueryCount(2, function() use (&$oQueryOQL) { + $oQueryOQL->DBIncrement('export_count', 1); + }); + } + } diff --git a/webservices/export-v2.php b/webservices/export-v2.php index 1ba005da2..c63629714 100644 --- a/webservices/export-v2.php +++ b/webservices/export-v2.php @@ -452,6 +452,7 @@ EOF function CheckParameters($sExpression, $sQueryId, $sFormat) { $oExporter = null; + $oQuery = null; if (utils::IsArchiveMode() && !UserRights::CanBrowseArchive()) { ReportErrorAndExit("The user account is not authorized to access the archives"); @@ -516,6 +517,11 @@ function CheckParameters($sExpression, $sQueryId, $sFormat) ReportErrorAndExit(utils::HtmlEntities($e->getMessage())); } + // update last export information if check parameters ok + if($oQuery != null){ + $oQuery->UpdateLastExportInformation(); + } + $oExporter->SetFormat($sFormat); $oExporter->SetChunkSize(EXPORTER_DEFAULT_CHUNK_SIZE); $oExporter->SetObjectList($oSearch); diff --git a/webservices/export.php b/webservices/export.php index 9c0064fdc..b7ae5735f 100644 --- a/webservices/export.php +++ b/webservices/export.php @@ -184,6 +184,11 @@ if (!empty($sExpression)) } } + // update last export information if check parameters ok + if($oQuery != null){ + $oQuery->UpdateLastExportInformation(); + } + if ($oFilter) { $oSet = new CMDBObjectSet($oFilter, array(), $aArgs);