diff --git a/Mapping/DateHelper.php b/Mapping/DateHelper.php new file mode 100644 index 00000000..388580d4 --- /dev/null +++ b/Mapping/DateHelper.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ONGR\ElasticsearchBundle\Mapping; + +/** + * Helps to format elasticsearch time string to interval in seconds. + */ +class DateHelper +{ + /** + * Parses elasticsearch type of string into milliseconds. + * + * @param string $timeString + * + * @return int + * + * @throws \InvalidArgumentException + */ + public static function parseString($timeString) + { + $results = []; + preg_match_all('/(\d+)([a-zA-Z]+)/', $timeString, $results); + $values = $results[1]; + $units = $results[2]; + + if (count($values) != count($units) || count($values) == 0) { + throw new \InvalidArgumentException("Invalid time string '{$timeString}'."); + } + + $result = 0; + foreach ($values as $key => $value) { + $result += $value * self::charToInterval($units[$key]); + } + + return $result; + } + + /** + * Converts a string to time interval. + * + * @param string $value + * + * @return int + * + * @throws \InvalidArgumentException + */ + private static function charToInterval($value) + { + switch($value) { + case 'w': + return 604800000; + case 'd': + return 86400000; + case 'h': + return 3600000; + case 'm': + return 60000; + case 'ms': + return 1; + default: + throw new \InvalidArgumentException("Unknown time unit '{$value}'."); + } + } +} diff --git a/Mapping/MappingTool.php b/Mapping/MappingTool.php index d72db9c7..f0890ef3 100644 --- a/Mapping/MappingTool.php +++ b/Mapping/MappingTool.php @@ -27,6 +27,14 @@ class MappingTool protected $ignoredFields = [ 'type' => 'object', '_routing' => ['required' => true], + 'format' => 'dateOptionalTime', + ]; + + /** + * @var array + */ + protected $formatFields = [ + '_ttl' => 'handleTime', ]; /** @@ -108,6 +116,11 @@ private function arrayFilterRecursive(array $array) unset($array[$key]); continue; } + + if (array_key_exists($key, $this->formatFields)) { + $array[$key] = call_user_func([$this, $this->formatFields[$key]], $array[$key]); + } + if (is_array($array[$key])) { $array[$key] = $this->arrayFilterRecursive($array[$key]); } @@ -115,4 +128,22 @@ private function arrayFilterRecursive(array $array) return $array; } + + /** + * Change time formats to fit elasticsearch. + * + * @param array $value + * + * @return array + */ + private function handleTime($value) + { + if (!isset($value['default']) || !is_string($value['default'])) { + return $value; + } + + $value['default'] = DateHelper::parseString($value['default']); + + return $value; + } } diff --git a/Resources/doc/mapping.md b/Resources/doc/mapping.md index 8737729d..50b84b88 100644 --- a/Resources/doc/mapping.md +++ b/Resources/doc/mapping.md @@ -98,6 +98,9 @@ class content implements DocumentInterface `type` parameter is for type name. This parame is optional, if there will be no param set Elasticsearch bundle will create type with lovercase class name. Additional params: * **TTL (time to live)** - `_ttl={"enabled": true, "default": "1d"}` param with which you can enable documents to have time to live and set default time interval. After time runs out document deletes itself automatically. +> You can use time units specified in [elasticsearch documentation][es-time-units]. + ESB parses it if needed using [DateHelper][date-helper], e.g. for type mapping update. + `DocumentTrait` includes support with all special fields in elasticsearch document such as `_id`, `_source`, `_ttl`, `_parent` handling. `DocumentTrait` has all parameters and setters already defined for you. Once there will be _ttl set Elasticsearch bundle will handle it automatically. @@ -173,3 +176,7 @@ class content implements DocumentInterface To define object fields the same `@ES\Property` annotations could be used. In the objects there is possibility to define other objects. > Nested types can be defined the same way as objects, except @ES\Nested annotation must be used. + + +[es-time-units]:http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/mapping-ttl-field.html#_default +[date-helper]:/Mapping/DateHelper.php diff --git a/Tests/Functional/Command/TypeUpdateCommandTest.php b/Tests/Functional/Command/TypeUpdateCommandTest.php new file mode 100644 index 00000000..67144fe7 --- /dev/null +++ b/Tests/Functional/Command/TypeUpdateCommandTest.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ONGR\ElasticsearchBundle\Tests\Functional\Command; + +use ONGR\ElasticsearchBundle\Command\IndexCreateCommand; +use ONGR\ElasticsearchBundle\Command\TypeUpdateCommand; +use ONGR\ElasticsearchBundle\ORM\Manager; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; +use Symfony\Component\Config\Definition\Exception\Exception; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Functional tests for type update command. + */ +class TypeUpdateCommandTest extends WebTestCase +{ + /** + * @var string + */ + private $documentDir; + + /** + * @var Manager + */ + private $manager; + + /** + * @var Application + */ + private $app; + + /** + * @var string + */ + private $file; + + /** + * @var ContainerInterface + */ + private $container; + + /** + * {@inheritdoc} + */ + public function setUp() + { + // Only a single instance of container should be used on all commands and throughout the test. + $this->container = self::createClient()->getContainer(); + $this->manager = $this->container->get('es.manager'); + + // Set up custom document to test mapping with. + $this->documentDir = $this->container->get('kernel')->locateResource('@ONGRTestingBundle/Document/'); + $this->file = $this->documentDir . 'Article.php'; + + // Create index for testing. + $this->app = new Application(); + $this->createIndexCommand(); + } + + /** + * Check if update works as expected. + */ + public function testExecute() + { + $this->assertMappingNotSet("Article mapping shouldn't be defined yet."); + + copy($this->documentDir . 'documentSample.txt', $this->file); + + $this->runUpdateCommand(); + $this->assertMappingSet('Article mapping should be defined after update.'); + } + + /** + * Check if updating works with type selected. + */ + public function testExecuteType() + { + $this->assertMappingNotSet("Article mapping shouldn't be defined yet."); + + copy($this->documentDir . 'documentSample.txt', $this->file); + + $this->runUpdateCommand('product'); + $this->assertMappingNotSet("Article mapping shouldn't be defined, type selected was `product`."); + + $this->runUpdateCommand('article'); + $this->assertMappingSet('Article mapping should be defined after update, type selected was `article`.'); + } + + /** + * Check if up to date mapping check works. + */ + public function testExecuteUpdated() + { + $this->assertStringStartsWith('Types are already up to date.', $this->runUpdateCommand()); + $this->assertMappingNotSet("Article was never added, type shouldn't be added."); + } + + /** + * Asserts mapping is set and correct. + * + * @param string $message + */ + protected function assertMappingSet($message) + { + $mapping = $this->manager->getConnection()->getMapping('article'); + $this->assertNotNull($mapping, $message); + $expectedMapping = [ + 'properties' => [ + 'title' => [ + 'type' => 'string', + ] + ] + ]; + $this->assertEquals($expectedMapping, $mapping); + } + + /** + * Asserts mapping isn't set. + * + * @param string $message + */ + protected function assertMappingNotSet($message) + { + $this->assertNull($this->manager->getConnection()->getMapping('article'), $message); + } + + /** + * Runs update command. + * + * @param string $type + * + * @return string + */ + protected function runUpdateCommand($type = '') + { + $command = new TypeUpdateCommand(); + $command->setContainer($this->container); + + $this->app->add($command); + $commandToTest = $this->app->find('es:type:update'); + $commandTester = new CommandTester($commandToTest); + + $result = $commandTester->execute( + [ + 'command' => $commandToTest->getName(), + '--force' => true, + '--type' => $type, + ] + ); + + $this->assertEquals(0, $result, "Mapping update wasn't executed successfully."); + + return $commandTester->getDisplay(); + } + + /** + * Creates index for testing. + * + * @param string $manager + */ + protected function createIndexCommand($manager = 'default') + { + $command = new IndexCreateCommand(); + $command->setContainer($this->container); + + $this->app->add($command); + $command = $this->app->find('es:index:create'); + $commandTester = new CommandTester($command); + $commandTester->execute( + [ + 'command' => $command->getName(), + '--manager' => $manager, + ] + ); + } + + /** + * {@inheritdoc} + */ + public function tearDown() + { + try { + $this->manager->getConnection()->dropIndex(); + } catch (Exception $ex) { + // Index wasn't actually created. + } + @unlink($this->file); + } +} diff --git a/Tests/Unit/Command/UpdateTypeCommandTest.php b/Tests/Unit/Command/UpdateTypeCommandTest.php index f525d644..699752fe 100644 --- a/Tests/Unit/Command/UpdateTypeCommandTest.php +++ b/Tests/Unit/Command/UpdateTypeCommandTest.php @@ -14,6 +14,7 @@ use ONGR\ElasticsearchBundle\Command\TypeUpdateCommand; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\DependencyInjection\Container; /** * Unit tests for UpdateTypeCommand. @@ -26,7 +27,7 @@ class UpdateTypeCommandTest extends \PHPUnit_Framework_TestCase * @expectedException \UnexpectedValueException * @expectedExceptionMessage Expected boolean value from Connection::updateMapping() */ - public function testExecute() + public function testExecuteUnexpectedValue() { /** @var TypeUpdateCommand|\PHPUnit_Framework_MockObject_MockObject $command */ $command = $this->getMockBuilder('ONGR\ElasticsearchBundle\Command\TypeUpdateCommand') @@ -48,4 +49,56 @@ public function testExecute() ] ); } + + /** + * Check if correct value is returned when force flag isn't set. + */ + public function testForceDisabled() + { + $command = new TypeUpdateCommand(); + $app = new Application(); + $app->add($command); + + $commandToTest = $app->find('es:type:update'); + $commandTester = new CommandTester($commandToTest); + $result = $commandTester->execute( + [ + 'command' => $command->getName(), + ] + ); + + $this->assertEquals(1, $result); + } + + /** + * Check if correct value is returned when undefined type is specified. + */ + public function testUndefinedType() + { + /** @var Container|\PHPUnit_Framework_MockObject_MockObject $containerMock */ + $containerMock = $this->getMockBuilder('Symfony\Component\DependencyInjection\Container') + ->setMethods(['getParameter', 'get']) + ->disableOriginalConstructor() + ->getMock(); + $containerMock->expects($this->any())->method('getParameter')->willReturn([]); + $containerMock->expects($this->any())->method('get')->willReturnSelf(); + $command = new TypeUpdateCommand(); + $command->setContainer($containerMock); + + + $app = new Application(); + $app->add($command); + + $commandToTest = $app->find('es:type:update'); + $commandTester = new CommandTester($commandToTest); + $result = $commandTester->execute( + [ + 'command' => $command->getName(), + '--force' => true, + '--type' => 'unkown', + ] + ); + + $this->assertEquals(2, $result); + } } diff --git a/Tests/Unit/Mapping/DateHelperTest.php b/Tests/Unit/Mapping/DateHelperTest.php new file mode 100644 index 00000000..7b7a6599 --- /dev/null +++ b/Tests/Unit/Mapping/DateHelperTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + + +namespace ONGR\ElasticsearchBundle\Tests\Unit\Mapping; + +use ONGR\ElasticsearchBundle\Mapping\DateHelper; + +/** + * Unit tests for date helper. + */ +class DateHelperTest extends \PHPUnit_Framework_TestCase +{ + /** + * Data provider for testParseString(). + * + * @return array + */ + public function getParseStringData() + { + $out = []; + + // Case #0: one day. + $out[] = ['1d', 86400000]; + + // Case #1: all unit types. + $out[] = ['2w3d4h5m6ms', 1483500006]; + + // Case #2: Invalid unit type. + $out[] = ['2w3d4h5g6ms', 0, 'InvalidArgumentException', "Unknown time unit 'g'"]; + + // Case #3: Invalid string. + $out[] = ['ggg', 0, 'InvalidArgumentException', "Invalid time string 'ggg'"]; + + return $out; + } + + /** + * Check if string are parsed correctly. + * + * @param string $stringValue + * @param int $expectedValue + * @param string $expectedException + * @param string $exceptionMessage + * + * @dataProvider getParseStringData() + */ + public function testParseString($stringValue, $expectedValue, $expectedException = null, $exceptionMessage = '') + { + if (!empty($expectedException)) { + $this->setExpectedException($expectedException, $exceptionMessage); + } + $this->assertEquals($expectedValue, DateHelper::parseString($stringValue)); + } +}