Openemr fhir search (#4349)
[openemr.git] / src / Services / Search / SearchFieldStatementResolver.php
blobbd55a34a9f6f64f351141de0ce75ee5996f33192
1 <?php
3 /**
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
10 * @package openemr
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;
23 /**
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);
48 } else {
49 throw new \InvalidArgumentException("Provided search field type was not implemented");
53 /**
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");
65 $clauses = [];
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;
84 } else {
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:
91 $operator = "<";
92 $dateSearchString = $lowerBoundDateRange->format($dateFormat);
93 break;
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
96 $operator = "<=";
97 $dateSearchString = $upperBoundDateRange->format($dateFormat);
98 break;
99 case SearchComparator::GREATER_THAN:
100 case SearchComparator::STARTS_AFTER:
101 $operator = ">";
102 $dateSearchString = $upperBoundDateRange->format($dateFormat);
103 break;
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
106 $operator = ">=";
107 $dateSearchString = $lowerBoundDateRange->format($dateFormat);
108 break;
109 case SearchComparator::NOT_EQUALS:
110 $operator = "!=";
111 break;
112 default:
113 $operator = "=";
114 break;
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));
128 } else {
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) . ")");
136 } else {
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.
146 * @param $depthCount
147 * @return SearchQueryFragment
149 public static function resolveCompositeSearchField(CompositeSearchField $field, $depthCount): SearchQueryFragment
151 $clauses = [];
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();
182 $clauses = [];
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) . ")");
193 } else {
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();
213 $clauses = [];
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) . ")");
226 } else {
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");
243 $clauses = [];
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) . ")");
264 } else {
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.
272 * @param $dateType
273 * @return string
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) {
279 $format = "Y-m-d";
281 return $format;