StringQuery.php
1 <?php
2 /**
3  * wCMF - wemove Content Management Framework
4  * Copyright (C) 2005-2020 wemove digital solutions GmbH
5  *
6  * Licensed under the terms of the MIT License.
7  *
8  * See the LICENSE file distributed with this work for
9  * additional information.
10  */
11 namespace wcmf\lib\model;
12 
21 use wcmf\lib\util\StringUtil;
22 
23 /**
24  * StringQuery executes queries from a string representation. Queries are
25  * constructed in two possible ways:
26  * 1. Like WHERE clauses in SQL
27  * 2. As RQL queries (https://github.com/persvr/rql)
28  *
29  * Note the following rules:
30  * - Foreign key relations between different types do not need to be included in
31  * the query string.
32  * - Attributes have to be prepended with the type name (or in case of ambiguity
33  * the role name), e.g. Author.name instead of name.
34  *
35  * @note: The query does not search in objects, that are created inside the current transaction.
36  *
37  * The following example shows the usage:
38  *
39  * @code
40  * $queryStr = "Author.name LIKE '%ingo%' AND (Recipe.name LIKE '%Salat%' OR Recipe.portions = 4)";
41  * $query = new StringQuery('Author');
42  * $query->setConditionString($queryStr);
43  * $authorOIDs = $query->execute(false);
44  *
45  * $queryStr = "(Profile.keyword1=in=8,1|Profile.keyword2=in=8,2|Profile.keywords3=in=8,3)&Profile.salary=gte=1000";
46  * $query = new StringQuery('Profile');
47  * $query->setConditionString($queryStr);
48  * $authorOIDs = $query->execute(false);
49  * @endcode
50  *
51  * @author ingo herwig <ingo@wemove.com>
52  */
53 class StringQuery extends ObjectQuery {
54 
55  private $condition = '';
56 
57  /**
58  * Get the query condition string
59  * @return String
60  */
61  public function getConditionString() {
62  return $this->condition;
63  }
64 
65  /**
66  * Set the query condition string
67  * @param $condition The query definition string
68  */
69  public function setConditionString($condition) {
70  $this->condition = $condition;
71  }
72 
73  /**
74  * Set the query condition string as RQL
75  * @param $condition The query definition string
76  */
77  public function setRQLConditionString($condition) {
78  $this->condition = $this->parseRQL($condition);
79  }
80 
81  /**
82  * @see AbstractQuery::buildQuery()
83  */
84  protected function buildQuery($buildDepth, $orderby=null, PagingInfo $pagingInfo=null) {
85  $queryType = $this->getQueryType();
86  $mapper = self::getMapper($queryType);
87 
88  // create the attribute string (use the default select from the mapper,
89  // since we are only interested in the attributes)
90  $attributes = $buildDepth === false ? $mapper->getPkNames() : null;
91  $selectStmt = $mapper->getSelectSQL(null, null, $attributes, null, $pagingInfo, $this->getId());
92  if (!$selectStmt->isCached()) {
93  // initialize the statement
94  $selectStmt->quantifier(SelectStatement::QUANTIFIER_DISTINCT);
95 
96  $persistenceFacade = ObjectFactory::getInstance('persistenceFacade');
97  $quoteIdentifierSymbol = $mapper->getQuoteIdentifierSymbol();
98  // get all referenced types/roles from the condition and translate
99  // attributes to column names
100  $conditionString = $this->condition;
101  $otherRoles = [];
102  $tokens = StringUtil::splitQuoted($conditionString, "/[\s=<>()!]+/", "'", true);
103  $operators = ['and', 'or', 'not', 'like', 'regexp', 'is', 'null', 'in'];
104  foreach ($tokens as $token) {
105  if (strlen($token) > 0) {
106  if (!in_array(strtolower($token), $operators)) {
107  // three possibilities left: token is
108  // 1. type or attribute (not allowed)
109  // 2. type.attribute
110  // 3. searchterm
111  if (!preg_match('/^\'|^[0-9]/', $token)) {
112  // token is no searchterm (does not start with a quote or a number)
113  $token = str_replace($quoteIdentifierSymbol, '', $token);
114  $pos = strpos($token, '.');
115  if ($pos > 0) {
116  // token is type/role.attribute
117  list($typeOrRole, $attribute) = explode('.', $token, 2);
118  // check if the token is a type
119  $fqType = $persistenceFacade->isKnownType($typeOrRole) ?
120  $persistenceFacade->getFullyQualifiedType($typeOrRole) : null;
121  if ($fqType == null || $fqType != $queryType) {
122  // find connection if the token does not match the queryType
123  if (!isset($otherRoles[$typeOrRole])) {
124  // find the path from the queryType to the other type/role
125  // (role is preferred)
126  $paths = NodeUtil::getConnections($queryType, $typeOrRole, null);
127  if (sizeof($paths) == 0) {
128  // fallback: search for type
129  $paths = NodeUtil::getConnections($queryType, null, $typeOrRole);
130  }
131  if (sizeof($paths) == 0) {
132  // no connection found
133  throw new IllegalArgumentException("There is no connection between '".$queryType."' and '".$typeOrRole."'.");
134  }
135  elseif (sizeof($paths) > 1) {
136  // more than one connection found
137  throw new IllegalArgumentException("There is more than one connection between '".$queryType."' and '".$typeOrRole."'. ".
138  "Try to use a role name for the target end.");
139  }
140  // exactly one connection (store it for later reference)
141  $otherRoles[$typeOrRole] = $paths[0];
142  }
143  // find the type of the referenced node
144  $path = $otherRoles[$typeOrRole];
145  $type = $path->getEndType();
146  }
147  else {
148  $type = $queryType;
149  }
150 
151  // map the attributes to columns
152  list($table, $column) = self::mapToDatabase($type, $attribute);
153  $conditionString = str_replace($quoteIdentifierSymbol.$attribute.$quoteIdentifierSymbol,
154  $quoteIdentifierSymbol.$column.$quoteIdentifierSymbol, $conditionString);
155  }
156  else {
157  throw new IllegalArgumentException("Please specify the type/role to that the attribute '".$token."' belongs: e.g. Author.name.");
158  }
159  }
160  }
161  }
162  }
163  if (strlen($conditionString)) {
164  $selectStmt->where($conditionString);
165  }
166 
167  // get relation conditions
168  // NOTE: temporarily created objects must be detached from the transaction
169  $tx = $persistenceFacade->getTransaction();
170  $rootNode = $persistenceFacade->create($queryType);
171  $tx->detach($rootNode->getOID());
172  foreach ($otherRoles as $typeOrRole => $pathDescription) {
173  $relationDescriptions = $pathDescription->getPath();
174  $parent = $rootNode;
175  foreach ($relationDescriptions as $relationDescription) {
176  $node = $persistenceFacade->create($relationDescription->getOtherType());
177  $tx->detach($node->getOID());
178  $parent->addNode($node, $relationDescription->getOtherRole());
179  $parent = $node;
180  }
181  // set the table name of the target node to the name that is
182  // referenced in the query condition
183  $node->setProperty(self::PROPERTY_TABLE_NAME, $typeOrRole);
184  }
185  $this->processObjectTemplate($rootNode, $selectStmt);
186  }
187 
188  // set orderby after all involved tables are known in order to
189  // prefix the correct table name
190  $this->processOrderBy($orderby, $selectStmt);
191 
192  // reset internal variables
193  $this->resetInternals();
194 
195  return $selectStmt;
196  }
197 
198  /**
199  * Parse the given query encoded in RQL (https://github.com/persvr/rql), e.g.
200  * (Profile.keyword1=in=8,1|Profile.keyword2=in=8,2|Profile.keywords3=in=8,3)&Profile.yearlySalary=gte=1000
201  * @param $query RQL query string
202  * @return String
203  */
204  protected function parseRQL($query) {
205  if (strlen($query) > 0) {
206  // replace rql operators
207  $operatorMap = ['eq' => '=', 'ne' => '!=', 'lt' => '<', 'lte' => '<=',
208  'gt' => '>', 'gte' => '>=', 'in' => 'in', 'match' => 'regexp', '=' => '='];
209  $combineMap = ['|' => 'OR', '&' => 'AND'];
210  $queryType = $this->getQueryType();
211  $mapper = self::getMapper($queryType);
212  $persistenceFacade = ObjectFactory::getInstance('persistenceFacade');
213  foreach ($operatorMap as $rqlOp => $sqlOp) {
214  // add '=' separator for letter-only operators
215  $operator = preg_match('/^[a-zA-Z]+$/', $rqlOp) ? '='.$rqlOp.'=' : $rqlOp;
216  $query = preg_replace_callback('/([^ <>!=|&\(\)]+) ?'.$operator.' ?([^ <>!=|&\(\)]+)/', function ($match)
217  use($rqlOp, $sqlOp, $mapper, $persistenceFacade) {
218  $typeAttr = $match[1];
219  $value = preg_replace('/^[\'"]|[\'"]$/', '', $match[2]);
220 
221  // try to determine value type
222  list($typeOrRole, $attribute) = explode('.', preg_replace('/[`"\'\(\)]/', '', $typeAttr), 2);
223  $attributeDef = null;
224  if ($persistenceFacade->isKnownType($typeOrRole) &&
225  $persistenceFacade->getFullyQualifiedType($typeOrRole) == $mapper->getType() && $mapper->hasAttribute($attribute)) {
226  $attributeDef = $mapper->getAttribute($attribute);
227  }
228  elseif ($mapper->hasRelation($typeOrRole)) {
229  $otherMapper = self::getMapper($mapper->getRelation($typeOrRole)->getOtherType());
230  $attributeDef = $otherMapper->hasAttribute($attribute) ? $otherMapper->getAttribute($attribute) : null;
231  }
232  $attributeType = $attributeDef != null ? strtolower($attributeDef->getType()) : null;
233  $isNumber = $attributeType == 'integer' || $attributeType == 'float';
234 
235  // strip slashes from regexp
236  if ($sqlOp === 'regexp') {
237  $value = preg_replace('/^\/|\/[a-zA-Z]*$/', '', $value);
238  $isNumber = false; // quote value
239  }
240 
241  // handle null values
242  if ($value === 'null') {
243  $sqlOp = $sqlOp == '=' ? 'is' : 'is not';
244  $replace = $typeAttr.' '.$sqlOp.' null';
245  }
246  else {
247  // quote value according to type
248  $replace = $typeAttr.' '.$sqlOp.' ';
249  if ($rqlOp == 'in') {
250  $replace .= '('.($isNumber ? $value : join(',', array_map([$mapper, 'quoteValue'], explode(',', $value)))).')';
251  }
252  else {
253  $replace .= $isNumber ? $value : $mapper->quoteValue($value);
254  }
255  }
256 
257  return $replace;
258  }, $query);
259  }
260  foreach ($combineMap as $rqlOp => $sqlOp) {
261  $query = str_replace($rqlOp, ' '.$sqlOp.' ', $query);
262  }
263  }
264  return $query;
265  }
266 
267  /**
268  * Map a application type and value name to the appropriate database names
269  * @param $type The type to map
270  * @param $valueName The name of the value to map
271  * @return An array with the table and column name or null if no mapper is found
272  */
273  protected static function mapToDatabase($type, $valueName) {
274  $mapper = self::getMapper($type);
275  if ($mapper->hasAttribute($valueName)) {
276  $attributeDescription = $mapper->getAttribute($valueName);
277  }
278  else {
279  // if the attribute does not exist, it might be the column name already
280  foreach ($mapper->getAttributes() as $curAttributeDesc) {
281  if ($curAttributeDesc->getColumn() == $valueName) {
282  $attributeDescription = $curAttributeDesc;
283  break;
284  }
285  }
286  }
287  if (!$attributeDescription) {
288  throw new PersistenceException("No attribute '".$valueName."' exists in '".$type."'");
289  }
290 
291  $table = $mapper->getRealTableName();
292  $column = $attributeDescription->getColumn();
293  return [$table, $column];
294  }
295 }
296 ?>
getId()
Get the query id.
parseRQL($query)
Parse the given query encoded in RQL (https://github.com/persvr/rql), e.g.
static getConnections($type, $otherRole, $otherType, $hierarchyType='all')
Get the shortest paths that connect a type to another type.
Definition: NodeUtil.php:39
PersistenceException signals an exception in the persistence service.
IllegalArgumentException signals an exception in method arguments.
setConditionString($condition)
Set the query condition string.
Definition: StringQuery.php:69
setRQLConditionString($condition)
Set the query condition string as RQL.
Definition: StringQuery.php:77
StringQuery executes queries from a string representation.
Definition: StringQuery.php:53
static getMapper($type)
Get the mapper for a Node and check if it is a supported one.
AbstractQuery is the base class for all query classes.
processOrderBy($orderby, SelectStatement $selectStmt)
Process an object template.
static mapToDatabase($type, $valueName)
Map a application type and value name to the appropriate database names.
processObjectTemplate(PersistentObject $tpl, SelectStatement $selectStmt)
Process an object template.
static getInstance($name, $dynamicConfiguration=[])
PagingInfo contains information about a paged list.
Definition: PagingInfo.php:18
Node related interfaces and classes.
Definition: namespaces.php:26
resetInternals()
Reset internal variables.
NodeUtil provides services for the Node class.
Definition: NodeUtil.php:28
ObjectFactory implements the service locator pattern by wrapping a Factory instance and providing sta...
buildQuery($buildDepth, $orderby=null, PagingInfo $pagingInfo=null)
Definition: StringQuery.php:84
getConditionString()
Get the query condition string.
Definition: StringQuery.php:61
ObjectQuery implements a template based object query.