diff --git a/src/Service/DatamodelService.php b/src/Service/DatamodelService.php index 7824f0e..f5ddb4d 100644 --- a/src/Service/DatamodelService.php +++ b/src/Service/DatamodelService.php @@ -29,6 +29,12 @@ public function getClasses(): array return $classes; } + public function IsValidClass(string $className): bool + { + $classes = $this->getClasses(); + return isset($classes[$className]); + } + public function getClassSchema(string $className): array { $classSchema = $this->datamodelCache->get('ClassSchema-'.$className.'-'.$this->language, function(ItemInterface $item) use ($className) { @@ -208,5 +214,26 @@ protected function getWorkflowfromXml(string $className): array } return $workflow; } + + /** + * Extract the name of the selected class from an OQL query and check that the class is valid for the current datamodel + * @param string $oql + */ + public function getClassFromOQL(string $oql) + { + $aMatches = []; + $oql = trim($oql); + if (!preg_match('/^SELECT +([_a-zA-Z][_a-zA-Z0-9]*) +(?:JOIN|WHERE|AS)/', $oql, $aMatches)) { + if (!preg_match('/^SELECT +(?:[_a-zA-Z][_a-zA-Z0-9, ]*) +FROM +([_a-zA-Z][_a-zA-Z0-9]*)/', $oql, $aMatches)) { + throw new \InvalidArgumentException("Syntax error: '$oql' does not look like a valid OQL query."); + } + } + + $className = $aMatches[1]; + if (!$this->IsValidClass($className)) { + throw new \InvalidArgumentException("Class '$className' is not a valid class in the current datamodel. Use the tool '".TOOL_PREFIX."list-all-classes' to get the list of valid classes."); + } + return $className; + } } diff --git a/src/Tools/core/get/iTopGetTools.php b/src/Tools/core/get/iTopGetTools.php index e4e69ad..e5133b1 100644 --- a/src/Tools/core/get/iTopGetTools.php +++ b/src/Tools/core/get/iTopGetTools.php @@ -71,7 +71,7 @@ public function getPersonFromTelephone(string $telephone): string if (count($phones) === 0) { throw new \Exception('Datamodel issue: there seem to be no phone number attribute on the Person class!'); } - $conditions = array_map(function($item) use($me, $telephone) { return "$item = '".iTopGetTools::quoteString($telephone)."'"; }, $phones); + $conditions = array_map(function($item) use($telephone) { return "$item = '".iTopGetTools::quoteString($telephone)."'"; }, $phones); $whereClause = implode(' OR ', $conditions); return $this->runToolFromTemplates('getPersonFromTelephone', 'Anything', [ 'query' => 'SELECT Person WHERE '.$whereClause, @@ -114,4 +114,36 @@ public function searchUserRequestByCallerStatusStartDate(#[Schema(format: 'email ] ); } + + /** + * This tool searches for any object in iTop based on an OQL query. + * The OQL query must be a valid iTop OQL query that returns objects of the desired class. + * The result will be a list of objects of the same class, with their fields as specified in the datamodel. + * IMPORTANT: Do NOT assume that the datamodel is the "standard" one, use the tool get-class-schema - before creating the OQL query - + * to find the possible values for the class, its fields and its relations. + * @param string $oql The OQL query to execute. It must be a valid OQL query that returns objects of the desired class. + * @param int $limit The number of objects per page (> 0) + * @param int $page The (one based) number of the page (>= 1) + */ + #[McpTool(name: TOOL_PREFIX.'search-any-object-by-oql', annotations: new ToolAnnotations(null, true, false, true, false))] + public function searchAnyObjectByOql(string $oql, int $limit = 20, int $page = 1 ): string + { + if ($limit <= 0) { + throw new \InvalidArgumentException('Limit must be a positive integer'); + } + if ($page < 1) { + throw new \InvalidArgumentException('Page must be a positive integer (1 or greater)'); + } + $className = $this->datamodel->getClassFromOQL($oql); + $outputFields = $this->datamodel->getListZlist($className); + return $this->runToolFromTemplates('searchAnyObjectByOql', 'Anything', + [ + 'class' => $className, + 'output_fields' => $outputFields, + 'oql' => $oql, + 'limit' => $limit, + 'page' => $page, + ] + ); + } } \ No newline at end of file diff --git a/templates/Anything-output.toon.twig b/templates/Anything-output.toon.twig index a1bfcc2..5c87b5b 100644 --- a/templates/Anything-output.toon.twig +++ b/templates/Anything-output.toon.twig @@ -10,7 +10,7 @@ class {{ className }}: {% endfor %} -{{ className }} { +{{ className }} {% if json.message is defined %}(Total number of objects {{json.message|lower}}){% endif %} { {% for obj in json.objects %} {{obj.key}}{% for code, value in obj.fields %}{% if code !== 'id' and value is not iterable %}, "{{ value }}"{% endif %}{% endfor ~%} {% endfor %} diff --git a/templates/searchAnyObjectByOql-input.json.twig b/templates/searchAnyObjectByOql-input.json.twig new file mode 100644 index 0000000..c66a395 --- /dev/null +++ b/templates/searchAnyObjectByOql-input.json.twig @@ -0,0 +1,8 @@ +{ + "operation":"core/get", + "class": "{{class}}", + "key": "{{oql}}", + "limit": {{limit}}, + "page": {{page}}, + "output_fields": "{{output_fields}}" +} \ No newline at end of file diff --git a/tests/phpunit/DatamodelServiceTest.php b/tests/phpunit/DatamodelServiceTest.php index 5063508..7049bb2 100644 --- a/tests/phpunit/DatamodelServiceTest.php +++ b/tests/phpunit/DatamodelServiceTest.php @@ -4,6 +4,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use App\Service\DatamodelService; use Symfony\Contracts\Cache\CacheInterface; +use PHPUnit\Framework\Attributes\DataProvider; class DatamodelServiceTest extends KernelTestCase @@ -32,7 +33,45 @@ public function testGetClassSchema(): void $service = new DatamodelService(__DIR__.'/../../data/datamodel-production.xml', $this->cache, 'FR FR'); $schema = $service->getClassSchema('Person'); $expected = 'name,status,org_id,org_name,email,phone,notify,function,cis_list,picture,first_name,employee_number,mobile_phone,location_id,location_name,manager_id,manager_name,team_list,user_list,tickets_list'; - $this->assertEquals(explode(',', $expected), array_keys($schema)); + $this->assertEquals(explode(',', $expected), array_keys($schema['fields'])); + } + + public function testGetClassFromOQLOk(): void + { + $service = new DatamodelService(__DIR__.'/../../data/datamodel-production.xml', $this->cache, 'FR FR'); + $this->assertEquals('Person', $service->getClassFromOQL(" SELECT Person WHERE name='Foo'")); + + $this->assertEquals('Person', $service->getClassFromOQL("SELECT Person WHERE name='Foo'")); + + $this->assertEquals('Person', $service->getClassFromOQL("SELECT P FROM Person WHERE name='Foo'")); + + $this->assertEquals('Person', $service->getClassFromOQL("SELECT P,O FROM Person JOIN Organization AS O ON P.org_d = O.id WHERE name='Foo'")); + + $this->assertEquals('Person', $service->getClassFromOQL("SELECT Person AS P WHERE name='Foo'")); + + $this->assertEquals('Person', $service->getClassFromOQL("SELECT Person AS P JOIN Organization AS O WHERE P.name='Foo'")); + } + + /** + * @param string $oql + */ + #[DataProvider('oqlKoProvider')] + public function testGetClassFromOQLKo(string $oql): void + { + $service = new DatamodelService(__DIR__.'/../../data/datamodel-production.xml', $this->cache, 'FR FR'); + + $this->expectException("InvalidArgumentException"); + $this->assertEquals('Person', $service->getClassFromOQL($oql)); + } + + public static function oqlKoProvider(): array + { + return [ + ["SELECT 123456 WHERE name='Foo'"], + ["NOT A SELECT Person"], + ["SELECT FROM Person WHERE name='Foo'"], + ["SELECT NotAValidClassName WHERE name='Foo'"], + ]; } } diff --git a/tests/phpunit/ToolsFromTemplateTest.php b/tests/phpunit/ToolsFromTemplateTest.php index 9b25af3..6d1156d 100644 --- a/tests/phpunit/ToolsFromTemplateTest.php +++ b/tests/phpunit/ToolsFromTemplateTest.php @@ -52,19 +52,20 @@ public function testGetPersonFromEmail(): void ] ]; $mockiTopClient = $this->MockiTopClientThatWillReturn($input, $output); - $tools = new iTopGetTools($this->twigEnvironment, $mockiTopClient, $this->logger, $this->datamodel); - - $this->assertEquals( + + $expected = <<getPersonFromEmail('test@demo.com')); +EXPECTED; + + $tools = new iTopGetTools($this->twigEnvironment, $mockiTopClient, $this->logger, $this->datamodel); + $this->assertEquals($expected, $tools->getPersonFromEmail('test@demo.com')); } public function testGetPersonFromTelephone(): void @@ -100,13 +101,52 @@ public function testGetPersonFromTelephone(): void class Person: id, friendlyname, email, org_id -Person { +Person { 1, "Test Person", "test@demo.com", "1" } EXPECTED; $this->assertEquals(trim($expected), trim($tools->getPersonFromTelephone('123456789'))); } + + public function testSearchAnyObjectByOql() + { + // Mock the iTopClient service, to check that the templating works + $oql = "SELECT Person WHERE name LIKE '%Smith%'"; + $input = +<< [ + 'Person::1' => [ + 'class' => 'Person', + 'key' => 1, + 'fields' => ['friendlyname' => 'John Smith', 'email' => 'john.smith@demo.com'], + ], + ], + ]; + $mockiTopClient = $this->MockiTopClientThatWillReturn($input, $output); + + $expected = +<<twigEnvironment, $mockiTopClient, $this->logger, $this->datamodel); + $this->assertEquals(trim($expected), trim($tools->searchAnyObjectByOql($oql))); + } protected function MockiTopClientThatWillReturn(string $inputData, array $outputData) {