diff --git a/CHANGELOG.md b/CHANGELOG.md index 98c90125..2e5e08e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ dev-master ### Features +- [query] Full support for manipulating multivalue properties + +alpha-5 +------- + +### Features + - [shell] Added "shell:clear" command to support clearing the console output - [general] The shell supports being embedded as a dependency - [node:edit] New command `node:edit` enables editing of entire node diff --git a/features/phpcr_query_update.feature b/features/phpcr_query_update.feature index 8cbec7b2..135b8bfb 100644 --- a/features/phpcr_query_update.feature +++ b/features/phpcr_query_update.feature @@ -24,21 +24,97 @@ Feature: Execute a a raw UPDATE query in JCR_SQL2 | UPDATE nt:unstructured AS a SET title = 'DTL' WHERE localname() = 'article1' | /cms/articles/article1 | title | DTL | | UPDATE nt:unstructured AS a SET title = 'DTL', foobar='barfoo' WHERE localname() = 'article1' | /cms/articles/article1 | foobar | barfoo | - Scenario: Update multivalue index by value - Given I execute the "UPDATE [nt:unstructured] AS a SET a.tags = 'Rockets' WHERE a.tags = 'Trains'" command + Scenario: Replace a multivalue index by value + Given I execute the "UPDATE [nt:unstructured] AS a SET a.tags = array_replace(a.tags, 'Trains', 'Rockets') WHERE a.tags = 'Trains'" command + Then the command should not fail + And I save the session + Then the command should not fail + And the node at "/cms/articles/article1" should have the property "tags" with value "Rockets" at index "1" + + Scenario: Set a multivalue value + Given I execute the "UPDATE [nt:unstructured] AS a SET a.tags = array('Rockets', 'Dragons') WHERE a.tags = 'Trains'" command + And I save the session + Then the command should not fail + And the node at "/cms/articles/article1" should have the property "tags" with value "Rockets" at index "0" + And the node at "/cms/articles/article1" should have the property "tags" with value "Dragons" at index "1" + + Scenario: Update single multivalue without selector + Given I execute the "UPDATE [nt:unstructured] SET tags = array_replace(tags, 'Planes', 'Rockets') WHERE tags = 'Planes'" command And I save the session Then the command should not fail And I should see the following: """ - Cannot update property "tags". Updating multi-value nodes with more than one element not currently supported + 1 row(s) affected """ + And the node at "/cms/articles/article1" should have the property "tags" with value "Rockets" at index "0" + And the node at "/cms/articles/article1" should have the property "tags" with value "Automobiles" at index "2" - Scenario: Update single multivalue - Given I execute the "UPDATE [nt:unstructured] AS a SET a.tag = 'Rockets' WHERE a.tags = 'Planes'" command + Scenario: Remove single multivalue + Given I execute the "UPDATE [nt:unstructured] AS a SET a.tags = array_remove(a.tags, 'Planes') WHERE a.tags = 'Planes'" command And I save the session Then the command should not fail And I should see the following: """ 1 row(s) affected """ - And the node at "/cms/articles/article1" should have the property "tag" with value "Rockets" at index "0" + And the node at "/cms/articles/article1" should have the property "tags" with value "Trains" at index "0" + And the node at "/cms/articles/article1" should have the property "tags" with value "Automobiles" at index "1" + + Scenario: Remove single multivalue by index + Given I execute the "UPDATE [nt:unstructured] AS a SET a.tags = array_set(a.tags, 0, NULL) WHERE a.tags = 'Planes'" command + And I save the session + Then the command should not fail + And I should see the following: + """ + 1 row(s) affected + """ + And the node at "/cms/articles/article1" should have the property "tags" with value "Trains" at index "0" + And the node at "/cms/articles/article1" should have the property "tags" with value "Automobiles" at index "1" + + Scenario: Add a multivalue property + Given I execute the "UPDATE [nt:unstructured] AS a SET a.tags = array_append(a.tags, 'Kite') WHERE a.tags = 'Planes'" command + And I save the session + Then the command should not fail + And I should see the following: + """ + 1 row(s) affected + """ + And the node at "/cms/articles/article1" should have the property "tags" with value "Planes" at index "0" + And the node at "/cms/articles/article1" should have the property "tags" with value "Automobiles" at index "2" + And the node at "/cms/articles/article1" should have the property "tags" with value "Kite" at index "3" + + Scenario: Replace a multivalue property by index + Given I execute the "UPDATE [nt:unstructured] AS a SET a.tags = array_set(a.tags, 1, 'Kite'), a.tags = array_set(a.tags, 2, 'foobar') WHERE a.tags = 'Planes'" command + And I save the session + Then the command should not fail + And I should see the following: + """ + 1 row(s) affected + """ + And the node at "/cms/articles/article1" should have the property "tags" with value "Planes" at index "0" + And the node at "/cms/articles/article1" should have the property "tags" with value "Kite" at index "1" + And the node at "/cms/articles/article1" should have the property "tags" with value "foobar" at index "2" + + Scenario: Replace a multivalue property by invalid index + Given I execute the "UPDATE [nt:unstructured] AS a SET a.tags = array_set(a.tags, 10, 'Kite') WHERE a.tags = 'Planes'" command + Then the command should fail + And I should see the following: + """ + Multivalue index "10" does not exist + """ + + Scenario: Attempt to update a numerically named property (must use a selector) + Given I execute the "UPDATE [nt:unstructured] AS a SET a.tags = array_set(a.tags, a.10, 'Kite') WHERE a.tags = 'Planes'" command + Then the command should fail + And I should see the following: + """ + [PHPCR\PathNotFoundException] Property 10 + """ + + Scenario: Replace a multivalue property by invalid index with array (invalid) + Given I execute the "UPDATE [nt:unstructured] AS a SET a.tags = array_set(a.tags, 0, array('Kite')) WHERE a.tags = 'Planes'" command + Then the command should fail + And I should see the following: + """ + Cannot use an array as a value in a multivalue property + """ diff --git a/spec/PHPCR/Shell/Query/UpdateParserSpec.php b/spec/PHPCR/Shell/Query/UpdateParserSpec.php index aa690b5e..c0e05d53 100644 --- a/spec/PHPCR/Shell/Query/UpdateParserSpec.php +++ b/spec/PHPCR/Shell/Query/UpdateParserSpec.php @@ -13,6 +13,8 @@ use PHPCR\Query\QOM\LiteralInterface; use PHPCR\Query\QOM\ComparisonInterface; use PHPCR\Query\QueryInterface; +use PHPCR\Shell\Query\FunctionOperand; +use PHPCR\Shell\Query\ColumnOperand; class UpdateParserSpec extends ObjectBehavior { @@ -66,16 +68,34 @@ function it_should_provide_a_qom_object_for_selecting( $res->offsetGet(0)->shouldHaveType('PHPCR\Query\QueryInterface'); $res->offsetGet(1)->shouldReturn(array( - 'parent.foo' => array( + array( 'selector' => 'parent', 'name' => 'foo', 'value' => 'PHPCR\\FOO\\Bar', ), - 'parent.bar' => array( + array( 'selector' => 'parent', 'name' => 'bar', 'value' => 'foo', ), )); } + + function it_should_parse_functions ( + QueryObjectModelFactoryInterface $qomf, + SourceInterface $source, + QueryInterface $query + ) + { + $qomf->selector('a', 'dtl:article')->willReturn($source); + $qomf->createQuery($source, null)->willReturn($query); + + + $sql = <<parse($sql); + + $res->offsetGet(0)->shouldHaveType('PHPCR\Query\QueryInterface'); + } } diff --git a/src/PHPCR/Shell/Console/Application/SessionApplication.php b/src/PHPCR/Shell/Console/Application/SessionApplication.php index d43d6098..e517e735 100644 --- a/src/PHPCR/Shell/Console/Application/SessionApplication.php +++ b/src/PHPCR/Shell/Console/Application/SessionApplication.php @@ -16,7 +16,7 @@ class SessionApplication extends BaseApplication { const APP_NAME = 'PHPCRSH'; - const APP_VERSION = '1.0.0-alpha4'; + const APP_VERSION = '1.0.0-alpha5'; protected $shellApplication; diff --git a/src/PHPCR/Shell/Console/Command/Phpcr/QueryUpdateCommand.php b/src/PHPCR/Shell/Console/Command/Phpcr/QueryUpdateCommand.php index cbd442c4..ed6e721b 100644 --- a/src/PHPCR/Shell/Console/Command/Phpcr/QueryUpdateCommand.php +++ b/src/PHPCR/Shell/Console/Command/Phpcr/QueryUpdateCommand.php @@ -6,19 +6,53 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use PHPCR\Shell\Query\UpdateParser; +use Jackalope\Query\QOM\ComparisonConstraint; +use Jackalope\Query\QOM\PropertyValue; +use PHPCR\Query\QOM\QueryObjectModelConstantsInterface; +use PHPCR\Query\QOM\LiteralInterface; +use PHPCR\Shell\Query\UpdateProcessor; class QueryUpdateCommand extends Command { + /** + * @var OutputInterface + */ + protected $output; + protected function configure() { $this->setName('update'); $this->setDescription('Execute an UPDATE JCR-SQL2 query'); $this->addArgument('query'); $this->setHelp(<<session:save to persist changes. Note that this command is not part of the JCR-SQL2 language but is implemented specifically @@ -29,6 +63,7 @@ protected function configure() public function execute(InputInterface $input, OutputInterface $output) { + $this->output = $output; $sql = $input->getRawCommand(); // trim ";" for people used to MysQL @@ -48,33 +83,12 @@ public function execute(InputInterface $input, OutputInterface $output) $result = $query->execute(); $rows = 0; + $updateProcessor = new UpdateProcessor(); + foreach ($result as $row) { $rows++; - foreach ($updates as $field => $property) { - $node = $row->getNode($property['selector']); - - if ($node->hasProperty($property['name'])) { - $phpcrProperty = $node->getProperty($property['name']); - - if ($phpcrProperty->isMultiple()) { - $currentValue = $phpcrProperty->getValue(); - - if (sizeof($currentValue) > 1) { - $output->writeln(sprintf( - 'Cannot update property "%s". Updating multi-value nodes with more than one element not currently supported', - $phpcrProperty->getName() - )); - $output->writeln(sprintf( - 'See: https://github.com/phpcr/phpcr-shell/issues/81', - $phpcrProperty->getName() - )); - } - - $property['value'] = (array) $property['value']; - } - } - - $node->setProperty($property['name'], $property['value']); + foreach ($updates as $property) { + $updateProcessor->updateNode($row, $property); } } diff --git a/src/PHPCR/Shell/Console/Helper/ResultFormatterHelper.php b/src/PHPCR/Shell/Console/Helper/ResultFormatterHelper.php index 2676ee05..d1f30012 100644 --- a/src/PHPCR/Shell/Console/Helper/ResultFormatterHelper.php +++ b/src/PHPCR/Shell/Console/Helper/ResultFormatterHelper.php @@ -60,11 +60,13 @@ public function formatQueryResult(QueryResultInterface $result, OutputInterface $table = new TableHelper; $table->setHeaders(array_merge(array( 'Path', + 'Index', ), $result->getColumnNames())); foreach ($result->getRows() as $i => $row) { $values = array_merge(array( $row->getPath(), + $row->getNode()->getIndex(), ), $row->getValues()); foreach ($values as $columnName => &$value) { diff --git a/src/PHPCR/Shell/Query/ColumnOperand.php b/src/PHPCR/Shell/Query/ColumnOperand.php new file mode 100644 index 00000000..93c71be6 --- /dev/null +++ b/src/PHPCR/Shell/Query/ColumnOperand.php @@ -0,0 +1,31 @@ + + */ +class ColumnOperand +{ + private $selectorName; + private $propertyName; + + public function __construct($selectorName, $propertyName) + { + $this->selectorName = $selectorName; + $this->propertyName = $propertyName; + } + + public function getSelectorName() + { + return $this->selectorName; + } + + public function getPropertyName() + { + return $this->propertyName; + } +} diff --git a/src/PHPCR/Shell/Query/FunctionOperand.php b/src/PHPCR/Shell/Query/FunctionOperand.php new file mode 100644 index 00000000..a3308677 --- /dev/null +++ b/src/PHPCR/Shell/Query/FunctionOperand.php @@ -0,0 +1,114 @@ + + */ +class FunctionOperand +{ + private $functionName; + private $arguments; + + public function __construct($functionName, $arguments) + { + $this->functionName = $functionName; + $this->arguments = $arguments; + } + + /** + * Replace the Operand objects with their evaluations + * + * @param array Array of function closures + * @param RowInterface $row + */ + private function replaceColumnOperands($functionMap, RowInterface $row) + { + foreach ($this->arguments as $key => $value) { + if ($value instanceof ColumnOperand) { + $this->arguments[$key] = $row->getNode($value->getSelectorName())->getPropertyValue($value->getPropertyName()); + } + + if ($value instanceof FunctionOperand) { + $this->arguments[$key] = $value->execute($functionMap, $row, $value); + } + } + } + + /** + * Evaluate the result of the function + * + * @param array Array of function closures + * @param RowInterface $row + */ + public function execute($functionMap, $row) + { + $this->replaceColumnOperands($functionMap, $row); + + $functionName = $this->getFunctionName(); + if (!isset($functionMap[$functionName])) { + throw new InvalidQueryException(sprintf('Unknown function "%s", known functions are "%s"', + $functionName, + implode(', ', array_keys($functionMap)) + )); + } + + $callable = $functionMap[$functionName]; + $args = $this->getArguments(); + array_unshift($args, $this); + $value = call_user_func_array($callable, $args); + + return $value; + } + + /** + * Used as callback for closure functions + * + * @param array Array of values which must be scalars + * @throws InvalidArgumentException + */ + public function validateScalarArray($array) + { + if (!is_array($array)) { + throw new \InvalidArgumentException(sprintf( + 'Expected array value, got: %s', + var_export($array, true) + )); + } + + foreach ($array as $key => $value) { + if (false == is_scalar($value)) { + throw new \InvalidArgumentException(sprintf( + 'Cannot use an array as a value in a multivalue property. Value was: %s', + var_export($array, true) + )); + } + } + } + + /** + * Return the name of the function to execute + * + * @return string + */ + public function getFunctionName() + { + return $this->functionName; + } + + /** + * Return the functions arguments + * + * @return mixed + */ + public function getArguments() + { + return $this->arguments; + } +} diff --git a/src/PHPCR/Shell/Query/UpdateParser.php b/src/PHPCR/Shell/Query/UpdateParser.php index 2511ebc4..37237ffc 100644 --- a/src/PHPCR/Shell/Query/UpdateParser.php +++ b/src/PHPCR/Shell/Query/UpdateParser.php @@ -3,10 +3,10 @@ namespace PHPCR\Shell\Query; use PHPCR\Util\ValueConverter; -use PHPCR\Util\QOM\Sql2Scanner; -use PHPCR\Util\QOM\Sql2ToQomQueryConverter; use PHPCR\Query\InvalidQueryException; use PHPCR\Query\QOM\SourceInterface; +use PHPCR\Util\QOM\Sql2ToQomQueryConverter; +use PHPCR\Util\QOM\Sql2Scanner; /** * Parse "UPDATE" queries. @@ -18,6 +18,13 @@ */ class UpdateParser extends Sql2ToQomQueryConverter { + public function parse($sql2) + { + $this->scanner = new Sql2Scanner($sql2); + $this->sql2 = $sql2; + return $this->doParse($sql2); + } + /** * Parse an "SQL2" UPDATE statement and construct a query builder * for selecting the rows and build a field => value mapping for the @@ -27,11 +34,10 @@ class UpdateParser extends Sql2ToQomQueryConverter * * @return array($query, $updates) */ - public function parse($sql2) + private function doParse($sql2) { $this->implicitSelectorName = null; $this->sql2 = $sql2; - $this->scanner = new Sql2Scanner($sql2); $source = null; $constraint = null; @@ -78,41 +84,35 @@ public function parse($sql2) * * @return array */ - protected function parseUpdates() + private function parseUpdates() { $updates = array(); while (true) { + $property = array( + 'selector' => null, + 'name' => null, + 'value' => null + ); + + // parse left side $selectorName = $this->scanner->fetchNextToken(); $delimiter = $this->scanner->fetchNextToken(); if ($delimiter !== '.') { - $property = array( - 'selector' => null, - 'name' => $selectorName - ); - $equals = $delimiter; + $property['selector'] = null; + $property['name'] = $selectorName; + $next = $delimiter; } else { - $property = array( - 'selector' => $selectorName, - 'name' => $this->scanner->fetchNextToken() - ); - $equals = $this->scanner->fetchNextToken(); - } - - - if ($equals !== '=') { - throw new InvalidQueryException(sprintf( - 'Expected "=" after property name in UPDATE query, got "%s"', - $equals, - $this->sql2 - )); + $property['selector'] = $selectorName; + $property['name'] = $this->scanner->fetchNextToken(); + $next = $this->scanner->fetchNextToken(); } - $value = $this->parseLiteralValue(); - $property['value'] = $value; + // parse right side + $property['value'] = $this->parseOperand(); - $updates[$property['selector'] . '.' . $property['name']] = $property; + $updates[] = $property; $next = $this->scanner->lookupNextToken(); @@ -125,4 +125,58 @@ protected function parseUpdates() return $updates; } + + private function isLiteral($token) + { + if (substr($token, 0, 1) === '\'') { + return true; + } elseif (is_numeric($token)) { + return true; + } elseif (substr($token, 0, 1) === '"') { + return true; + } + + return false; + } + + private function parseOperand() + { + $token = strtoupper($this->scanner->lookupNextToken()); + + if ($this->scanner->lookupNextToken(1) == '(') { + $functionData = $this->parseFunction(); + return new FunctionOperand($functionData[0], $functionData[1]); + } + + if ($this->isLiteral($token)) { + return $this->parseLiteralValue(); + } + + if ($token === 'NULL') { + $this->scanner->fetchNextToken(); + return null; + } + + $columnData = $this->scanColumn(); + return new ColumnOperand($columnData[0], $columnData[1]); + } + + private function parseFunction() + { + $functionName = $this->scanner->fetchNextToken(); + $this->scanner->expectToken('('); + + $args = array(); + $next = true; + while ($next && $next !== ')') { + $args[] = $this->parseOperand(); + + $next = $this->scanner->fetchNextToken(); + if (!in_array($next, array(',', ')', ''))) { + throw new InvalidQueryException(sprintf('Invalid function argument delimiter "%s" in "%s"', $next, $this->sql2)); + } + } + + return array($functionName, $args); + } } diff --git a/src/PHPCR/Shell/Query/UpdateProcessor.php b/src/PHPCR/Shell/Query/UpdateProcessor.php new file mode 100644 index 00000000..93562e7b --- /dev/null +++ b/src/PHPCR/Shell/Query/UpdateProcessor.php @@ -0,0 +1,129 @@ +functionMap = array( + 'array_replace' => function ($operand, $v, $x, $y) { + $operand->validateScalarArray($v); + foreach ($v as $key => $value) { + if ($value === $x) { + $v[$key] = $y; + } + } + + return $v; + }, + 'array_remove' => function ($operand, $v, $x) { + foreach ($v as $key => $value) { + if ($value === $x) { + unset($v[$key]); + } + } + + return array_values($v); + }, + 'array_append' => function ($operand, $v, $x) { + $operand->validateScalarArray($v); + $v[] = $x; + return $v; + }, + 'array' => function () { + $values = func_get_args(); + + // first argument is the operand + array_shift($values); + return $values; + }, + 'array_set' => function ($operand, $current, $index, $value) { + if (!isset($current[$index])) { + throw new \InvalidArgumentException(sprintf( + 'Multivalue index "%s" does not exist', + $index + )); + } + + if (null !== $value && !is_scalar($value)) { + throw new \InvalidArgumentException('Cannot use an array as a value in a multivalue property'); + } + + if (null === $value) { + unset($current[$index]); + } else { + $current[$index] = $value; + } + + return array_values($current); + }, + ); + } + + /** + * Update a node indicated in $propertyData in $row + * + * @param PHPCR\Query\RowInterface + * @param array + */ + public function updateNode(RowInterface $row, $propertyData) + { + $node = $row->getNode($propertyData['selector']); + + if ($node->hasProperty($propertyData['name'])) { + $value = $this->handleExisting($row, $node, $propertyData); + } else { + $value = $propertyData['value']; + } + + $node->setProperty($propertyData['name'], $value); + } + + private function handleExisting($row, $node, $propertyData) + { + $phpcrProperty = $node->getProperty($propertyData['name']); + $value = $propertyData['value']; + + if ($value instanceof FunctionOperand) { + return $this->handleFunction($row, $node, $phpcrProperty, $propertyData); + } + + return $value; + } + + private function handleFunction($row, $node, $phpcrProperty, $propertyData) + { + $currentValue = $phpcrProperty->getValue(); + $value = $propertyData['value']; + + $value = $value->execute($this->functionMap, $row); + + if ($phpcrProperty->isMultiple()) { + // do not allow updating multivalue with scalar + if (false === is_array($value) && sizeof($currentValue) > 1) { + throw new \InvalidArgumentException(sprintf( + 'Cannot update multivalue property "%s" with a scalar value.', + $phpcrProperty->getName() + )); + } + } + + return $value; + } +}