4 * SearchFieldStatementResolver is a utility class that takes SearchField's and converts into a SQL SearchQueryFragment
5 * with the corresponding SQL statement and bound values that represent that search field. Nested Composite search
6 * fields are traversed and converted into their corresponding values.
8 * TODO: adunsulag maybe we can rename this to be SearchFieldQueryConverter I wonder if that will make more sense to people
11 * @link http://www.open-emr.org
12 * @author Stephen Nielson <stephen@nielson.org>
13 * @copyright Copyright (c) 2021 Stephen Nielson <stephen@nielson.org>
14 * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
17 namespace OpenEMR\Services\Search
;
19 class SearchFieldStatementResolver
21 const MAX_NESTED_LEVEL
= 10;
24 * Given a search field that implements the ISearchField interface, convert the field based upon its type to a full
25 * SQL Where Query fragment with its corresponding bound parameterized values. This is a recursive method as it will
26 * traverse any composite search fields up to a heirachical depth of the class constant MAX_NESTED_LEVEL levels.
27 * @param ISearchField $field The field to convert to a SQL SearchQueryFragment
28 * @param int $count The current nested count
29 * @return SearchQueryFragment
31 public static function getStatementForSearchField(ISearchField
$field, $count = 0): SearchQueryFragment
33 // we allow for more complicated searching by allowing combined search fields but there's a limit to how much
34 // we want to allow this to happen.
35 if ($count > self
::MAX_NESTED_LEVEL
) {
36 throw new \
RuntimeException("Exceeded maximum nested method calls for search fields.");
38 if ($field instanceof StringSearchField
) {
39 return self
::resolveStringSearchField($field);
40 } else if ($field instanceof DateSearchField
) {
41 return self
::resolveDateField($field);
42 } else if ($field instanceof TokenSearchField
) {
43 return self
::resolveTokenField($field);
44 } else if ($field instanceof ReferenceSearchField
) {
45 return self
::resolveReferenceField($field);
46 } else if ($field instanceof CompositeSearchField
) {
47 return self
::resolveCompositeSearchField($field, $count);
49 throw new \
InvalidArgumentException("Provided search field type was not implemented");
54 * Given a DateSearchField with a list of SearchFieldComparableValue objects in the search field a SQL query fragment
55 * is generated that handles the date field searching.
56 * @param DateSearchField $searchField
57 * @return SearchQueryFragment
59 public static function resolveDateField(DateSearchField
$searchField)
61 if (empty($searchField->getValues())) {
62 throw new \
InvalidArgumentException("Search field " . $searchField->getField() . " does not have a value to search on");
66 $searchFragment = new SearchQueryFragment();
67 $values = $searchField->getValues();
69 /** @var SearchFieldComparableValue $value */
70 foreach ($values as $comparableValue) {
71 // convert our value to an actual string
72 $value = $comparableValue->getValue();
73 $lowerBoundDateRange = null;
74 $upperBoundDateRange = null;
75 $dateSearchString = null;
76 $dateFormat = self
::getDateFieldFormatForDateType($searchField->getDateType());
77 if ($value instanceof \DatePeriod
) {
78 $lowerBoundDateRange = $value->getStartDate();
79 $upperBoundDateRange = $value->getEndDate();
80 } else if ($value instanceof \DateTime
) {
81 // in the future if we want to just have a DateTime value
82 $lowerBoundDateRange = $value;
83 $upperBoundDateRange = $value;
85 throw new \
InvalidArgumentException("DateSearchField " . $searchField->getField() . " contained value that was not a DatePeriod or DateTime object");
88 switch ($comparableValue->getComparator()) {
89 case SearchComparator
::LESS_THAN
:
90 case SearchComparator
::ENDS_BEFORE
:
92 $dateSearchString = $lowerBoundDateRange->format($dateFormat);
94 case SearchComparator
::LESS_THAN_OR_EQUAL_TO
:
95 // when dealing with an equal to we need to take the upper range of our fuzzy date interval
97 $dateSearchString = $upperBoundDateRange->format($dateFormat);
99 case SearchComparator
::GREATER_THAN
:
100 case SearchComparator
::STARTS_AFTER
:
102 $dateSearchString = $upperBoundDateRange->format($dateFormat);
104 case SearchComparator
::GREATER_THAN_OR_EQUAL_TO
:
105 // when dealing with an equal to we need to take the lower range of our fuzzy date interval
107 $dateSearchString = $lowerBoundDateRange->format($dateFormat);
109 case SearchComparator
::NOT_EQUALS
:
116 // for equality and also inequality (!=) we have to make sure we deal with the fuzzy ranges since search can
117 // specify date ranges of just Year, Year+Month, Year+month+day, Year+month+day+hour&minute, Year+month+day+hour&minute+second
118 if ($operator === '=') {
119 array_push($clauses, $searchField->getField() . ' BETWEEN ? AND ? ');
120 $searchFragment->addBoundValue($lowerBoundDateRange->format($dateFormat));
121 $searchFragment->addBoundValue($upperBoundDateRange->format($dateFormat));
122 } else if ($operator === '!=') {
123 // we have to make sure we deal with the fuzzy range when we have an = operator since the user
124 // can specify date ranges of just Year, Year+Month, Year+month+day, Year+month+day+hour&minute, Year+month+day+hour&minute+second
125 array_push($clauses, $searchField->getField() . ' NOT BETWEEN ? AND ? ');
126 $searchFragment->addBoundValue($lowerBoundDateRange->format($dateFormat));
127 $searchFragment->addBoundValue($upperBoundDateRange->format($dateFormat));
129 array_push($clauses, $searchField->getField() . ' ' . $operator . ' ?');
130 $searchFragment->addBoundValue($dateSearchString);
133 if (count($clauses) > 1) {
134 $multipleClause = $searchField->isAnd() ?
" AND " : " OR ";
135 $searchFragment->setFragment("(" . implode($multipleClause, $clauses) . ")");
137 $searchFragment->setFragment($clauses[0]);
139 return $searchFragment;
143 * Given a composite search field resolve each child field and aggregate into a union or intersection depending on
144 * the composite's isAnd setting.
145 * @param CompositeSearchField $field The composite field to aggregate.
147 * @return SearchQueryFragment
149 public static function resolveCompositeSearchField(CompositeSearchField
$field, $depthCount): SearchQueryFragment
152 $combinedFragment = new SearchQueryFragment();
153 foreach ($field->getChildren() as $searchField) {
154 $statement = self
::getStatementForSearchField($searchField, $depthCount +
1);
155 foreach ($statement->getBoundValues() as $value) {
156 $combinedFragment->addBoundValue($value);
158 $clauses[] = $statement->getFragment();
160 // TODO: stephen do we need to handle OR clauses here for our sub clause?
161 $joinType = $field->isAnd() ?
" AND " : " OR ";
162 $combinedFragment->setFragment("(" . implode($joinType, $clauses) . ")");
163 return $combinedFragment;
167 * Converts a reference search field into the appropriate query statement to be executed in the database engine
169 * TODO: adunsulag this seems like a lot of duplicate code similar to the resolveTokenField... reference doesn't have
170 * the modifiers like the token does so I'm not sure if we keep this duplicative code here or not.
171 * @param ReferenceSearchField $searchField
172 * @return SearchQueryFragment
174 public static function resolveReferenceField(ReferenceSearchField
$searchField)
176 if (empty($searchField->getValues())) {
177 throw new \
InvalidArgumentException("Search field " . $searchField->getField() . " does not have a value to search on");
180 $searchFragment = new SearchQueryFragment();
181 $values = $searchField->getValues();
184 foreach ($values as $value) {
185 /** @var ReferenceSearchValue $value */
186 $clauses[] = $searchField->getField() . ' = ?';
187 $searchFragment->addBoundValue($value->getId());
190 if (count($clauses) > 1) {
191 $multipleClause = $searchField->isAnd() ?
" AND " : " OR ";
192 $searchFragment->setFragment("(" . implode($multipleClause, $clauses) . ")");
194 $searchFragment->setFragment($clauses[0]);
196 return $searchFragment;
200 * Resolves a TokenSearchField to its corresponding value.
201 * @param TokenSearchField $searchField
202 * @return SearchQueryFragment
204 public static function resolveTokenField(TokenSearchField
$searchField)
206 if (empty($searchField->getValues())) {
207 throw new \
InvalidArgumentException("Search field " . $searchField->getField() . " does not have a value to search on");
210 $searchFragment = new SearchQueryFragment();
211 $modifier = $searchField->getModifier(); // we aren't going to deal with modifiers just yet
212 $values = $searchField->getValues();
215 foreach ($values as $value) {
216 /** @var TokenSearchValue $value */
217 $clauses[] = $searchField->getField() . ' = ?';
218 // TODO: adunsulag when we better understand Token's we will improve this process of how we resolve the token
219 // field to its representative bound value
220 $searchFragment->addBoundValue($value->getCode());
223 if (count($clauses) > 1) {
224 $multipleClause = $searchField->isAnd() ?
" AND " : " OR ";
225 $searchFragment->setFragment("(" . implode($multipleClause, $clauses) . ")");
227 $searchFragment->setFragment($clauses[0]);
229 return $searchFragment;
233 * Given a search field and any modifier's it may have it converts it to the corresponding SearchQueryFragment
234 * @param StringSearchField $searchField
235 * @return SearchQueryFragment
237 public static function resolveStringSearchField(StringSearchField
$searchField)
239 if (empty($searchField->getValues())) {
240 throw new \
InvalidArgumentException("Search field " . $searchField->getField() . " does not have a value to search on");
244 $searchFragment = new SearchQueryFragment();
245 $modifier = $searchField->getModifier();
246 $values = $searchField->getValues();
247 foreach ($values as $value) {
248 if ($modifier === 'prefix') {
249 array_push($clauses, $searchField->getField() . ' LIKE ?');
250 $searchFragment->addBoundValue($value . "%");
251 } else if ($modifier === 'contains') {
252 array_push($clauses, $searchField->getField() . ' LIKE ?');
253 $searchFragment->addBoundValue('%' . $value . '%');
254 } else if ($modifier === 'exact') {
255 // not we may want to grab the specific table collation here in order to improve performance
256 // and avoid db casting...
257 array_push($clauses, "BINARY " . $searchField->getField() . ' = ?');
258 $searchFragment->addBoundValue($value);
261 if (count($clauses) > 1) {
262 $multipleClause = $searchField->isAnd() ?
" AND " : " OR ";
263 $searchFragment->setFragment("(" . implode($multipleClause, $clauses) . ")");
265 $searchFragment->setFragment($clauses[0]);
267 return $searchFragment;
271 * Retrieves the date search field date format that should be used for the type of date.
275 public static function getDateFieldFormatForDateType($dateType)
277 $format = "Y-m-d H:i:s"; // default format is datetime
278 if ($dateType == DateSearchField
::DATE_TYPE_DATE
) {