From 93c71a76f84910059b00543ee63a698e7e531e95 Mon Sep 17 00:00:00 2001 From: Stephen Nielson Date: Tue, 18 May 2021 02:38:45 -0400 Subject: [PATCH] Openemr fhir search (#4349) * Initial FHIR Search implementation. Built out the FHIR search algorithm and implemented it in FhirServiceBase and BaseService. I built the search upon a 3 layer approach. The FHIR layer that is aware of FHIR search fields types, FHIR concepts, the OpenEMR Service layer, and then the database / SQL layer. I believe I've kept those layers separate from each other with the bottom layers not making any calls or dependencies on the top layers. The FHIR search uses the factory design pattern to build a set of ISearchField objects that represent the various types of search objects in both FHIR and now in the OpenEMR service layers. Currently the type of search fields that are supported are the String and Date fields which are almost fully supported with Token kinda working and the others not supported at this point. The only thing missing in the Date search type is timezone support which I haven't got a good solution to just yet. I also implemented an IPatientCompartmentResourceService that any FHIR service that touches patient data should implement. It specifies the patient field that will bind a FHIR search query into the patient context. This will make sure that only a single user's patient data will be returned when operating within just that patient's context. I've gone through and added a bunch of tests in the FhirPatientServiceQueryTest object that deals with the different modifiers, and comparators that FHIR supports. I abstracted it away so that the same kinds of modifiers and comparators (greater than, less than, etc) can be used in the service classes without knowledge of FHIR. I built it so we can have composite search fields with nested heirarchies to support all kinds of search operations that can combine both unions and intersections of search fields. There is also a big bug fix as the sqlThrowException operation was only working on insert/update operations that expected no data back. I fixed a bug that prevented the operation from returning data. * Fix the patient fixtures not cleaning. * Fix coding standards, full patient search. Got all of the required us core patient search and the original patient field searches working and tested in the query search. Looks like I broke a bunch of unit tests though and I'll have to go about fixing those. * Code style fixes, unit test fixes. Got all of the unit tests back up and running, had to fix a number of problems with how the search parameters were defined and setup. Also fixed some broken tests with the Practitioner service classes. Put more logic into the BaseService to handle and be able to override table joins and selection of data from table joins. I've partially implemented a very primitive ORM, I keep wondering if itd be better just to incorporate a pre-existing ORM but it seems like the project has put those in and ripped them out multiple times so I'm hedging away from that. Had to temporarily hard code the Practitioner search values (which we were doing originally anyways) until I can get the npi:missing stuff working for a search modifier. The challenge is that the :missing identifier overrides the search value to be a boolean true/false which is going to kill a number of the searchfield data validators. I'm debating on whether I will under the hood change the type to be a SearchMissingField class instead of relying on whatever the actual search type of class should be. I see pros and cons to that approach. I'll just have to play around with it and see what I end up with. Overall this should be a pretty good initial stab at a more robust FHIR searching solution that will pass ONC requirements. * Fix php7.3 FHIR date search preg_match bug php7.3 handles the preg_match criteria different than 7.4+. Switching to a !empty clause on each preg_match group that was found resolves this issue. * US Core resource endpoints, reference type. Added a way for each resource to update, map, or change records retrieved from the data layer. Added the reference search field type enum. Added the missing US Core entities and have them return empty responses for now. Enabled all of the patient us core entities scopes. Alphabetized the rest routes for FHIR so we can easily locate the routes. * Fix uuid return on Patient Service. * Initial method stub for reference type creation * Better logging of Authorization Controller. * Patient Provenance and US-Core Patient requirements. Got the Single Patient API ONC Us Core Inferno test suite to pass for the US Core Patient Profile. All of the core search criteria is working as well as the gender, language, and race core extensions. The communication is throwing a warning but we use the same data values for CCDA so I believe this is correct, but the test still passes despite the warning. Also implemented the Patient Provenance. We'll see if the implementation still works in the future as I'm not sure if our Provenance record needs to contain EVERY single entity for that Provenance target... we'll have to see as that could be pretty big at the organization level. * Unit test fixes, ONC Patient API implementation. Got the ONC Patient API implementation working correctly. Had to fix a number of unit tests that had broken in the various classes like CarePlan,Device,DiagnosticReport,Goal,Organization. Also fixed some code style issues. Extended the List service to include retrieving records from list_options table. * Style fixes, Fix search params. Had a unit test failing due to the _REWRITE_COMMAND get query string added the get stuff to the HTTP Request object and exclude the server openemr get params so we have a clean param list in our API requests. * Renamed the file to fix psr4 errors. * Fix PSR12 case statement style problems. * One last style fix urgh. * Initial search work for FHIR Allergy Intolerance. Created a query utils to put common query methods in that can be used by the project that also throw exceptions and are easier to catch / handle in the framework (IE returning an operation outcome if something goes wrong rather than just dieing). Extended FixtureManager to create fixtures for Allergy Intolerance. Not sure I like it as we want something more extensible. Started the initial underpinnings to support the allergy intolerance searching with FHIR. We will be doing a reference search so we will have that piece supported soon. * FHIR Reference Field, AllergyIntollerance Search. Implemented the AllergyIntollerance search on patient. Also implemented the initial FHIR reference field. Wrote unit tests to verify the FHIR AllergyIntolerance is working properly. * Finalized AllergyIntolerance and passed ONC tests. Got the final pieces implemented in the AllergyIntolerance FHIR resource. Also fixed a bug in FacilityService with the primary organization query. Fixed the getOne api signature on a number of the services. * Fixed unit tests,added logging,query helper method Added another query helper method. Fixed the unit tests on allergy intolerance. Fixed some other unit tests to support an environment variable on the admin user for unit tests since jerry's test database uses a different admin account. * Fixed lookup codes if codeset is not installed We had an error on the continuous integration service where the code sets for allergy-intolerance are not installed. Fixed it so that it doesn't throw the error if the codeset is not found. I'm not sure if the FHIR unit tests just never checked against snomed codes before, but I find it strange/bizarre that this never threw errors beforehand. * Style fixes * Style fixes * Fixed multiple value search conditions. @brady.miller helped identify a problem that was breaking the unit tests. Found out that we weren't handling the OR condition on search parameters that had multiple values. Fixed the search fields to handle that properly. * Force patient binding, fixed missing phone numbers. Tracked why the patient fixtures would fail the test cases sometimes. Since the patient is chosen at random in some of the patient fixtures it was messing up on the phone records. Found there was a patient that did not have all of the phone numbers specified. Fixing that resolves the test cases that were failing. Co-authored-by: Stephen Nielson --- _rest_routes.inc.php | 503 +++++++++++------- custom/code_types.inc.php | 135 +++-- library/sql.inc | 3 +- .../Grant/CustomPasswordGrant.php | 16 +- .../Repositories/ScopeRepository.php | 21 +- src/Common/Database/QueryUtils.php | 167 ++++++ src/Common/Http/HttpRestRequest.php | 20 + src/Common/Utils/QueryUtils.php | 78 --- .../AuthorizationController.php | 8 +- .../FhirAllergyIntoleranceRestController.php | 5 +- .../FHIR/FhirCarePlanRestController.php | 62 +++ .../FHIR/FhirDeviceRestController.php | 62 +++ .../FhirDiagnosticReportRestController.php | 61 +++ .../FhirDocumentReferenceRestController.php | 62 +++ .../FHIR/FhirGoalRestController.php | 62 +++ .../FHIR/FhirMetaDataRestController.php | 3 + .../FHIR/FhirOrganizationRestController.php | 4 + .../FHIR/FhirProvenanceRestController.php | 71 +++ src/RestControllers/RestControllerHelper.php | 48 +- src/Services/AllergyIntoleranceService.php | 238 ++++----- src/Services/BaseService.php | 205 +++++-- .../FHIR/FhirAllergyIntoleranceService.php | 152 +++--- src/Services/FHIR/FhirConditionService.php | 6 +- src/Services/FHIR/FhirCoverageService.php | 10 +- src/Services/FHIR/FhirEncounterService.php | 8 +- src/Services/FHIR/FhirImmunizationService.php | 4 +- src/Services/FHIR/FhirLocationService.php | 2 +- .../FHIR/FhirMedicationRequestService.php | 4 +- src/Services/FHIR/FhirMedicationService.php | 2 +- src/Services/FHIR/FhirOrganizationService.php | 89 +++- src/Services/FHIR/FhirPatientService.php | 373 ++++++++----- src/Services/FHIR/FhirPersonService.php | 30 +- .../FHIR/FhirPractitionerRoleService.php | 8 +- src/Services/FHIR/FhirPractitionerService.php | 44 +- src/Services/FHIR/FhirProcedureService.php | 4 +- src/Services/FHIR/FhirProvenanceService.php | 174 ++++++ src/Services/FHIR/FhirServiceBase.php | 148 ++++-- src/Services/FHIR/FhirUrlResolver.php | 39 ++ .../IPatientCompartmentResourceService.php | 22 + src/Services/FacilityService.php | 88 +-- src/Services/InsuranceCompanyService.php | 88 +-- src/Services/ListService.php | 32 +- src/Services/OrganizationService.php | 53 +- src/Services/PatientService.php | 98 +--- src/Services/PractitionerService.php | 182 +++---- src/Services/Search/BasicSearchField.php | 151 ++++++ src/Services/Search/CompositeSearchField.php | 135 +++++ src/Services/Search/DateSearchField.php | 202 +++++++ .../Search/FHIRSearchFieldFactory.php | 266 +++++++++ .../Search/FhirSearchParameterDefinition.php | 74 +++ .../Search/FhirSearchWhereClauseBuilder.php | 64 +++ src/Services/Search/ISearchField.php | 43 ++ src/Services/Search/ReferenceSearchField.php | 57 ++ src/Services/Search/ReferenceSearchValue.php | 105 ++++ src/Services/Search/SearchComparator.php | 40 ++ .../Search/SearchFieldComparableValue.php | 66 +++ src/Services/Search/SearchFieldException.php | 36 ++ .../Search/SearchFieldStatementResolver.php | 283 ++++++++++ .../Search/SearchFieldType.php} | 13 +- src/Services/Search/SearchModifier.php | 20 + src/Services/Search/SearchQueryFragment.php | 80 +++ src/Services/Search/ServiceField.php | 46 ++ src/Services/Search/StringSearchField.php | 26 + src/Services/Search/TableSearchProcessor.php | 149 ++++++ src/Services/Search/TokenSearchField.php | 52 ++ src/Services/Search/TokenSearchValue.php | 114 ++++ tests/Tests/Api/PractitionerApiTest.php | 2 +- tests/Tests/Fixtures/FixtureManager.php | 54 +- tests/Tests/Fixtures/allergy-intolerance.json | 22 + tests/Tests/Fixtures/patients.json | 85 ++- .../FhirOrganizationRestControllerTest.php | 7 +- .../FhirPractitionerRestControllerTest.php | 4 + ...FhirAllergyIntoleranceServiceQueryTest.php | 177 ++++++ .../FHIR/FhirPatientServiceMappingTest.php | 4 +- .../FHIR/FhirPatientServiceQueryTest.php | 142 ++++- .../Services/PractitionerServiceTest.php | 4 + tests/Tests/Unit/Common/Acl/AclMainTest.php | 2 +- 77 files changed, 4879 insertions(+), 1140 deletions(-) create mode 100644 src/Common/Database/QueryUtils.php delete mode 100644 src/Common/Utils/QueryUtils.php create mode 100644 src/RestControllers/FHIR/FhirCarePlanRestController.php create mode 100644 src/RestControllers/FHIR/FhirDeviceRestController.php create mode 100644 src/RestControllers/FHIR/FhirDiagnosticReportRestController.php create mode 100644 src/RestControllers/FHIR/FhirDocumentReferenceRestController.php create mode 100644 src/RestControllers/FHIR/FhirGoalRestController.php create mode 100644 src/RestControllers/FHIR/FhirProvenanceRestController.php create mode 100644 src/Services/FHIR/FhirProvenanceService.php create mode 100644 src/Services/FHIR/FhirUrlResolver.php create mode 100644 src/Services/FHIR/IPatientCompartmentResourceService.php create mode 100644 src/Services/Search/BasicSearchField.php create mode 100644 src/Services/Search/CompositeSearchField.php create mode 100644 src/Services/Search/DateSearchField.php create mode 100644 src/Services/Search/FHIRSearchFieldFactory.php create mode 100644 src/Services/Search/FhirSearchParameterDefinition.php create mode 100644 src/Services/Search/FhirSearchWhereClauseBuilder.php create mode 100644 src/Services/Search/ISearchField.php create mode 100644 src/Services/Search/ReferenceSearchField.php create mode 100644 src/Services/Search/ReferenceSearchValue.php create mode 100644 src/Services/Search/SearchComparator.php create mode 100644 src/Services/Search/SearchFieldComparableValue.php create mode 100644 src/Services/Search/SearchFieldException.php create mode 100644 src/Services/Search/SearchFieldStatementResolver.php rename src/{FHIR/FhirSearchParameterType.php => Services/Search/SearchFieldType.php} (62%) create mode 100644 src/Services/Search/SearchModifier.php create mode 100644 src/Services/Search/SearchQueryFragment.php create mode 100644 src/Services/Search/ServiceField.php create mode 100644 src/Services/Search/StringSearchField.php create mode 100644 src/Services/Search/TableSearchProcessor.php create mode 100644 src/Services/Search/TokenSearchField.php create mode 100644 src/Services/Search/TokenSearchValue.php create mode 100644 tests/Tests/Fixtures/allergy-intolerance.json create mode 100644 tests/Tests/Services/FHIR/FhirAllergyIntoleranceServiceQueryTest.php diff --git a/_rest_routes.inc.php b/_rest_routes.inc.php index 3fe9e1de565..c86be2c9cb1 100644 --- a/_rest_routes.inc.php +++ b/_rest_routes.inc.php @@ -577,13 +577,18 @@ use OpenEMR\Common\Http\StatusCode; use OpenEMR\Common\Http\Psr17Factory; use OpenEMR\RestControllers\FHIR\FhirAllergyIntoleranceRestController; +use OpenEMR\RestControllers\FHIR\FhirCarePlanRestController; use OpenEMR\RestControllers\FHIR\FhirCareTeamRestController; use OpenEMR\RestControllers\FHIR\FhirConditionRestController; use OpenEMR\RestControllers\FHIR\FhirCoverageRestController; +use OpenEMR\RestControllers\FHIR\FhirDeviceRestController; +use OpenEMR\RestControllers\FHIR\FhirDiagnosticReportRestController; +use OpenEMR\RestControllers\FHIR\FhirDocumentReferenceRestController; use OpenEMR\RestControllers\FHIR\FhirEncounterRestController; use OpenEMR\RestControllers\FHIR\FhirExportRestController; use OpenEMR\RestControllers\FHIR\FhirObservationRestController; use OpenEMR\RestControllers\FHIR\FhirImmunizationRestController; +use OpenEMR\RestControllers\FHIR\FhirGoalRestController; use OpenEMR\RestControllers\FHIR\FhirLocationRestController; use OpenEMR\RestControllers\FHIR\FhirMedicationRestController; use OpenEMR\RestControllers\FHIR\FhirMedicationRequestRestController; @@ -593,214 +598,225 @@ use OpenEMR\RestControllers\FHIR\FhirPractitionerRoleRestController; use OpenEMR\RestControllers\FHIR\FhirPractitionerRestController; use OpenEMR\RestControllers\FHIR\FhirProcedureRestController; +use OpenEMR\RestControllers\FHIR\FhirProvenanceRestController; use OpenEMR\RestControllers\FHIR\FhirMetaDataRestController; // Note that the fhir route includes both user role and patient role // (there is a mechanism in place to ensure patient role is binded // to only see the data of the one patient) RestConfig::$FHIR_ROUTE_MAP = array( - "GET /fhir/metadata" => function () { - $return = (new FhirMetaDataRestController())->getMetaData(); + "GET /fhir/AllergyIntolerance" => function (HttpRestRequest $request) { + $getParams = $request->getQueryParams(); + if ($request->isPatientRequest()) { + // only allow access to data of binded patient + $return = (new FhirAllergyIntoleranceRestController($request))->getAll($getParams, $request->getPatientUUIDString()); + } else { + RestConfig::authorization_check("patients", "med"); + $return = (new FhirAllergyIntoleranceRestController($request))->getAll($getParams); + } RestConfig::apiLog($return); return $return; }, - "GET /fhir/.well-known/smart-configuration" => function () { - $authController = new \OpenEMR\RestControllers\AuthorizationController(); - $return = (new \OpenEMR\RestControllers\SMART\SMARTConfigurationController($authController))->getConfig(); + "GET /fhir/AllergyIntolerance/:id" => function ($id, HttpRestRequest $request) { + if ($request->isPatientRequest()) { + // only allow access to data of binded patient + $return = (new FhirAllergyIntoleranceRestController($request))->getOne($id, $request->getPatientUUIDString()); + } else { + RestConfig::authorization_check("patients", "med"); + $return = (new FhirAllergyIntoleranceRestController($request))->getOne($id); + } RestConfig::apiLog($return); return $return; - }, - "POST /fhir/Patient" => function (HttpRestRequest $request) { - RestConfig::authorization_check("patients", "demo"); - $data = (array) (json_decode(file_get_contents("php://input"), true)); - $return = (new FhirPatientRestController())->post($data); - RestConfig::apiLog($return, $data); - return $return; - }, - "PUT /fhir/Patient/:id" => function ($id, HttpRestRequest $request) { - RestConfig::authorization_check("patients", "demo"); - $data = (array) (json_decode(file_get_contents("php://input"), true)); - $return = (new FhirPatientRestController())->put($id, $data); - RestConfig::apiLog($return, $data); - return $return; - }, - "GET /fhir/Patient" => function (HttpRestRequest $request) { - $params = $_GET; + },"GET /fhir/CarePlan" => function (HttpRestRequest $request) { + $getParams = $request->getQueryParams(); if ($request->isPatientRequest()) { // only allow access to data of binded patient - // Note in Patient context still have to return a bundle even if it is just one resource. (ie. - // need to use getAll rather than getOne) - $params['_id'] = $request->getPatientUUIDString(); - $return = (new FhirPatientRestController())->getAll($params, $request->getPatientUUIDString()); + $return = (new FhirCarePlanRestController())->getAll($getParams, $request->getPatientUUIDString()); } else { - RestConfig::authorization_check("patients", "demo"); - $return = (new FhirPatientRestController())->getAll($params); + RestConfig::authorization_check("patients", "med"); + $return = (new FhirCareTeamRestController())->getAll($getParams); } RestConfig::apiLog($return); return $return; }, - // we have to have the bulk fhir export operation here otherwise it will match $export to the patient $id - 'GET /fhir/Patient/$export' => function (HttpRestRequest $request) { - RestConfig::authorization_check("admin", "users"); - $fhirExportService = new FhirExportRestController($request); - $return = $fhirExportService->processExport( - $_GET, - 'Patient', - $request->getHeader('Accept'), - $request->getHeader('Prefer') - ); + "GET /fhir/CarePlan/:uuid" => function ($uuid, HttpRestRequest $request) { + if ($request->isPatientRequest()) { + // only allow access to data of binded patient + $return = (new FhirCarePlanRestController())->getOne($uuid, $request->getPatientUUIDString()); + } else { + RestConfig::authorization_check("patients", "med"); + $return = (new FhirCarePlanRestController())->getOne($uuid); + } RestConfig::apiLog($return); return $return; }, - "GET /fhir/Patient/:id" => function ($id, HttpRestRequest $request) { + "GET /fhir/CareTeam" => function (HttpRestRequest $request) { + $getParams = $request->getQueryParams(); if ($request->isPatientRequest()) { // only allow access to data of binded patient - if (empty($id) || ($id != $request->getPatientUUIDString())) { - throw new AccessDeniedException("patients", "demo", "patient id invalid"); - } - $id = $request->getPatientUUIDString(); + $return = (new FhirCareTeamRestController())->getAll($getParams, $request->getPatientUUIDString()); } else { - RestConfig::authorization_check("patients", "demo"); + RestConfig::authorization_check("patients", "med"); + $return = (new FhirCareTeamRestController())->getAll($getParams); } - $return = (new FhirPatientRestController())->getOne($id); RestConfig::apiLog($return); return $return; }, - "GET /fhir/Encounter" => function (HttpRestRequest $request) { - $getParams = $_GET; + "GET /fhir/CareTeam/:uuid" => function ($uuid, HttpRestRequest $request) { if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirEncounterRestController())->getAll($getParams, $request->getPatientUUIDString()); + $return = (new FhirCareTeamRestController())->getOne($uuid, $request->getPatientUUIDString()); } else { - RestConfig::authorization_check("encounters", "auth_a"); - $return = (new FhirEncounterRestController())->getAll($getParams); + RestConfig::authorization_check("patients", "med"); + $return = (new FhirCareTeamRestController())->getOne($uuid); } RestConfig::apiLog($return); return $return; }, - "GET /fhir/Encounter/:id" => function ($id, HttpRestRequest $request) { + "GET /fhir/Condition" => function (HttpRestRequest $request) { + $getParams = $request->getQueryParams(); if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirEncounterRestController())->getOne($id, $request->getPatientUUIDString()); + $return = (new FhirConditionRestController())->getAll($getParams, $request->getPatientUUIDString()); } else { - RestConfig::authorization_check("encounters", "auth_a"); - $return = (new FhirEncounterRestController())->getOne($id); + RestConfig::authorization_check("patients", "med"); + $return = (new FhirConditionRestController())->getAll($getParams); } RestConfig::apiLog($return); return $return; }, - "GET /fhir/Practitioner" => function (HttpRestRequest $request) { - RestConfig::authorization_check("admin", "users"); - $return = (new FhirPractitionerRestController())->getAll($_GET); + "GET /fhir/Condition/:id" => function ($uuid, HttpRestRequest $request) { + if ($request->isPatientRequest()) { + // only allow access to data of binded patient + $return = (new FhirConditionRestController())->getOne($uuid, $request->getPatientUUIDString()); + } else { + RestConfig::authorization_check("patients", "med"); + $return = (new FhirConditionRestController())->getOne($uuid); + } RestConfig::apiLog($return); return $return; }, - "GET /fhir/Practitioner/:id" => function ($id, HttpRestRequest $request) { - RestConfig::authorization_check("admin", "users"); - $return = (new FhirPractitionerRestController())->getOne($id); + "GET /fhir/Coverage" => function (HttpRestRequest $request) { + RestConfig::authorization_check("admin", "super"); + $return = (new FhirCoverageRestController())->getAll($request->getQueryParams()); RestConfig::apiLog($return); return $return; }, - "POST /fhir/Practitioner" => function (HttpRestRequest $request) { - RestConfig::authorization_check("admin", "users"); - $data = (array) (json_decode(file_get_contents("php://input"), true)); - $return = (new FhirPractitionerRestController())->post($data); - RestConfig::apiLog($return, $data); + "GET /fhir/Coverage/:uuid" => function ($uuid, HttpRestRequest $request) { + RestConfig::authorization_check("admin", "super"); + $return = (new FhirCoverageRestController())->getOne($uuid); + RestConfig::apiLog($return); return $return; }, - "PUT /fhir/Practitioner/:id" => function ($id, HttpRestRequest $request) { - RestConfig::authorization_check("admin", "users"); - $data = (array) (json_decode(file_get_contents("php://input"), true)); - $return = (new FhirPractitionerRestController())->patch($id, $data); - RestConfig::apiLog($return, $data); + "GET /fhir/Device" => function (HttpRestRequest $request) { + RestConfig::authorization_check("admin", "super"); + $return = (new FhirDeviceRestController())->getAll($request->getQueryParams()); + RestConfig::apiLog($return); return $return; }, - "GET /fhir/Organization" => function (HttpRestRequest $request) { - RestConfig::authorization_check("admin", "users"); - $return = (new FhirOrganizationRestController())->getAll($_GET); + "GET /fhir/Device/:uuid" => function ($uuid, HttpRestRequest $request) { + RestConfig::authorization_check("admin", "super"); + $return = (new FhirDeviceRestController())->getOne($uuid); RestConfig::apiLog($return); return $return; }, - "GET /fhir/Organization/:id" => function ($id, HttpRestRequest $request) { - RestConfig::authorization_check("admin", "users"); - $return = (new FhirOrganizationRestController())->getOne($id); + "GET /fhir/DiagnosticReport" => function (HttpRestRequest $request) { + RestConfig::authorization_check("admin", "super"); + $return = (new FhirDiagnosticReportRestController())->getAll($request->getQueryParams()); RestConfig::apiLog($return); return $return; }, - "POST /fhir/Organization" => function (HttpRestRequest $request) { + "GET /fhir/DiagnosticReport/:uuid" => function ($uuid, HttpRestRequest $request) { RestConfig::authorization_check("admin", "super"); - $data = (array) (json_decode(file_get_contents("php://input"), true)); - $return = (new FhirOrganizationRestController())->post($data); - RestConfig::apiLog($return, $data); + $return = (new FhirDiagnosticReportRestController())->getOne($uuid); + RestConfig::apiLog($return); return $return; }, - "PUT /fhir/Organization/:id" => function ($id, HttpRestRequest $request) { + 'GET /fhir/DocumentReference' => function (HttpRestRequest $request) { RestConfig::authorization_check("admin", "super"); - $data = (array) (json_decode(file_get_contents("php://input"), true)); - $return = (new FhirOrganizationRestController())->patch($id, $data); - RestConfig::apiLog($return, $data); + $return = (new FhirDocumentReferenceRestController())->getAll($request->getQueryParams()); + RestConfig::apiLog($return); return $return; }, - "GET /fhir/PractitionerRole" => function (HttpRestRequest $request) { - RestConfig::authorization_check("admin", "users"); - $return = (new FhirPractitionerRoleRestController())->getAll($_GET); + "GET /fhir/DocumentReference/:uuid" => function ($uuid, HttpRestRequest $request) { + RestConfig::authorization_check("admin", "super"); + $return = (new FhirDocumentReferenceRestController())->getOne($uuid); RestConfig::apiLog($return); return $return; }, - "GET /fhir/PractitionerRole/:id" => function ($id, HttpRestRequest $request) { + 'GET /fhir/Document/:id/Binary' => function ($documentId, HttpRestRequest $request) { + // currently only allow users with the same permissions as export to take a file out + // this could be relaxed to allow other types of files ie such as patient access etc. RestConfig::authorization_check("admin", "users"); - $return = (new FhirPractitionerRoleRestController())->getOne($id); - RestConfig::apiLog($return); - return $return; + + // Grab the document id + $docController = new \OpenEMR\RestControllers\FHIR\FhirDocumentRestController($request); + $response = $docController->downloadDocument($documentId, $request->getRequestUserId()); + return $response; }, - "GET /fhir/AllergyIntolerance" => function (HttpRestRequest $request) { - $getParams = $_GET; + "GET /fhir/Encounter" => function (HttpRestRequest $request) { + $getParams = $request->getQueryParams(); if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirAllergyIntoleranceRestController())->getAll($getParams, $request->getPatientUUIDString()); + $return = (new FhirEncounterRestController())->getAll($getParams, $request->getPatientUUIDString()); } else { - RestConfig::authorization_check("patients", "med"); - $return = (new FhirAllergyIntoleranceRestController())->getAll($getParams); + RestConfig::authorization_check("encounters", "auth_a"); + $return = (new FhirEncounterRestController())->getAll($getParams); } RestConfig::apiLog($return); return $return; }, - "GET /fhir/AllergyIntolerance/:id" => function ($id, HttpRestRequest $request) { + "GET /fhir/Encounter/:id" => function ($id, HttpRestRequest $request) { if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirAllergyIntoleranceRestController())->getOne($id, $request->getPatientUUIDString()); + $return = (new FhirEncounterRestController())->getOne($id, $request->getPatientUUIDString()); } else { - RestConfig::authorization_check("patients", "med"); - $return = (new FhirAllergyIntoleranceRestController())->getOne($id); + RestConfig::authorization_check("admin", "super"); + $return = (new FhirEncounterRestController())->getOne($id); } RestConfig::apiLog($return); return $return; }, - "GET /fhir/Observation" => function (HttpRestRequest $request) { - $getParams = $_GET; + "GET /fhir/Goal" => function (HttpRestRequest $request) { + $getParams = $request->getQueryParams(); if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirObservationRestController())->getAll($getParams, $request->getPatientUUIDString()); + $return = (new FhirGoalRestController())->getAll($getParams, $request->getPatientUUIDString()); } else { - RestConfig::authorization_check("patients", "med"); - $return = (new FhirObservationRestController())->getAll($getParams); + RestConfig::authorization_check("admin", "super"); + $return = (new FhirGoalRestController())->getAll($getParams); } RestConfig::apiLog($return); return $return; }, - "GET /fhir/Observation/:uuid" => function ($uuid, HttpRestRequest $request) { + "GET /fhir/Goal/:id" => function ($id, HttpRestRequest $request) { if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirObservationRestController())->getOne($uuid, $request->getPatientUUIDString()); + $return = (new FhirGoalRestController())->getOne($id, $request->getPatientUUIDString()); } else { - RestConfig::authorization_check("patients", "med"); - $return = (new FhirObservationRestController())->getOne($uuid); + RestConfig::authorization_check("admin", "super"); + $return = (new FhirGoalRestController())->getOne($id); } RestConfig::apiLog($return); return $return; }, + + 'GET /fhir/Group/:id/$export' => function ($groupId, HttpRestRequest $request) { + RestConfig::authorization_check("admin", "users"); + $fhirExportService = new FhirExportRestController($request); + $exportParams = $request->getQueryParams(); + $exportParams['groupId'] = $groupId; + $return = $fhirExportService->processExport( + $exportParams, + 'Group', + $request->getHeader('Accept'), + $request->getHeader('Prefer') + ); + RestConfig::apiLog($return); + return $return; + }, "GET /fhir/Immunization" => function (HttpRestRequest $request) { - $getParams = $_GET; + $getParams = $request->getQueryParams(); if ($request->isPatientRequest()) { // only allow access to data of binded patient $return = (new FhirImmunizationRestController())->getAll($getParams, $request->getPatientUUIDString()); @@ -822,176 +838,275 @@ RestConfig::apiLog($return); return $return; }, - "GET /fhir/Condition" => function (HttpRestRequest $request) { - $getParams = $_GET; + "GET /fhir/Location" => function (HttpRestRequest $request) { + RestConfig::authorization_check("patients", "med"); + $return = (new FhirLocationRestController())->getAll($request->getQueryParams()); + RestConfig::apiLog($return); + return $return; + }, + "GET /fhir/Location/:uuid" => function ($uuid, HttpRestRequest $request) { + RestConfig::authorization_check("patients", "med"); + $return = (new FhirLocationRestController())->getOne($uuid); + RestConfig::apiLog($return); + return $return; + }, + "GET /fhir/Medication" => function (HttpRestRequest $request) { + RestConfig::authorization_check("patients", "med"); + $return = (new FhirMedicationRestController())->getAll($request->getQueryParams()); + RestConfig::apiLog($return); + return $return; + }, + "GET /fhir/Medication/:uuid" => function ($uuid, HttpRestRequest $request) { + RestConfig::authorization_check("patients", "med"); + $return = (new FhirMedicationRestController())->getOne($uuid); + RestConfig::apiLog($return); + return $return; + }, + "GET /fhir/MedicationRequest" => function (HttpRestRequest $request) { + $getParams = $request->getQueryParams(); if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirConditionRestController())->getAll($getParams, $request->getPatientUUIDString()); + $return = (new FhirMedicationRequestRestController())->getAll($getParams, $request->getPatientUUIDString()); } else { RestConfig::authorization_check("patients", "med"); - $return = (new FhirConditionRestController())->getAll($getParams); + $return = (new FhirMedicationRequestRestController())->getAll($getParams); } RestConfig::apiLog($return); return $return; }, - "GET /fhir/Condition/:id" => function ($uuid, HttpRestRequest $request) { + "GET /fhir/MedicationRequest/:uuid" => function ($uuid, HttpRestRequest $request) { if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirConditionRestController())->getOne($uuid, $request->getPatientUUIDString()); + $return = (new FhirMedicationRequestRestController())->getOne($uuid, $request->getPatientUUIDString()); } else { RestConfig::authorization_check("patients", "med"); - $return = (new FhirConditionRestController())->getOne($uuid); + $return = (new FhirMedicationRequestRestController())->getOne($uuid); } RestConfig::apiLog($return); return $return; }, - "GET /fhir/Procedure" => function (HttpRestRequest $request) { - $getParams = $_GET; + "GET /fhir/Organization" => function (HttpRestRequest $request) { + RestConfig::authorization_check("admin", "users"); + $return = (new FhirOrganizationRestController())->getAll($request->getQueryParams()); + RestConfig::apiLog($return); + return $return; + }, + "GET /fhir/Organization/:id" => function ($id, HttpRestRequest $request) { + RestConfig::authorization_check("admin", "users"); + $return = (new FhirOrganizationRestController())->getOne($id); + RestConfig::apiLog($return); + return $return; + }, + "POST /fhir/Organization" => function (HttpRestRequest $request) { + RestConfig::authorization_check("admin", "super"); + $data = (array) (json_decode(file_get_contents("php://input"), true)); + $return = (new FhirOrganizationRestController())->post($data); + RestConfig::apiLog($return, $data); + return $return; + }, + "PUT /fhir/Organization/:id" => function ($id, HttpRestRequest $request) { + RestConfig::authorization_check("admin", "super"); + $data = (array) (json_decode(file_get_contents("php://input"), true)); + $return = (new FhirOrganizationRestController())->patch($id, $data); + RestConfig::apiLog($return, $data); + return $return; + }, + "GET /fhir/Observation" => function (HttpRestRequest $request) { + $getParams = $request->getQueryParams(); if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirProcedureRestController())->getAll($getParams, $request->getPatientUUIDString()); + $return = (new FhirObservationRestController())->getAll($getParams, $request->getPatientUUIDString()); } else { RestConfig::authorization_check("patients", "med"); - $return = (new FhirProcedureRestController())->getAll($getParams); + $return = (new FhirObservationRestController())->getAll($getParams); } RestConfig::apiLog($return); return $return; }, - "GET /fhir/Procedure/:uuid" => function ($uuid, HttpRestRequest $request) { + "GET /fhir/Observation/:uuid" => function ($uuid, HttpRestRequest $request) { if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirProcedureRestController())->getOne($uuid, $request->getPatientUUIDString()); + $return = (new FhirObservationRestController())->getOne($uuid, $request->getPatientUUIDString()); } else { RestConfig::authorization_check("patients", "med"); - $return = (new FhirProcedureRestController())->getOne($uuid); + $return = (new FhirObservationRestController())->getOne($uuid); } RestConfig::apiLog($return); return $return; }, - "GET /fhir/MedicationRequest" => function (HttpRestRequest $request) { - $getParams = $_GET; + "POST /fhir/Patient" => function (HttpRestRequest $request) { + RestConfig::authorization_check("patients", "demo"); + $data = (array) (json_decode(file_get_contents("php://input"), true)); + $return = (new FhirPatientRestController())->post($data); + RestConfig::apiLog($return, $data); + return $return; + }, + "PUT /fhir/Patient/:id" => function ($id, HttpRestRequest $request) { + RestConfig::authorization_check("patients", "demo"); + $data = (array) (json_decode(file_get_contents("php://input"), true)); + $return = (new FhirPatientRestController())->put($id, $data); + RestConfig::apiLog($return, $data); + return $return; + }, + "GET /fhir/Patient" => function (HttpRestRequest $request) { + $params = $request->getQueryParams(); if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirMedicationRequestRestController())->getAll($getParams, $request->getPatientUUIDString()); + // Note in Patient context still have to return a bundle even if it is just one resource. (ie. + // need to use getAll rather than getOne) + $params['_id'] = $request->getPatientUUIDString(); + $return = (new FhirPatientRestController())->getAll($params, $request->getPatientUUIDString()); } else { - RestConfig::authorization_check("patients", "med"); - $return = (new FhirMedicationRequestRestController())->getAll($getParams); + RestConfig::authorization_check("patients", "demo"); + $return = (new FhirPatientRestController())->getAll($params); } RestConfig::apiLog($return); return $return; }, - "GET /fhir/MedicationRequest/:uuid" => function ($uuid, HttpRestRequest $request) { + // we have to have the bulk fhir export operation here otherwise it will match $export to the patient $id + 'GET /fhir/Patient/$export' => function (HttpRestRequest $request) { + RestConfig::authorization_check("admin", "users"); + $fhirExportService = new FhirExportRestController($request); + $return = $fhirExportService->processExport( + $request->getQueryParams(), + 'Patient', + $request->getHeader('Accept'), + $request->getHeader('Prefer') + ); + RestConfig::apiLog($return); + return $return; + }, + "GET /fhir/Patient/:id" => function ($id, HttpRestRequest $request) { if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirMedicationRequestRestController())->getOne($uuid, $request->getPatientUUIDString()); + if (empty($id) || ($id != $request->getPatientUUIDString())) { + throw new AccessDeniedException("patients", "demo", "patient id invalid"); + } + $id = $request->getPatientUUIDString(); } else { - RestConfig::authorization_check("patients", "med"); - $return = (new FhirMedicationRequestRestController())->getOne($uuid); + RestConfig::authorization_check("patients", "demo"); } + $return = (new FhirPatientRestController())->getOne($id); RestConfig::apiLog($return); return $return; }, - "GET /fhir/Medication" => function (HttpRestRequest $request) { - RestConfig::authorization_check("patients", "med"); - $return = (new FhirMedicationRestController())->getAll($_GET); + "GET /fhir/Person" => function (HttpRestRequest $request) { + RestConfig::authorization_check("admin", "users"); + $return = (new FhirPersonRestController())->getAll($request->getQueryParams()); RestConfig::apiLog($return); return $return; }, - "GET /fhir/Medication/:uuid" => function ($uuid, HttpRestRequest $request) { - RestConfig::authorization_check("patients", "med"); - $return = (new FhirMedicationRestController())->getOne($uuid); + "GET /fhir/Person/:uuid" => function ($uuid, HttpRestRequest $request) { + RestConfig::authorization_check("admin", "users"); + $return = (new FhirPersonRestController())->getOne($uuid); RestConfig::apiLog($return); return $return; }, - "GET /fhir/Location" => function (HttpRestRequest $request) { - RestConfig::authorization_check("patients", "med"); - $return = (new FhirLocationRestController())->getAll($_GET); + "GET /fhir/Practitioner" => function (HttpRestRequest $request) { + + // TODO: @adunsulag talk with brady.miller about patients needing access to any practitioner resource + // that is referenced in connected patient resources -- such as AllergyIntollerance. + // I don't believe patients are assigned to a particular practitioner + // should we allow just open api access to admin information? Should we restrict particular pieces + // of data in the practitioner side (phone number, address information) based on a permission set? + if (!$request->isPatientRequest()) { + RestConfig::authorization_check("admin", "users"); + } + $return = (new FhirPractitionerRestController())->getAll($request->getQueryParams()); RestConfig::apiLog($return); return $return; }, - "GET /fhir/Location/:uuid" => function ($uuid, HttpRestRequest $request) { - RestConfig::authorization_check("patients", "med"); - $return = (new FhirLocationRestController())->getOne($uuid); + "GET /fhir/Practitioner/:id" => function ($id, HttpRestRequest $request) { + // TODO: @adunsulag talk with brady.miller about patients needing access to any practitioner resource + // that is referenced in connected patient resources -- such as AllergyIntollerance. + // I don't believe patients are assigned to a particular practitioner + // should we allow just open api access to admin information? Should we restrict particular pieces + // of data in the practitioner side (phone number, address information) based on a permission set? + if (!$request->isPatientRequest()) { + RestConfig::authorization_check("admin", "users"); + } + $return = (new FhirPractitionerRestController())->getOne($id); RestConfig::apiLog($return); return $return; }, - "GET /fhir/CareTeam" => function (HttpRestRequest $request) { - $getParams = $_GET; + "POST /fhir/Practitioner" => function (HttpRestRequest $request) { + RestConfig::authorization_check("admin", "users"); + $data = (array) (json_decode(file_get_contents("php://input"), true)); + $return = (new FhirPractitionerRestController())->post($data); + RestConfig::apiLog($return, $data); + return $return; + }, + "PUT /fhir/Practitioner/:id" => function ($id, HttpRestRequest $request) { + RestConfig::authorization_check("admin", "users"); + $data = (array) (json_decode(file_get_contents("php://input"), true)); + $return = (new FhirPractitionerRestController())->patch($id, $data); + RestConfig::apiLog($return, $data); + return $return; + }, + "GET /fhir/PractitionerRole" => function (HttpRestRequest $request) { + RestConfig::authorization_check("admin", "users"); + $return = (new FhirPractitionerRoleRestController())->getAll($request->getQueryParams()); + RestConfig::apiLog($return); + return $return; + }, + "GET /fhir/PractitionerRole/:id" => function ($id, HttpRestRequest $request) { + RestConfig::authorization_check("admin", "users"); + $return = (new FhirPractitionerRoleRestController())->getOne($id); + RestConfig::apiLog($return); + return $return; + }, + "GET /fhir/Procedure" => function (HttpRestRequest $request) { if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirCareTeamRestController())->getAll($getParams, $request->getPatientUUIDString()); + $return = (new FhirProcedureRestController())->getAll($request->getQueryParams(), $request->getPatientUUIDString()); } else { RestConfig::authorization_check("patients", "med"); - $return = (new FhirCareTeamRestController())->getAll($getParams); + $return = (new FhirProcedureRestController())->getAll($request->getQueryParams()); } RestConfig::apiLog($return); return $return; }, - "GET /fhir/CareTeam/:uuid" => function ($uuid, HttpRestRequest $request) { + "GET /fhir/Procedure/:uuid" => function ($uuid, HttpRestRequest $request) { if ($request->isPatientRequest()) { // only allow access to data of binded patient - $return = (new FhirCareTeamRestController())->getOne($uuid, $request->getPatientUUIDString()); + $return = (new FhirProcedureRestController())->getOne($uuid, $request->getPatientUUIDString()); } else { RestConfig::authorization_check("patients", "med"); - $return = (new FhirCareTeamRestController())->getOne($uuid); + $return = (new FhirProcedureRestController())->getOne($uuid); } RestConfig::apiLog($return); return $return; }, - "GET /fhir/Coverage" => function (HttpRestRequest $request) { - RestConfig::authorization_check("admin", "super"); - $return = (new FhirCoverageRestController())->getAll($_GET); - RestConfig::apiLog($return); - return $return; - }, - "GET /fhir/Coverage/:uuid" => function ($uuid, HttpRestRequest $request) { - RestConfig::authorization_check("admin", "super"); - $return = (new FhirCoverageRestController())->getOne($uuid); + "GET /fhir/Provenance/:uuid" => function ($uuid, HttpRestRequest $request) { + if ($request->isPatientRequest()) { + // only allow access to data of binded patient + $return = (new FhirProvenanceRestController())->getOne($uuid, $request->getPatientUUIDString()); + } else { + RestConfig::authorization_check("admin", "super"); + $return = (new FhirProvenanceRestController())->getOne($uuid); + } RestConfig::apiLog($return); return $return; }, - "GET /fhir/Person" => function (HttpRestRequest $request) { - RestConfig::authorization_check("admin", "users"); - $return = (new FhirPersonRestController())->getAll($_GET); + // other endpoints + "GET /fhir/metadata" => function () { + $return = (new FhirMetaDataRestController())->getMetaData(); RestConfig::apiLog($return); return $return; }, - "GET /fhir/Person/:uuid" => function ($uuid, HttpRestRequest $request) { - RestConfig::authorization_check("admin", "users"); - $return = (new FhirPersonRestController())->getOne($uuid); + "GET /fhir/.well-known/smart-configuration" => function () { + $authController = new \OpenEMR\RestControllers\AuthorizationController(); + $return = (new \OpenEMR\RestControllers\SMART\SMARTConfigurationController($authController))->getConfig(); RestConfig::apiLog($return); return $return; }, - // Bulk FHIR api endpoints - 'GET /fhir/Document/:id/Binary' => function ($documentId, HttpRestRequest $request) { - // currently only allow users with the same permissions as export to take a file out - // this could be relaxed to allow other types of files ie such as patient access etc. - RestConfig::authorization_check("admin", "users"); - // Grab the document id - $docController = new \OpenEMR\RestControllers\FHIR\FhirDocumentRestController($request); - $response = $docController->downloadDocument($documentId, $request->getRequestUserId()); - return $response; - }, - 'GET /fhir/Group/:id/$export' => function ($groupId, HttpRestRequest $request) { - RestConfig::authorization_check("admin", "users"); - $fhirExportService = new FhirExportRestController($request); - $exportParams = $_GET; - $exportParams['groupId'] = $groupId; - $return = $fhirExportService->processExport( - $exportParams, - 'Group', - $request->getHeader('Accept'), - $request->getHeader('Prefer') - ); - RestConfig::apiLog($return); - return $return; - }, + // FHIR root level operations 'GET /fhir/$export' => function (HttpRestRequest $request) { RestConfig::authorization_check("admin", "users"); $fhirExportService = new FhirExportRestController($request); $return = $fhirExportService->processExport( - $_GET, + $request->getQueryParams(), 'System', $request->getHeader('Accept'), $request->getHeader('Prefer') @@ -1004,7 +1119,7 @@ // @see https://ibm.github.io/FHIR/guides/FHIRBulkOperations/ 'GET /fhir/$bulkdata-status' => function (HttpRestRequest $request) { RestConfig::authorization_check("admin", "users"); - $jobUuidString = $_GET['job']; + $jobUuidString = $request->getQueryParam('job'); // if we were truly async we would return 202 here to say we are in progress with a JSON response // since OpenEMR data is so small we just return the JSON from the database $fhirExportService = new FhirExportRestController($request); @@ -1014,7 +1129,7 @@ }, 'DELETE /fhir/$bulkdata-status' => function (HttpRestRequest $request) { RestConfig::authorization_check("admin", "users"); - $job = $_GET['job']; + $job = $request->getQueryParam('job'); $fhirExportService = new FhirExportRestController($request); $return = $fhirExportService->processDeleteExportForJob($job); RestConfig::apiLog($return); diff --git a/custom/code_types.inc.php b/custom/code_types.inc.php index 2d91c786350..8e481cfb724 100644 --- a/custom/code_types.inc.php +++ b/custom/code_types.inc.php @@ -698,83 +698,100 @@ function lookup_code_descriptions($codes, $desc_detail = "code_text") } // added $modifier for HCPCS and other internal codesets so can grab exact entry in codes table - list($codetype, $code, $modifier) = explode(':', $codestring); + $code_parts = explode(':', $codestring); + $codetype = $code_parts[0] ?? null; + $code = $code_parts[1] ?? null; + $modifier = $code_parts[2] ?? null; + // if we don't have the code types we can't do much here + if (!isset($code_types[$codetype])) { + // we can't do much so we will just continue here... + continue; + } + $table_id = $code_types[$codetype]['external'] ?? ''; - if (isset($code_external_tables[$table_id])) { - $table_info = $code_external_tables[$table_id]; - $table_name = $table_info[EXT_TABLE_NAME]; - $code_col = $table_info[EXT_COL_CODE]; - $desc_col = $table_info[DISPLAY_DESCRIPTION] == "" ? $table_info[EXT_COL_DESCRIPTION] : $table_info[DISPLAY_DESCRIPTION]; - $desc_col_short = $table_info[DISPLAY_DESCRIPTION] == "" ? $table_info[EXT_COL_DESCRIPTION_BRIEF] : $table_info[DISPLAY_DESCRIPTION]; - $sqlArray = array(); - $sql = "SELECT " . $desc_col . " as code_text," . $desc_col_short . " as code_text_short FROM " . $table_name; - - // include the "JOINS" so that we get the preferred term instead of the FullySpecifiedName when appropriate. - foreach ($table_info[EXT_JOINS] as $join_info) { - $join_table = $join_info[JOIN_TABLE]; - $check_table = sqlQuery("SHOW TABLES LIKE '" . $join_table . "'"); - if ((empty($check_table))) { - HelpfulDie("Missing join table in code set search:" . $join_table); - } + if (!isset($code_external_tables[$table_id])) { + //using an external code that is not yet supported, so skip. + continue; + } + + $table_info = $code_external_tables[$table_id]; + $table_name = $table_info[EXT_TABLE_NAME]; + $code_col = $table_info[EXT_COL_CODE]; + $desc_col = $table_info[DISPLAY_DESCRIPTION] == "" ? $table_info[EXT_COL_DESCRIPTION] : $table_info[DISPLAY_DESCRIPTION]; + $desc_col_short = $table_info[DISPLAY_DESCRIPTION] == "" ? $table_info[EXT_COL_DESCRIPTION_BRIEF] : $table_info[DISPLAY_DESCRIPTION]; + $sqlArray = array(); + $sql = "SELECT " . $desc_col . " as code_text," . $desc_col_short . " as code_text_short FROM " . $table_name; - $sql .= " INNER JOIN " . $join_table; - $sql .= " ON "; - $not_first = false; - foreach ($join_info[JOIN_FIELDS] as $field) { - if ($not_first) { - $sql .= " AND "; - } + // include the "JOINS" so that we get the preferred term instead of the FullySpecifiedName when appropriate. + foreach ($table_info[EXT_JOINS] as $join_info) { + $join_table = $join_info[JOIN_TABLE]; + $check_table = sqlQuery("SHOW TABLES LIKE '" . $join_table . "'"); + if ((empty($check_table))) { + HelpfulDie("Missing join table in code set search:" . $join_table); + } - $sql .= $field; - $not_first = true; + $sql .= " INNER JOIN " . $join_table; + $sql .= " ON "; + $not_first = false; + foreach ($join_info[JOIN_FIELDS] as $field) { + if ($not_first) { + $sql .= " AND "; } + + $sql .= $field; + $not_first = true; } + } - $sql .= " WHERE "; + $sql .= " WHERE "; - // Start building up the WHERE clause + // Start building up the WHERE clause - // When using the external codes table, we have to filter by the code_type. (All the other tables only contain one type) - if ($table_id == 0) { - $sql .= " code_type = '" . add_escape_custom($code_types[$codetype]['id']) . "' AND "; - } + // When using the external codes table, we have to filter by the code_type. (All the other tables only contain one type) + if ($table_id == 0) { + $sql .= " code_type = '" . add_escape_custom($code_types[$codetype]['id']) . "' AND "; + } - // Specify the code in the query. - $sql .= $table_name . "." . $code_col . "=? "; - array_push($sqlArray, $code); + // Specify the code in the query. + $sql .= $table_name . "." . $code_col . "=? "; + array_push($sqlArray, $code); - // Add the modifier if necessary for CPT and HCPCS which differentiates code - if ($modifier) { - $sql .= " AND modifier = ? "; - array_push($sqlArray, $modifier); - } + // Add the modifier if necessary for CPT and HCPCS which differentiates code + if ($modifier) { + $sql .= " AND modifier = ? "; + array_push($sqlArray, $modifier); + } - // We need to include the filter clauses - // For SNOMED and SNOMED-CT this ensures that we get the Preferred Term or the Fully Specified Term as appropriate - // It also prevents returning "inactive" results - foreach ($table_info[EXT_FILTER_CLAUSES] as $filter_clause) { - $sql .= " AND " . $filter_clause; - } + // We need to include the filter clauses + // For SNOMED and SNOMED-CT this ensures that we get the Preferred Term or the Fully Specified Term as appropriate + // It also prevents returning "inactive" results + foreach ($table_info[EXT_FILTER_CLAUSES] as $filter_clause) { + $sql .= " AND " . $filter_clause; + } - // END building the WHERE CLAUSE + // END building the WHERE CLAUSE - if ($table_info[EXT_VERSION_ORDER]) { - $sql .= " ORDER BY " . $table_info[EXT_VERSION_ORDER]; - } + if ($table_info[EXT_VERSION_ORDER]) { + $sql .= " ORDER BY " . $table_info[EXT_VERSION_ORDER]; + } + + // END building the WHERE CLAUSE - $sql .= " LIMIT 1"; - $crow = sqlQuery($sql, $sqlArray); - if (!empty($crow[$desc_detail])) { - if ($code_text) { - $code_text .= '; '; - } - $code_text .= $crow[$desc_detail]; + if ($table_info[EXT_VERSION_ORDER]) { + $sql .= " ORDER BY " . $table_info[EXT_VERSION_ORDER]; + } + + $sql .= " LIMIT 1"; + $crow = sqlQuery($sql, $sqlArray); + if (!empty($crow[$desc_detail])) { + if ($code_text) { + $code_text .= '; '; } - } else { - //using an external code that is not yet supported, so skip. + + $code_text .= $crow[$desc_detail]; } } } diff --git a/library/sql.inc b/library/sql.inc index ada9ca107d6..6cdada06d91 100644 --- a/library/sql.inc +++ b/library/sql.inc @@ -161,8 +161,9 @@ function sqlStatementThrowException($statement, $binds = false) // Execute function. $recordset = $GLOBALS['adodb']['db']->Execute($statement, $binds, true); if ($recordset === false) { - throw new \OpenEMR\Common\Database\SqlQueryException($statement, "Failed to execute statement. Error: " . getSqlLastError()); + throw new \OpenEMR\Common\Database\SqlQueryException($statement, "Failed to execute statement. Error: " . getSqlLastError() . " Statement: " . $statement); } + return $recordset; } /** diff --git a/src/Common/Auth/OpenIDConnect/Grant/CustomPasswordGrant.php b/src/Common/Auth/OpenIDConnect/Grant/CustomPasswordGrant.php index 62b797e3ddb..4f3b532e017 100644 --- a/src/Common/Auth/OpenIDConnect/Grant/CustomPasswordGrant.php +++ b/src/Common/Auth/OpenIDConnect/Grant/CustomPasswordGrant.php @@ -21,6 +21,7 @@ use League\OAuth2\Server\Repositories\UserRepositoryInterface; use League\OAuth2\Server\RequestEvent; use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; +use OpenEMR\Common\Logging\SystemLogger; use Psr\Http\Message\ServerRequestInterface; class CustomPasswordGrant extends PasswordGrant @@ -60,18 +61,29 @@ protected function validateUser(ServerRequestInterface $request, ClientEntityInt } + $identifier = $this->getIdentifier(); $user = $this->userRepository->getCustomUserEntityByUserCredentials( $userrole, $username, $password, $email, - $this->getIdentifier(), + $identifier, $client, ); if ($user instanceof UserEntityInterface === false) { $this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request)); - + $clientVars = "undefined"; + if (empty($client)) { + $clientVars = ['id' => $client->getIdentifier(), 'name' => $client->getName(), 'redirectUri' => $client->getRedirectUri()]; + } + + (new SystemLogger())->debug( + "CustomPasswordGrant->validateUser() Failed to find user for request", + ['userrole' => $userrole,'username' => $username, 'email' => $email, 'identifier' => $identifier + , + 'client' => $clientVars] + ); throw OAuthServerException::invalidGrant('Failed Authentication'); } $_SESSION['pass_user_id'] = $user->getIdentifier(); diff --git a/src/Common/Auth/OpenIDConnect/Repositories/ScopeRepository.php b/src/Common/Auth/OpenIDConnect/Repositories/ScopeRepository.php index 0322cc926e7..ac7bcfdb6d7 100644 --- a/src/Common/Auth/OpenIDConnect/Repositories/ScopeRepository.php +++ b/src/Common/Auth/OpenIDConnect/Repositories/ScopeRepository.php @@ -316,40 +316,41 @@ public function fhirScopes(): array // "patient/AllergyIntolerance.write", // "patient/Appointment.read", // "patient/Appointment.write", -// "patient/CarePlan.read", + "patient/CarePlan.read", "patient/CareTeam.read", "patient/Condition.read", // "patient/Condition.write", // "patient/Consent.read", // "patient/Coverage.read", // "patient/Coverage.write", -// "patient/Device.read", -// "patient/DocumentReference.read", + "patient/DiagnosticReport.read", + "patient/Device.read", + "patient/DocumentReference.read", // "patient/DocumentReference.write", "patient/Encounter.read", // "patient/Encounter.write", -// "patient/Goal.read", + "patient/Goal.read", "patient/Immunization.read", // "patient/Immunization.write", -// "patient/Location.read", -// "patient/Medication.read", + "patient/Location.read", + "patient/Medication.read", "patient/MedicationRequest.read", // "patient/MedicationRequest.write", // "patient/NutritionOrder.read", "patient/Observation.read", // "patient/Observation.write", -// "patient/Organization.read", + "patient/Organization.read", // "patient/Organization.write", "patient/Patient.read", // "patient/Patient.write", -// "patient/Person.read", -// "patient/Practitioner.read", + "patient/Person.read", + "patient/Practitioner.read", // "patient/Practitioner.write", // "patient/PractitionerRole.read", // "patient/PractitionerRole.write", "patient/Procedure.read", // "patient/Procedure.write", -// "patient/Provenance.read", + "patient/Provenance.read", // "patient/Provenance.write", // "patient/RelatedPerson.read", // "patient/RelatedPerson.write", diff --git a/src/Common/Database/QueryUtils.php b/src/Common/Database/QueryUtils.php new file mode 100644 index 00000000000..037b2a17ea4 --- /dev/null +++ b/src/Common/Database/QueryUtils.php @@ -0,0 +1,167 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Common\Database; + +class QueryUtils +{ + /** + * Executes the SQL statement passed in and returns a list of all of the values contained in the column + * @param $sqlStatement + * @param $column The column you want returned + * @param array $binds + * @throws SqlQueryException Thrown if there is an error in the database executing the statement + * @return array + */ + public static function fetchTableColumn($sqlStatement, $column, $binds = array()) + { + $recordSet = self::sqlStatementThrowException($sqlStatement, $binds); + $list = []; + while ($record = sqlFetchArray($recordSet)) { + $list[] = $record[$column] ?? null; + } + return $list; + } + + public static function fetchSingleValue($sqlStatement, $column, $binds = array()) + { + $records = self::fetchTableColumn($sqlStatement, $column, $binds); + if (!empty($records[0])) { + return $records[0]; + } + return null; + } + + public static function fetchRecords($sqlStatement, $binds = array()) + { + $result = self::sqlStatementThrowException($sqlStatement, $binds); + $list = []; + while ($record = sqlFetchArray($result)) { + $list[] = $record; + } + return $list; + } + + /** + * Executes the sql statement and returns an associative array for a single column of a table + * @param $sqlStatement The statement to run + * @param $column The column you want returned + * @param array $binds + * @throws SqlQueryException Thrown if there is an error in the database executing the statement + * @return array + */ + public static function fetchTableColumnAssoc($sqlStatement, $column, $binds = array()) + { + $recordSet = self::sqlStatementThrowException($sqlStatement, $binds); + $list = []; + while ($record = sqlFetchArray($recordSet)) { + $list[$column] = $record[$column] ?? null; + } + return $list; + } + + /** + * Standard sql query in OpenEMR. + * + * Function that will allow use of the adodb binding + * feature to prevent sql-injection. Will continue to + * be compatible with previous function calls that do + * not use binding. + * It will return a recordset object. + * The sqlFetchArray() function should be used to + * utilize the return object. + * + * @param string $statement query + * @param array $binds binded variables array (optional) + * @throws SqlQueryException Thrown if there is an error in the database executing the statement + * @return recordset + */ + public static function sqlStatementThrowException($statement, $binds) + { + return sqlStatementThrowException($statement, $binds); + } + + /** + * Sql insert query in OpenEMR. + * Only use this function if you need to have the + * id returned. If doing an insert query and do + * not need the id returned, then use the + * sqlStatement function instead. + * + * Function that will allow use of the adodb binding + * feature to prevent sql-injection. This function + * is specialized for insert function and will return + * the last id generated from the insert. + * + * @param string $statement query + * @param array $binds binded variables array (optional) + * @throws SqlQueryException Thrown if there is an error in the database executing the statement + * @return integer Last id generated from the sql insert command + */ + public static function sqlInsert($statement, $binds = array()) + { + // Below line is to avoid a nasty bug in windows. + if (empty($binds)) { + $binds = false; + } + + //Run a adodb execute + // Note the auditSQLEvent function is embedded in the + // Execute function. + $recordset = $GLOBALS['adodb']['db']->Execute($statement, $binds, true); + if ($recordset === false) { + throw new SqlQueryException($statement, "Insert failed. SQL error " . getSqlLastError()); + } + + // Return the correct last id generated using function + // that is safe with the audit engine. + return $GLOBALS['lastidado'] > 0 ? $GLOBALS['lastidado'] : $GLOBALS['adodb']['db']->Insert_ID(); + } + + /** + * Shared getter for SQL selects. + * + * @param $sqlUpToFromStatement - The sql string up to (and including) the FROM line. + * @param $map - Query information (where clause(s), join clause(s), order, data, etc). + * @throws SqlQueryException If the query is invalid + * @return array of associative arrays | one associative array. + */ + public static function selectHelper($sqlUpToFromStatement, $map) + { + $where = isset($map["where"]) ? $map["where"] : null; + $data = isset($map["data"]) && is_array($map['data']) ? $map["data"] : []; + $join = isset($map["join"]) ? $map["join"] : null; + $order = isset($map["order"]) ? $map["order"] : null; + $limit = isset($map["limit"]) ? intval($map["limit"]) : null; + + $sql = $sqlUpToFromStatement; + + $sql .= !empty($join) ? " " . $join : ""; + $sql .= !empty($where) ? " " . $where : ""; + $sql .= !empty($order) ? " " . $order : ""; + $sql .= !empty($limit) ? " LIMIT " . $limit : ""; + + $multipleResults = sqlStatementThrowException($sql, $data); + + $results = array(); + + while ($row = sqlFetchArray($multipleResults)) { + array_push($results, $row); + } + + if ($limit === 1) { + return $results[0]; + } + + return $results; + } +} diff --git a/src/Common/Http/HttpRestRequest.php b/src/Common/Http/HttpRestRequest.php index dda14a16936..24b52b3f329 100644 --- a/src/Common/Http/HttpRestRequest.php +++ b/src/Common/Http/HttpRestRequest.php @@ -107,6 +107,11 @@ class HttpRestRequest */ private $headers; + /** + * @var mixed[] + */ + private $queryParams; + public function __construct($restConfig, $server) { $this->restConfig = $restConfig; @@ -115,6 +120,21 @@ public function __construct($restConfig, $server) $this->requestMethod = $server["REQUEST_METHOD"]; $this->setRequestURI($server['REQUEST_URI'] ?? ""); $this->headers = $this->parseHeadersFromServer($server); + $this->queryParams = $_GET ?? []; + // remove the OpenEMR queryParams that our rewrite command injected so we don't mess stuff up. + if (isset($this->queryParams['_REWRITE_COMMAND'])) { + unset($this->queryParams['_REWRITE_COMMAND']); + } + } + + public function getQueryParams() + { + return $this->queryParams; + } + + public function getQueryParam($key) + { + return $this->queryParams[$key] ?? null; } /** diff --git a/src/Common/Utils/QueryUtils.php b/src/Common/Utils/QueryUtils.php deleted file mode 100644 index e65fa59b680..00000000000 --- a/src/Common/Utils/QueryUtils.php +++ /dev/null @@ -1,78 +0,0 @@ -;. - * - * @package OpenEMR - * @author Matthew Vita - * @link http://www.open-emr.org - */ - -namespace OpenEMR\Common\Utils; - -class QueryUtils -{ - /** - * Shared getter for SQL selects. - * - * @param $sqlUpToFromStatement - The sql string up to (and including) the FROM line. - * @param $map - Query information (where clause(s), join clause(s), order, data, etc). - * @return array of associative arrays | one associative array. - */ - public static function selectHelper($sqlUpToFromStatement, $map) - { - $where = isset($map["where"]) ? $map["where"] : null; - $data = isset($map["data"]) ? $map["data"] : null; - $join = isset($map["join"]) ? $map["join"] : null; - $order = isset($map["order"]) ? $map["order"] : null; - $limit = isset($map["limit"]) ? $map["limit"] : null; - - $sql = $sqlUpToFromStatement; - - $sql .= !empty($join) ? " " . $join : ""; - $sql .= !empty($where) ? " " . $where : ""; - $sql .= !empty($order) ? " " . $order : ""; - $sql .= !empty($limit) ? " LIMIT " . $limit : ""; - - if (!empty($data)) { - if (empty($limit) || $limit > 1) { - $multipleResults = sqlStatement($sql, $data); - $results = array(); - - while ($row = sqlFetchArray($multipleResults)) { - array_push($results, $row); - } - - return $results; - } - - return sqlQuery($sql, $data); - } - - if (empty($limit) || $limit > 1) { - $multipleResults = sqlStatement($sql); - $results = array(); - - while ($row = sqlFetchArray($multipleResults)) { - array_push($results, $row); - } - - return $results; - } - - return sqlQuery($sql); - } -} diff --git a/src/RestControllers/AuthorizationController.php b/src/RestControllers/AuthorizationController.php index df4545e82c1..8bd11eb0a1d 100644 --- a/src/RestControllers/AuthorizationController.php +++ b/src/RestControllers/AuthorizationController.php @@ -877,17 +877,19 @@ private function verifyLogin($username, $password, $email = '', $type = 'api'): $auth = new AuthUtils($type); $is_true = $auth->confirmPassword($username, $password, $email); if (!$is_true) { - $this->logger->debug("AuthorizationController->verifyLogin() login attempt failed", ['username' => $username]); + $this->logger->debug("AuthorizationController->verifyLogin() login attempt failed", ['username' => $username, 'email' => $email, 'type' => $type]); return false; } if ($this->userId = $auth->getUserId()) { $_SESSION['user_id'] = $this->getUserUuid($this->userId, 'users'); - $this->logger->debug("AuthorizationController->verifyLogin() user login", ['pid' => $_SESSION['user_id']]); + $this->logger->debug("AuthorizationController->verifyLogin() user login", ['user_id' => $_SESSION['user_id'], + 'username' => $username, 'email' => $email, 'type' => $type]); return true; } if ($id = $auth->getPatientId()) { $_SESSION['user_id'] = $this->getUserUuid($id, 'patient'); - $this->logger->debug("AuthorizationController->verifyLogin() patient login", ['pid' => $_SESSION['user_id']]); + $this->logger->debug("AuthorizationController->verifyLogin() patient login", ['pid' => $_SESSION['user_id'] + , 'username' => $username, 'email' => $email, 'type' => $type]); $_SESSION['pid'] = $_SESSION['user_id']; return true; } diff --git a/src/RestControllers/FHIR/FhirAllergyIntoleranceRestController.php b/src/RestControllers/FHIR/FhirAllergyIntoleranceRestController.php index eec6033e7c8..3d62985b708 100644 --- a/src/RestControllers/FHIR/FhirAllergyIntoleranceRestController.php +++ b/src/RestControllers/FHIR/FhirAllergyIntoleranceRestController.php @@ -12,6 +12,7 @@ namespace OpenEMR\RestControllers\FHIR; +use OpenEMR\Common\Http\HttpRestRequest; use OpenEMR\Services\FHIR\FhirAllergyIntoleranceService; use OpenEMR\Services\FHIR\FhirResourcesService; use OpenEMR\RestControllers\RestControllerHelper; @@ -22,9 +23,9 @@ class FhirAllergyIntoleranceRestController private $fhirAllergyIntoleranceService; private $fhirService; - public function __construct() + public function __construct(HttpRestRequest $request) { - $this->fhirAllergyIntoleranceService = new FhirAllergyIntoleranceService(); + $this->fhirAllergyIntoleranceService = new FhirAllergyIntoleranceService($request->getApiBaseFullUrl()); $this->fhirService = new FhirResourcesService(); } diff --git a/src/RestControllers/FHIR/FhirCarePlanRestController.php b/src/RestControllers/FHIR/FhirCarePlanRestController.php new file mode 100644 index 00000000000..8a676db345a --- /dev/null +++ b/src/RestControllers/FHIR/FhirCarePlanRestController.php @@ -0,0 +1,62 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\RestControllers\FHIR; + +use OpenEMR\Services\FHIR\FhirCareTeamService; +use OpenEMR\Services\FHIR\FhirResourcesService; +use OpenEMR\RestControllers\RestControllerHelper; +use OpenEMR\FHIR\R4\FHIRResource\FHIRBundle\FHIRBundleEntry; +use OpenEMR\Validators\ProcessingResult; + +class FhirCarePlanRestController +{ + private $fhirService; + + public function __construct() + { + $this->fhirService = new FhirResourcesService(); + } + + /** + * Queries for a single FHIR location resource by FHIR id + * @param $fhirId The FHIR location resource id (uuid) + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @returns 200 if the operation completes successfully + */ + public function getOne($fhirId, $puuidBind = null) + { + $processingResult = new ProcessingResult(); // return nothing for now + return RestControllerHelper::handleFhirProcessingResult($processingResult, 200); + } + + /** + * Queries for FHIR location resources using various search parameters. + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @return FHIR bundle with query results, if found + */ + public function getAll($searchParams, $puuidBind = null) + { + $processingResult = new ProcessingResult(); // return nothing for now + $bundleEntries = array(); + foreach ($processingResult->getData() as $index => $searchResult) { + $bundleEntry = [ + 'fullUrl' => $GLOBALS['site_addr_oath'] . ($_SERVER['REDIRECT_URL'] ?? '') . '/' . $searchResult->getId(), + 'resource' => $searchResult + ]; + $fhirBundleEntry = new FHIRBundleEntry($bundleEntry); + array_push($bundleEntries, $fhirBundleEntry); + } + $bundleSearchResult = $this->fhirService->createBundle('CarePlan', $bundleEntries, false); + $searchResponseBody = RestControllerHelper::responseHandler($bundleSearchResult, null, 200); + return $searchResponseBody; + } +} diff --git a/src/RestControllers/FHIR/FhirDeviceRestController.php b/src/RestControllers/FHIR/FhirDeviceRestController.php new file mode 100644 index 00000000000..c15edfd4bef --- /dev/null +++ b/src/RestControllers/FHIR/FhirDeviceRestController.php @@ -0,0 +1,62 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\RestControllers\FHIR; + +use OpenEMR\Services\FHIR\FhirCareTeamService; +use OpenEMR\Services\FHIR\FhirResourcesService; +use OpenEMR\RestControllers\RestControllerHelper; +use OpenEMR\FHIR\R4\FHIRResource\FHIRBundle\FHIRBundleEntry; +use OpenEMR\Validators\ProcessingResult; + +class FhirDeviceRestController +{ + private $fhirService; + + public function __construct() + { + $this->fhirService = new FhirResourcesService(); + } + + /** + * Queries for a single FHIR location resource by FHIR id + * @param $fhirId The FHIR location resource id (uuid) + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @returns 200 if the operation completes successfully + */ + public function getOne($fhirId, $puuidBind = null) + { + $processingResult = new ProcessingResult(); // return nothing for now + return RestControllerHelper::handleFhirProcessingResult($processingResult, 200); + } + + /** + * Queries for FHIR location resources using various search parameters. + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @return FHIR bundle with query results, if found + */ + public function getAll($searchParams, $puuidBind = null) + { + $processingResult = new ProcessingResult(); // return nothing for now + $bundleEntries = array(); + foreach ($processingResult->getData() as $index => $searchResult) { + $bundleEntry = [ + 'fullUrl' => $GLOBALS['site_addr_oath'] . ($_SERVER['REDIRECT_URL'] ?? '') . '/' . $searchResult->getId(), + 'resource' => $searchResult + ]; + $fhirBundleEntry = new FHIRBundleEntry($bundleEntry); + array_push($bundleEntries, $fhirBundleEntry); + } + $bundleSearchResult = $this->fhirService->createBundle('CarePlan', $bundleEntries, false); + $searchResponseBody = RestControllerHelper::responseHandler($bundleSearchResult, null, 200); + return $searchResponseBody; + } +} diff --git a/src/RestControllers/FHIR/FhirDiagnosticReportRestController.php b/src/RestControllers/FHIR/FhirDiagnosticReportRestController.php new file mode 100644 index 00000000000..8be5b177821 --- /dev/null +++ b/src/RestControllers/FHIR/FhirDiagnosticReportRestController.php @@ -0,0 +1,61 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\RestControllers\FHIR; + +use OpenEMR\Services\FHIR\FhirResourcesService; +use OpenEMR\RestControllers\RestControllerHelper; +use OpenEMR\FHIR\R4\FHIRResource\FHIRBundle\FHIRBundleEntry; +use OpenEMR\Validators\ProcessingResult; + +class FhirDiagnosticReportRestController +{ + private $fhirService; + + public function __construct() + { + $this->fhirService = new FhirResourcesService(); + } + + /** + * Queries for a single FHIR location resource by FHIR id + * @param $fhirId The FHIR location resource id (uuid) + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @returns 200 if the operation completes successfully + */ + public function getOne($fhirId, $puuidBind = null) + { + $processingResult = new ProcessingResult(); // return nothing for now + return RestControllerHelper::handleFhirProcessingResult($processingResult, 200); + } + + /** + * Queries for FHIR location resources using various search parameters. + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @return FHIR bundle with query results, if found + */ + public function getAll($searchParams, $puuidBind = null) + { + $processingResult = new ProcessingResult(); // return nothing for now + $bundleEntries = array(); + foreach ($processingResult->getData() as $index => $searchResult) { + $bundleEntry = [ + 'fullUrl' => $GLOBALS['site_addr_oath'] . ($_SERVER['REDIRECT_URL'] ?? '') . '/' . $searchResult->getId(), + 'resource' => $searchResult + ]; + $fhirBundleEntry = new FHIRBundleEntry($bundleEntry); + array_push($bundleEntries, $fhirBundleEntry); + } + $bundleSearchResult = $this->fhirService->createBundle('CarePlan', $bundleEntries, false); + $searchResponseBody = RestControllerHelper::responseHandler($bundleSearchResult, null, 200); + return $searchResponseBody; + } +} diff --git a/src/RestControllers/FHIR/FhirDocumentReferenceRestController.php b/src/RestControllers/FHIR/FhirDocumentReferenceRestController.php new file mode 100644 index 00000000000..8b8763fab92 --- /dev/null +++ b/src/RestControllers/FHIR/FhirDocumentReferenceRestController.php @@ -0,0 +1,62 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\RestControllers\FHIR; + +use OpenEMR\Services\FHIR\FhirCareTeamService; +use OpenEMR\Services\FHIR\FhirResourcesService; +use OpenEMR\RestControllers\RestControllerHelper; +use OpenEMR\FHIR\R4\FHIRResource\FHIRBundle\FHIRBundleEntry; +use OpenEMR\Validators\ProcessingResult; + +class FhirDocumentReferenceRestController +{ + private $fhirService; + + public function __construct() + { + $this->fhirService = new FhirResourcesService(); + } + + /** + * Queries for a single FHIR location resource by FHIR id + * @param $fhirId The FHIR location resource id (uuid) + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @returns 200 if the operation completes successfully + */ + public function getOne($fhirId, $puuidBind = null) + { + $processingResult = new ProcessingResult(); // return nothing for now + return RestControllerHelper::handleFhirProcessingResult($processingResult, 200); + } + + /** + * Queries for FHIR location resources using various search parameters. + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @return FHIR bundle with query results, if found + */ + public function getAll($searchParams, $puuidBind = null) + { + $processingResult = new ProcessingResult(); // return nothing for now + $bundleEntries = array(); + foreach ($processingResult->getData() as $index => $searchResult) { + $bundleEntry = [ + 'fullUrl' => $GLOBALS['site_addr_oath'] . ($_SERVER['REDIRECT_URL'] ?? '') . '/' . $searchResult->getId(), + 'resource' => $searchResult + ]; + $fhirBundleEntry = new FHIRBundleEntry($bundleEntry); + array_push($bundleEntries, $fhirBundleEntry); + } + $bundleSearchResult = $this->fhirService->createBundle('CarePlan', $bundleEntries, false); + $searchResponseBody = RestControllerHelper::responseHandler($bundleSearchResult, null, 200); + return $searchResponseBody; + } +} diff --git a/src/RestControllers/FHIR/FhirGoalRestController.php b/src/RestControllers/FHIR/FhirGoalRestController.php new file mode 100644 index 00000000000..d02b5ea406c --- /dev/null +++ b/src/RestControllers/FHIR/FhirGoalRestController.php @@ -0,0 +1,62 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\RestControllers\FHIR; + +use OpenEMR\Services\FHIR\FhirCareTeamService; +use OpenEMR\Services\FHIR\FhirResourcesService; +use OpenEMR\RestControllers\RestControllerHelper; +use OpenEMR\FHIR\R4\FHIRResource\FHIRBundle\FHIRBundleEntry; +use OpenEMR\Validators\ProcessingResult; + +class FhirGoalRestController +{ + private $fhirService; + + public function __construct() + { + $this->fhirService = new FhirResourcesService(); + } + + /** + * Queries for a single FHIR location resource by FHIR id + * @param $fhirId The FHIR location resource id (uuid) + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @returns 200 if the operation completes successfully + */ + public function getOne($fhirId, $puuidBind = null) + { + $processingResult = new ProcessingResult(); // return nothing for now + return RestControllerHelper::handleFhirProcessingResult($processingResult, 200); + } + + /** + * Queries for FHIR location resources using various search parameters. + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @return FHIR bundle with query results, if found + */ + public function getAll($searchParams, $puuidBind = null) + { + $processingResult = new ProcessingResult(); // return nothing for now + $bundleEntries = array(); + foreach ($processingResult->getData() as $index => $searchResult) { + $bundleEntry = [ + 'fullUrl' => $GLOBALS['site_addr_oath'] . ($_SERVER['REDIRECT_URL'] ?? '') . '/' . $searchResult->getId(), + 'resource' => $searchResult + ]; + $fhirBundleEntry = new FHIRBundleEntry($bundleEntry); + array_push($bundleEntries, $fhirBundleEntry); + } + $bundleSearchResult = $this->fhirService->createBundle('CarePlan', $bundleEntries, false); + $searchResponseBody = RestControllerHelper::responseHandler($bundleSearchResult, null, 200); + return $searchResponseBody; + } +} diff --git a/src/RestControllers/FHIR/FhirMetaDataRestController.php b/src/RestControllers/FHIR/FhirMetaDataRestController.php index 6e79eb9df84..66fba2a2a68 100644 --- a/src/RestControllers/FHIR/FhirMetaDataRestController.php +++ b/src/RestControllers/FHIR/FhirMetaDataRestController.php @@ -70,8 +70,11 @@ protected function buildCapabilityStatement(): FHIRCapabilityStatement $dateTime = new FHIRDateTime(); $dateTime->setValue(date("Y-m-d", time())); $capabilityStatement->setDate($dateTime); + + // we build our rest object with our helpers here $restObj = $this->restHelper->getCapabilityRESTObject($routes); $restObj->setSecurity($this->getRestSecurity()); + $capabilityStatement->addRest($restObj); $composerStr = file_get_contents($serverRoot . "/composer.json"); $composerObj = json_decode($composerStr, true); diff --git a/src/RestControllers/FHIR/FhirOrganizationRestController.php b/src/RestControllers/FHIR/FhirOrganizationRestController.php index e9ecfdacdc3..2314cacd702 100644 --- a/src/RestControllers/FHIR/FhirOrganizationRestController.php +++ b/src/RestControllers/FHIR/FhirOrganizationRestController.php @@ -23,6 +23,9 @@ */ class FhirOrganizationRestController { + /** + * @var FhirOrganizationService + */ private $fhirOrganizationService; private $fhirService; private $fhirValidationService; @@ -51,6 +54,7 @@ public function getAll($searchParams) { $processingResult = $this->fhirOrganizationService->getAll($searchParams); $bundleEntries = array(); + // TODO: adunsulag why isn't this work done in the fhirService->createBundle? foreach ($processingResult->getData() as $index => $searchResult) { $bundleEntry = [ 'fullUrl' => $GLOBALS['site_addr_oath'] . ($_SERVER['REDIRECT_URL'] ?? '') . '/' . $searchResult->getId(), diff --git a/src/RestControllers/FHIR/FhirProvenanceRestController.php b/src/RestControllers/FHIR/FhirProvenanceRestController.php new file mode 100644 index 00000000000..06e1ef4598f --- /dev/null +++ b/src/RestControllers/FHIR/FhirProvenanceRestController.php @@ -0,0 +1,71 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +/** + * FhirProvenanceRestController.php + * @package openemr + * @link http://www.open-emr.org + * @author Stephen Nielson + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\RestControllers\FHIR; + +use OpenEMR\Services\FHIR\FhirCareTeamService; +use OpenEMR\Services\FHIR\FhirResourcesService; +use OpenEMR\RestControllers\RestControllerHelper; +use OpenEMR\FHIR\R4\FHIRResource\FHIRBundle\FHIRBundleEntry; +use OpenEMR\Validators\ProcessingResult; + +class FhirProvenanceRestController +{ + private $fhirService; + + public function __construct() + { + $this->fhirService = new FhirResourcesService(); + } + + /** + * Queries for a single FHIR location resource by FHIR id + * @param $fhirId The FHIR location resource id (uuid) + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @returns 200 if the operation completes successfully + */ + public function getOne($fhirId, $puuidBind = null) + { + $processingResult = new ProcessingResult(); // return nothing for now + return RestControllerHelper::handleFhirProcessingResult($processingResult, 200); + } + + /** + * Queries for FHIR location resources using various search parameters. + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @return FHIR bundle with query results, if found + */ + public function getAll($searchParams, $puuidBind = null) + { + $processingResult = new ProcessingResult(); // return nothing for now + $bundleEntries = array(); + foreach ($processingResult->getData() as $index => $searchResult) { + $bundleEntry = [ + 'fullUrl' => $GLOBALS['site_addr_oath'] . ($_SERVER['REDIRECT_URL'] ?? '') . '/' . $searchResult->getId(), + 'resource' => $searchResult + ]; + $fhirBundleEntry = new FHIRBundleEntry($bundleEntry); + array_push($bundleEntries, $fhirBundleEntry); + } + $bundleSearchResult = $this->fhirService->createBundle('CarePlan', $bundleEntries, false); + $searchResponseBody = RestControllerHelper::responseHandler($bundleSearchResult, null, 200); + return $searchResponseBody; + } +} diff --git a/src/RestControllers/RestControllerHelper.php b/src/RestControllers/RestControllerHelper.php index 8cf915476f0..f192e078dec 100644 --- a/src/RestControllers/RestControllerHelper.php +++ b/src/RestControllers/RestControllerHelper.php @@ -12,17 +12,13 @@ namespace OpenEMR\RestControllers; -use OpenEMR\Common\Logging\SystemLogger; -use OpenEMR\Common\System\System; -use OpenEMR\FHIR\FhirSearchParameterType; -use OpenEMR\FHIR\R4\FHIRDomainResource\FHIROperationDefinition; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\SearchFieldType; use OpenEMR\FHIR\R4\FHIRDomainResource\FHIRPatient; use OpenEMR\FHIR\R4\FHIRElement\FHIRCanonical; use OpenEMR\FHIR\R4\FHIRElement\FHIRCode; use OpenEMR\FHIR\R4\FHIRElement\FHIRExtension; -use OpenEMR\FHIR\R4\FHIRElement\FHIRReference; use OpenEMR\FHIR\R4\FHIRElement\FHIRRestfulCapabilityMode; -use OpenEMR\FHIR\R4\FHIRElement\FHIRString; use OpenEMR\FHIR\R4\FHIRElement\FHIRTypeRestfulInteraction; use OpenEMR\FHIR\R4\FHIRResource; use OpenEMR\FHIR\R4\FHIRResource\FHIRCapabilityStatement\FHIRCapabilityStatementInteraction; @@ -30,6 +26,7 @@ use OpenEMR\FHIR\R4\FHIRResource\FHIRCapabilityStatement\FHIRCapabilityStatementResource; use OpenEMR\FHIR\R4\FHIRResource\FHIRCapabilityStatement\FHIRCapabilityStatementRest; use OpenEMR\Services\FHIR\IResourceUSCIGProfileService; +use OpenEMR\Validators\ProcessingResult; class RestControllerHelper { @@ -43,6 +40,9 @@ class RestControllerHelper */ const FHIR_SERVICES_NAMESPACE = "OpenEMR\\Services\\FHIR\\Fhir"; + // @see https://www.hl7.org/fhir/search.html#table + const FHIR_SEARCH_CONTROL_PARAM_REV_INCLUDE_PROVENANCE = "Provenance:target"; + /** * Configures the HTTP status code and payload returned within a response. * @@ -136,12 +136,14 @@ public static function handleProcessingResult($processingResult, $successStatusC * @param $successStatusCode - The HTTP status code to return for a successful operation that completes without error. * @return array|mixed */ - public static function handleFhirProcessingResult($processingResult, $successStatusCode) + public static function handleFhirProcessingResult(ProcessingResult $processingResult, $successStatusCode) { $httpResponseBody = []; if (!$processingResult->isValid()) { http_response_code(400); $httpResponseBody["validationErrors"] = $processingResult->getValidationMessages(); + } elseif (count($processingResult->getData()) <= 0) { + http_response_code(404); } elseif ($processingResult->hasInternalErrors()) { http_response_code(500); $httpResponseBody["internalErrors"] = $processingResult->getInternalErrors(); @@ -160,11 +162,22 @@ public function setSearchParams($resource, FHIRCapabilityStatementResource $capR if (empty($service)) { return; // nothing to do here as the service isn't defined. } - $capResource->addSearchInclude('*'); - foreach ($service->getSearchParams() as $fhirSearchField => $searchDefinition) { - $paramExists = false; + if (empty($capResource->getSearchInclude())) { + $capResource->addSearchInclude('*'); + } + if ($service instanceof IResourceUSCIGProfileService && empty($capResource->getSearchRevInclude())) { + $capResource->addSearchRevInclude(self::FHIR_SEARCH_CONTROL_PARAM_REV_INCLUDE_PROVENANCE); + } + $searchParams = $service->getSearchParams(); + $searchParams = is_array($searchParams) ? $searchParams : []; + foreach ($searchParams as $fhirSearchField => $searchDefinition) { + + /** + * @var FhirSearchParameterDefinition $searchDefinition + */ - $type = $searchDefinition['type'] ?? FhirSearchParameterType::STRING; + $paramExists = false; + $type = $searchDefinition->getType(); foreach ($capResource->getSearchParam() as $searchParam) { if (strcmp($searchParam->getName(), $fhirSearchField) == 0) { @@ -209,11 +222,14 @@ public function addOperations($resource, $items, FHIRCapabilityStatementResource $operation->setName($operationName); $operation->setDefinition(new FHIRCanonical('http://hl7.org/fhir/uv/bulkdata/OperationDefinition/' . $operationName)); - $extension = new FHIRExtension(); - $extension->setValueCode(new FHIRCode('SHOULD')); - $extension->setUrl('http://hl7.org/fhir/StructureDefinition/capabilitystatement-expectation'); - $operation->addExtension($extension); - $capResource->addOperation($operation); + // TODO: adunsulag so the Single Patient API fails on this expectation being here yet the Multi-Patient API failed when it wasn't here + // need to investigate what, if anything we are missing, perhaps another extension definition that tells the inferno server + // that this should be parsed in a single patient context?? +// $extension = new FHIRExtension(); +// $extension->setValueCode(new FHIRCode('SHOULD')); +// $extension->setUrl('http://hl7.org/fhir/StructureDefinition/capabilitystatement-expectation'); +// $operation->addExtension($extension); +// $capResource->addOperation($operation); } } diff --git a/src/Services/AllergyIntoleranceService.php b/src/Services/AllergyIntoleranceService.php index ffae0be316b..dad9d277114 100644 --- a/src/Services/AllergyIntoleranceService.php +++ b/src/Services/AllergyIntoleranceService.php @@ -12,7 +12,13 @@ namespace OpenEMR\Services; +use OpenEMR\Common\Database\QueryUtils; use OpenEMR\Common\Uuid\UuidRegistry; +use OpenEMR\Services\Search\FhirSearchWhereClauseBuilder; +use OpenEMR\Services\Search\ISearchField; +use OpenEMR\Services\Search\SearchModifier; +use OpenEMR\Services\Search\StringSearchField; +use OpenEMR\Services\Search\TokenSearchField; use OpenEMR\Validators\AllergyIntoleranceValidator; use OpenEMR\Validators\ProcessingResult; @@ -39,6 +45,88 @@ public function __construct() $this->allergyIntoleranceValidator = new AllergyIntoleranceValidator(); } + public function search($search, $isAndCondition = true) + { + // we inner join on lists itself so we can grab our uuids, we do this so we can search on each of the uuids + // such as allergy_uuid, practitioner_uuid,organization_uuid, etc. You can't use an 'AS' clause in a select + // so we have to have actual column names in our WHERE clause. To make that work in a searchable way we extend + // out our queries into sub queries which through the power of index's & keys it is pretty highly optimized by the + // database query engine. + + $sql = "SELECT lists.*, + lists.pid AS patient_id, + lists.title, + practitioners.uuid as practitioner, + practitioners.practitioner_uuid, + organizations.uuid as organization, + organizations.organization_uuid, + patient.puuid, + patient.patient_uuid, + allergy_ids.allergy_uuid, + reaction.title as reaction_title, + reaction.codes AS reaction_codes, + verification.title as verification_title + FROM lists + INNER JOIN ( + SELECT lists.uuid AS allergy_uuid FROM lists + ) allergy_ids ON lists.uuid = allergy_ids.allergy_uuid + LEFT JOIN list_options as reaction ON (reaction.option_id = lists.reaction and reaction.list_id = 'reaction') + LEFT JOIN list_options as verification ON verification.option_id = lists.verification and verification.list_id = 'allergyintolerance-verification' + RIGHT JOIN ( + SELECT + patient_data.uuid AS puuid + ,patient_data.pid + ,patient_data.uuid AS patient_uuid + FROM patient_data + ) patient ON patient.pid = lists.pid + LEFT JOIN ( + select + users.uuid + ,users.uuid AS practitioner_uuid + ,users.username + ,users.facility AS organization + FROM users + -- US CORE only allows physicians or patients to be our allergy recorder + -- so we will filter out anyone who is not actually a practitioner (May 14th 2021) + WHERE users.npi IS NOT NULL -- we only want actual physicians here rather than all users + ) practitioners ON practitioners.username = lists.user + LEFT JOIN ( + select + facility.uuid + ,facility.uuid AS organization_uuid + ,facility.name + FROM facility + ) organizations ON organizations.name = practitioners.organization"; + + // make sure we only search for allergy fields + $search['type'] = new StringSearchField('type', ['allergy'], SearchModifier::EXACT); + $whereClause = FhirSearchWhereClauseBuilder::build($search, $isAndCondition); + + $sql .= $whereClause->getFragment(); + $sqlBindArray = $whereClause->getBoundValues(); + $statementResults = QueryUtils::sqlStatementThrowException($sql, $sqlBindArray); + + $processingResult = new ProcessingResult(); + while ($row = sqlFetchArray($statementResults)) { + $row['uuid'] = UuidRegistry::uuidToString($row['allergy_uuid']); + $row['puuid'] = UuidRegistry::uuidToString($row['puuid']); + $row['practitioner'] = $row['practitioner'] ? + UuidRegistry::uuidToString($row['practitioner']) : + $row['practitioner']; + $row['organization'] = $row['organization'] ? + UuidRegistry::uuidToString($row['organization']) : + $row['organization']; + if ($row['diagnosis'] != "") { + $row['diagnosis'] = $this->addCoding($row['diagnosis']); + } + if (!empty($row['reaction']) && !empty($row['reaction_codes'])) { + $row['reaction'] = $this->addCoding($row['reaction_codes']); + } + $processingResult->addData($row); + } + return $processingResult; + } + /** * Returns a list of allergyIntolerance matching optional search criteria. * Search criteria is conveyed by array where key = field/column name, value = field value. @@ -52,35 +140,39 @@ public function __construct() */ public function getAll($search = array(), $isAndCondition = true, $puuidBind = null) { - - // Validating and Converting Patient UUID to PID + // backwards compatible we let sub tables be referenced before, we want those to go away as it's a leaky abstraction if (isset($search['lists.pid'])) { + $search['puuid'] = $search['lists.pid']; + unset($search['lists.pid']); + } + if (isset($search['lists.id'])) { + $search['allergy_uuid'] = $search['lists.id']; + unset($search['lists.id']); + } + // Validating and Converting Patient UUID to PID + if (isset($search['puuid'])) { $isValidPatient = $this->allergyIntoleranceValidator->validateId( 'uuid', self::PATIENT_TABLE, - $search['lists.pid'], + $search['puuid'], true ); if ($isValidPatient !== true) { return $isValidPatient; } - $puuidBytes = UuidRegistry::uuidToBytes($search['lists.pid']); - $search['lists.pid'] = $this->getIdByUuid($puuidBytes, self::PATIENT_TABLE, "pid"); } // Validating and Converting UUID to ID - if (isset($search['lists.id'])) { + if (isset($search['allergy_uuid'])) { $isValidAllergy = $this->allergyIntoleranceValidator->validateId( 'uuid', self::ALLERGY_TABLE, - $search['lists.id'], + $search['allergy_uuid'], true ); if ($isValidAllergy !== true) { return $isValidAllergy; } - $uuidBytes = UuidRegistry::uuidToBytes($search['lists.id']); - $search['lists.id'] = $this->getIdByUuid($uuidBytes, self::ALLERGY_TABLE, "id"); } if (!empty($puuidBind)) { @@ -95,64 +187,21 @@ public function getAll($search = array(), $isAndCondition = true, $puuidBind = n return $isValidPatient; } } - - $sqlBindArray = array(); - $sql = "SELECT lists.*, - users.uuid as practitioner, - facility.uuid as organization, - patient.uuid as puuid, - reaction.title as reaction_title, - verification.title as verification_title - FROM lists - LEFT JOIN list_options as reaction ON (reaction.option_id = lists.reaction and reaction.list_id = 'reaction') - LEFT JOIN list_options as verification ON verification.option_id = lists.verification and verification.list_id = 'allergyintolerance-verification' - RIGHT JOIN patient_data as patient ON patient.pid = lists.pid - LEFT JOIN users as users ON users.username = lists.user - LEFT JOIN facility as facility ON facility.name = users.facility - WHERE type = 'allergy'"; - - if (!empty($search)) { - $sql .= ' AND '; - if (!empty($puuidBind)) { - // code to support patient binding - $sql .= '('; - } - $whereClauses = array(); - foreach ($search as $fieldName => $fieldValue) { - array_push($whereClauses, $fieldName . ' = ?'); - array_push($sqlBindArray, $fieldValue); + $newSearch = []; + foreach ($search as $key => $value) { + if (!$value instanceof ISearchField) { + $newSearch[] = new StringSearchField($key, [$value], SearchModifier::EXACT); + } else { + $newSearch[$key] = $value; } - $sqlCondition = ($isAndCondition == true) ? 'AND' : 'OR'; - $sql .= implode(' ' . $sqlCondition . ' ', $whereClauses); - if (!empty($puuidBind)) { - // code to support patient binding - $sql .= ") AND `patient`.`uuid` = ?"; - $sqlBindArray[] = UuidRegistry::uuidToBytes($puuidBind); - } - } elseif (!empty($puuidBind)) { - // code to support patient binding - $sql .= " AND `patient`.`uuid` = ?"; - $sqlBindArray[] = UuidRegistry::uuidToBytes($puuidBind); } - $statementResults = sqlStatement($sql, $sqlBindArray); - - $processingResult = new ProcessingResult(); - while ($row = sqlFetchArray($statementResults)) { - $row['uuid'] = UuidRegistry::uuidToString($row['uuid']); - $row['puuid'] = UuidRegistry::uuidToString($row['puuid']); - $row['practitioner'] = $row['practitioner'] ? - UuidRegistry::uuidToString($row['practitioner']) : - $row['practitioner']; - $row['organization'] = $row['organization'] ? - UuidRegistry::uuidToString($row['organization']) : - $row['organization']; - if ($row['diagnosis'] != "") { - $row['diagnosis'] = $this->addCoding($row['diagnosis']); - } - $processingResult->addData($row); + // override puuid, this replaces anything in search if it is already specified. + if (isset($puuidBind)) { + $search['puuid'] = new TokenSearchField('puuid', $puuidBind, true); } - return $processingResult; + + return $this->search($search, $isAndCondition); } /** @@ -164,66 +213,11 @@ public function getAll($search = array(), $isAndCondition = true, $puuidBind = n */ public function getOne($uuid, $puuidBind = null) { - $processingResult = new ProcessingResult(); - - $isValid = $this->allergyIntoleranceValidator->validateId("uuid", "lists", $uuid, true); - if ($isValid !== true) { - $validationMessages = [ - 'uuid' => ["invalid or nonexisting value" => " value " . $uuid] - ]; - $processingResult->setValidationMessages($validationMessages); - return $processingResult; - } - - if (!empty($puuidBind)) { - $isValid = $this->allergyIntoleranceValidator->validateId("uuid", "patient_data", $puuidBind, true); - if ($isValid !== true) { - $validationMessages = [ - 'puuid' => ["invalid or nonexisting value" => " value " . $puuidBind] - ]; - $processingResult->setValidationMessages($validationMessages); - return $processingResult; - } + $search['allergy_uuid'] = new TokenSearchField('allergy_uuid', $uuid, true); + if (isset($puuidBind)) { + $search['puuid'] = new TokenSearchField('puuid', $puuidBind, true); } - - $sql = "SELECT lists.*, - users.uuid as practitioner, - facility.uuid as organization, - patient.uuid as puuid, - reaction.title as reaction_title, - verification.title as verification_title - FROM lists - LEFT JOIN list_options as reaction ON (reaction.option_id = lists.reaction and reaction.list_id = 'reaction') - LEFT JOIN list_options as verification ON verification.option_id = lists.verification and verification.list_id = 'allergyintolerance-verification' - RIGHT JOIN patient_data as patient ON patient.pid = lists.pid - LEFT JOIN users as users ON users.username = lists.user - LEFT JOIN facility as facility ON facility.name = users.facility - WHERE type = 'allergy' AND lists.uuid = ?"; - - $uuidBinary = UuidRegistry::uuidToBytes($uuid); - $sqlBindArray = [$uuidBinary]; - - if (!empty($puuidBind)) { - $sql .= " AND `patient`.`uuid` = ?"; - $sqlBindArray[] = UuidRegistry::uuidToBytes($puuidBind); - } - - $sqlResult = sqlQuery($sql, $sqlBindArray); - if (!empty($sqlResult)) { - $sqlResult['uuid'] = UuidRegistry::uuidToString($sqlResult['uuid']); - $sqlResult['puuid'] = UuidRegistry::uuidToString($sqlResult['puuid']); - $sqlResult['practitioner'] = $sqlResult['practitioner'] ? - UuidRegistry::uuidToString($sqlResult['practitioner']) : - $sqlResult['practitioner']; - $sqlResult['organization'] = $sqlResult['organization'] ? - UuidRegistry::uuidToString($sqlResult['organization']) : - $sqlResult['organization']; - if ($sqlResult['diagnosis'] != "") { - $row['diagnosis'] = $this->addCoding($sqlResult['diagnosis']); - } - $processingResult->addData($sqlResult); - } - return $processingResult; + return $this->search($search); } /** diff --git a/src/Services/BaseService.php b/src/Services/BaseService.php index a0e1e2d9c24..1ab99102a76 100644 --- a/src/Services/BaseService.php +++ b/src/Services/BaseService.php @@ -12,8 +12,16 @@ namespace OpenEMR\Services; +use OpenEMR\Common\Logging\SystemLogger; +use OpenEMR\Common\Database\QueryUtils; use OpenEMR\Common\Uuid\UuidRegistry; +use OpenEMR\Services\Search\FhirSearchWhereClauseBuilder; +use OpenEMR\Services\Search\ISearchField; +use OpenEMR\Services\Search\SearchFieldException; +use OpenEMR\Services\Search\SearchFieldStatementResolver; +use OpenEMR\Validators\ProcessingResult; use Particle\Validator\Exception\InvalidValueException; +use Psr\Log\LoggerInterface; require_once(__DIR__ . '/../../custom/code_types.inc.php'); @@ -27,6 +35,11 @@ class BaseService private $fields; private $autoIncrements; + /** + * @var SystemLogger + */ + private $logger; + private const PREFIXES = array( 'eq' => "=", 'ne' => "!=", @@ -47,6 +60,7 @@ public function __construct($table) $this->table = $table; $this->fields = sqlListFields($table); $this->autoIncrements = self::getAutoIncrements($table); + $this->setLogger(new SystemLogger()); } /** @@ -69,6 +83,47 @@ public function getFields(): array return $this->fields; } + /** + * Return the fields that should be used in a standard select clause. Can be overwritten by inheriting classes + * @return array + */ + public function getSelectFields(): array + { + // since we are often joining a bunch of fields we need to make sure we normalize our regular field array by adding + // the table name for our own table values. + $fields = $this->getFields(); + $normalizedFields = []; + // processing is cheap + foreach ($fields as $field) { + $normalizedFields[] = '`' . $this->getTable() . '`.`' . $field . '`'; + } + + return $normalizedFields; + } + + public function getUuidFields(): array + { + return []; + } + + /** + * Allows sub classes to grab additional table joins to add to the select query. Each join table definition needs + * to be in the following format: + * [ + * 'table' => JOIN_TABLE_NAME, 'alias' => JOIN_TABLE_ALIAS, 'type' => JOIN_TYPE(left,right,outer,etc) + * , 'column' => TABLE_COLUMN_NAME, 'join_column' => JOIN_TABLE_COLUMN_NAME] + * ] + * + * An example of a join on the users table joining against list_options like so: + * ['table' => 'list_options', 'alias' => 'abook', 'type' => 'LEFT JOIN', 'column' => 'abook_type', 'join_column' => 'option_id'] + * + * @return array + */ + public function getSelectJoinTables(): array + { + return []; + } + /** * queryFields * Build SQL Query for Selecting Fields @@ -87,6 +142,16 @@ public function queryFields($map = null, $data = null) return $this->selectHelper($sql, $map); } + public function setLogger(LoggerInterface $logger) + { + $this->logger = $logger; + } + + public function getLogger(): LoggerInterface + { + return $this->logger; + } + /** * buildInsertColumns * Build an insert set and bindings @@ -192,54 +257,18 @@ private static function getAutoIncrements($table) /** * Shared getter for SQL selects. - * Shared from original OpenEMR\Common\Utils\QueryUtils * * @param $sqlUpToFromStatement - The sql string up to (and including) the FROM line. * @param $map - Query information (where clause(s), join clause(s), order, data, etc). - * @return array of associative arrays | one associative array. + * @return array of associative arrays */ - public static function selectHelper($sqlUpToFromStatement, $map) + public function selectHelper($sqlUpToFromStatement, $map) { - $where = isset($map["where"]) ? $map["where"] : null; - $data = isset($map["data"]) ? $map["data"] : null; - $join = isset($map["join"]) ? $map["join"] : null; - $order = isset($map["order"]) ? $map["order"] : null; - $limit = isset($map["limit"]) ? $map["limit"] : null; - - $sql = $sqlUpToFromStatement; - - $sql .= !empty($join) ? " " . $join : ""; - $sql .= !empty($where) ? " " . $where : ""; - $sql .= !empty($order) ? " " . $order : ""; - $sql .= !empty($limit) ? " LIMIT " . $limit : ""; - - if (!empty($data)) { - if (empty($limit) || $limit > 1) { - $multipleResults = sqlStatement($sql, $data); - $results = array(); - - while ($row = sqlFetchArray($multipleResults)) { - array_push($results, $row); - } - - return $results; - } - - return sqlQuery($sql, $data); + $records = QueryUtils::selectHelper($sqlUpToFromStatement, $map); + if ($records !== null) { + $records = is_array($records) ? $records : [$records]; } - - if (empty($limit) || $limit > 1) { - $multipleResults = sqlStatement($sql); - $results = array(); - - while ($row = sqlFetchArray($multipleResults)) { - array_push($results, $row); - } - - return $results; - } - - return sqlQuery($sql); + return $records; } /** @@ -367,6 +396,74 @@ function ($key) use ($whitelistedFields) { ); } + /** + * Returns a list of records matching the search criteria. + * Search criteria is conveyed by array where key = field/column name, value is an ISearchField + * If an empty array of search criteria is provided, all records are returned. + * + * The search will grab the intersection of all possible values if $isAndCondition is true, otherwise it returns + * the union (logical OR) of the search. + * + * More complicated searches with various sub unions / intersections can be accomplished through a CompositeSearchField + * that allows you to combine multiple search clauses on a single search field. + * + * @param ISearchField[] $search Hashmap of string => ISearchField where the key is the field name of the search field + * @param bool $isAndCondition Whether to join each search field with a logical OR or a logical AND. + * @return ProcessingResult The results of the search. + */ + public function search($search, $isAndCondition = true) + { + $processingResult = new ProcessingResult(); + try { + $selectFields = $this->getSelectFields(); + + $selectFields = array_combine($selectFields, $selectFields); // make it a dictionary so we can add/remove this. + $from = [$this->getTable()]; + $sql = "SELECT " . implode(",", array_keys($selectFields)) . " FROM " . implode(",", $from); + $join = $this->getSelectJoinClauses(); + $whereFragment = FhirSearchWhereClauseBuilder::build($search, $isAndCondition); + + + $selectHelperMap = [ + 'join' => $join + , 'where' => $whereFragment->getFragment() + , 'data' => $whereFragment->getBoundValues() + ]; + $records = $this->selectHelper($sql, $selectHelperMap); + + if (!empty($records)) { + foreach ($records as $row) { + $resultRecord = $this->createResultRecordFromDatabaseResult($row); + $processingResult->addData($resultRecord); + } + } + } catch (SearchFieldException $exception) { + $processingResult->setValidationMessages([$exception->getField() => $exception->getMessage()]); + } + + return $processingResult; + } + + /** + * Allows any mapping data conversion or other properties needed by a service to be returned. + * @param $row The record returned from the database + */ + protected function createResultRecordFromDatabaseResult($row) + { + $uuidFields = $this->getUuidFields(); + if (empty($uuidFields)) { + return $row; + } else { + // convert all of our byte columns to strings + foreach ($uuidFields as $fieldName) { + if (isset($row[$fieldName])) { + $row[$fieldName] = UuidRegistry::uuidToString($row[$fieldName]); + } + } + } + return $row; + } + /** * Convert Diagnosis Codes String to Code:Description Array * @@ -375,6 +472,9 @@ function ($key) use ($whitelistedFields) { */ protected function addCoding($diagnosis) { + if (empty($diagnosis)) { + return []; + } $diags = explode(";", $diagnosis); $diagnosis = array(); foreach ($diags as $diag) { @@ -406,4 +506,25 @@ protected function splitAndProcessMultipleFields($fields, $table, $primaryId = " } return $result; } + + protected function getSelectJoinClauses() + { + $joins = $this->getSelectJoinTables(); + $clause = ''; + if (empty($joins)) { + return $clause; + } + foreach ($joins as $tableDefinition) { + $clause .= $tableDefinition['type'] . ' `' . $tableDefinition['table'] . "` `{$tableDefinition['alias']}` " + . ' ON `'; + if (isset($tableDefinition['join_clause'])) { + $clause .= $tableDefinition['join_clause']; + } else { + $table = $tableDefinition['join_table'] ?? $this->getTable(); + $clause .= $table . '`.`' . $tableDefinition['column'] + . '` = `' . $tableDefinition['alias'] . '`.`' . $tableDefinition['join_column'] . '` '; + } + } + return $clause; + } } diff --git a/src/Services/FHIR/FhirAllergyIntoleranceService.php b/src/Services/FHIR/FhirAllergyIntoleranceService.php index 45254df4544..3799d7324b1 100644 --- a/src/Services/FHIR/FhirAllergyIntoleranceService.php +++ b/src/Services/FHIR/FhirAllergyIntoleranceService.php @@ -2,6 +2,10 @@ namespace OpenEMR\Services\FHIR; +use Google\Service; +use OpenEMR\FHIR\R4\FHIRElement\FHIRMeta; +use OpenEMR\FHIR\R4\FHIRElement\FHIRUri; +use OpenEMR\FHIR\R4\FHIRResource\FHIRAllergyIntolerance\FHIRAllergyIntoleranceReaction; use OpenEMR\Services\FHIR\FhirServiceBase; use OpenEMR\Services\AllergyIntoleranceService; use OpenEMR\FHIR\R4\FHIRDomainResource\FHIRAllergyIntolerance; @@ -16,6 +20,11 @@ use OpenEMR\FHIR\R4\FHIRElement\FHIRCoding; use OpenEMR\FHIR\R4\FHIRElement\FHIRId; use OpenEMR\FHIR\R4\FHIRElement\FHIRReference; +use OpenEMR\Services\PractitionerService; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\ReferenceSearchValue; +use OpenEMR\Services\Search\SearchFieldType; +use OpenEMR\Services\Search\ServiceField; use OpenEMR\Validators\ProcessingResult; use OpenEMR\Common\Uuid; use OpenEMR\Common\Uuid\UuidRegistry; @@ -31,16 +40,18 @@ * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 * */ -class FhirAllergyIntoleranceService extends FhirServiceBase +class FhirAllergyIntoleranceService extends FhirServiceBase implements IResourceUSCIGProfileService, IPatientCompartmentResourceService { /** * @var AllergyIntoleranceService */ private $allergyIntoleranceService; - public function __construct() + const USCGI_PROFILE_URI = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-allergyintolerance'; + + public function __construct($fhirAPIURL = null) { - parent::__construct(); + parent::__construct($fhirAPIURL); $this->allergyIntoleranceService = new AllergyIntoleranceService(); } @@ -51,8 +62,8 @@ public function __construct() protected function loadSearchParameters() { return [ - 'patient' => ['lists.pid'], - '_id' => ['lists.id'] + 'patient' => $this->getPatientContextSearchField(), + '_id' => new FhirSearchParameterDefinition('_id', SearchFieldType::TOKEN, [new ServiceField('allergy_uuid', ServiceField::TYPE_UUID)]), ]; } @@ -64,48 +75,18 @@ protected function loadSearchParameters() * @param boolean $encode Indicates if the returned resource is encoded into a string. Defaults to false. * @return FHIRAllergyIntolerance */ - public function createProvenanceResource($dataRecord = array(), $encode = false) + public function createProvenanceResource($dataRecord, $encode = false) { - $allergyProvenance = new FHIRProvenance(); - $meta = array('versionId' => '1', 'lastUpdated' => gmdate('c')); - $allergyProvenance->setMeta($meta); - - $id = new FHIRId(); - $uuidString = UuidRegistry::uuidToString((new UuidRegistry(['disable_tracker' => true]))->createUuid()); - $id->setValue($uuidString); - $allergyProvenance->setId($id); - - if (isset($dataRecord['uuid'])) { - $allergyReference = new FHIRReference(); - $allergyReference->setReference('AllergyIntolerance/' . $dataRecord['uuid']); - $allergyProvenance->addTarget($allergyReference); - } - if (isset($dataRecord['date'])) { - $allergyProvenance->setRecorded($dataRecord['date']); - } - if ((isset($dataRecord['practitioner'])) && isset($dataRecord['organization'])) { - $agent = new FHIRProvenanceAgent(); - $agentType = new FHIRCodeableConcept(); - $agentTypeCoding = array( - 'system' => "http://terminology.hl7.org/CodeSystem/provenance-participant-type", - 'code' => 'author', - 'display' => 'Author', - ); - $agentType->addCoding($agentTypeCoding); - $agent->setType($agentType); - $allergyProvenance->addAgent($agent); - $agentWho = new FHIRReference(); - $agentWho->setReference('Practitioner/' . $dataRecord['practitioner']); - $agent->setWho($agentWho); - $agentBehalf = new FHIRReference(); - $agentBehalf->setReference('Organization/' . $dataRecord['organization']); - $agent->setOnBehalfOf($agentBehalf); + if (!($dataRecord instanceof FHIRAllergyIntolerance)) { + throw new \BadMethodCallException("Data record should be correct instance class"); } + $fhirProvenanceService = new FhirProvenanceService(); + $fhirProvenance = $fhirProvenanceService->createProvenanceForDomainResource($dataRecord, $dataRecord->getRecorder()); if ($encode) { - return json_encode($allergyProvenance); + return json_encode($fhirProvenance); } else { - return $allergyProvenance; + return $fhirProvenance; } } @@ -119,9 +100,10 @@ public function createProvenanceResource($dataRecord = array(), $encode = false) public function parseOpenEMRRecord($dataRecord = array(), $encode = false) { $allergyIntoleranceResource = new FHIRAllergyIntolerance(); - - $meta = array('versionId' => '1', 'lastUpdated' => gmdate('c')); - $allergyIntoleranceResource->setMeta($meta); + $fhirMeta = new FHIRMeta(); + $fhirMeta->setVersionId("1"); + $fhirMeta->setLastUpdated(gmdate('c')); + $allergyIntoleranceResource->setMeta($fhirMeta); $id = new FHIRId(); $id->setValue($dataRecord['uuid']); @@ -144,6 +126,7 @@ public function parseOpenEMRRecord($dataRecord = array(), $encode = false) $allergyIntoleranceResource->setClinicalStatus($clinical_Status); $allergyIntoleranceCategory = new FHIRAllergyIntoleranceCategory(); + // @see https://www.hl7.org/fhir/us/core/StructureDefinition-us-core-allergyintolerance-definitions.html#AllergyIntolerance.category $allergyIntoleranceCategory->setValue("medication"); $allergyIntoleranceResource->addCategory($allergyIntoleranceCategory); @@ -175,15 +158,60 @@ public function parseOpenEMRRecord($dataRecord = array(), $encode = false) $allergyIntoleranceResource->setRecorder($recorder); } + // cardinality is 0..* + // however in OpenEMR we currently only track a single reaction, we will populate it if we have it. + if (!empty($dataRecord['reaction'])) { + $reaction = new FHIRAllergyIntoleranceReaction(); + $reactionConcept = new FHIRCodeableConcept(); + $conceptText = $dataRecord['reaction_title'] ?? ""; + $reactionConcept->setText($conceptText); + + foreach ($dataRecord['reaction'] as $code => $display) { + $reactionCoding = new FHIRCoding(); + // some of our codes are parsed as numbers on the underlying service.. and we need to force them as + // strings + if (is_numeric($code)) { + $code = "$code"; + } + + $reactionCoding->setCode($code); + $display = !empty($display) ? $display : $dataRecord['reaction_title']; + // we trim as some of the database values have white space which violates ONC spec + $reactionCoding->setDisplay(trim($display)); + // @see http://hl7.org/fhir/R4/valueset-clinical-findings.html + // TODO: @adunsulag check with @brady.miller if we can hard code these to SNOMED as that appears to be + // the values in our reaction list... will we have allergy reactions that are NOT SNOMED? + $reactionCoding->setSystem('http://snomed.info/sct'); + $reactionConcept->addCoding($reactionCoding); + } + $reaction->addManifestation($reactionConcept); + $allergyIntoleranceResource->addReaction($reaction); + } + if (!empty($dataRecord['diagnosis'])) { $diagnosisCoding = new FHIRCoding(); $diagnosisCode = new FHIRCodeableConcept(); foreach ($dataRecord['diagnosis'] as $code => $display) { + // some of our codes are parsed as numbers on the underlying service.. and we need to force them as + // strings + if (is_numeric($code)) { + $code = "$code"; + } $diagnosisCoding->setCode($code); - $diagnosisCoding->setDisplay($display); + // if we have no display value we will just show the code value here + $display = !empty($display) ? $display : $dataRecord['title']; + // we trim as some of the database values have white space which violates ONC spec + $diagnosisCoding->setDisplay(trim($display)); $diagnosisCode->addCoding($diagnosisCoding); } $allergyIntoleranceResource->setCode($diagnosisCode); + } else { + $diagnosisCode = new FHIRCodeableConcept(); + $diagnosisCoding = new FHIRCoding(); + $diagnosisCoding->setCode("unknown"); + $diagnosisCoding->setDisplay(xlt("Unknown")); + $diagnosisCode->addCoding($diagnosisCoding); + $allergyIntoleranceResource->setCode($diagnosisCode); } $verificationStatus = new FHIRCodeableConcept(); @@ -209,26 +237,6 @@ public function parseOpenEMRRecord($dataRecord = array(), $encode = false) } } - - /** - * Performs a FHIR AllergyIntolerance Resource lookup by FHIR Resource ID - * @param $fhirResourceId //The OpenEMR record's FHIR AllergyIntolerance Resource ID. - * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. - */ - public function getOne($fhirResourceId, $puuidBind = null) - { - $processingResult = $this->allergyIntoleranceService->getOne($fhirResourceId, $puuidBind); - if (!$processingResult->hasErrors()) { - if (count($processingResult->getData()) > 0) { - $openEmrRecord = $processingResult->getData()[0]; - $fhirRecord = $this->parseOpenEMRRecord($openEmrRecord); - $processingResult->setData([]); - $processingResult->addData($fhirRecord); - } - } - return $processingResult; - } - /** * Searches for OpenEMR records using OpenEMR search parameters * @@ -238,7 +246,7 @@ public function getOne($fhirResourceId, $puuidBind = null) */ public function searchForOpenEMRRecords($openEMRSearchParameters, $puuidBind = null) { - return $this->allergyIntoleranceService->getAll($openEMRSearchParameters, false, $puuidBind); + return $this->allergyIntoleranceService->search($openEMRSearchParameters, true, $puuidBind); } public function parseFhirResource($fhirResource = array()) @@ -255,4 +263,14 @@ public function updateOpenEMRRecord($fhirResourceId, $updatedOpenEMRRecord) { // TODO: If Required in Future } + + public function getProfileURIs(): array + { + return [self::USCGI_PROFILE_URI]; + } + + public function getPatientContextSearchField(): FhirSearchParameterDefinition + { + return new FhirSearchParameterDefinition('patient', SearchFieldType::REFERENCE, [new ServiceField('puuid', ServiceField::TYPE_UUID)]); + } } diff --git a/src/Services/FHIR/FhirConditionService.php b/src/Services/FHIR/FhirConditionService.php index 2574141418e..7567d9239be 100644 --- a/src/Services/FHIR/FhirConditionService.php +++ b/src/Services/FHIR/FhirConditionService.php @@ -9,6 +9,8 @@ use OpenEMR\FHIR\R4\FHIRDomainResource\FHIRCondition; use OpenEMR\Services\FHIR\FhirServiceBase; use OpenEMR\Services\ConditionService; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\SearchFieldType; use OpenEMR\Validators\ProcessingResult; /** @@ -42,8 +44,8 @@ public function __construct() protected function loadSearchParameters() { return [ - 'patient' => ['lists.pid'], - '_id' => ['lists.id'] + 'patient' => new FhirSearchParameterDefinition('patient', SearchFieldType::TOKEN, ['lists.pid']), + '_id' => new FhirSearchParameterDefinition('_id', SearchFieldType::TOKEN, ['lists.id']), ]; } diff --git a/src/Services/FHIR/FhirCoverageService.php b/src/Services/FHIR/FhirCoverageService.php index 5f74b774182..f3a4bb11841 100644 --- a/src/Services/FHIR/FhirCoverageService.php +++ b/src/Services/FHIR/FhirCoverageService.php @@ -10,6 +10,8 @@ use OpenEMR\FHIR\R4\FHIRDomainResource\FHIRCoverage; use OpenEMR\Services\FHIR\FhirServiceBase; use OpenEMR\Services\InsuranceService; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\SearchFieldType; use OpenEMR\Validators\ProcessingResult; /** @@ -44,9 +46,9 @@ public function __construct() protected function loadSearchParameters() { return [ - 'patient' => ['pid'], - '_id' => ['id'], - 'payor' => ['provider'] + 'patient' => new FhirSearchParameterDefinition('patient', SearchFieldType::TOKEN, ['pid']), + '_id' => new FhirSearchParameterDefinition('_id', SearchFieldType::TOKEN, ['id']), + 'payor' => new FhirSearchParameterDefinition('payor', SearchFieldType::TOKEN, ['provider']) ]; } @@ -106,7 +108,7 @@ public function parseOpenEMRRecord($dataRecord = array(), $encode = false) * * @param $fhirResourceId //The OpenEMR record's FHIR Condition Resource ID. */ - public function getOne($fhirResourceId) + public function getOne($fhirResourceId, $puuidBind = null) { $processingResult = $this->coverageService->getOne($fhirResourceId); if (!$processingResult->hasErrors()) { diff --git a/src/Services/FHIR/FhirEncounterService.php b/src/Services/FHIR/FhirEncounterService.php index 3d37e6a76ab..39836989ec1 100644 --- a/src/Services/FHIR/FhirEncounterService.php +++ b/src/Services/FHIR/FhirEncounterService.php @@ -17,6 +17,8 @@ use OpenEMR\FHIR\R4\FHIRElement\FHIRPeriod; use OpenEMR\FHIR\R4\FHIRElement\FHIRReference; use OpenEMR\FHIR\R4\FHIRResource\FHIREncounter\FHIREncounterParticipant; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\SearchFieldType; use OpenEMR\Validators\ProcessingResult; class FhirEncounterService extends FhirServiceBase implements IFhirExportableResourceService @@ -39,9 +41,9 @@ public function __construct() protected function loadSearchParameters() { return [ - '_id' => ['uuid'], - 'patient' => ['pid'], - 'date' => ['date'] + 'patient' => new FhirSearchParameterDefinition('patient', SearchFieldType::TOKEN, ['pid']), + '_id' => new FhirSearchParameterDefinition('_id', SearchFieldType::TOKEN, ['uuid']), + 'date' => new FhirSearchParameterDefinition('date', SearchFieldType::DATETIME, ['date']) ]; } diff --git a/src/Services/FHIR/FhirImmunizationService.php b/src/Services/FHIR/FhirImmunizationService.php index ef278101881..e22d3812453 100644 --- a/src/Services/FHIR/FhirImmunizationService.php +++ b/src/Services/FHIR/FhirImmunizationService.php @@ -14,6 +14,8 @@ use OpenEMR\FHIR\R4\FHIRElement\FHIRQuantity; use OpenEMR\FHIR\R4\FHIRElement\FHIRReference; use OpenEMR\FHIR\R4\FHIRResource\FHIRImmunization\FHIRImmunizationEducation; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\SearchFieldType; /** * FHIR Immunization Service @@ -46,7 +48,7 @@ public function __construct() protected function loadSearchParameters() { return [ - "patient" => ["patient.uuid"] + 'patient' => new FhirSearchParameterDefinition('patient', SearchFieldType::TOKEN, ['patient.uuid']), ]; } diff --git a/src/Services/FHIR/FhirLocationService.php b/src/Services/FHIR/FhirLocationService.php index 4b1fdc12fd7..4fba4646bdf 100644 --- a/src/Services/FHIR/FhirLocationService.php +++ b/src/Services/FHIR/FhirLocationService.php @@ -110,7 +110,7 @@ public function parseOpenEMRRecord($dataRecord = array(), $encode = false) * * @param $fhirResourceId //The OpenEMR record's FHIR Location Resource ID. */ - public function getOne($fhirResourceId) + public function getOne($fhirResourceId, $puuidBind = null) { $processingResult = $this->locationService->getOne($fhirResourceId); if (!$processingResult->hasErrors()) { diff --git a/src/Services/FHIR/FhirMedicationRequestService.php b/src/Services/FHIR/FhirMedicationRequestService.php index 2976b0fd664..a2da5dcb522 100644 --- a/src/Services/FHIR/FhirMedicationRequestService.php +++ b/src/Services/FHIR/FhirMedicationRequestService.php @@ -15,6 +15,8 @@ use OpenEMR\FHIR\R4\FHIRResource\FHIRTiming; use OpenEMR\FHIR\R4\FHIRResource\FHIRTiming\FHIRTimingRepeat; use OpenEMR\Services\PrescriptionService; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\SearchFieldType; class FhirMedicationRequestService extends FhirServiceBase { @@ -36,7 +38,7 @@ public function __construct() protected function loadSearchParameters() { return [ - 'patient' => ['patient.uuid'], + 'patient' => new FhirSearchParameterDefinition('patient', SearchFieldType::TOKEN, ['patient.uuid']) ]; } diff --git a/src/Services/FHIR/FhirMedicationService.php b/src/Services/FHIR/FhirMedicationService.php index 154bb0de90a..b335ae87cf2 100644 --- a/src/Services/FHIR/FhirMedicationService.php +++ b/src/Services/FHIR/FhirMedicationService.php @@ -130,7 +130,7 @@ public function parseOpenEMRRecord($dataRecord = array(), $encode = false) * * @param $fhirResourceId //The OpenEMR record's FHIR Condition Resource ID. */ - public function getOne($fhirResourceId) + public function getOne($fhirResourceId, $puuidBind = null) { $processingResult = $this->medicationService->getOne($fhirResourceId, true); if (!$processingResult->hasErrors()) { diff --git a/src/Services/FHIR/FhirOrganizationService.php b/src/Services/FHIR/FhirOrganizationService.php index ce394838698..f4e1093f400 100644 --- a/src/Services/FHIR/FhirOrganizationService.php +++ b/src/Services/FHIR/FhirOrganizationService.php @@ -2,12 +2,23 @@ namespace OpenEMR\Services\FHIR; +use OpenEMR\Common\Uuid\UuidRegistry; use OpenEMR\FHIR\R4\FHIRDomainResource\FHIROrganization; use OpenEMR\FHIR\R4\FHIRElement\FHIRAddress; use OpenEMR\FHIR\R4\FHIRElement\FHIRCodeableConcept; use OpenEMR\FHIR\R4\FHIRElement\FHIRCoding; use OpenEMR\FHIR\R4\FHIRElement\FHIRId; +use OpenEMR\FHIR\R4\FHIRElement\FHIRReference; +use OpenEMR\Services\FacilityService; use OpenEMR\Services\OrganizationService; +use OpenEMR\Services\PractitionerService; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\ISearchField; +use OpenEMR\Services\Search\SearchFieldType; +use OpenEMR\Services\Search\ServiceField; +use OpenEMR\Services\Search\TokenSearchField; +use OpenEMR\Services\Search\TokenSearchValue; +use OpenEMR\Services\UserService; use OpenEMR\Validators\ProcessingResult; /** @@ -23,7 +34,7 @@ class FhirOrganizationService extends FhirServiceBase { /** - * @var FacilityService + * @var OrganizationService */ private $organizationService; @@ -41,14 +52,15 @@ public function __construct() protected function loadSearchParameters() { return [ - "email" => ["email"], - "phone" => ["phone"], - "telecom" => ["email", "phone",], - "address" => ["street", "postal_code", "city", "state", "country_code","line1"], - "address-city" => ["city"], - "address-postalcode" => ["postal_code","zip"], - "address-state" => ["state"], - "name" => ["name"], + '_id' => new FhirSearchParameterDefinition('_id', SearchFieldType::TOKEN, [new ServiceField('uuid', ServiceField::TYPE_UUID)]), + 'email' => new FhirSearchParameterDefinition('email', SearchFieldType::TOKEN, ['email']), + 'phone' => new FhirSearchParameterDefinition('phone', SearchFieldType::TOKEN, ['phone']), + 'telecom' => new FhirSearchParameterDefinition('telecom', SearchFieldType::TOKEN, ['email', 'phone']), + 'address' => new FhirSearchParameterDefinition('address', SearchFieldType::STRING, ["street", "postal_code", "city", "state", "country_code","line1"]), + 'address-city' => new FhirSearchParameterDefinition('address-city', SearchFieldType::STRING, ['city']), + 'address-postalcode' => new FhirSearchParameterDefinition('address-postalcode', SearchFieldType::STRING, ['postal_code', "zip"]), + 'address-state' => new FhirSearchParameterDefinition('address-state', SearchFieldType::STRING, ['state']), + 'name' => new FhirSearchParameterDefinition('name', SearchFieldType::STRING, ['name']) ]; } @@ -297,25 +309,6 @@ public function updateOpenEMRRecord($fhirResourceId, $updatedOpenEMRRecord) return $processingResult; } - /** - * Performs a FHIR Organization Resource lookup by FHIR Resource ID - * - * @param $fhirResourceId //The OpenEMR record's FHIR Organization Resource ID. - */ - public function getOne($fhirResourceId) - { - $processingResult = $this->organizationService->getOne($fhirResourceId); - if (!$processingResult->hasErrors()) { - if (count($processingResult->getData()) > 0) { - $openEmrRecord = $processingResult->getData()[0]; - $fhirRecord = $this->parseOpenEMRRecord($openEmrRecord); - $processingResult->setData([]); - $processingResult->addData($fhirRecord); - } - } - return $processingResult; - } - /** * Searches for OpenEMR records using OpenEMR search parameters * @@ -325,11 +318,49 @@ public function getOne($fhirResourceId) */ public function searchForOpenEMRRecords($openEMRSearchParameters, $puuidBind = null) { - $processingResult = $this->organizationService->getall($openEMRSearchParameters, false); + $processingResult = $this->organizationService->search($openEMRSearchParameters, false); return $processingResult; } public function createProvenanceResource($dataRecord = array(), $encode = false) { // TODO: If Required in Future } + + public function getPrimaryBusinessEntityReference() + { + $organization = $this->organizationService->getPrimaryBusinessEntity(); + if (!empty($organization)) { + $fhirOrganization = new FHIROrganization(); + $ref = new FHIRReference(); + $ref->setType($fhirOrganization->get_fhirElementName()); + $uuid = UuidRegistry::uuidToString($organization['uuid']); + $ref->setReference("Organization/" . $uuid); + $ref->setId($uuid); + return $ref; + } + return null; + } + + /** + * Given the uuid of a user assigned to an organization, return a FHIR Reference to the organization record. + * @param $userUuid The unique user id of the user we are retrieving the reference for. + * @return FHIRReference|null The reference to the organization the user belongs to + */ + public function getOrganizationReferenceForUser($userUuid) + { + $userService = new UserService(); + $user = $userService->getUserByUUID($userUuid); + if (!empty($user)) { + $organization = $this->organizationService->getFacilityOrganizationById($user['facility_id']); + if (!empty($organization)) { + $reference = new FHIRReference(); + $fhirOrganization = new FHIROrganization(); + $reference->setType($fhirOrganization->get_fhirElementName()); + $reference->setReference(($fhirOrganization->get_fhirElementName() . $organization['uuid'])); + $reference->setId($organization['id']); + return $reference; + } + } + return null; + } } diff --git a/src/Services/FHIR/FhirPatientService.php b/src/Services/FHIR/FhirPatientService.php index 5532f241233..ad57c98d7ea 100644 --- a/src/Services/FHIR/FhirPatientService.php +++ b/src/Services/FHIR/FhirPatientService.php @@ -6,8 +6,7 @@ use OpenEMR\FHIR\Export\ExportException; use OpenEMR\FHIR\Export\ExportStreamWriter; use OpenEMR\FHIR\Export\ExportWillShutdownException; -use OpenEMR\FHIR\FhirSearchParameterType; -use OpenEMR\FHIR\R4\FHIRDomainResource\FHIRCommunication; +use OpenEMR\FHIR\R4\FHIRDomainResource\FHIRProvenance; use OpenEMR\FHIR\R4\FHIRElement\FHIRCode; use OpenEMR\FHIR\R4\FHIRElement\FHIRCodeableConcept; use OpenEMR\FHIR\R4\FHIRElement\FHIRCoding; @@ -18,17 +17,26 @@ use OpenEMR\FHIR\R4\FHIRElement\FHIRExtension; use OpenEMR\FHIR\R4\FHIRElement\FHIRIdentifier; use OpenEMR\FHIR\R4\FHIRElement\FHIRIdentifierUse; +use OpenEMR\FHIR\R4\FHIRElement\FHIRMeta; use OpenEMR\FHIR\R4\FHIRElement\FHIRPeriod; +use OpenEMR\FHIR\R4\FHIRElement\FHIRReference; use OpenEMR\FHIR\R4\FHIRElement\FHIRString; use OpenEMR\FHIR\R4\FHIRElement\FHIRUri; use OpenEMR\FHIR\Export\ExportJob; use OpenEMR\FHIR\R4\FHIRResource\FHIRPatient\FHIRPatientCommunication; +use OpenEMR\FHIR\R4\FHIRResource\FHIRProvenance\FHIRProvenanceAgent; +use OpenEMR\Services\ListService; use OpenEMR\Services\PatientService; use OpenEMR\FHIR\R4\FHIRDomainResource\FHIRPatient; use OpenEMR\FHIR\R4\FHIRElement\FHIRAddress; use OpenEMR\FHIR\R4\FHIRElement\FHIRHumanName; use OpenEMR\FHIR\R4\FHIRElement\FHIRAdministrativeGender; use OpenEMR\FHIR\R4\FHIRElement\FHIRId; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\ISearchField; +use OpenEMR\Services\Search\SearchFieldType; +use OpenEMR\Services\Search\ServiceField; +use OpenEMR\Services\Search\TokenSearchValue; use OpenEMR\Validators\ProcessingResult; /** @@ -50,6 +58,11 @@ class FhirPatientService extends FhirServiceBase implements IFhirExportableResou */ private $patientService; + /** + * @var ListService + */ + private $listService; + /** * Note requirements for US Core are: * Each Patient must HAVE (if missing data in EMR, must have a data missing definition extension) @@ -78,10 +91,13 @@ class FhirPatientService extends FhirServiceBase implements IFhirExportableResou */ const USCGI_PROFILE_URI = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient'; + const FIELD_NAME_GENDER = 'sex'; + public function __construct() { parent::__construct(); $this->patientService = new PatientService(); + $this->listService = new ListService(); } /** @@ -92,22 +108,24 @@ protected function loadSearchParameters() { // @see https://www.hl7.org/fhir/patient.html#search return [ - '_id' => ['type' => FhirSearchParameterType::TOKEN, 'fields' => ['uuid'] ], - // TODO: this must be an exact match OR condition, since we don't have that supported yet we are just going off ssn -// 'identifier' => ['type' => FhirSearchParameterType::TOKEN, 'fields' => ['ssn', 'pubid'] ], - 'identifier' => ['type' => FhirSearchParameterType::TOKEN, 'fields' => ['ss'] ], - 'address' => ['type' => FhirSearchParameterType::STRING, 'fields' => ['street', 'postal_code', 'city', 'state'] ], - 'address-city' => ['type' => FhirSearchParameterType::STRING, 'fields' => ['city'] ], - 'address-postalcode' => ['type' => FhirSearchParameterType::STRING, 'fields' => ['postal_code'] ], - 'address-state' => ['type' => FhirSearchParameterType::STRING, 'fields' => ['state'] ], - 'birthdate' => ['type' => FhirSearchParameterType::DATE, 'fields' => ['DOB'] ], - 'email' => ['type' => FhirSearchParameterType::TOKEN, 'fields' => ['email'] ], - 'family' => ['type' => FhirSearchParameterType::STRING, 'fields' => ['lname'] ], - 'gender' => ['type' => FhirSearchParameterType::TOKEN, 'fields' => ['sex'] ], - 'given' => ['type' => FhirSearchParameterType::STRING, 'fields' => ['fname', 'mname'] ], - 'name' => ['type' => FhirSearchParameterType::STRING, 'fields' => ['title', 'fname', 'mname', 'lname'] ], - 'phone' => ['type' => FhirSearchParameterType::TOKEN, 'fields' => ['phone_home', 'phone_biz', 'phone_cell'] ], - 'telecom' => ['type' => FhirSearchParameterType::TOKEN, 'fields' => ['email', 'phone_home', 'phone_biz', 'phone_cell'] ] + // core FHIR required fields for now + '_id' => new FhirSearchParameterDefinition('_id', SearchFieldType::TOKEN, [new ServiceField('uuid', ServiceField::TYPE_UUID)]), + 'identifier' => new FhirSearchParameterDefinition('identifier', SearchFieldType::TOKEN, ['ss', 'pubpid']), + 'name' => new FhirSearchParameterDefinition('name', SearchFieldType::STRING, ['title', 'fname', 'mname', 'lname']), + 'birthdate' => new FhirSearchParameterDefinition('birthdate', SearchFieldType::DATE, ['DOB']), + 'gender' => new FhirSearchParameterDefinition('gender', SearchFieldType::TOKEN, [self::FIELD_NAME_GENDER]), + 'address' => new FhirSearchParameterDefinition('address', SearchFieldType::STRING, ['street', 'postal_code', 'city', 'state']), + + // these are not standard in US Core + 'address-city' => new FhirSearchParameterDefinition('address-city', SearchFieldType::STRING, ['city']), + 'address-postalcode' => new FhirSearchParameterDefinition('address-postalcode', SearchFieldType::STRING, ['postal_code']), + 'address-state' => new FhirSearchParameterDefinition('address-state', SearchFieldType::STRING, ['state']), + + 'email' => new FhirSearchParameterDefinition('email', SearchFieldType::TOKEN, ['email']), + 'family' => new FhirSearchParameterDefinition('family', SearchFieldType::STRING, ['lname']), + 'given' => new FhirSearchParameterDefinition('given', SearchFieldType::STRING, ['fname', 'mname']), + 'phone' => new FhirSearchParameterDefinition('phone', SearchFieldType::TOKEN, ['phone_home', 'phone_biz', 'phone_cell']), + 'telecom' => new FhirSearchParameterDefinition('telecom', SearchFieldType::TOKEN, ['email', 'phone_home', 'phone_biz', 'phone_cell']) ]; } @@ -123,9 +141,36 @@ public function parseOpenEMRRecord($dataRecord = array(), $encode = false) $patientResource = new FHIRPatient(); $meta = array('versionId' => '1', 'lastUpdated' => gmdate('c')); - $patientResource->setMeta($meta); + $patientResource->setMeta(new FHIRMeta($meta)); $patientResource->setActive(true); + $id = new FHIRId(); + $id->setValue($dataRecord['uuid']); + $patientResource->setId($id); + + $this->parseOpenEMRPatientSummaryText($patientResource, $dataRecord); + $this->parseOpenEMRPatientName($patientResource, $dataRecord); + $this->parseOpenEMRPatientAddress($patientResource, $dataRecord); + $this->parseOpenEMRPatientTelecom($patientResource, $dataRecord); + + $this->parseOpenEMRDateOfBirth($patientResource, $dataRecord['DOB']); + $this->parseOpenEMRGenderAndBirthSex($patientResource, $dataRecord['sex']); + $this->parseOpenEMRRaceRecord($patientResource, $dataRecord['race']); + $this->parseOpenEMREthnicityRecord($patientResource, $dataRecord['ethnicity']); + $this->parseOpenEMRSocialSecurityRecord($patientResource, $dataRecord['ss']); + $this->parseOpenEMRPublicPatientIdentifier($patientResource, $dataRecord['pubpid']); + $this->parseOpenEMRCommunicationRecord($patientResource, $dataRecord['language']); + + + if ($encode) { + return json_encode($patientResource); + } else { + return $patientResource; + } + } + + private function parseOpenEMRPatientSummaryText(FHIRPatient $patientResource, $dataRecord) + { $narrativeText = ''; if (!empty($dataRecord['fname'])) { @@ -141,10 +186,17 @@ public function parseOpenEMRRecord($dataRecord = array(), $encode = false) ); $patientResource->setText($text); } + } + + private function parseOpenEMRDateOfBirth(FHIRPatient $patientResource, $dateOfBirth) + { + if (isset($dateOfBirth)) { + $patientResource->setBirthDate($dateOfBirth); + } + } - $id = new FHIRId(); - $id->setValue($dataRecord['uuid']); - $patientResource->setId($id); + private function parseOpenEMRPatientName(FHIRPatient $patientResource, $dataRecord) + { $name = new FHIRHumanName(); $name->setUse('official'); @@ -165,11 +217,10 @@ public function parseOpenEMRRecord($dataRecord = array(), $encode = false) } $patientResource->addName($name); + } - if (isset($dataRecord['DOB'])) { - $patientResource->setBirthDate($dataRecord['DOB']); - } - + private function parseOpenEMRPatientAddress(FHIRPatient $patientResource, $dataRecord) + { $address = new FHIRAddress(); // TODO: we don't track start and end periods for dates so what value should go here...? $addressPeriod = new FHIRPeriod(); @@ -204,6 +255,10 @@ public function parseOpenEMRRecord($dataRecord = array(), $encode = false) if ($hasAddress) { $patientResource->addAddress($address); } + } + + private function parseOpenEMRPatientTelecom(FHIRPatient $patientResource, $dataRecord) + { if (!empty($dataRecord['phone_home'])) { $patientResource->addTelecom($this->createContactPoint('phone', $dataRecord['phone_home'], 'home')); @@ -220,134 +275,151 @@ public function parseOpenEMRRecord($dataRecord = array(), $encode = false) if (!empty($dataRecord['email'])) { $patientResource->addTelecom($this->createContactPoint('email', $dataRecord['email'], 'home')); } + } + private function parseOpenEMRGenderAndBirthSex(FHIRPatient $patientResource, $sex) + { + // @see https://www.hl7.org/fhir/us/core/ValueSet-birthsex.html + $genderValue = $sex ?? 'Unknown'; + $birthSex = "UNK"; $gender = new FHIRAdministrativeGender(); - if (!empty($dataRecord['sex'])) { - $gender->setValue(strtolower($dataRecord['sex'])); - - // if this is not here we have to add a data missing element - // birth sex - // TODO: I don't see anywhere we are tracking birth sex and we will need to handle that... for now we - // just key off recorded sex - $birthSex = $dataRecord['sex'] == 'Male' ? 'M' : 'F'; - $birthSexExtension = new FHIRExtension(); - $birthSexExtension->setUrl("http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex"); - $birthSexExtension->setValueCode($birthSex); - $patientResource->addExtension($birthSexExtension); + $birthSexExtension = new FHIRExtension(); + if ($genderValue !== 'Unknown') { + if ($genderValue === 'Male') { + $birthSex = 'M'; + } else if ($genderValue === 'Female') { + $birthSex = 'F'; + } } + $gender->setValue(strtolower($genderValue)); + $birthSexExtension->setUrl("http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex"); + $birthSexExtension->setValueCode($birthSex); + $patientResource->addExtension($birthSexExtension); $patientResource->setGender($gender); - - // note to figure out what the crap to put in this FHIR thing you have to look at the DETAILED Descriptions - // of us-core-patient Profile and see the race has a USCoreRaceExtension in the race property. Clicking on - // that absurd rabit whole brings you to the ACTUAL way that this field should be populated: - // @see http://hl7.org/fhir/us/core/STU3.1.1/StructureDefinition-us-core-race.html - // what a frickin mess. - if (isset($dataRecord['race'])) { - // race is defined as containing 2 required extensions, text & ombCategory - $raceExtension = new FHIRExtension(); - $raceExtension->setUrl("http://hl7.org/fhir/StructureDefinition/us-core-race"); - - $ombCategory = new FHIRExtension(); - $ombCategory->setUrl("ombCategory"); - $ombCategoryCoding = new FHIRCoding(); - $ombCategoryCoding->setSystem(new FHIRUri("urn:oid:2.16.840.1.113883.6.238")); - $coding = new FHIRCoding(); - $coding->setSystem(new FHIRUri("http://hl7.org/fhir/v3/Race")); - // 2106-3 is White - // 2076-8 is Native Hawaiian or Other Pacific Islander - // 2131-1 is Other Race - // 2054-5 is Black or African American - // 2028-9 is Asian - // 1002-5 is American Indian or Alaska Native - if ($dataRecord['race'] == 'amer_ind_or_alaska_native') { - $ombCategoryCoding->setCode("1002-5"); - $ombCategoryCoding->setDisplay("American Indian or Alaska Native"); - } else if ($dataRecord['race'] == 'white') { - $ombCategoryCoding->setCode("2106-3"); - $ombCategoryCoding->setDisplay("White"); + } + private function parseOpenEMRRaceRecord(FHIRPatient $patientResource, $race) + { + $code = 'UNK'; + $display = xlt("Unknown"); + // race is defined as containing 2 required extensions, text & ombCategory + $raceExtension = new FHIRExtension(); + $raceExtension->setUrl("http://hl7.org/fhir/us/core/StructureDefinition/us-core-race"); + + $ombCategory = new FHIRExtension(); + $ombCategory->setUrl("ombCategory"); + $ombCategoryCoding = new FHIRCoding(); + $ombCategoryCoding->setSystem(new FHIRUri("urn:oid:2.16.840.1.113883.6.238")); + if (isset($race)) { + $record = $this->listService->getListOption('race', $race); + if (empty($record)) { + // TODO: adunsulag need to handle a data missing exception here + } else if ($race === 'declne_to_specfy') { + // @see https://www.hl7.org/fhir/us/core/ValueSet-omb-race-category.html + $code = "ASKU"; + $display = xlt("Asked but no answer"); } else { - // TODO: this is just to pass for FHIR, we need to map whatever we track for race onto this. - // TODO: we need to populate these values + $code = $record['notes']; + $display = $record['title']; } - $ombCategory->setValueCoding($coding); - $raceExtension->addExtension($ombCategory); - $textExtension = new FHIRExtension(); - $textExtension->setUrl("text"); - $textExtension->setValueString(new FHIRString($ombCategoryCoding->getDisplay())); - $raceExtension->addExtension($textExtension); + $ombCategoryCoding->setCode($code); + $ombCategoryCoding->setDisplay(xlt($display)); } + $ombCategory->setValueCoding($ombCategoryCoding); + $raceExtension->addExtension($ombCategory); + + $textExtension = new FHIRExtension(); + $textExtension->setUrl("text"); + $textExtension->setValueString(new FHIRString($ombCategoryCoding->getDisplay())); + $raceExtension->addExtension($textExtension); + $patientResource->addExtension($raceExtension); + } + private function parseOpenEMREthnicityRecord(FHIRPatient $patientResource, $ethnicity) + { // TODO: this is a required field, so not sure what we want to do if this is missing? - if (!empty($dataRecord['ethnicity'])) { + if (!empty($ethnicity)) { $ethnicityExtension = new FHIRExtension(); - $ethnicityExtension->setUrl("http://hl7.org/fhir/StructureDefinition/us-core-ethnicity"); + $ethnicityExtension->setUrl("http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity"); + + $ombCategoryExtension = new FHIRExtension(); + $ombCategoryExtension->setUrl("ombCategory"); + + $textExtension = new FHIRExtension(); + $textExtension->setUrl("text"); + $coding = new FHIRCoding(); $coding->setSystem(new FHIRUri("http://terminology.hl7.org/CodeSystem/v3-Ethnicity")); - $codeableConcept = new FHIRCodeableConcept(); - // 2135-2 is Hispanic or Latino - // 2186-5 is Not Hispanic or Latino - if ($dataRecord['ethnicity'] != 'not_hisp_or_latin') { - $coding->setCode("2135-2"); - $coding->setDisplay("Hispanic or Latino"); - $codeableConcept->setText("Hispanic or Latino"); + + $record = $this->listService->getListOption('ethnicity', $ethnicity); + if (empty($record)) { + // TODO: stephen put a data missing reason where the coding could not be found for some reason } else { - $coding->setCode("2186-5"); - $coding->setDisplay("Not Hispanic or Latino"); - $codeableConcept->setText("Not Hispanic or Latino"); + $coding->setCode($record['notes']); + $coding->setDisplay($record['title']); + $coding->setSystem("urn:oid:2.16.840.1.113883.6.238"); + $textExtension->setValueString($record['title']); } - $codeableConcept->addCoding($coding); - $ethnicityExtension->setValueCodeableConcept($codeableConcept); + + $ombCategoryExtension->setValueCoding($coding); + $ethnicityExtension->addExtension($ombCategoryExtension); + $ethnicityExtension->addExtension($textExtension); + $patientResource->addExtension($ethnicityExtension); } + } + private function parseOpenEMRSocialSecurityRecord(FHIRPatient $patientResource, $ssn) + { // Not sure what to do here but this is on the 2021 HL7 US Core page about SSN // * The Patient’s Social Security Numbers SHOULD NOT be used as a patient identifier in Patient.identifier.value. // There is increasing concern over the use of Social Security Numbers in healthcare due to the risk of identity // theft and related issues. Many payers and providers have actively purged them from their systems and // filter them out of incoming data. // @see http://hl7.org/fhir/us/core/2021Jan/StructureDefinition-us-core-patient.html#FHIR-27731 - if (!empty($dataRecord['ss'])) { + if (!empty($ssn)) { $patientResource->addIdentifier( $this->createIdentifier( 'official', 'http://terminology.hl7.org/CodeSystem/v2-0203', 'SS', 'http://hl7.org/fhir/sid/us-ssn', - $dataRecord['ss'] + $ssn ) ); } + } - if (!empty($dataRecord['pubpid'])) { + private function parseOpenEMRPublicPatientIdentifier(FHIRPatient $patientResource, $pubpid) + { + if (!empty($pubpid)) { $patientResource->addIdentifier( - // not sure if the SystemURI for PT should be the same or not. + // not sure if the SystemURI for PT should be the same or not. $this->createIdentifier( 'official', 'http://terminology.hl7.org/CodeSystem/v2-0203', 'PT', 'http://terminology.hl7.org/CodeSystem/v2-0203', - $dataRecord['pubpid'] + $pubpid ) ); } + } - $communication = new FHIRPatientCommunication(); - $languageConcept = new FHIRCodeableConcept(); - $language = new FHIRCoding(); - $language->setSystem(new FHIRUri("urn:ietf:bcp:47")); - // TODO: @bradymiller @sjpadget what should go here? What should we pull from here? - $language->setCode(new FHIRCode('en-US')); - $language->setDisplay("English"); - $languageConcept->addCoding($language); - $languageConcept->setText("English"); - $communication->setLanguage($languageConcept); - $patientResource->addCommunication($communication); - - if ($encode) { - return json_encode($patientResource); - } else { - return $patientResource; + private function parseOpenEMRCommunicationRecord(FHIRPatient $patientResource, $language) + { + $record = $this->listService->getListOption('language', $language); + if (!empty($record)) { + $communication = new FHIRPatientCommunication(); + $languageConcept = new FHIRCodeableConcept(); + $language = new FHIRCoding(); + $language->setSystem(new FHIRUri("http://hl7.org/fhir/us/core/ValueSet/simple-language")); + $language->setCode(new FHIRCode($record['notes'])); + $language->setDisplay(xlt($record['title'])); + $languageConcept->addCoding($language); + $languageConcept->setText(xlt($record['title'])); + $communication->setLanguage($languageConcept); + $patientResource->addCommunication($communication); } } @@ -385,6 +457,9 @@ private function createContactPoint($system, $value, $use): FHIRContactPoint */ public function parseFhirResource($fhirResource = array()) { + // TODO: ONC certification only deals with READ operations, the mapping of FHIR values such as language,ethnicity + // etc are NOT being done here and so the creation/updating of resources is currently NOT correct, this will + // need to be addressed by future development work. $data = array(); if (isset($fhirResource['id'])) { @@ -504,42 +579,68 @@ public function updateOpenEMRRecord($fhirResourceId, $updatedOpenEMRRecord) return $processingResult; } - /** - * Performs a FHIR Patient Resource lookup by FHIR Resource ID - * @param $fhirResourceId //The OpenEMR record's FHIR Patient Resource ID. - */ - public function getOne($fhirResourceId) - { - $processingResult = $this->patientService->getOne($fhirResourceId); - if (!$processingResult->hasErrors()) { - if (count($processingResult->getData()) > 0) { - $openEmrRecord = $processingResult->getData()[0]; - $fhirRecord = $this->parseOpenEMRRecord($openEmrRecord); - $processingResult->setData([]); - $processingResult->addData($fhirRecord); - } - } - return $processingResult; - } - /** * Searches for OpenEMR records using OpenEMR search parameters * - * @param array openEMRSearchParameters OpenEMR search fields + * @param ISearchField[] openEMRSearchParameters OpenEMR search fields * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. * @return ProcessingResult */ - public function searchForOpenEMRRecords($openEMRSearchParameters, $puuidBind = null) + public function searchForOpenEMRRecords($openEMRSearchParameters) { - // TODO: @bradymiller all the patient unit tests require this to be set to false for fuzzy matching. However, - // we need to redo all of the search stuff to have each search param - // have it's own search conditions (AND, OR, prefix string, suffix string, fuzzy match, etc). - return $this->patientService->getAll($openEMRSearchParameters, false, $puuidBind); + // do any conversions on the data that we need here + + // we need to process our gender values here. +// var_dump($openEMRSearchParameters); + if (isset($openEMRSearchParameters[self::FIELD_NAME_GENDER])) { + /** + * @var $field ISearchField + */ + $field = $openEMRSearchParameters[self::FIELD_NAME_GENDER]; + + $upperCaseCode = function (TokenSearchValue $tokenSearchValue) { + $tokenSearchValue->setCode(ucfirst($tokenSearchValue->getCode())); + return $tokenSearchValue; + }; + + // need to convert our gender's to a format that are stored in the database. + $field->setValues(array_map($upperCaseCode, $field->getValues())); + } + + return $this->patientService->search($openEMRSearchParameters); } - public function createProvenanceResource($dataRecord = array(), $encode = false) + public function createProvenanceResource($dataRecord, $encode = false) { - // TODO: If Required in Future + if (!($dataRecord instanceof FHIRPatient)) { + throw new \BadMethodCallException("Data record should be correct instance class"); + } + $targetReference = new FHIRReference(); + $targetReference->setType("Patient"); + $targetReference->setReference("Patient/" . $dataRecord->getId()); + + $fhirProvenance = new FHIRProvenance(); + $fhirProvenance->addTarget($targetReference); + $fhirProvenance->setRecorded($dataRecord->getMeta()->getLastUpdated()); + + $agent = new FHIRProvenanceAgent(); + $agentConcept = new FHIRCodeableConcept(); + $agentConceptCoding = new FHIRCoding(); + $agentConceptCoding->setSystem("http://terminology.hl7.org/CodeSystem/provenance-participant-type"); + $agentConceptCoding->setCode("author"); + $agentConceptCoding->setDisplay(xlt("Author")); + $agentConcept->addCoding($agentConceptCoding); + $agent->setType($agentConcept); + + // easiest provenance is to make the primary business entity organization be the author of the provenance + // resource. + $fhirOrganizationService = new FhirOrganizationService(); + // TODO: adunsulag check with @sjpadgett or @brady.miller to see if we will always have a primary business entity. + $organizationReference = $fhirOrganizationService->getPrimaryBusinessEntityReference(); + + $agent->setWho($organizationReference); + $fhirProvenance->addAgent($agent); + return $fhirProvenance; } /** diff --git a/src/Services/FHIR/FhirPersonService.php b/src/Services/FHIR/FhirPersonService.php index 61bf8d77f61..84f74aa8a15 100644 --- a/src/Services/FHIR/FhirPersonService.php +++ b/src/Services/FHIR/FhirPersonService.php @@ -18,6 +18,8 @@ use OpenEMR\FHIR\R4\FHIRElement\FHIRId; use OpenEMR\FHIR\R4\FHIRElement\FHIRHumanName; use OpenEMR\FHIR\R4\FHIRElement\FHIRAddress; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\SearchFieldType; use OpenEMR\Services\UserService; use OpenEMR\Validators\ProcessingResult; @@ -43,17 +45,21 @@ public function __construct() protected function loadSearchParameters() { return [ - "active" => ["active"], - "email" => ["email"], - "phone" => ["phonew1", "phone", "phonecell"], - "telecom" => ["email", "phone", "phonew1", "phonecell"], - "address" => ["street", "streetb", "zip", "city", "state"], - "address-city" => ["city"], - "address-postalcode" => ["zip"], - "address-state" => ["state"], - "family" => ["lname"], - "given" => ["fname", "mname"], - "name" => ["title", "fname", "mname", "lname"] + // not sure if this a token or not + 'active' => new FhirSearchParameterDefinition('active', SearchFieldType::TOKEN, ['active']), + + 'email' => new FhirSearchParameterDefinition('email', SearchFieldType::TOKEN, ['email']), + 'phone' => new FhirSearchParameterDefinition('phone', SearchFieldType::TOKEN, ["phonew1", "phone", "phonecell"]), + 'telecom' => new FhirSearchParameterDefinition('telecom', SearchFieldType::TOKEN, ["email", "phone", "phonew1", "phonecell"]), + 'address' => new FhirSearchParameterDefinition('address', SearchFieldType::STRING, ["street", "streetb", "zip", "city", "state"]), + 'address-city' => new FhirSearchParameterDefinition('address-city', SearchFieldType::STRING, ['city']), + 'address-postalcode' => new FhirSearchParameterDefinition('address-postalcode', SearchFieldType::STRING, ['zip']), + 'address-state' => new FhirSearchParameterDefinition('address-state', SearchFieldType::STRING, ['state']), + + 'family' => new FhirSearchParameterDefinition('family', SearchFieldType::STRING, ["lname"]), + 'given' => new FhirSearchParameterDefinition('given', SearchFieldType::STRING, ["fname", "mname"]), + 'name' => new FhirSearchParameterDefinition('name', SearchFieldType::STRING, ["title", "fname", "mname", "lname"]) + ]; } @@ -256,7 +262,7 @@ public function parseFhirResource($fhirResource = array()) * Performs a FHIR Practitioner Resource lookup by FHIR Resource ID * @param $fhirResourceId //The OpenEMR record's FHIR Practitioner Resource ID. */ - public function getOne($fhirResourceId) + public function getOne($fhirResourceId, $puuidBind = null) { $user = $this->userService->getUserByUUID($fhirResourceId); $processingResult = new ProcessingResult(); diff --git a/src/Services/FHIR/FhirPractitionerRoleService.php b/src/Services/FHIR/FhirPractitionerRoleService.php index 796bd6bcf49..a42c4df392b 100644 --- a/src/Services/FHIR/FhirPractitionerRoleService.php +++ b/src/Services/FHIR/FhirPractitionerRoleService.php @@ -7,6 +7,8 @@ use OpenEMR\FHIR\R4\FHIRElement\FHIRReference; use OpenEMR\FHIR\R4\FHIRElement\FHIRId; use OpenEMR\Services\PractitionerRoleService; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\SearchFieldType; /** * FHIR PractitionerRole Service @@ -39,8 +41,8 @@ public function __construct() protected function loadSearchParameters() { return [ - "specialty" => ["specialty_code"], - "practitioner" => ["user_name"], + 'specialty' => new FhirSearchParameterDefinition('specialty', SearchFieldType::TOKEN, ['specialty_code']), + 'practitioner' => new FhirSearchParameterDefinition('practitioner', SearchFieldType::STRING, ['user_name']) ]; } @@ -139,7 +141,7 @@ public function updateOpenEMRRecord($fhirResourceId, $updatedOpenEMRRecord) * Performs a FHIR PractitionerRole Resource lookup by FHIR Resource ID * @param $fhirResourceId //The OpenEMR record's FHIR PractitionerRole Resource ID. */ - public function getOne($fhirResourceId) + public function getOne($fhirResourceId, $puuidBind = null) { $processingResult = $this->practitionerRoleService->getOne($fhirResourceId); if (!$processingResult->hasErrors()) { diff --git a/src/Services/FHIR/FhirPractitionerService.php b/src/Services/FHIR/FhirPractitionerService.php index 69b113f85eb..1e6ad2b9997 100644 --- a/src/Services/FHIR/FhirPractitionerService.php +++ b/src/Services/FHIR/FhirPractitionerService.php @@ -8,6 +8,9 @@ use OpenEMR\FHIR\R4\FHIRElement\FHIRId; use OpenEMR\FHIR\R4\FHIRElement\FHIRHumanName; use OpenEMR\FHIR\R4\FHIRElement\FHIRAddress; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\SearchFieldType; +use OpenEMR\Services\Search\ServiceField; /** * FHIR Practitioner Service @@ -41,17 +44,18 @@ public function __construct() protected function loadSearchParameters() { return [ - "active" => ["active"], - "email" => ["email"], - "phone" => ["phonew1", "phone", "phonecell"], - "telecom" => ["email", "phone", "phonew1", "phonecell"], - "address" => ["street", "streetb", "zip", "city", "state"], - "address-city" => ["city"], - "address-postalcode" => ["zip"], - "address-state" => ["state"], - "family" => ["lname"], - "given" => ["fname", "mname"], - "name" => ["title", "fname", "mname", "lname"] + '_id' => new FhirSearchParameterDefinition('_id', SearchFieldType::TOKEN, [new ServiceField('uuid', ServiceField::TYPE_UUID)]), + 'active' => new FhirSearchParameterDefinition('active', SearchFieldType::TOKEN, ['active']), + 'email' => new FhirSearchParameterDefinition('email', SearchFieldType::TOKEN, ['email']), + 'phone' => new FhirSearchParameterDefinition('phone', SearchFieldType::TOKEN, ["phonew1", "phone", "phonecell"]), + 'telecom' => new FhirSearchParameterDefinition('telecom', SearchFieldType::TOKEN, ["email", "phone", "phonew1", "phonecell"]), + 'address' => new FhirSearchParameterDefinition('address', SearchFieldType::STRING, ["street", "streetb", "zip", "city", "state"]), + 'address-city' => new FhirSearchParameterDefinition('address-city', SearchFieldType::STRING, ['city']), + 'address-postalcode' => new FhirSearchParameterDefinition('address-postalcode', SearchFieldType::STRING, ['zip']), + 'address-state' => new FhirSearchParameterDefinition('address-state', SearchFieldType::STRING, ['state']), + 'family' => new FhirSearchParameterDefinition('family', SearchFieldType::STRING, ["lname"]), + 'given' => new FhirSearchParameterDefinition('given', SearchFieldType::STRING, ["fname", "mname"]), + 'name' => new FhirSearchParameterDefinition('name', SearchFieldType::STRING, ["title", "fname", "mname", "lname"]) ]; } @@ -288,24 +292,6 @@ public function updateOpenEMRRecord($fhirResourceId, $updatedOpenEMRRecord) return $processingResult; } - /** - * Performs a FHIR Practitioner Resource lookup by FHIR Resource ID - * @param $fhirResourceId //The OpenEMR record's FHIR Practitioner Resource ID. - */ - public function getOne($fhirResourceId) - { - $processingResult = $this->practitionerService->getOne($fhirResourceId); - if (!$processingResult->hasErrors()) { - if (count($processingResult->getData()) > 0) { - $openEmrRecord = $processingResult->getData()[0]; - $fhirRecord = $this->parseOpenEMRRecord($openEmrRecord); - $processingResult->setData([]); - $processingResult->addData($fhirRecord); - } - } - return $processingResult; - } - /** * Searches for OpenEMR records using OpenEMR search parameters * diff --git a/src/Services/FHIR/FhirProcedureService.php b/src/Services/FHIR/FhirProcedureService.php index 1bc021bb30d..9adfa40ae03 100644 --- a/src/Services/FHIR/FhirProcedureService.php +++ b/src/Services/FHIR/FhirProcedureService.php @@ -9,6 +9,8 @@ use OpenEMR\FHIR\R4\FHIRElement\FHIRReference; use OpenEMR\Services\FHIR\FhirServiceBase; use OpenEMR\Services\ProcedureService; +use OpenEMR\Services\Search\FhirSearchParameterDefinition; +use OpenEMR\Services\Search\SearchFieldType; use OpenEMR\Validators\ProcessingResult; use OpenEMR\Services\SurgeryService; @@ -44,7 +46,7 @@ public function __construct() protected function loadSearchParameters() { return [ - 'patient' => ['patient.uuid'] + 'patient' => new FhirSearchParameterDefinition('patient', SearchFieldType::TOKEN, ['patient.uuid']), ]; } diff --git a/src/Services/FHIR/FhirProvenanceService.php b/src/Services/FHIR/FhirProvenanceService.php new file mode 100644 index 00000000000..96d6d5f5530 --- /dev/null +++ b/src/Services/FHIR/FhirProvenanceService.php @@ -0,0 +1,174 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\FHIR; + +use OpenEMR\FHIR\R4\FHIRDomainResource\FHIRProvenance; +use OpenEMR\FHIR\R4\FHIRElement\FHIRCodeableConcept; +use OpenEMR\FHIR\R4\FHIRElement\FHIRCoding; +use OpenEMR\FHIR\R4\FHIRElement\FHIRReference; +use OpenEMR\FHIR\R4\FHIRResource\FHIRDomainResource; +use OpenEMR\FHIR\R4\FHIRResource\FHIRProvenance\FHIRProvenanceAgent; +use OpenEMR\Services\Search\ReferenceSearchValue; + +class FhirProvenanceService extends FhirServiceBase implements IResourceUSCIGProfileService +{ + const USCGI_PROFILE_URI = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-provenance'; + + /** + * Given a FHIR domain object (such as Patient/AllergyIntolerance,etc), grab the provenance record for that resource. + * If there is a connected user object to the resource attempt to create the provenance record using that individual. + * Otherwise the provenance is tied to the main business organization designated in the system. + * @param FHIRDomainResource $resource The resource we are retrieving the provenance for + * @param FHIRReference|null $userWHO The user that will be the provenance agent + * @return FHIRProvenance|null + */ + public function createProvenanceForDomainResource(FHIRDomainResource $resource, FHIRReference $userWHO = null) + { + + $targetReference = new FHIRReference(); + $targetReference->setType($resource->get_fhirElementName()); + $targetReference->setReference($resource->get_fhirElementName() . "/" . $resource->getId()); + + $fhirProvenance = new FHIRProvenance(); + $fhirProvenance->addTarget($targetReference); + + // we are only going to provide the meta if we have it + if (!empty($resource->getMeta())) { + $fhirProvenance->setRecorded($resource->getMeta()->getLastUpdated()); + } + + $agent = new FHIRProvenanceAgent(); + $agentConcept = new FHIRCodeableConcept(); + $agentConceptCoding = new FHIRCoding(); + $agentConceptCoding->setSystem("http://terminology.hl7.org/CodeSystem/provenance-participant-type"); + $agentConceptCoding->setCode("author"); + $agentConceptCoding->setDisplay(xlt("Author")); + $agentConcept->addCoding($agentConceptCoding); + $agent->setType($agentConcept); + + $orgReference = null; + $whoReference = null; + + if (!empty($userWHO)) { + $whoReference = $userWHO; + + // attempt to get the org for our agent + $searchValue = ReferenceSearchValue::createFromRelativeUri($userWHO->getReference()); + $fhirOrganizationService = new FhirOrganizationService(); + $orgReference = $fhirOrganizationService->getOrganizationReferenceForUser($searchValue->getId()); + } + + if (empty($orgReference)) { + // easiest provenance is to make the primary business entity organization be the author of the provenance + // resource. + $fhirOrganizationService = new FhirOrganizationService(); + // TODO: adunsulag check with @sjpadgett or @brady.miller to see if we will always have a primary business entity. + $orgReference = $fhirOrganizationService->getPrimaryBusinessEntityReference(); + $whoReference = $orgReference; // if we didn't get an org reference from our WHO we will overwrite our WHO + } + if (!empty($orgReference)) { + $fhirProvenance->setId($orgReference->getId()); + $agent->setWho($whoReference); + $agent->setOnBehalfOf($orgReference); + } else { + return null; + } + $fhirProvenance->addAgent($agent); + return $fhirProvenance; + } + + /** + * Returns an array mapping FHIR Resource search parameters to OpenEMR search parameters + */ + protected function loadSearchParameters() + { + // TODO: Implement loadSearchParameters() method. + } + + /** + * Parses an OpenEMR data record, returning the equivalent FHIR Resource + * + * @param $dataRecord The source OpenEMR data record + * @param $encode Indicates if the returned resource is encoded into a string. Defaults to True. + * @return the FHIR Resource. Returned format is defined using $encode parameter. + */ + public function parseOpenEMRRecord($dataRecord = array(), $encode = false) + { + // TODO: Implement parseOpenEMRRecord() method. + } + + /** + * Parses a FHIR Resource, returning the equivalent OpenEMR record. + * + * @param $fhirResource The source FHIR resource + * @return a mapped OpenEMR data record (array) + */ + public function parseFhirResource($fhirResource = array()) + { + // TODO: Implement parseFhirResource() method. + } + + /** + * Inserts an OpenEMR record into the sytem. + * @return The OpenEMR processing result. + */ + protected function insertOpenEMRRecord($openEmrRecord) + { + // TODO: Implement insertOpenEMRRecord() method. + } + + /** + * Updates an existing OpenEMR record. + * @param $fhirResourceId The OpenEMR record's FHIR Resource ID. + * @param $updatedOpenEMRRecord The "updated" OpenEMR record. + * @return The OpenEMR Service Result + */ + protected function updateOpenEMRRecord($fhirResourceId, $updatedOpenEMRRecord) + { + // TODO: Implement updateOpenEMRRecord() method. + } + + /** + * Searches for OpenEMR records using OpenEMR search parameters + * @param openEMRSearchParameters OpenEMR search fields + * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. + * @return OpenEMR records + */ + protected function searchForOpenEMRRecords($openEMRSearchParameters) + { + // TODO: Implement searchForOpenEMRRecords() method. + } + + /** + * Creates the Provenance resource for the equivalent FHIR Resource + * + * @param $dataRecord The source OpenEMR data record + * @param $encode Indicates if the returned resource is encoded into a string. Defaults to True. + * @return the FHIR Resource. Returned format is defined using $encode parameter. + */ + public function createProvenanceResource($dataRecord, $encode = false) + { + // TODO: Implement createProvenanceResource() method. + } + + /** + * Returns the Canonical URIs for the FHIR resource for each of the US Core Implementation Guide Profiles that the + * resource implements. Most resources have only one profile, but several like DiagnosticReport and Observation + * has multiple profiles that must be conformed to. + * @see https://www.hl7.org/fhir/us/core/CapabilityStatement-us-core-server.html for the list of profiles + * @return string[] + */ + function getProfileURIs(): array + { + return [self::USCGI_PROFILE_URI]; + } +} diff --git a/src/Services/FHIR/FhirServiceBase.php b/src/Services/FHIR/FhirServiceBase.php index e81f59f7df7..d17bd048ee1 100644 --- a/src/Services/FHIR/FhirServiceBase.php +++ b/src/Services/FHIR/FhirServiceBase.php @@ -2,7 +2,10 @@ namespace OpenEMR\Services\FHIR; -use OpenEMR\FHIR\FhirSearchParameterType; +use OpenEMR\Common\Http\HttpRestRequest; +use OpenEMR\Common\Logging\SystemLogger; +use OpenEMR\Services\Search\FHIRSearchFieldFactory; +use OpenEMR\Services\Search\SearchFieldException; use OpenEMR\Validators\ProcessingResult; /** @@ -30,9 +33,24 @@ abstract class FhirServiceBase */ protected $resourceSearchParameters = array(); - public function __construct() + /** + * @var FHIRSearchFieldFactory + */ + private $searchFieldFactory; + + public function __construct($fhirApiURL = null) { - $this->resourceSearchParameters = $this->loadSearchParameters(); + $params = $this->loadSearchParameters(); + $this->resourceSearchParameters = is_array($params) ? $params : []; + $searchFieldFactory = new FHIRSearchFieldFactory($this->resourceSearchParameters); + + // anything using a 'reference' search field MUST have a URL resolver to handle the reference translation + // so if we have the api url we are going to create our resolver. + if (!empty($fhirApiURL)) { + $urlResolver = new FhirUrlResolver($fhirApiURL); + $searchFieldFactory->setFhirUrlResolver($urlResolver); + } + $this->setSearchFieldFactory($searchFieldFactory); } /** @@ -113,57 +131,123 @@ abstract protected function updateOpenEMRRecord($fhirResourceId, $updatedOpenEMR * Performs a FHIR Resource lookup by FHIR Resource ID * @param $fhirResourceId The OpenEMR record's FHIR Resource ID. */ - abstract protected function getOne($fhirResourceId); + public function getOne($fhirResourceId, $puuidBind = null) + { + // every FHIR resource must support the _id search parameter so we will just piggy bag on + $searchParam = ['_id' => $fhirResourceId]; + return $this->getAll($searchParam, $puuidBind); + } /** * Executes a FHIR Resource search given a set of parameters. - * TODO: This whole search needs to be revisited with the different search types (token for exact match, string fuzzy match, etc) * @param $fhirSearchParameters The FHIR resource search parameters * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. * @return processing result */ public function getAll($fhirSearchParameters, $puuidBind = null): ProcessingResult { - $oeSearchParameters = array(); $provenanceRequest = false; //Checking for provenance reqest if (isset($fhirSearchParameters['_revinclude'])) { if ($fhirSearchParameters['_revinclude'] == 'Provenance:target') { $provenanceRequest = true; } + // once we've got our flag we clear it as it doesn't map to anything in our search parameters. + unset($fhirSearchParameters['_revinclude']); } - foreach ($fhirSearchParameters as $fhirSearchField => $searchValue) { - if (isset($this->resourceSearchParameters[$fhirSearchField])) { - $oeSearchFields = $this->resourceSearchParameters[$fhirSearchField]; - // backwards compatability - // use our string matching like before - $searchType = $oeSearchFields['type'] ?? FhirSearchParameterType::STRING; - $oeSearchFields = $oeSearchFields['fields'] ?? $oeSearchFields; - foreach ($oeSearchFields as $index => $oeSearchField) { - $oeSearchParameters[$oeSearchField] = $searchValue; + $fhirSearchResult = new ProcessingResult(); + + try { + $oeSearchParameters = $this->createOpenEMRSearchParameters($fhirSearchParameters, $puuidBind); + + (new SystemLogger())->debug("FhirServiceBase->getAll() Created search parameters ", ['searchParameters' => array_keys($oeSearchParameters)]); + // gives a ton of information but this can be helpful in debugging this stuff. +// array_walk($oeSearchParameters, function ($v) { +// echo $v; +// }); + $oeSearchResult = $this->searchForOpenEMRRecords($oeSearchParameters); + + + $fhirSearchResult->setInternalErrors($oeSearchResult->getInternalErrors()); + $fhirSearchResult->setValidationMessages($oeSearchResult->getValidationMessages()); + + if ($oeSearchResult->isValid()) { + foreach ($oeSearchResult->getData() as $index => $oeRecord) { + $fhirResource = $this->parseOpenEMRRecord($oeRecord); + $fhirSearchResult->addData($fhirResource); + if ($provenanceRequest) { + $provenanceResource = $this->createProvenanceResource($fhirResource); + if ($provenanceResource) { + $fhirSearchResult->addData($provenanceResource); + } + } } } + } catch (SearchFieldException $exception) { + (new SystemLogger())->error("FhirServiceBase->getAll() exception thrown", ['message' => $exception->getMessage(), + 'field' => $exception->getField()]); + // put our exception information here + $fhirSearchResult->setValidationMessages([$exception->getField() => $exception->getMessage()]); } + return $fhirSearchResult; + } - $oeSearchResult = $this->searchForOpenEMRRecords($oeSearchParameters, $puuidBind); + public function setSearchFieldFactory(FHIRSearchFieldFactory $factory) + { + $this->searchFieldFactory = $factory; + } - $fhirSearchResult = new ProcessingResult(); - $fhirSearchResult->setInternalErrors($oeSearchResult->getInternalErrors()); - $fhirSearchResult->setValidationMessages($oeSearchResult->getValidationMessages()); - - if ($oeSearchResult->isValid()) { - foreach ($oeSearchResult->getData() as $index => $oeRecord) { - $fhirResource = $this->parseOpenEMRRecord($oeRecord); - $fhirSearchResult->addData($fhirResource); - if ($provenanceRequest) { - $provenanceResource = $this->createProvenanceResource($oeRecord); - if ($provenanceResource) { - $fhirSearchResult->addData($provenanceResource); - } + public function getSearchFieldFactory(): FHIRSearchFieldFactory + { + return $this->searchFieldFactory; + } + + /** + * Given the hashmap of search parameters to values it generates a map of search keys to ISearchField objects that + * are used to search in the OpenEMR system. Service classes that extend the base class can override this method + * + * to either add search fields or change the functionality of the created ISearchFields. + * + * @param $fhirSearchParameters + * @param $puuidBind The patient unique id if searching in a patient context + * @return ISearchField[] where the keys are the search fields. + */ + protected function createOpenEMRSearchParameters($fhirSearchParameters, $puuidBind) + { + $oeSearchParameters = array(); + $searchFactory = $this->getSearchFieldFactory(); + + foreach ($fhirSearchParameters as $fhirSearchField => $searchValue) { + try { + // format: {:modifier1|:modifier2}={comparator1|comparator2}[value1{,value2}] + // field is the FHIR search field + // modifier is the search modifier ie :exact, :contains, etc + // comparator is used with dates / numbers, ie :gt, :lt + // values can be comma separated and are treated as an OR condition + // if the $searchValue is an array then this is treated as an AND condition + // if $searchValue is an array and individual fields contains comma separated values the and clause takes + // precedence and ALL values will be UNIONED (AND clause). + if ($searchFactory->hasSearchField($fhirSearchField)) { + $searchField = $searchFactory->buildSearchField($fhirSearchField, $searchValue); + $oeSearchParameters[$searchField->getName()] = $searchField; + } else { + throw new SearchFieldException($fhirSearchField, xlt("This search field does not exist or is not supported")); } + } catch (\InvalidArgumentException $exception) { + throw new SearchFieldException($fhirSearchField, "The search field argument was invalid, improperly formatted, or could not be parsed", $exception->getCode(), $exception); } } - return $fhirSearchResult; + + // we make sure if we are a resource that deals with patient data and we are in a patient bound context that + // we restrict the data to JUST that patient. + if (!empty($puuidBind) && $this instanceof IPatientCompartmentResourceService) { + $patientField = $this->getPatientContextSearchField(); + // TODO: @adunsulag not sure if every service will already have a defined binding for the patient... I'm assuming for Patient compartments we would... + // yet we may need to extend the factory in the future to handle this. + $oeSearchParameters[$patientField->getName()] = $searchFactory->buildSearchField($patientField->getName(), [$puuidBind]); + } + + return $oeSearchParameters; } /** @@ -172,7 +256,7 @@ public function getAll($fhirSearchParameters, $puuidBind = null): ProcessingResu * @param $puuidBind - Optional variable to only allow visibility of the patient with this puuid. * @return OpenEMR records */ - abstract protected function searchForOpenEMRRecords($openEMRSearchParameters, $puuidBind = null); + abstract protected function searchForOpenEMRRecords($openEMRSearchParameters); /** * Creates the Provenance resource for the equivalent FHIR Resource @@ -181,7 +265,7 @@ abstract protected function searchForOpenEMRRecords($openEMRSearchParameters, $p * @param $encode Indicates if the returned resource is encoded into a string. Defaults to True. * @return the FHIR Resource. Returned format is defined using $encode parameter. */ - abstract public function createProvenanceResource($dataRecord = array(), $encode = false); + abstract public function createProvenanceResource($dataRecord, $encode = false); /* * public function to return search params diff --git a/src/Services/FHIR/FhirUrlResolver.php b/src/Services/FHIR/FhirUrlResolver.php new file mode 100644 index 00000000000..20438ce623b --- /dev/null +++ b/src/Services/FHIR/FhirUrlResolver.php @@ -0,0 +1,39 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\FHIR; + +class FhirUrlResolver +{ + private $fhirBaseURL; + + private $baseUrlLength; + + public function __construct($baseURL) + { + $this->fhirBaseURL = $baseURL; + $this->baseUrlLength = strlen($baseURL); + } + + public function getRelativeUrl($url): ?string + { + // extracts everything but the resource/:id portion of a URL from the base url. + // if the URI passed in does not match the base fhir URI we do nothing with it + if (strstr($url, $this->fhirBaseURL) === false) { + return null; + } else { + // grab everything from our string onwards... + $relativeUrl = substr($url, $this->baseUrlLength - 1); + return $relativeUrl !== false ? $relativeUrl : null; + } + } +} diff --git a/src/Services/FHIR/IPatientCompartmentResourceService.php b/src/Services/FHIR/IPatientCompartmentResourceService.php new file mode 100644 index 00000000000..4b8307a63d3 --- /dev/null +++ b/src/Services/FHIR/IPatientCompartmentResourceService.php @@ -0,0 +1,22 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\FHIR; + +use OpenEMR\Services\Search\FhirSearchParameterDefinition; + +interface IPatientCompartmentResourceService +{ + public function getPatientContextSearchField(): FhirSearchParameterDefinition; +} diff --git a/src/Services/FacilityService.php b/src/Services/FacilityService.php index 8febb55da4c..7ba5bacabfa 100644 --- a/src/Services/FacilityService.php +++ b/src/Services/FacilityService.php @@ -17,6 +17,8 @@ namespace OpenEMR\Services; +use OpenEMR\Common\Database\SqlQueryException; +use OpenEMR\Common\Logging\SystemLogger; use OpenEMR\Common\Uuid\UuidRegistry; use OpenEMR\Validators\FacilityValidator; use OpenEMR\Validators\ProcessingResult; @@ -92,7 +94,7 @@ public function getPrimaryBusinessEntity($options = null) if (!empty($options) && !empty($options["excludedId"])) { $args["where"] .= " AND FAC.id != ?"; - $args["data"] = $options["excludedId"]; + $args["data"] = array($options["excludedId"]); return $this->get($args); } @@ -133,6 +135,9 @@ public function getAllBillingLocations() public function getById($id) { + if (empty($id)) { + throw new \InvalidArgumentException("Cannot retrieve facility for empty id"); + } return $this->get(array( "where" => "WHERE FAC.id = ?", "data" => array($id), @@ -234,44 +239,49 @@ public function updateUsersFacility($facility_name, $facility_id) */ private function get($map) { - $sql = " SELECT FAC.id,"; - $sql .= " FAC.uuid,"; - $sql .= " FAC.name,"; - $sql .= " FAC.phone,"; - $sql .= " FAC.fax,"; - $sql .= " FAC.street,"; - $sql .= " FAC.city,"; - $sql .= " FAC.state,"; - $sql .= " FAC.postal_code,"; - $sql .= " FAC.country_code,"; - $sql .= " FAC.federal_ein,"; - $sql .= " FAC.website,"; - $sql .= " FAC.email,"; - $sql .= " FAC.service_location,"; - $sql .= " FAC.billing_location,"; - $sql .= " FAC.accepts_assignment,"; - $sql .= " FAC.pos_code,"; - $sql .= " FAC.x12_sender_id,"; - $sql .= " FAC.attn,"; - $sql .= " FAC.domain_identifier,"; - $sql .= " FAC.facility_npi,"; - $sql .= " FAC.facility_taxonomy,"; - $sql .= " FAC.tax_id_type,"; - $sql .= " FAC.color,"; - $sql .= " FAC.primary_business_entity,"; - $sql .= " FAC.facility_code,"; - $sql .= " FAC.extra_validation,"; - $sql .= " FAC.mail_street,"; - $sql .= " FAC.mail_street2,"; - $sql .= " FAC.mail_city,"; - $sql .= " FAC.mail_state,"; - $sql .= " FAC.mail_zip,"; - $sql .= " FAC.oid,"; - $sql .= " FAC.iban,"; - $sql .= " FAC.info"; - $sql .= " FROM facility FAC"; - - return self::selectHelper($sql, $map); + try { + $sql = " SELECT FAC.id,"; + $sql .= " FAC.uuid,"; + $sql .= " FAC.name,"; + $sql .= " FAC.phone,"; + $sql .= " FAC.fax,"; + $sql .= " FAC.street,"; + $sql .= " FAC.city,"; + $sql .= " FAC.state,"; + $sql .= " FAC.postal_code,"; + $sql .= " FAC.country_code,"; + $sql .= " FAC.federal_ein,"; + $sql .= " FAC.website,"; + $sql .= " FAC.email,"; + $sql .= " FAC.service_location,"; + $sql .= " FAC.billing_location,"; + $sql .= " FAC.accepts_assignment,"; + $sql .= " FAC.pos_code,"; + $sql .= " FAC.x12_sender_id,"; + $sql .= " FAC.attn,"; + $sql .= " FAC.domain_identifier,"; + $sql .= " FAC.facility_npi,"; + $sql .= " FAC.facility_taxonomy,"; + $sql .= " FAC.tax_id_type,"; + $sql .= " FAC.color,"; + $sql .= " FAC.primary_business_entity,"; + $sql .= " FAC.facility_code,"; + $sql .= " FAC.extra_validation,"; + $sql .= " FAC.mail_street,"; + $sql .= " FAC.mail_street2,"; + $sql .= " FAC.mail_city,"; + $sql .= " FAC.mail_state,"; + $sql .= " FAC.mail_zip,"; + $sql .= " FAC.oid,"; + $sql .= " FAC.iban,"; + $sql .= " FAC.info"; + $sql .= " FROM facility FAC"; + + return self::selectHelper($sql, $map); + } catch (SqlQueryException $exception) { + (new SystemLogger())->error($exception->getMessage(), ['trace' => $exception->getTraceAsString()]); + throw $exception; + } } private function getPrimaryBusinessEntityLegacy() diff --git a/src/Services/InsuranceCompanyService.php b/src/Services/InsuranceCompanyService.php index fbc87254c38..ec6f5d98614 100644 --- a/src/Services/InsuranceCompanyService.php +++ b/src/Services/InsuranceCompanyService.php @@ -14,7 +14,11 @@ namespace OpenEMR\Services; +use OpenEMR\Common\Database\SqlQueryException; +use OpenEMR\Common\Logging\SystemLogger; use OpenEMR\Common\Uuid\UuidRegistry; +use OpenEMR\Services\Search\FhirSearchWhereClauseBuilder; +use OpenEMR\Services\Search\SearchFieldException; use OpenEMR\Validators\ProcessingResult; use OpenEMR\Services\AddressService; use OpenEMR\Validators\InsuranceValidator; @@ -37,6 +41,51 @@ public function __construct() $this->uuidRegistry = new UuidRegistry(['table_name' => self::INSURANCE_TABLE]); $this->uuidRegistry->createMissingUuids(); $this->insuranceValidator = new InsuranceValidator(); + parent::__construct(self::INSURANCE_TABLE); + } + + public function search($search, $isAndCondition = true) + { + $sqlBindArray = array(); + $sql = " SELECT i.id,"; + $sql .= " i.uuid,"; + $sql .= " i.name,"; + $sql .= " i.attn,"; + $sql .= " i.cms_id,"; + $sql .= " i.ins_type_code,"; + $sql .= " i.x12_receiver_id,"; + $sql .= " i.x12_default_partner_id,"; + $sql .= " i.alt_cms_id,"; + $sql .= " i.inactive,"; + $sql .= " a.line1,"; + $sql .= " a.line2,"; + $sql .= " a.city,"; + $sql .= " a.state,"; + $sql .= " a.zip,"; + $sql .= " a.country"; + $sql .= " FROM insurance_companies i"; + $sql .= " JOIN addresses a ON i.id = a.foreign_id"; + $processingResult = new ProcessingResult(); + try { + $whereFragment = FhirSearchWhereClauseBuilder::build($search, $isAndCondition); + $sql .= $whereFragment->getFragment(); + sqlStatementThrowException($sql, $whereFragment->getBoundValues()); + + if (!empty($records)) { + foreach ($records as $row) { + $resultRecord = $this->createResultRecordFromDatabaseResult($row); + $processingResult->addData($resultRecord); + } + } + } catch (SqlQueryException $exception) { + // we shouldn't hit a query exception + (new SystemLogger())->error($exception->getMessage(), $exception); + $processingResult->addInternalError("Error selecting data from database"); + } catch (SearchFieldException $exception) { + (new SystemLogger())->error($exception->getMessage(), $exception); + $processingResult->setValidationMessages([$exception->getField() => $exception->getMessage()]); + } + return $processingResult; } public function getAll($search = array(), $isAndCondition = true) @@ -55,6 +104,7 @@ public function getAll($search = array(), $isAndCondition = true) $uuidBytes = UuidRegistry::uuidToBytes($search['id']); $search['id'] = $this->getIdByUuid($uuidBytes, self::INSURANCE_TABLE, "id"); } + $sqlBindArray = array(); $sql = " SELECT i.id,"; $sql .= " i.uuid,"; @@ -98,47 +148,15 @@ public function getAll($search = array(), $isAndCondition = true) public function getOneById($id) { + // TODO: this should be refactored to use getAll but its selecting all the columns and for backwards + // compatibility we will live this here. $sql = "SELECT * FROM insurance_companies WHERE id=?"; return sqlQuery($sql, array($id)); } public function getOne($uuid) { - $processingResult = new ProcessingResult(); - $isValid = $this->insuranceValidator->validateId('uuid', self::INSURANCE_TABLE, $uuid, true); - if ($isValid !== true) { - return $isValid; - } - $uuidBytes = UuidRegistry::uuidToBytes($uuid); - $sql = " SELECT i.id,"; - $sql .= " i.uuid,"; - $sql .= " i.name,"; - $sql .= " i.attn,"; - $sql .= " i.cms_id,"; - $sql .= " i.ins_type_code,"; - $sql .= " i.x12_receiver_id,"; - $sql .= " i.x12_default_partner_id,"; - $sql .= " i.alt_cms_id,"; - $sql .= " i.inactive,"; - $sql .= " a.line1,"; - $sql .= " a.line2,"; - $sql .= " a.city,"; - $sql .= " a.state,"; - $sql .= " a.zip,"; - $sql .= " a.country"; - $sql .= " FROM insurance_companies i"; - $sql .= " JOIN addresses a ON i.id = a.foreign_id"; - $sql .= " WHERE i.uuid = ?"; - - $sqlResult = sqlQuery($sql, array($uuidBytes)); - if ($sqlResult) { - $sqlResult['uuid'] = UuidRegistry::uuidToString($sqlResult['uuid']); - $processingResult->addData($sqlResult); - } else { - $processingResult->addInternalError("error processing SQL"); - } - - return $processingResult; + return $this->getAll(['uuid' => $uuid]); } public function getInsuranceTypes() diff --git a/src/Services/ListService.php b/src/Services/ListService.php index 30620ce1f2c..a2ae9fe75e3 100644 --- a/src/Services/ListService.php +++ b/src/Services/ListService.php @@ -54,11 +54,23 @@ public function getAll($pid, $list_type) return $results; } - public function getOptionsByListName($list_name) + public function getOptionsByListName($list_name, $search = array()) { $sql = "SELECT * FROM list_options WHERE list_id = ?"; + $binding = [$list_name]; - $statementResults = sqlStatement($sql, array($list_name)); + + $whitelisted_columns = [ + "option_id", "seq", "is_default", "option_value", "mapping", "notes", "codes", "activity", "edit_options", "toggle_setting_1", "toggle_setting_2", "subtype" + ]; + foreach ($whitelisted_columns as $column) { + if (!empty($search[$column])) { + $sql .= " AND $column = ? "; + $binding[] = $search[$column]; + } + } + + $statementResults = sqlStatementThrowException($sql, $binding); $results = array(); while ($row = sqlFetchArray($statementResults)) { @@ -68,6 +80,22 @@ public function getOptionsByListName($list_name) return $results; } + /** + * Returns the list option record that was found + * @param $list_id + * @param $option_id + * @param array $search + * @return array Record + */ + public function getListOption($list_id, $option_id) + { + $records = $this->getOptionsByListName($list_id, ['option_id' => $option_id]); + if (!empty($records)) { // should only be one record + return $records[0]; + } + return null; + } + public function getOne($pid, $list_type, $list_id) { $sql = "SELECT * FROM lists WHERE pid=? AND type=? AND id=? ORDER BY date DESC"; diff --git a/src/Services/OrganizationService.php b/src/Services/OrganizationService.php index 45165a82372..15c957d2736 100644 --- a/src/Services/OrganizationService.php +++ b/src/Services/OrganizationService.php @@ -20,6 +20,16 @@ class OrganizationService extends BaseService { + /** + * @var \OpenEMR\Services\FacilityService + */ + private $facilityService; + + /** + * @var \OpenEMR\Services\InsuranceCompanyService + */ + private $insuranceService; + /** * Default constructor. */ @@ -32,16 +42,34 @@ public function __construct() public function getOne($uuid) { - $facilityResult = $this->facilityService->getOne($uuid); $insuranceResult = $this->insuranceService->getOne($uuid); return $this->processResults($facilityResult, $insuranceResult); } + /** + * Retrieves an organization representing a facility given the facility id. If the organization cannot be found + * it returns null. + * @param $facilityId The id of the facility to search on. + * @return array|null + */ + public function getFacilityOrganizationById($facilityId) + { + $facilityResult = $this->facilityService->getById($facilityId); + if (!empty($facilityResult)) { + $facilityOrgs = $this->getFacilityOrg($facilityResult); + return array_pop($facilityOrgs); // return only one record + } + return null; + } + private function getFacilityOrg($facilityRecords) { $facilityOrgs = array(); foreach ($facilityRecords as $index => $org) { + if (isset($org['uuid'])) { + $org['uuid'] = UuidRegistry::uuidToString($org['uuid']); + } $address = array(); if (isset($org['street'])) { $org['line1'] = $org['street']; @@ -56,6 +84,9 @@ private function getInsuranceOrg($insuranceRecords) { $insuranceOrgs = array(); foreach ($insuranceRecords as $index => $org) { + if (isset($org['uuid'])) { + $org['uuid'] = UuidRegistry::uuidToString($org['uuid']); + } if (isset($org['zip'])) { $org['postal_code'] = $org['zip']; } @@ -63,6 +94,10 @@ private function getInsuranceOrg($insuranceRecords) $org['country_code'] = $org['country']; } $org['orgType'] = "insurance"; + // TODO: @adunsulag check with code reviewers to make sure this is the right value for an insurance org + // since the callers of this service are 'viewing' an organization which is a facade over insurance & facility + // we need to make sure both records have the same column. + $org['service_location'] = 0; array_push($insuranceOrgs, $org); } return $insuranceOrgs; @@ -85,11 +120,23 @@ private function processResults($facilityResult, $insuranceResult) return $processingResult; } + public function getPrimaryBusinessEntity() + { + return $this->facilityService->getPrimaryBusinessEntity(); + } + + public function search($search = array(), $isAndCondition = true) + { + $facilityResult = $this->facilityService->search($search, $isAndCondition); + $insuranceResult = $this->insuranceService->search($search, $isAndCondition); + return $this->processResults($facilityResult, $insuranceResult); + } + public function getAll($search = array(), $isAndCondition = true) { - $facilityResult = $this->facilityService->getAll($search = array(), $isAndCondition = true); - $insuranceResult = $this->insuranceService->getAll($search = array(), $isAndCondition = true); + $facilityResult = $this->facilityService->getAll($search, $isAndCondition); + $insuranceResult = $this->insuranceService->getAll($search, $isAndCondition); return $this->processResults($facilityResult, $insuranceResult); } diff --git a/src/Services/PatientService.php b/src/Services/PatientService.php index 95dfa647aa6..a1b9669d973 100644 --- a/src/Services/PatientService.php +++ b/src/Services/PatientService.php @@ -21,6 +21,11 @@ use OpenEMR\Events\Patient\BeforePatientUpdatedEvent; use OpenEMR\Events\Patient\PatientCreatedEvent; use OpenEMR\Events\Patient\PatientUpdatedEvent; +use OpenEMR\Services\Search\ISearchField; +use OpenEMR\Services\Search\TokenSearchField; +use OpenEMR\Services\Search\SearchModifier; +use OpenEMR\Services\Search\StringSearchField; +use OpenEMR\Services\Search\TokenSearchValue; use OpenEMR\Validators\PatientValidator; use OpenEMR\Validators\ProcessingResult; @@ -270,6 +275,16 @@ public function update($puuidString, $data) return $processingResult; } + protected function createResultRecordFromDatabaseResult($record) + { + if (!empty($record['uuid'])) { + $record['uuid'] = UuidRegistry::uuidToString($record['uuid']); + } + + return $record; + } + + /** * Returns a list of patients matching optional search criteria. * Search criteria is conveyed by array where key = field/column name, value = field value. @@ -283,83 +298,21 @@ public function update($puuidString, $data) */ public function getAll($search = array(), $isAndCondition = true, $puuidBind = null) { - $sqlBindArray = array(); - - //Converting _id to UUID byte - if (isset($search['uuid'])) { - $search['uuid'] = UuidRegistry::uuidToBytes($search['uuid']); - } - - $sql = 'SELECT id, - pid, - uuid, - pubpid, - title, - fname, - mname, - lname, - ss, - street, - postal_code, - city, - state, - county, - country_code, - drivers_license, - contact_relationship, - phone_contact, - phone_home, - phone_biz, - phone_cell, - email, - DOB, - sex, - race, - ethnicity, - status - FROM patient_data'; - + $querySearch = []; if (!empty($search)) { - $sql .= ' WHERE '; - if (!empty($puuidBind)) { - // code to support patient binding - $sql .= '('; + if (isset($puuidBind)) { + $querySearch['uuid'] = new TokenSearchField('uuid', $puuidBind); + } else if (isset($search['uuid'])) { + $querySearch['uuid'] = new TokenSearchField('uuid', $search['uuid']); } - $whereClauses = array(); $wildcardFields = array('fname', 'mname', 'lname', 'street', 'city', 'state','postal_code','title'); - foreach ($search as $fieldName => $fieldValue) { - // support wildcard match on specific fields - if (in_array($fieldName, $wildcardFields)) { - array_push($whereClauses, $fieldName . ' LIKE ?'); - array_push($sqlBindArray, '%' . $fieldValue . '%'); - } else { - // equality match - array_push($whereClauses, $fieldName . ' = ?'); - array_push($sqlBindArray, $fieldValue); + foreach ($wildcardFields as $field) { + if (isset($search[$field])) { + $querySearch[$field] = new StringSearchField($field, $search[$field], SearchModifier::CONTAINS, $isAndCondition); } } - $sqlCondition = ($isAndCondition == true) ? 'AND' : 'OR'; - $sql .= implode(' ' . $sqlCondition . ' ', $whereClauses); - if (!empty($puuidBind)) { - // code to support patient binding - $sql .= ") AND `uuid` = ?"; - $sqlBindArray[] = UuidRegistry::uuidToBytes($puuidBind); - } - } elseif (!empty($puuidBind)) { - // code to support patient binding - $sql .= " WHERE `uuid` = ?"; - $sqlBindArray[] = UuidRegistry::uuidToBytes($puuidBind); - } - - $statementResults = sqlStatement($sql, $sqlBindArray); - - $processingResult = new ProcessingResult(); - while ($row = sqlFetchArray($statementResults)) { - $row['uuid'] = UuidRegistry::uuidToString($row['uuid']); - $processingResult->addData($row); } - - return $processingResult; + return $this->search($querySearch, $isAndCondition); } /** @@ -408,7 +361,8 @@ public function getOne($puuidString) sex, race, ethnicity, - status + status, + `language` FROM patient_data WHERE uuid = ?"; diff --git a/src/Services/PractitionerService.php b/src/Services/PractitionerService.php index a23ec2cc929..3939d88d1fb 100644 --- a/src/Services/PractitionerService.php +++ b/src/Services/PractitionerService.php @@ -13,7 +13,13 @@ namespace OpenEMR\Services; +use OpenEMR\Common\Logging\SystemLogger; use OpenEMR\Common\Uuid\UuidRegistry; +use OpenEMR\Services\Search\ISearchField; +use OpenEMR\Services\Search\SearchModifier; +use OpenEMR\Services\Search\StringSearchField; +use OpenEMR\Services\Search\TokenSearchField; +use OpenEMR\Services\Search\TokenSearchValue; use OpenEMR\Validators\PractitionerValidator; use OpenEMR\Validators\ProcessingResult; @@ -35,9 +41,62 @@ public function __construct() $this->practitionerValidator = new PractitionerValidator(); } + public function getSelectJoinTables(): array + { + return + [ + ['table' => 'list_options', 'alias' => 'abook', 'type' => 'LEFT JOIN', 'column' => 'abook_type', 'join_column' => 'option_id'] + ,['table' => 'list_options', 'alias' => 'physician', 'type' => 'LEFT JOIN', 'column' => 'physician_type', 'join_column' => 'option_id'] + ]; + } + + public function getSelectFields(): array + { + // since we are joining a bunch of fields we need to make sure we normalize our regular field array by adding + // the table name for our own table values. + $fields = $this->getFields(); + $normalizedFields = []; + // processing is cheap + foreach ($fields as $field) { + $normalizedFields[] = '`' . $this->getTable() . '`.`' . $field . '`'; + } + + return array_merge($normalizedFields, ['abook.title as abook_title', 'physician.title as physician_title', 'physician.codes as physician_code']); + } + + public function getUuidFields(): array + { + return ['uuid']; + } + + public function selectHelper($sqlUpToFromStatement, $map) + { + // TODO: adunsulag we only are putting this in here until we can get the npi:missing modifier to work properly + // and then we will remove this stuff. + if (!empty($map['where'])) { + $map['where'] .= " AND NPI IS NOT null"; + } else { + $map['where'] = "WHERE npi IS NOT null"; + } + return parent::selectHelper($sqlUpToFromStatement, $map); + } + + public function search($search, $isAndCondition = true) + { + // we make sure we only get NPI values + // TODO: adunsulag when we can get the Missing modifier working we can do that here... +// $search['npi'] = new TokenSearchField('npi'); +// $search['npi']->setModifier(SearchModifier::MISSING); + return parent::search($search, $isAndCondition); + } + /** * Returns a list of practitioners matching optional search criteria. - * Search criteria is conveyed by array where key = field/column name, value = field value. + * Search criteria is conveyed by array where key = field/column name, value = ISearchField|primitive value + * + * If a primitive value is provided it will do an exact match on that field. If an ISearchField is provided it will + * use whatever modifiers, comparators, and composite search settings that are specified in the search field. + * * If no search criteria is provided, all records are returned. * * @param $search search array parameters @@ -47,67 +106,19 @@ public function __construct() */ public function getAll($search = array(), $isAndCondition = true) { - $sqlBindArray = array(); - - $sql = "SELECT id, - uuid, - users.title as title, - fname, - lname, - mname, - federaltaxid, - federaldrugid, - upin, - facility_id, - facility, - npi, - email, - active, - specialty, - billname, - url, - assistant, - organization, - valedictory, - street, - streetb, - city, - state, - zip, - phone, - fax, - phonew1, - phonecell, - users.notes, - state_license_number, - abook.title as abook_title, - physician.title as physician_title, - physician.codes as physician_code - FROM users - LEFT JOIN list_options as abook ON abook.option_id = users.abook_type - LEFT JOIN list_options as physician ON physician.option_id = users.physician_type - WHERE npi is not null"; - if (!empty($search)) { - $sql .= ' AND '; - $whereClauses = array(); + $fields = $this->getFields(); + $validKeys = array_combine($fields, $fields); + + // We need to be backwards compatible with all other uses of the service so we are going to make this a + // exact match string param on everything, but only if they are not sending in any Search Field options foreach ($search as $fieldName => $fieldValue) { - array_push($whereClauses, $fieldName . ' = ?'); - array_push($sqlBindArray, $fieldValue); + if (isset($validKeys[$fieldName]) && !($fieldValue instanceof ISearchField)) { + $search[$fieldName] = new StringSearchField($fieldName, $fieldValue, SearchModifier::EXACT, $isAndCondition); + } } - $sqlCondition = ($isAndCondition == true) ? 'AND' : 'OR'; - $sql .= implode(' ' . $sqlCondition . ' ', $whereClauses); - } - - $statementResults = sqlStatement($sql, $sqlBindArray); - - $processingResult = new ProcessingResult(); - while ($row = sqlFetchArray($statementResults)) { - $row['uuid'] = UuidRegistry::uuidToString($row['uuid']); - $processingResult->addData($row); } - - return $processingResult; + return $this->search($search, $isAndCondition); } /** @@ -130,51 +141,16 @@ public function getOne($uuid) return $processingResult; } - $sql = "SELECT id, - uuid, - users.title as title, - fname, - lname, - mname, - federaltaxid, - federaldrugid, - upin, - facility_id, - facility, - npi, - email, - active, - specialty, - billname, - url, - assistant, - organization, - valedictory, - street, - streetb, - city, - state, - zip, - phone, - fax, - phonew1, - phonecell, - users.notes, - state_license_number, - abook.title as abook_title, - physician.title as physician_title, - physician.codes as physician_code - FROM users - LEFT JOIN list_options as abook ON abook.option_id = users.abook_type - LEFT JOIN list_options as physician ON physician.option_id = users.physician_type - WHERE uuid = ? AND npi is not null"; - - $uuidBinary = UuidRegistry::uuidToBytes($uuid); - $sqlResult = sqlQuery($sql, [$uuidBinary]); - - $sqlResult['uuid'] = $uuid; - $processingResult->addData($sqlResult); - return $processingResult; + // there should not be a single duplicate id so we will grab that + $search = ['uuid' => new TokenSearchField('uuid', new TokenSearchValue(UuidRegistry::uuidToBytes($uuid)))]; + $results = $this->search($search); + $data = $results->getData(); + if (count($data) > 1) { + // we will log this error and return just the single value + $results->setData([$data[0]]); + (new SystemLogger())->error("PractionerService->getOne() Duplicate records found for uuid", ['uuid' => $uuid]); + } + return $results; } diff --git a/src/Services/Search/BasicSearchField.php b/src/Services/Search/BasicSearchField.php new file mode 100644 index 00000000000..d7b2b80aa6c --- /dev/null +++ b/src/Services/Search/BasicSearchField.php @@ -0,0 +1,151 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +use OpenEMR\Services\Search\SearchFieldType; + +class BasicSearchField implements ISearchField +{ + private $field; + private $name; + private $modifier; + private $values; + private $isAnd; + + /** + * BasicSearchField constructor. + * @param $name The documented name of this search field. Can be the same as field name but is not required to be. + * @param $type The type of + * @param $field + * @param $values + * @param null $modifier + * @param bool $isAnd + */ + public function __construct($name, $type, $field, $values, $modifier = null) + { + $this->setName($name); + $this->setType($type); + $this->setField($field); + $this->setModifier($modifier); + $values = $values ?? []; + $values = is_array($values) ? $values : [$values]; + $isAnd = count($values) > 0 ? false : true; + $this->setIsAnd($isAnd); + $this->setValues($values); + } + + public function getName() + { + return $this->name; + } + + protected function setName($name) + { + $this->name = $name; + } + + public function getType() + { + return $this->type; + } + + protected function setType($type) + { + $this->type = $type; + } + + /** + * @return mixed + */ + public function getField() + { + return $this->field; + } + + /** + * @param mixed $field + * @return BasicSearchField + */ + protected function setField($field) + { + $this->field = $field; + return $this; + } + + /** + * @return string + */ + public function getModifier() + { + return $this->modifier; + } + + /** + * @param string $modifier + * @return BasicSearchField + */ + public function setModifier($modifier) + { + $this->modifier = $modifier; + return $this; + } + + /** + * @return mixed[] + */ + public function getValues() + { + return $this->values; + } + + /** + * @param mixed[] $values + * @return BasicSearchField + */ + public function setValues(array $values) + { + $this->values = $values; + return $this; + } + + /** + * Returns whether the array of values that this search field can have should be logically intersected(AND) or logically + * unioned(OR). + * @return bool + */ + public function isAnd(): bool + { + return $this->isAnd; + } + + /** + * @param bool $isAnd + * @return BasicSearchField + */ + protected function setIsAnd(bool $isAnd): ISearchField + { + $this->isAnd = $isAnd; + return $this; + } + + /** + * Useful for debugging, you can echo the object to see its values. + * @return string + */ + public function __toString() + { + return "(field=" . $this->getField() . ",type=" . $this->getType() + . ",values=[" . implode(",", $this->getValues()) . "],modifier=" . ($this->getModifier() ?? "") . ")"; + } +} diff --git a/src/Services/Search/CompositeSearchField.php b/src/Services/Search/CompositeSearchField.php new file mode 100644 index 00000000000..af5fdb0bc2b --- /dev/null +++ b/src/Services/Search/CompositeSearchField.php @@ -0,0 +1,135 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +use OpenEMR\Services\Search\SearchFieldType; + +class CompositeSearchField implements ISearchField +{ + /** + * @var string + */ + private $name; + + /** + * @var string + */ + private $field; + + /** + * @var mixed[] + */ + private $values; + + /** + * @var ISearchField[] + */ + private $children; + + /** + * @var boolean Whether the composite fields should be treated as a logical AND (intersection) + * or a logical OR (UNION) + */ + private $isAnd; + + public function __construct($name, $values, $isAnd = true) + { + $this->name = $name; + $this->field = $name; // we will give the field the same name as our name. + $this->values = $values; + $this->children = []; + $this->isAnd === true; + } + + /** + * @param string $name + */ + public function setName(string $name): void + { + $this->name = $name; + } + + public function getName() + { + return $this->name; + } + + public function getValues() + { + return $this->values; + } + + /** + * @param mixed[] $values + */ + public function setValues(array $values): void + { + $this->values = $values; + } + + public function addValue($value) + { + $this->values[] = $value; + } + + public function getType() + { + return SearchFieldType::COMPOSITE; + } + + /** + * @return ISearchField[] + */ + public function getChildren(): array + { + return $this->children; + } + + /** + * @param ISearchField[] $children + */ + public function setChildren(array $children): void + { + $this->children = $children; + } + + public function addChild(ISearchField $child) + { + $this->children[] = $child; + } + + public function getField() + { + return $this->field; + } + + public function isAnd() + { + return $this->isAnd; + } + + /** + * Useful for debugging, you can echo the object to see its values. + * @return string + */ + public function __toString() + { + $values = $this->getValues ?? []; + $children = $this->getChildren() ?? []; + + return "(field=" . $this->getField() . ",type=" . $this->getType() + . ",values=[" . implode(",", $values) . "],children=[{" . implode("},{", $children) . "}])"; + } +} diff --git a/src/Services/Search/DateSearchField.php b/src/Services/Search/DateSearchField.php new file mode 100644 index 00000000000..4e42b6dcbcc --- /dev/null +++ b/src/Services/Search/DateSearchField.php @@ -0,0 +1,202 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +use OpenEMR\Services\Search\SearchFieldType; + +class DateSearchField extends BasicSearchField +{ + /** + * The field being searched is a date (year, month, day) field + */ + const DATE_TYPE_DATE = 'date'; + + /** + * The field being searched on has both a date component as well as a time (Hour, minute, second) component. + */ + const DATE_TYPE_DATETIME = 'datetime'; + + // This regex pattern was adopted from the Asymmetrik/node-fhir-server-mongo project. It can be seen here + // https://github.com/Asymmetrik/node-fhir-server-mongo/blob/104037de07a1a1a49adb3de45beb1ae1283f67bb/src/utils/querybuilder.util.js#L273 + // This line is licensed under the MIT license and was last accessed on April 20th 2021 + private const COMPARATOR_MATCH = "/^(\D{2})?(\d{4})(-\d{2})?(-\d{2})?(?:(T\d{2}:\d{2})(:\d{2})?)?(Z|(\+|-)(\d{2}):(\d{2}))?$/"; + + /** + * The different types of dates that are available + */ + const DATE_TYPES = [self::DATE_TYPE_DATE, self::DATE_TYPE_DATETIME]; + + /** + * @var Tracks the type of search date this is. Must be a value contined in the DATE_TYPES constant + */ + private $dateType; + + /** + * DateSearchField constructor. Constructs the object and parses all values into valid SearchFieldComparableValue objects + * that can be used by OpenEMR services to perform searches. + * @param $field + * @param $values + * @param bool $isAnd + */ + public function __construct($field, $values, $dateType = self::DATE_TYPE_DATETIME) + { + $this->setDateType($dateType); + + $modifier = null; + parent::__construct($field, SearchFieldType::DATE, $field, $values, $modifier); + } + + public function getDateType() + { + return $this->dateType; + } + + public function setDateType($dateType) + { + if (array_search($dateType, self::DATE_TYPES) === false) { + throw new \InvalidArgumentException("Invalid date type found '$dateType'"); + } + $this->dateType = $dateType; + } + + public function setValues(array $values) + { + // need to parse for comparators + $convertedFields = []; + + foreach ($values as $value) { + if ($value instanceof SearchFieldComparableValue) { + $convertedFields[] = $value; + continue; + } + + $convertedFields[] = $this->createDateComparableValue($value); + } + parent::setValues($convertedFields); + } + + /** + * @return SearchFieldComparableValue[] + */ + public function getValues() + { + return parent::getValues(); // TODO: Change the autogenerated stub + } + + + /** + * @param $value + * @return SearchFieldComparableValue The created comparable value that will be used for querying against this search field. + * @throws \InvalidArgumentException if the date format is not a valid ISO8610 format or if the date format does not follow FHIR spec. + */ + private function createDateComparableValue($value): SearchFieldComparableValue + { + + // we can't use something like DateTime::createFromFormat to conform with ISO8601 (xml date format) + // unfortunately php will fill in the date with the current date which does not conform to spec. + // spec requires that we fill in missing values with the lowest bounds of missing parameters. + + if (preg_match(self::COMPARATOR_MATCH, $value, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { + throw new \InvalidArgumentException("Date format invalid must match ISO8610 format and values SHALL be populated from left to right"); + } + if (empty($matches[2])) { + throw new \InvalidArgumentException("Date format requires the year to be specified"); + } + + if (!empty($matches[5]) && empty($matches[6])) { + throw new \InvalidArgumentException("Date format requires minutes to be specified if an hour is provided"); + } + + $comparator = $matches[1] ?? SearchComparator::EQUALS; + if (!SearchComparator::isValidComparator($comparator)) { + // TODO: adunsulag should we make this a specific Search Exception so we can report back an Operation Outcome? + throw new \InvalidArgumentException("Invalid comparator found for value " . $value); + } + + $lowerBoundRange = ['y' => $matches[2], 'm' => 1, 'd' => 1, 'H' => 0, 'i' => 0, 's' => 0]; + $upperBoundRange = ['y' => $matches[2], 'm' => 12, 'd' => 31, 'H' => 23, 'i' => 59, 's' => 59]; + + // month + if (!empty($matches[3])) { + $month = intval(substr($matches[3], 1)); + $month = min([max([$month, 1]), 12]); + $lowerBoundRange['m'] = $month; + $upperBoundRange['m'] = $month; + } + + // day + if (!empty($matches[4])) { + $day = intval(substr($matches[4], 1)); + $day = min([max([$day, 1]), cal_days_in_month(CAL_GREGORIAN, $upperBoundRange['m'], $upperBoundRange['y'])]); + $lowerBoundRange['d'] = $day; + $upperBoundRange['d'] = $day; + } else { + $upperBoundRange['d'] = cal_days_in_month(CAL_GREGORIAN, $upperBoundRange['m'], $upperBoundRange['y']); + } + + // hour:minutes + if (!empty($matches[5])) { + $parts = explode(":", $matches[5]); + // remove the T and grab the value for the hour + $hour = intval(substr($parts[0], 1)); + // hours: 0 <= hours <= 23 + $hour = min([max([$hour, 0]), 23]); + $minutes = intval($parts[1]); + // minutes: 0 <= minutes <= 60 + $minutes = min([max([$minutes, 0]), 59]); + + $lowerBoundRange['H'] = $hour; + $upperBoundRange['H'] = $hour; + $lowerBoundRange['i'] = $minutes; + $upperBoundRange['i'] = $minutes; + } + + // seconds + if (!empty($matches[5])) { + // remove the ':' + $seconds = intval(substr($matches[5], 1)); + $seconds = min([max([$seconds, 0]), 59]); + $lowerBoundRange['s'] = $seconds; + $upperBoundRange['s'] = $seconds; + } + + $startRange = $this->createDateTimeFromArray($lowerBoundRange); + $endRange = $this->createDateTimeFromArray($upperBoundRange); + + // not sure if the date period lazy creates the interval traversal or not so we will go with the maximum interval + // we can think of. We just are leveraging an existing PHP object that represents a pair of start/end dates + $datePeriod = new \DatePeriod($startRange, new \DateInterval('P1Y'), $endRange); + + // TODO: adunsulag figure out how to handle timezones here... + + return new SearchFieldComparableValue($datePeriod, $comparator); + } + + private function createDateTimeFromArray(array $datetime) + { + // Not sure how we want to handle timezone + // we create a DateTime object as not all search fields are a DateTime so we go as precise as we can + // and let the services go more imprecise if needed. + $stringDate = sprintf("%d-%02d-%02d %02d:%02d:%02d", $datetime['y'], $datetime['m'], $datetime['d'], $datetime['H'], $datetime['i'], $datetime['s']); + // 'n' & 'j' don't have leading zeros + $dateValue = \DateTime::createFromFormat("Y-m-d H:i:s", $stringDate); + return $dateValue; + } +} diff --git a/src/Services/Search/FHIRSearchFieldFactory.php b/src/Services/Search/FHIRSearchFieldFactory.php new file mode 100644 index 00000000000..f2be25f6e4a --- /dev/null +++ b/src/Services/Search/FHIRSearchFieldFactory.php @@ -0,0 +1,266 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +use Laminas\Mvc\Exception\BadMethodCallException; +use OpenEMR\Services\FHIR\FhirUrlResolver; +use OpenEMR\Services\Search\SearchFieldType; +use OpenEMR\FHIR\R4\FHIRDomainResource\FHIRSearchParameter; + +class FHIRSearchFieldFactory +{ + /** + * @var FhirSearchParameterDefinition[]; + */ + private $resourceSearchParameters; + + /** + * @var FhirUrlResolver + */ + private $fhirUrlResolver; + + /** + * FHIRSearchFieldFactory constructor. + * @param FhirSearchParameterDefinition[] $searchFieldDefinitions + * @throws \InvalidArgumentException if $searchFieldDefinitions are not an instance of FhirSearchParameterDefinition + */ + public function __construct(array $searchFieldDefinitions) + { + $this->resourceSearchParameters = $searchFieldDefinitions; + foreach ($searchFieldDefinitions as $key => $definition) { + if (!$definition instanceof FhirSearchParameterDefinition) { + throw new \InvalidArgumentException("Search parameter contains invalid class definition " . $key); + } + } + } + + public function setFhirUrlResolver(FhirUrlResolver $urlResolver) + { + $this->fhirUrlResolver = $urlResolver; + } + + public function getFhirUrlResolver(): FhirUrlResolver + { + return $this->fhirUrlResolver; + } + + /** + * Checks whethere the factory has a search definition for the passed in search field name + * @param $fhirSearchField + * @return bool + */ + public function hasSearchField($fhirSearchField) + { + $fieldName = $this->extractSearchFieldName($fhirSearchField); + return isset($this->resourceSearchParameters[$fieldName]); + } + + /** + * Factory method to build a search field using the factory's search field definitions. + * @param $fhirSearchField The passed in parameter name for the search field the user agent sent. Can contain search modifiers + * @param $fhirSearchValues The array of search values the user agent sent for the $fhirSearchField + * @throws \InvalidArgumentException If the factory does not have a search definition for $fhirSearchField + * @return CompositeSearchField|DateSearchField|StringSearchField|TokenSearchField + */ + public function buildSearchField($fhirSearchField, $fhirSearchValues) + { + + if (!$this->hasSearchField($fhirSearchField)) { + throw new \InvalidArgumentException("Search definition not found for passed in field " . $fhirSearchField); + } + + $fieldName = $this->extractSearchFieldName($fhirSearchField); + $searchDefinition = $this->resourceSearchParameters[$fieldName]; + + // a FHIR composite field can be composed of multiple FHIR fields, we send the lookup table so we can grab them + if ($searchDefinition->getType() === SearchFieldType::COMPOSITE) { + $searchField = $this->buildFhirCompositeField($searchDefinition, $fhirSearchField, $fhirSearchValues); + } else { + $mappedFields = $searchDefinition->getMappedFields(); + if (count($mappedFields) > 1) { + // create a composite field + $searchField = $this->createCompositeFieldForMultipleMappedFields($searchDefinition, $fhirSearchField, $fhirSearchValues); + } else { + // create a regular field + $modifiers = $this->extractFieldModifiers($fhirSearchField); + + $searchField = $this->createFieldForType($searchDefinition->getType(), $mappedFields[0], $fhirSearchValues, $modifiers); + } + } + return $searchField; + } + + /** + * Given a user provided search field that may contain search modifiers we extract the search name from the field. + * @param $fhirSearchField + * @return string + */ + private function extractSearchFieldName($fhirSearchField) + { + $fieldNameWithModifiers = explode(":", $fhirSearchField); + $fieldName = $fieldNameWithModifiers[0]; + return $fieldName; + } + + /** + * Creates a ISearchField for the given type + * @param $type a value from SearchFieldType + * @param $field string|ServiceField The name of the search field or a service field definition + * @param $fhirSearchValues The values that will be searched on + * @param string[] $modifiers Any search modifiers such as :exact or :contains + * @return DateSearchField|StringSearchField|TokenSearchField + */ + private function createFieldForType($type, $field, $fhirSearchValues, $modifiers = null) + { + // we currently only support a single modifier, not going to support multiple modifiers right now in the system + $modifier = is_array($modifiers) ? array_pop($modifiers) : null; + + // need to handle the fact that we can have multiple OR values that are separated in CSV format. + if (is_string($fhirSearchValues) && strpos($fhirSearchValues, ',') !== false) { + $fhirSearchValues = explode(',', $fhirSearchValues); + } + + $isUUID = false; + if ($field instanceof ServiceField) { + $fieldName = $field->getField(); + $isUUID = $field->getType() == ServiceField::TYPE_UUID ? true : false; + } else { + $fieldName = $field; + } + + if ($type == SearchFieldType::TOKEN) { + return new TokenSearchField($fieldName, $fhirSearchValues, $isUUID); + } else if ($type == SearchFieldType::URI) { + throw new \BadMethodCallException("URI Search Parameter not implemented yet"); + } else if ($type == SearchFieldType::DATE) { + return new DateSearchField($fieldName, $fhirSearchValues, DateSearchField::DATE_TYPE_DATE); + } else if ($type == SearchFieldType::DATETIME) { + return new DateSearchField($fieldName, $fhirSearchValues, DateSearchField::DATE_TYPE_DATETIME); + } else if ($type == SearchFieldType::NUMBER) { + throw new \BadMethodCallException("Number search parameter not implemented yet"); + } else if ($type == SearchFieldType::REFERENCE) { + return $this->createReferenceFieldType($fieldName, $fhirSearchValues, $modifiers, $isUUID); + } else { + // default is a string token + return new StringSearchField($fieldName, $fhirSearchValues, $modifier); + } + } + + private function createReferenceFieldType($fieldName, $fhirSearchValues, $modifiers, $isUUID) + { + $referenceOptions = $this->resourceSearchParameters[$fieldName] ?? []; + + $values = $fhirSearchValues ?? []; + $values = is_array($values) ? $values : [$values]; + + $normalizedValues = []; + foreach ($values as $searchValue) { + if (strpos($searchValue, '://') !== false) { + $url = $this->resolveReferenceRelativeUrl($searchValue); + $normalizedValues[] = $url; + } else { + $normalizedValues[] = $searchValue; + } + } + return new ReferenceSearchField($fieldName, $normalizedValues, $isUUID); + } + + /** + * Returns the relative url + * @param $urlToResolve + * @throws BadMethodCallException if the FhirUrlResolver is not setup for this class + * @throws \InvalidArgumentException if the URL does not match the server base URL + * @return string + */ + private function resolveReferenceRelativeUrl($urlToResolve) + { + if (empty($this->getFhirUrlResolver())) { + throw new \BadMethodCallException("FHIR URL Resolver is not properly setup. This is a developer error"); + } + $url = $this->getFhirUrlResolver()->getRelativeUrl($urlToResolve); + if (empty($url)) { + throw new \InvalidArgumentException("URL does not match fhir server or relative url for reference could not be found"); + } + return $url; + } + + /** + * If a single search field has multiple mapped fields we create a composite field that is the union (logical OR) of + * those values. This field will return a value if ANY of the mapped fields has the $fhirSearchValues in it. + * @param FhirSearchParameterDefinition $definition The search definition object + * @param $fhirSearchField The name of the search definition + * @param $fhirSearchValues The values that will be searched on for each of the composite fields. + * @return CompositeSearchField + */ + private function createCompositeFieldForMultipleMappedFields(FhirSearchParameterDefinition $definition, $fhirSearchField, $fhirSearchValues) + { + $isAnd = false; // when we are building our composite field here we want the UNION of values since the internal + // we want to search across all of the mapped OpenEMR columns which is an intersection(logical OR) rather than + // the logical AND of everything. + $composite = new CompositeSearchField($definition->getName(), $fhirSearchValues, $isAnd); + $modifiers = $this->extractFieldModifiers($fhirSearchField); + foreach ($definition->getMappedFields() as $key => $field) { + // for token types we want to make if we have a system we are only going to the key + + // for now let's treat everything as a string... + // we won't give any modifier here for now + $childField = $this->createFieldForType($definition->getType(), $field, $fhirSearchValues, $modifiers); + $composite->addChild($childField); + } + return $composite; + } + + /** + * Given a search field that may or may not contain FHIR modifiers (noted by a : after the field name) it will remove + * all the modifiers and return them as an array of strings to the caller. + * @param $fhirSearchField + * @return array + */ + private function extractFieldModifiers($fhirSearchField) + { + $fieldNameWithModifiers = explode(":", $fhirSearchField); + $fieldName = $fieldNameWithModifiers[0]; + array_shift($fieldNameWithModifiers); // grab our modifiers + return $fieldNameWithModifiers; + } + + /** + * Creates a composite search field (searching across multiple fhir sub-fields such as gender and birthdate in a patient) + * @param FhirSearchParameterDefinition $definition The definition for this FHIR composite search field + * @param $fhirSearchField The name of the search field + * @param $fhirSearchValues The values that were sent by the calling user agent. + * @return CompositeSearchField The created composite search field. + */ + private function buildFHIRCompositeField(FhirSearchParameterDefinition $definition, $fhirSearchField, $fhirSearchValues) + { + + $composite = new CompositeSearchField($definition->getName(), $fhirSearchValues); + + foreach ($definition->getMappedFields() as $fieldName) { + if (isset($this->resourceSearchParameters[$fieldName])) { + $childDefinition = $this->resourceSearchParameters[$fieldName]; + // for now let's treat everything as a string... + // we won't give any modifier here for now + // not sure how we handle modifiers here... + $childField = $this->buildSearchField($childDefinition, $fieldName, $fhirSearchValues); + $composite->addChild($childField); + } + } + + return $composite; + } +} diff --git a/src/Services/Search/FhirSearchParameterDefinition.php b/src/Services/Search/FhirSearchParameterDefinition.php new file mode 100644 index 00000000000..40d610df7ad --- /dev/null +++ b/src/Services/Search/FhirSearchParameterDefinition.php @@ -0,0 +1,74 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +class FhirSearchParameterDefinition +{ + /** + * @var string + */ + private $name; + + /** + * @var string + */ + private $type; + /** + * @var string[] + */ + private $mappedFields; + + /** + * @var string[] + */ + private $options; + + public function __construct($name, $type, $mappedFields, $options = array()) + { + $this->name = $name; + $this->type = $type; + $this->mappedFields = $mappedFields; + $this->options = $options; + } + + /** + * @return string[] + */ + public function getMappedFields() + { + return $this->mappedFields; + } + + /** + * @return string + */ + public function getType() + { + return $this->type; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + public function getOptions(): array + { + return $this->options; + } +} diff --git a/src/Services/Search/FhirSearchWhereClauseBuilder.php b/src/Services/Search/FhirSearchWhereClauseBuilder.php new file mode 100644 index 00000000000..4711132f4e2 --- /dev/null +++ b/src/Services/Search/FhirSearchWhereClauseBuilder.php @@ -0,0 +1,64 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +class FhirSearchWhereClauseBuilder +{ + + /** + * Given a list of ISearchField objects it constructs a WHERE clause query that can be used in a query statement + * + * @param ISearchField[] $search Hashmap of string => ISearchField where the key is the field name of the search field + * @param bool $isAndCondition Whether to join each search field with a logical OR or a logical AND. + * @return string The where clause query string. + */ + public static function build($search, $isAndCondition = true): SearchQueryFragment + { + $sqlBindArray = []; + $whereClauses = array( + 'and' => [] + ,'or' => [] + ); + + if (!empty($search)) { + // make sure all the parameters are actual search fields and clean up any field that is a uuid + foreach ($search as $key => $field) { + if (!$field instanceof ISearchField) { + // developer logic error + throw new \BadMethodCallException("Method called with invalid parameter. Expected SearchField object for parameter '" . $key . "'"); + } + $whereType = $isAndCondition ? "and" : "or"; + + $whereClauses[$whereType][] = SearchFieldStatementResolver::getStatementForSearchField($field); + } + } + $where = ''; + + if (! (empty($whereClauses['or']) && empty($whereClauses['and']) )) { + $where = " WHERE "; + $andClauses = []; + foreach ($whereClauses['and'] as $clause) { + $andClauses[] = $clause->getFragment(); + $sqlBindArray = array_merge($sqlBindArray, $clause->getBoundValues()); + } + $where = empty($andClauses) ? $where : $where . implode(" AND ", $andClauses); + + $orClauses = []; + foreach ($whereClauses['or'] as $clause) { + $orClauses[] = $clause->getFragment(); + $sqlBindArray = array_merge($sqlBindArray, $clause->getBoundValues()); + } + $where = empty($orClauses) ? $where : $where . "(" . implode(" OR ", $orClauses) . ")"; + } + return new SearchQueryFragment($where, $sqlBindArray); + } +} diff --git a/src/Services/Search/ISearchField.php b/src/Services/Search/ISearchField.php new file mode 100644 index 00000000000..b311590c817 --- /dev/null +++ b/src/Services/Search/ISearchField.php @@ -0,0 +1,43 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +interface ISearchField +{ + /** + * The name of the field in OpenEMR that will be searched on. + * @return string + */ + public function getField(); + + /** + * Represents the unique documented name of this search field. When there is a 1:1 relationship between a FHIR + * search field and an OpenEMR field it is often the same as the field name. + * @return mixed + */ + public function getName(); + + /** + * Array of search values. Can be objects or primitive values based upon whatever the implementing class decides to + * hold. + * @return mixed[] + */ + public function getValues(); + + /** + * @return string The search type as defined in the SearchFieldType class + */ + public function getType(); +} diff --git a/src/Services/Search/ReferenceSearchField.php b/src/Services/Search/ReferenceSearchField.php new file mode 100644 index 00000000000..25bb38bca4e --- /dev/null +++ b/src/Services/Search/ReferenceSearchField.php @@ -0,0 +1,57 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +use Psr\Log\InvalidArgumentException; + +class ReferenceSearchField extends BasicSearchField +{ + /** + * @var boolean + */ + private $isUuid; + + public function __construct($field, $values, $isUuid = false) + { + $this->isUuid = $isUuid; + parent::__construct($field, SearchFieldType::REFERENCE, $field, $values); + } + + public function setValues(array $values) + { + $convertedFields = []; + + foreach ($values as $value) { + if ($value instanceof ReferenceSearchValue) { + $convertedFields[] = $value; + continue; + } + + $convertedFields[] = $this->createReferenceSearchValue($value); + } + parent::setValues($convertedFields); + } + + /** + * @param $value + * @return ReferenceSearchValue + * @throws InvalidArgumentException if $value is not a valid string + */ + private function createReferenceSearchValue($value) + { + if (!is_string($value)) { + throw new \InvalidArgumentException("Reference value must be a valid string"); + } + + return ReferenceSearchValue::createFromRelativeUri($value, $this->isUuid); + } +} diff --git a/src/Services/Search/ReferenceSearchValue.php b/src/Services/Search/ReferenceSearchValue.php new file mode 100644 index 00000000000..09ec5b0eb5e --- /dev/null +++ b/src/Services/Search/ReferenceSearchValue.php @@ -0,0 +1,105 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +use OpenEMR\Common\Uuid\UuidRegistry; + +class ReferenceSearchValue +{ + /** + * @var string|number + */ + private $id; + + /** + * @var string|null + */ + private $resource; + + /** + * Tracks if the reference search value is a unique user id (stored in binary) and needs to be converted. + * All reference values should be uuids but this gives us the option in the future to have non-uuids if this changes + * @var boolean + */ + private $isUuid; + + + public function __construct($id, $resource = null, $isUuid = false) + { + $this->resource = $resource; + $this->isUuid = $isUuid; + + if ($this->isUuid) { + if (UuidRegistry::isValidStringUUID($id)) { + $this->id = UuidRegistry::uuidToBytes($id); + } else { + throw new \InvalidArgumentException("UUID columns must be a valid UUID string"); + } + } else { + $this->id = $id; + } + } + + /** + * Parses a relative URL to create the reference search value. For example for a relative url such as Patient/23 + * return a reference search value with Patient as the resource and 23 as the id. + * @param $relativeUri string the URI to parse + * @return ReferenceSearchValue + */ + public static function createFromRelativeUri($relativeUri, $isUuid = false) + { + $id = $relativeUri; + $resource = null; + if (strpos($relativeUri, "/") !== false) { + $parts = explode("/", $relativeUri); + $resource = $parts[0]; + $id = end($parts); + } + $reference = new ReferenceSearchValue($id, $resource, $isUuid); + return $reference; + } + + /** + * @return string + */ + public function getResource(): ?string + { + return $this->resource; + } + + /** + * @return number|string + */ + public function getId() + { + return $this->id; + } + + public function getHumanReadableId() + { + $id = $this->getId(); + if ($this->isUuid && !empty($id)) { + return UuidRegistry::uuidToString($id); + } else { + return $id; + } + } + + public function __toString() + { + if ($this->getResource()) { + return $this->getResource() . "/" . $this->getHumanReadableId(); + } else { + return $this->getHumanReadableId(); + } + } +} diff --git a/src/Services/Search/SearchComparator.php b/src/Services/Search/SearchComparator.php new file mode 100644 index 00000000000..dd4dbc73fc8 --- /dev/null +++ b/src/Services/Search/SearchComparator.php @@ -0,0 +1,40 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +final class SearchComparator +{ + // This list comes from the FHIR search comparators for number, date, and quantity + // @see http://hl7.org/fhir/R4/search.html#prefix + public const EQUALS = "eq"; + public const NOT_EQUALS = "ne"; + public const GREATER_THAN = "gt"; + public const LESS_THAN = "lt"; + public const GREATER_THAN_OR_EQUAL_TO = "ge"; + public const LESS_THAN_OR_EQUAL_TO = "le"; + + // these two options appear to be equivalent to gt & lt, not sure why we have them. + public const STARTS_AFTER = "sa"; + public const ENDS_BEFORE = "eb"; + + // we have this here for reference but we are currently not supporting aproximation which has a recommended + // aproximation of 10%. + public const APROXIMATELY_SAME = "ap"; + + public const ALL_COMPARATORS = [self::EQUALS, self::NOT_EQUALS, self::GREATER_THAN, self::LESS_THAN + , self::GREATER_THAN_OR_EQUAL_TO, self::LESS_THAN_OR_EQUAL_TO, self::STARTS_AFTER, self::ENDS_BEFORE]; + + public static function isValidComparator($comparator) + { + return array_search($comparator, self::ALL_COMPARATORS) !== false; + } +} diff --git a/src/Services/Search/SearchFieldComparableValue.php b/src/Services/Search/SearchFieldComparableValue.php new file mode 100644 index 00000000000..68e544513e2 --- /dev/null +++ b/src/Services/Search/SearchFieldComparableValue.php @@ -0,0 +1,66 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +class SearchFieldComparableValue +{ + /** + * @var mixed + */ + private $value; + + /** + * @var string + */ + private $comparator; + + public function __construct($value, $comparator = SearchComparator::EQUALS) + { + if (!SearchComparator::isValidComparator($comparator)) { + throw new \InvalidArgumentException("Invalid comparator of '" . $comparator . "' found"); + } + $this->value = $value; + $this->comparator = $comparator; + } + + /** + * @return string + */ + public function getComparator(): string + { + return $this->comparator; + } + + /** + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + public function __toString() + { + $value = $this->getValue() || ""; + if (is_object($value)) { + if (method_exists($value, '__toString')) { + $value = $value->__toString(); + } else { + $value = get_class($value); + } + } + return "(value=" . $value . ",comparator=" . $this->getComparator() ?? "" . ")"; + } +} diff --git a/src/Services/Search/SearchFieldException.php b/src/Services/Search/SearchFieldException.php new file mode 100644 index 00000000000..5ce80d0e844 --- /dev/null +++ b/src/Services/Search/SearchFieldException.php @@ -0,0 +1,36 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +use Throwable; + +class SearchFieldException extends \InvalidArgumentException +{ + /** + * @var string The name of the field that the exception was triggered on + */ + private $field; + + public function __construct($field, $message = "", $code = 0, Throwable $previous = null) + { + $this->field = $field; + parent::__construct($message, $code, $previous); + } + + /** + * @return string + */ + public function getField(): string + { + return $this->field; + } +} diff --git a/src/Services/Search/SearchFieldStatementResolver.php b/src/Services/Search/SearchFieldStatementResolver.php new file mode 100644 index 00000000000..bd55a34a9f6 --- /dev/null +++ b/src/Services/Search/SearchFieldStatementResolver.php @@ -0,0 +1,283 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +class SearchFieldStatementResolver +{ + const MAX_NESTED_LEVEL = 10; + + /** + * Given a search field that implements the ISearchField interface, convert the field based upon its type to a full + * SQL Where Query fragment with its corresponding bound parameterized values. This is a recursive method as it will + * traverse any composite search fields up to a heirachical depth of the class constant MAX_NESTED_LEVEL levels. + * @param ISearchField $field The field to convert to a SQL SearchQueryFragment + * @param int $count The current nested count + * @return SearchQueryFragment + */ + public static function getStatementForSearchField(ISearchField $field, $count = 0): SearchQueryFragment + { + // we allow for more complicated searching by allowing combined search fields but there's a limit to how much + // we want to allow this to happen. + if ($count > self::MAX_NESTED_LEVEL) { + throw new \RuntimeException("Exceeded maximum nested method calls for search fields."); + } + if ($field instanceof StringSearchField) { + return self::resolveStringSearchField($field); + } else if ($field instanceof DateSearchField) { + return self::resolveDateField($field); + } else if ($field instanceof TokenSearchField) { + return self::resolveTokenField($field); + } else if ($field instanceof ReferenceSearchField) { + return self::resolveReferenceField($field); + } else if ($field instanceof CompositeSearchField) { + return self::resolveCompositeSearchField($field, $count); + } else { + throw new \InvalidArgumentException("Provided search field type was not implemented"); + } + } + + /** + * Given a DateSearchField with a list of SearchFieldComparableValue objects in the search field a SQL query fragment + * is generated that handles the date field searching. + * @param DateSearchField $searchField + * @return SearchQueryFragment + */ + public static function resolveDateField(DateSearchField $searchField) + { + if (empty($searchField->getValues())) { + throw new \InvalidArgumentException("Search field " . $searchField->getField() . " does not have a value to search on"); + } + + $clauses = []; + $searchFragment = new SearchQueryFragment(); + $values = $searchField->getValues(); + + /** @var SearchFieldComparableValue $value */ + foreach ($values as $comparableValue) { + // convert our value to an actual string + $value = $comparableValue->getValue(); + $lowerBoundDateRange = null; + $upperBoundDateRange = null; + $dateSearchString = null; + $dateFormat = self::getDateFieldFormatForDateType($searchField->getDateType()); + if ($value instanceof \DatePeriod) { + $lowerBoundDateRange = $value->getStartDate(); + $upperBoundDateRange = $value->getEndDate(); + } else if ($value instanceof \DateTime) { + // in the future if we want to just have a DateTime value + $lowerBoundDateRange = $value; + $upperBoundDateRange = $value; + } else { + throw new \InvalidArgumentException("DateSearchField " . $searchField->getField() . " contained value that was not a DatePeriod or DateTime object"); + } + + switch ($comparableValue->getComparator()) { + case SearchComparator::LESS_THAN: + case SearchComparator::ENDS_BEFORE: + $operator = "<"; + $dateSearchString = $lowerBoundDateRange->format($dateFormat); + break; + case SearchComparator::LESS_THAN_OR_EQUAL_TO: + // when dealing with an equal to we need to take the upper range of our fuzzy date interval + $operator = "<="; + $dateSearchString = $upperBoundDateRange->format($dateFormat); + break; + case SearchComparator::GREATER_THAN: + case SearchComparator::STARTS_AFTER: + $operator = ">"; + $dateSearchString = $upperBoundDateRange->format($dateFormat); + break; + case SearchComparator::GREATER_THAN_OR_EQUAL_TO: + // when dealing with an equal to we need to take the lower range of our fuzzy date interval + $operator = ">="; + $dateSearchString = $lowerBoundDateRange->format($dateFormat); + break; + case SearchComparator::NOT_EQUALS: + $operator = "!="; + break; + default: + $operator = "="; + break; + } + // for equality and also inequality (!=) we have to make sure we deal with the fuzzy ranges since search can + // specify date ranges of just Year, Year+Month, Year+month+day, Year+month+day+hour&minute, Year+month+day+hour&minute+second + if ($operator === '=') { + array_push($clauses, $searchField->getField() . ' BETWEEN ? AND ? '); + $searchFragment->addBoundValue($lowerBoundDateRange->format($dateFormat)); + $searchFragment->addBoundValue($upperBoundDateRange->format($dateFormat)); + } else if ($operator === '!=') { + // we have to make sure we deal with the fuzzy range when we have an = operator since the user + // can specify date ranges of just Year, Year+Month, Year+month+day, Year+month+day+hour&minute, Year+month+day+hour&minute+second + array_push($clauses, $searchField->getField() . ' NOT BETWEEN ? AND ? '); + $searchFragment->addBoundValue($lowerBoundDateRange->format($dateFormat)); + $searchFragment->addBoundValue($upperBoundDateRange->format($dateFormat)); + } else { + array_push($clauses, $searchField->getField() . ' ' . $operator . ' ?'); + $searchFragment->addBoundValue($dateSearchString); + } + } + if (count($clauses) > 1) { + $multipleClause = $searchField->isAnd() ? " AND " : " OR "; + $searchFragment->setFragment("(" . implode($multipleClause, $clauses) . ")"); + } else { + $searchFragment->setFragment($clauses[0]); + } + return $searchFragment; + } + + /** + * Given a composite search field resolve each child field and aggregate into a union or intersection depending on + * the composite's isAnd setting. + * @param CompositeSearchField $field The composite field to aggregate. + * @param $depthCount + * @return SearchQueryFragment + */ + public static function resolveCompositeSearchField(CompositeSearchField $field, $depthCount): SearchQueryFragment + { + $clauses = []; + $combinedFragment = new SearchQueryFragment(); + foreach ($field->getChildren() as $searchField) { + $statement = self::getStatementForSearchField($searchField, $depthCount + 1); + foreach ($statement->getBoundValues() as $value) { + $combinedFragment->addBoundValue($value); + } + $clauses[] = $statement->getFragment(); + } + // TODO: stephen do we need to handle OR clauses here for our sub clause? + $joinType = $field->isAnd() ? " AND " : " OR "; + $combinedFragment->setFragment("(" . implode($joinType, $clauses) . ")"); + return $combinedFragment; + } + + /** + * Converts a reference search field into the appropriate query statement to be executed in the database engine + * + * TODO: adunsulag this seems like a lot of duplicate code similar to the resolveTokenField... reference doesn't have + * the modifiers like the token does so I'm not sure if we keep this duplicative code here or not. + * @param ReferenceSearchField $searchField + * @return SearchQueryFragment + */ + public static function resolveReferenceField(ReferenceSearchField $searchField) + { + if (empty($searchField->getValues())) { + throw new \InvalidArgumentException("Search field " . $searchField->getField() . " does not have a value to search on"); + } + + $searchFragment = new SearchQueryFragment(); + $values = $searchField->getValues(); + $clauses = []; + + foreach ($values as $value) { + /** @var ReferenceSearchValue $value */ + $clauses[] = $searchField->getField() . ' = ?'; + $searchFragment->addBoundValue($value->getId()); + } + + if (count($clauses) > 1) { + $multipleClause = $searchField->isAnd() ? " AND " : " OR "; + $searchFragment->setFragment("(" . implode($multipleClause, $clauses) . ")"); + } else { + $searchFragment->setFragment($clauses[0]); + } + return $searchFragment; + } + + /** + * Resolves a TokenSearchField to its corresponding value. + * @param TokenSearchField $searchField + * @return SearchQueryFragment + */ + public static function resolveTokenField(TokenSearchField $searchField) + { + if (empty($searchField->getValues())) { + throw new \InvalidArgumentException("Search field " . $searchField->getField() . " does not have a value to search on"); + } + + $searchFragment = new SearchQueryFragment(); + $modifier = $searchField->getModifier(); // we aren't going to deal with modifiers just yet + $values = $searchField->getValues(); + $clauses = []; + + foreach ($values as $value) { + /** @var TokenSearchValue $value */ + $clauses[] = $searchField->getField() . ' = ?'; + // TODO: adunsulag when we better understand Token's we will improve this process of how we resolve the token + // field to its representative bound value + $searchFragment->addBoundValue($value->getCode()); + } + + if (count($clauses) > 1) { + $multipleClause = $searchField->isAnd() ? " AND " : " OR "; + $searchFragment->setFragment("(" . implode($multipleClause, $clauses) . ")"); + } else { + $searchFragment->setFragment($clauses[0]); + } + return $searchFragment; + } + + /** + * Given a search field and any modifier's it may have it converts it to the corresponding SearchQueryFragment + * @param StringSearchField $searchField + * @return SearchQueryFragment + */ + public static function resolveStringSearchField(StringSearchField $searchField) + { + if (empty($searchField->getValues())) { + throw new \InvalidArgumentException("Search field " . $searchField->getField() . " does not have a value to search on"); + } + + $clauses = []; + $searchFragment = new SearchQueryFragment(); + $modifier = $searchField->getModifier(); + $values = $searchField->getValues(); + foreach ($values as $value) { + if ($modifier === 'prefix') { + array_push($clauses, $searchField->getField() . ' LIKE ?'); + $searchFragment->addBoundValue($value . "%"); + } else if ($modifier === 'contains') { + array_push($clauses, $searchField->getField() . ' LIKE ?'); + $searchFragment->addBoundValue('%' . $value . '%'); + } else if ($modifier === 'exact') { + // not we may want to grab the specific table collation here in order to improve performance + // and avoid db casting... + array_push($clauses, "BINARY " . $searchField->getField() . ' = ?'); + $searchFragment->addBoundValue($value); + } + } + if (count($clauses) > 1) { + $multipleClause = $searchField->isAnd() ? " AND " : " OR "; + $searchFragment->setFragment("(" . implode($multipleClause, $clauses) . ")"); + } else { + $searchFragment->setFragment($clauses[0]); + } + return $searchFragment; + } + + /** + * Retrieves the date search field date format that should be used for the type of date. + * @param $dateType + * @return string + */ + public static function getDateFieldFormatForDateType($dateType) + { + $format = "Y-m-d H:i:s"; // default format is datetime + if ($dateType == DateSearchField::DATE_TYPE_DATE) { + $format = "Y-m-d"; + } + return $format; + } +} diff --git a/src/FHIR/FhirSearchParameterType.php b/src/Services/Search/SearchFieldType.php similarity index 62% rename from src/FHIR/FhirSearchParameterType.php rename to src/Services/Search/SearchFieldType.php index d07720f8054..6dd9f29f679 100644 --- a/src/FHIR/FhirSearchParameterType.php +++ b/src/Services/Search/SearchFieldType.php @@ -1,7 +1,7 @@ + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +final class SearchModifier +{ + public const CONTAINS = "contains"; + public const EXACT = "exact"; + public const PREFIX = "prefix"; // default for string + public const MISSING = "missing"; +} diff --git a/src/Services/Search/SearchQueryFragment.php b/src/Services/Search/SearchQueryFragment.php new file mode 100644 index 00000000000..77c6822f257 --- /dev/null +++ b/src/Services/Search/SearchQueryFragment.php @@ -0,0 +1,80 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +class SearchQueryFragment +{ + /** + * @var string + */ + private $fragment; + + /** + * @var mixed[] + */ + private $boundValues; + + + public function __construct($fragment = "", $boundValues = null) + { + $this->setFragment($fragment); + $this->boundValues = is_array($boundValues) ? $boundValues : []; + } + + /** + * @return mixed[] + */ + public function getBoundValues(): array + { + return $this->boundValues; + } + + /** + * @param mixed $boundValue + */ + public function addBoundValue($boundValue): void + { + $this->boundValues[] = $boundValue; + } + + /** + * @return string + */ + public function getFragment(): string + { + return $this->fragment; + } + + /** + * @param string $fragment + */ + public function setFragment(string $fragment): void + { + $this->fragment = $fragment; + } + + public function setQueryFragment(string $fragment, $boundValue) + { + $this->setFragment($fragment); + $this->addBoundValue($boundValue); + } + + /** + * Helper statement useful for debugging the query fragment. + * @return string + */ + public function __toString() + { + return "(fragment=" . $this->getFragment() . ", boundValues=[" . implode(",", $this->boundValues) . "])"; + } +} diff --git a/src/Services/Search/ServiceField.php b/src/Services/Search/ServiceField.php new file mode 100644 index 00000000000..9ab0930607e --- /dev/null +++ b/src/Services/Search/ServiceField.php @@ -0,0 +1,46 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +use OpenEMR\Common\Uuid\UuidRegistry; + +class ServiceField +{ + private $field; + private $type; + + const TYPE_STRING = "string"; + const TYPE_NUMBER = "number"; + const TYPE_UUID = "uuid"; + + public function __construct($field, $type = self::TYPE_STRING) + { + $this->field = $field; + $this->type = $type; + } + + /** + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return mixed + */ + public function getField() + { + return $this->field; + } +} diff --git a/src/Services/Search/StringSearchField.php b/src/Services/Search/StringSearchField.php new file mode 100644 index 00000000000..d59a6c0f7a0 --- /dev/null +++ b/src/Services/Search/StringSearchField.php @@ -0,0 +1,26 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +use OpenEMR\Services\Search\SearchFieldType; + +class StringSearchField extends BasicSearchField +{ + private const VALID_MODIFIERS = [SearchModifier::CONTAINS, SearchModifier::EXACT, SearchModifier::PREFIX]; + public function __construct($field, $values, $modifier = null, $isAnd = true) + { + if (array_search($modifier, self::VALID_MODIFIERS) === false) { + $modifier = SearchModifier::PREFIX; + } + parent::__construct($field, SearchFieldType::STRING, $field, $values, $modifier, $isAnd); + } +} diff --git a/src/Services/Search/TableSearchProcessor.php b/src/Services/Search/TableSearchProcessor.php new file mode 100644 index 00000000000..3709f9e78f2 --- /dev/null +++ b/src/Services/Search/TableSearchProcessor.php @@ -0,0 +1,149 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +use OpenEMR\Validators\ProcessingResult; + +class TableSearchProcessor +{ + public function buildQuery($selectFields, $joinClauses, $search, $isAndCondition = true) + { + $sqlBindArray = array(); + +// $selectFields = $this->getSelectFields(); + + $selectFields = array_combine($selectFields, $selectFields); // make it a dictionary so we can add/remove this. + $from = [$this->getTable()]; + + $sql = "SELECT " . implode(",", array_keys($selectFields)) . " FROM " . implode(",", $from); + + $join = $this->getSelectJoinClauses(); + + $whereClauses = array( + 'and' => [] + ,'or' => [] + ); + + if (!empty($search)) { + // make sure all the parameters are actual search fields and clean up any field that is a uuid + foreach ($search as $key => $field) { + if (!$field instanceof ISearchField) { + throw new \InvalidArgumentException("Method called with invalid parameter. Expected SearchField object for parameter '" . $key . "'"); + } + $whereType = $isAndCondition ? "and" : "or"; + + $whereClauses[$whereType][] = SearchFieldStatementResolver::getStatementForSearchField($field); + } + } + $where = ''; + + if (!(empty($whereClauses['or']) && empty($whereClauses['and']))) { + $where = " WHERE "; + $andClauses = []; + foreach ($whereClauses['and'] as $clause) { + $andClauses[] = $clause->getFragment(); + $sqlBindArray = array_merge($sqlBindArray, $clause->getBoundValues()); + } + $where = empty($andClauses) ? $where : $where . implode(" AND ", $andClauses); + + $orClauses = []; + foreach ($whereClauses['or'] as $clause) { + $orClauses[] = $clause->getFragment(); + $sqlBindArray = array_merge($sqlBindArray, $clause->getBoundValues()); + } + $where = empty($orClauses) ? $where : $where . "(" . implode(" OR ", $orClauses) . ")"; + } + + $records = $this->selectHelper($sql, [ + 'join' => $join + ,'where' => $where + ,'data' => $sqlBindArray + ]); + } + /** + * Returns a list of records matching the search criteria. + * Search criteria is conveyed by array where key = field/column name, value is an ISearchField + * If an empty array of search criteria is provided, all records are returned. + * + * The search will grab the intersection of all possible values if $isAndCondition is true, otherwise it returns + * the union (logical OR) of the search. + * + * More complicated searches with various sub unions / intersections can be accomplished through a CompositeSearchField + * that allows you to combine multiple search clauses on a single search field. + * + * @param ISearchField[] $search Hashmap of string => ISearchField where the key is the field name of the search field + * @param bool $isAndCondition Whether to join each search field with a logical OR or a logical AND. + * @return ProcessingResult The results of the search. + */ + public function search($selectFields, $search, $isAndCondition = true) + { + $sqlBindArray = array(); + +// $selectFields = $this->getSelectFields(); + + $selectFields = array_combine($selectFields, $selectFields); // make it a dictionary so we can add/remove this. + $from = [$this->getTable()]; + + $sql = "SELECT " . implode(",", array_keys($selectFields)) . " FROM " . implode(",", $from); + + $join = $this->getSelectJoinClauses(); + + $whereClauses = array( + 'and' => [] + ,'or' => [] + ); + + if (!empty($search)) { + // make sure all the parameters are actual search fields and clean up any field that is a uuid + foreach ($search as $key => $field) { + if (!$field instanceof ISearchField) { + throw new \InvalidArgumentException("Method called with invalid parameter. Expected SearchField object for parameter '" . $key . "'"); + } + $whereType = $isAndCondition ? "and" : "or"; + + $whereClauses[$whereType][] = SearchFieldStatementResolver::getStatementForSearchField($field); + } + } + $where = ''; + + if (!(empty($whereClauses['or']) && empty($whereClauses['and']))) { + $where = " WHERE "; + $andClauses = []; + foreach ($whereClauses['and'] as $clause) { + $andClauses[] = $clause->getFragment(); + $sqlBindArray = array_merge($sqlBindArray, $clause->getBoundValues()); + } + $where = empty($andClauses) ? $where : $where . implode(" AND ", $andClauses); + + $orClauses = []; + foreach ($whereClauses['or'] as $clause) { + $orClauses[] = $clause->getFragment(); + $sqlBindArray = array_merge($sqlBindArray, $clause->getBoundValues()); + } + $where = empty($orClauses) ? $where : $where . "(" . implode(" OR ", $orClauses) . ")"; + } + + $records = $this->selectHelper($sql, [ + 'join' => $join + ,'where' => $where + ,'data' => $sqlBindArray + ]); + + $processingResult = new ProcessingResult(); + foreach ($records as $row) { + $resultRecord = $this->createResultRecordFromDatabaseResult($row); + $processingResult->addData($resultRecord); + } + + return $processingResult; + } +} diff --git a/src/Services/Search/TokenSearchField.php b/src/Services/Search/TokenSearchField.php new file mode 100644 index 00000000000..427dc1fbf4e --- /dev/null +++ b/src/Services/Search/TokenSearchField.php @@ -0,0 +1,52 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +use OpenEMR\Services\Search\SearchFieldType; + +class TokenSearchField extends BasicSearchField +{ + /** + * @var boolean True if the token represents a UUID that is a binary field in the database + */ + private $isUUID; + + public function __construct($field, $values, $isUUID = false) + { + $this->isUUID = $isUUID; + parent::__construct($field, SearchFieldType::TOKEN, $field, $values); + } + + public function setValues(array $values) + { + $convertedFields = []; + + foreach ($values as $value) { + if ($value instanceof TokenSearchValue) { + $convertedFields[] = $value; + continue; + } + + $convertedFields[] = $this->createTokenSearchValue($value); + } + parent::setValues($convertedFields); + } + + private function createTokenSearchValue($value) + { + if (!is_string($value)) { + throw new \InvalidArgumentException("Token value must be a valid string"); + } + + return TokenSearchValue::buildFromFHIRString($value, $this->isUUID); + } +} diff --git a/src/Services/Search/TokenSearchValue.php b/src/Services/Search/TokenSearchValue.php new file mode 100644 index 00000000000..c0caf963e98 --- /dev/null +++ b/src/Services/Search/TokenSearchValue.php @@ -0,0 +1,114 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Services\Search; + +use OpenEMR\Common\Uuid\UuidRegistry; + +class TokenSearchValue +{ + /** + * @var string|int|float + */ + private $code; + + /** + * @var string + */ + private $system; + + /** + * @var + */ + private $isUuid; + + public function __construct($code, $system = null, $isUuid = false) + { + $this->isUuid = $isUuid; + $this->setCode($code); + $this->system = $system; + } + + /** + * Given a FHIR code system string, return the FHIR class value. + * @param $codeSystemValue + * @param @isUuid Whether the code system value represents a unique uuid in the system and should be converted to binary + * @return TokenSearchValue + */ + public static function buildFromFHIRString($codeSystemValue, $isUuid = false) + { + $code = $codeSystemValue; + $valueParts = explode("|", $codeSystemValue); + if (count($valueParts) == 1) { + $system = null; + } else { + $system = $valueParts[0]; + $code = end($valueParts); + } + return new TokenSearchValue($code, $system, $isUuid); + } + + /** + * @return float|int|string + */ + public function getCode() + { + return $this->code; + } + + /** + * @param float|int|string $code + * @throws \InvalidArgumentException if the class search value is a UUID the code value must be a valid UUID string + */ + public function setCode($code): void + { + if ($this->isUuid) { + if (UuidRegistry::isValidStringUUID($code)) { + $this->code = UuidRegistry::uuidToBytes($code); + } else { + throw new \InvalidArgumentException("UUID columns must be a valid UUID string"); + } + } else { + $this->code = $code; + } + } + + /** + * @return string + */ + public function getSystem(): ?string + { + return $this->system; + } + + /** + * @param string $system + */ + public function setSystem(string $system): void + { + $this->system = $system; + } + + public function getHumanReadableCode() + { + $code = $this->getCode(); + if ($this->isUuid && !empty($code)) { + return UuidRegistry::uuidToString($code); + } else { + return $code; + } + } + + public function __toString() + { + return ($this->getCode() ? $this->getHumanReadableCode() : "") . "|" . ($this->getSystem() ? $this->getSystem() : ""); + } +} diff --git a/tests/Tests/Api/PractitionerApiTest.php b/tests/Tests/Api/PractitionerApiTest.php index 24ba2cdebed..ca8a6c59b6c 100644 --- a/tests/Tests/Api/PractitionerApiTest.php +++ b/tests/Tests/Api/PractitionerApiTest.php @@ -26,7 +26,7 @@ protected function setUp(): void { $baseUrl = getenv("OPENEMR_BASE_URL_API", true) ?: "https://localhost"; $this->testClient = new ApiTestClient($baseUrl, false); - $this->testClient->setAuthToken(ApiTestClient::OPENEMR_AUTH_ENDPOINT); + $this->testClient->setAuthToken(ApiTestClient::OPENEMR_AUTH_ENDPOINT); $this->fixtureManager = new PractitionerFixtureManager(); $this->practitionerRecord = (array) $this->fixtureManager->getSinglePractitionerFixture(); diff --git a/tests/Tests/Fixtures/FixtureManager.php b/tests/Tests/Fixtures/FixtureManager.php index 8412a198fef..be9c320935a 100644 --- a/tests/Tests/Fixtures/FixtureManager.php +++ b/tests/Tests/Fixtures/FixtureManager.php @@ -3,6 +3,7 @@ namespace OpenEMR\Tests\Fixtures; use OpenEMR\Common\Uuid\UuidRegistry; +use OpenEMR\Common\Database\QueryUtils; use Ramsey\Uuid\Uuid; /** @@ -28,6 +29,11 @@ class FixtureManager private $patientFixtures; private $fhirPatientFixtures; + /** + * @var + */ + private $fhirAllergyIntoleranceFixtures; + public function __construct() { $this->patientFixtures = $this->loadJsonFile("patients.json"); @@ -87,7 +93,15 @@ private function installFixtures($tableName, $fixtures) $sqlBinds = array(); foreach ($fixture as $field => $fieldValue) { - $sqlColumnValues .= $field . " = ?, "; + // not sure I like table specific comparisons here... + if ($tableName == 'lists' && $field == 'pid') { + $sqlColumnValues .= "pid = (SELECT pid FROM patient_data WHERE pubpid=? LIMIT 1) ,"; + } else { + $sqlColumnValues .= $field . " = ?, "; + } +// if ($field === 'uuid') { +// $fieldValue = UuidRegistry::uuidToBytes($fieldValue); +// } array_push($sqlBinds, $fieldValue); } @@ -101,10 +115,9 @@ private function installFixtures($tableName, $fixtures) $uuidPatient = $this->getUuid("patient_data"); array_push($sqlBinds, $uuidPatient); } - $sqlColumnValues = rtrim($sqlColumnValues, " ,"); - $isInserted = sqlInsert($sqlInsert . $sqlColumnValues, $sqlBinds); + $isInserted = QueryUtils::sqlInsert($sqlInsert . $sqlColumnValues, $sqlBinds); if ($isInserted) { $insertCount += 1; } @@ -136,6 +149,14 @@ public function getPatientFixtures() return $this->patientFixtures; } + public function getAllergyIntoleranceFixtures() + { + if (empty($this->fhirAllergyIntoleranceFixtures)) { + $this->fhirAllergyIntoleranceFixtures = $this->loadJsonFile("allergy-intolerance.json"); + } + return $this->fhirAllergyIntoleranceFixtures; + } + /** * @return random single entry from an array. */ @@ -190,6 +211,33 @@ public function removePatientFixtures() sqlStatement($delete, array($bindVariable)); } + public function installAllergyIntoleranceFixtures() + { + $this->installPatientFixtures(); + $installed = $this->installFixtures("lists", $this->getAllergyIntoleranceFixtures()); + if ($installed < 1) { + throw new \RuntimeException("Failed to install allergy intolerance fixtures"); + } + } + + public function removeAllergyIntoleranceFixtures() + { + $pubpid = self::PATIENT_FIXTURE_PUBPID_PREFIX . "%"; + $pids = QueryUtils::fetchTableColumn( + "SELECT `pid` FROM `patient_data` WHERE `pubpid` LIKE ?", + 'pid', + [$pubpid] + ); + + if (!empty($pids)) { + $count = count($pids) - 1; + $where = "WHERE pid = ? " . str_repeat("OR pid = ? ", $count); + $sqlStatement = "DELETE FROM `lists` " . $where; + QueryUtils::sqlStatementThrowException($sqlStatement, $pids); + } + $this->removePatientFixtures(); + } + /** * Returns an unregistered/unlogged UUID for use in testing fixtures * @return uuid4 string value diff --git a/tests/Tests/Fixtures/allergy-intolerance.json b/tests/Tests/Fixtures/allergy-intolerance.json new file mode 100644 index 00000000000..8cc574576a9 --- /dev/null +++ b/tests/Tests/Fixtures/allergy-intolerance.json @@ -0,0 +1,22 @@ +[{ + "type": "allergy", + "title": "Ampicillin", + "pid": "test-fixture-789456", + "reaction": "hives", + "verification": "unconfirmed", + "diagnosis": "RXCUI:7980", + "occurrence": 1, + "begdate": "1980-05-10" +} + ,{ + "type": "allergy", + "title": "Penicillin G", + "pid": "test-fixture-8", + "reaction": "hives", + "verification": "confirmed", + "diagnosis": "RXCUI:733", + "occurrence": 1, + "begdate": "1980-05-10" + +} +] \ No newline at end of file diff --git a/tests/Tests/Fixtures/patients.json b/tests/Tests/Fixtures/patients.json index 17c0a3a2586..3a176e2d08f 100644 --- a/tests/Tests/Fixtures/patients.json +++ b/tests/Tests/Fixtures/patients.json @@ -19,7 +19,10 @@ "DOB" : "1957-01-09", "sex" : "Male", "status": "married", - "drivers_license": "1234567890" + "drivers_license": "1234567890", + "race": "white", + "ethnicity": "hisp_or_latin", + "language": "latin" }, { "pubpid" : "test-fixture-8", @@ -41,7 +44,10 @@ "DOB" : "1967-06-04", "sex" : "Female", "status" : "married", - "drivers_license": "0234567891" + "drivers_license": "0234567891", + "race": "amer_ind_or_alaska_native", + "ethnicity": "not_hisp_or_latin", + "language": "deaf" }, { "pubpid" : "test-fixture-17", @@ -63,7 +69,10 @@ "DOB" : "1945-02-14", "sex" : "Male", "status": "single", - "drivers_license": "92345678910" + "drivers_license": "92345678910", + "race": "Asian", + "ethnicity": "not_hisp_or_latin", + "language": "german" }, { "pubpid" : "test-fixture-18", @@ -85,7 +94,10 @@ "DOB" : "1940-12-16", "status" : "married", "sex": "Male", - "drivers_license": "23456789109" + "drivers_license": "23456789109", + "race": "white", + "ethnicity": "not_hisp_or_latin", + "language": "English" }, { "pubpid" : "test-fixture-22", @@ -107,7 +119,10 @@ "DOB" : "1933-03-22", "sex" : "Female", "status" : "single", - "drivers_license": "34567891092" + "drivers_license": "34567891092", + "race": "black_or_afri_amer", + "ethnicity": "hisp_or_latin", + "language": "latin" }, { "pubpid" : "test-fixture-25", @@ -129,7 +144,10 @@ "DOB" : "1977-05-02", "sex": "Male", "status" : "single", - "drivers_license": "45678910923" + "drivers_license": "45678910923", + "race": "declne_to_specfy", + "ethnicity": "hisp_or_latin", + "language": "declne_to_specfy" }, { "pubpid" : "test-fixture-26", @@ -151,7 +169,10 @@ "DOB" : "1966-04-28", "sex" : "Male", "status" : "single", - "drivers_license": "56789109234" + "drivers_license": "56789109234", + "race": "white", + "ethnicity": "not_hisp_or_latin", + "language": "danish" }, { "pubpid" : "test-fixture-30", @@ -173,7 +194,10 @@ "DOB" : "1961-12-11", "sex" : "Male", "status" : "single", - "drivers_license": "67891092345" + "drivers_license": "67891092345", + "race": "white", + "ethnicity": "not_hisp_or_latin", + "language": "russian" }, { "pubpid" : "test-fixture-34", @@ -195,7 +219,10 @@ "DOB" : "1955-04-12", "sex": "Male", "status" : "married", - "drivers_license": "78910923456" + "drivers_license": "78910923456", + "race": "white", + "ethnicity": "not_hisp_or_latin", + "language": "polish" }, { "pubpid" : "test-fixture-35", @@ -217,7 +244,10 @@ "DOB" : "1968-08-11", "sex" : "Female", "status" : "married", - "drivers_license": "89109234567" + "drivers_license": "89109234567", + "race": "white", + "ethnicity": "not_hisp_or_latin", + "language": "greek" }, { "pubpid" : "test-fixture-40", @@ -239,7 +269,10 @@ "DOB" : "1952-04-03", "sex": "Male", "status" : "domestic partner", - "drivers_license": "91092345678" + "drivers_license": "91092345678", + "race": "Asian", + "ethnicity": "hisp_or_latin", + "language": "armenian" }, { "pubpid" : "test-fixture-1001", @@ -261,6 +294,34 @@ "DOB" : "1960-01-01", "sex" : "Male", "status" : "married", - "drivers_license": "10923456789" + "drivers_license": "10923456789", + "race": "white", + "ethnicity": "not_hisp_or_latin", + "language": "german" + }, + { + "pubpid" : "test-fixture-1002", + "title" : "Mr.", + "fname" : "John", + "mname": "Colin", + "lname" : "Adams", + "ss" : "123456789", + "street" : "1234 2nd Shorter", + "postal_code" : "50641", + "city" : "Sacramento", + "state" : "CA", + "contact_relationship" : "Mr. Brent Kenneth", + "phone_contact" : "(619) 952-8321", + "phone_home" : "(619) 952-8322", + "phone_biz" : "(619) 952-8323", + "phone_cell" : "(888) 480-5054", + "email" : "jcadams@someotherfirm.com", + "DOB" : "1955-08-12", + "sex" : "Unknown", + "status" : "married", + "drivers_license": "12943276589", + "race": "black_or_afri_amer", + "ethnicity": "not_hisp_or_latin", + "language": "declne_to_specfy" } ] diff --git a/tests/Tests/RestControllers/FHIR/FhirOrganizationRestControllerTest.php b/tests/Tests/RestControllers/FHIR/FhirOrganizationRestControllerTest.php index 1e59998cd14..561c81da5bd 100644 --- a/tests/Tests/RestControllers/FHIR/FhirOrganizationRestControllerTest.php +++ b/tests/Tests/RestControllers/FHIR/FhirOrganizationRestControllerTest.php @@ -18,7 +18,11 @@ class FhirOrganizationRestControllerTest extends TestCase { + /** + * @var FhirOrganizationRestController + */ private $fhirOrganizationController; + private $fixtureManager; private $fhirFixture; @@ -94,6 +98,7 @@ public function testGetOne() $fhirId = $actualResult['uuid']; $actualResult = $this->fhirOrganizationController->getOne($fhirId); + $this->assertNotEmpty($actualResult, "getOne() should have returned a result"); $this->assertEquals($fhirId, $actualResult->getId()); } @@ -102,7 +107,7 @@ public function testGetOneNoMatch() $this->fhirOrganizationController->post($this->fhirFixture); $actualResult = $this->fhirOrganizationController->getOne("not-a-matching-uuid"); - $this->assertGreaterThan(0, count($actualResult['validationErrors'])); + $this->assertEquals(1, count($actualResult['validationErrors'])); } public function testGetAll() diff --git a/tests/Tests/RestControllers/FHIR/FhirPractitionerRestControllerTest.php b/tests/Tests/RestControllers/FHIR/FhirPractitionerRestControllerTest.php index d5624b19b36..6d9e13276d5 100644 --- a/tests/Tests/RestControllers/FHIR/FhirPractitionerRestControllerTest.php +++ b/tests/Tests/RestControllers/FHIR/FhirPractitionerRestControllerTest.php @@ -18,7 +18,11 @@ class FhirPractitionerRestControllerTest extends TestCase { + /** + * @var FhirPractitionerRestController + */ private $fhirPractitionerController; + private $fixtureManager; private $fhirFixture; diff --git a/tests/Tests/Services/FHIR/FhirAllergyIntoleranceServiceQueryTest.php b/tests/Tests/Services/FHIR/FhirAllergyIntoleranceServiceQueryTest.php new file mode 100644 index 00000000000..1ccf2004477 --- /dev/null +++ b/tests/Tests/Services/FHIR/FhirAllergyIntoleranceServiceQueryTest.php @@ -0,0 +1,177 @@ + + * @copyright Copyright (c) 2021 Stephen Nielson + * @license https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3 + */ + +namespace OpenEMR\Tests\Services\FHIR; + +use OpenEMR\Common\Database\QueryUtils; +use OpenEMR\Common\Uuid\UuidRegistry; +use OpenEMR\FHIR\R4\FHIRDomainResource\FHIRAllergyIntolerance; +use OpenEMR\Services\FHIR\FhirAllergyIntoleranceService; +use OpenEMR\Services\FHIR\FhirUrlResolver; +use PHPUnit\Framework\TestCase; +use OpenEMR\Tests\Fixtures\FixtureManager; + +class FhirAllergyIntoleranceServiceQueryTest extends TestCase +{ + /** + * @var FixtureManager + */ + private $fixtureManager; + private $patientFixture; + private $fhirPatientFixture; + + /** + * @var FhirAllergyIntoleranceService + */ + private $fhirService; + + const FHIR_BASE_URL = "/api/fhirs/default"; + + private $apiBaseURL; + + public function __construct(?string $name = null, array $data = [], $dataName = '') + { + parent::__construct($name, $data, $dataName); + + $baseUrl = getenv("OPENEMR_BASE_URL_API", true) ?: "https://localhost"; + $fhirUrl = $baseUrl . "/apis/default/fhir/"; + $this->apiBaseURL = $fhirUrl; + } + + protected function setUp(): void + { + + $this->fixtureManager = new FixtureManager(); + $this->fixtureManager->installAllergyIntoleranceFixtures(); + $this->fhirService = new FhirAllergyIntoleranceService($this->apiBaseURL); + } + + protected function tearDown(): void + { + $this->fixtureManager->removeAllergyIntoleranceFixtures(); + } + + /** + * Executes assertions against a 'GetAll' AllergyIntolerance Search processing result + * @param $processingResult The OpenEMR Processing Result + * @param $isExpectedToHaveAResult Indicates if the result is expected to have at least one search result + */ + private function assertGetAllSearchResults($processingResult, $isExpectedToHaveAResult = true) + { + $this->assertTrue($processingResult->isValid()); + + if ($isExpectedToHaveAResult) { + $this->assertGreaterThan(0, count($processingResult->getData())); + } else { + $this->assertEquals(0, count($processingResult->getData())); + } + } + + private function getReferenceURL($reference) + { + $url = $this->apiBaseURL . $reference; + return $url; + } + + /** + * PHPUnit Data Provider for FHIR AllergyIntolerance searches + */ + public function searchParameterPatientReferenceDataProvider() + { + return [ + ['patient', "Patient/:uuid1"], + ['patient', ":uuid1"], +// make sure we can handle different ids + ['patient', "Patient/:uuid2"], + ['patient', ":uuid2"], + + // full URL resolution + ["patient", $this->getReferenceURL("Patient/:uuid1")], + ["patient", $this->getReferenceURL("Patient/:uuid2")], + + // select reference value on multiple voices + ['patient', "Patient/:uuid1,Patient/:uuid2"], + ['patient', ":uuid1,:uuid2"], + ]; + } + + /** + * Tests getAll queries + * @covers ::getAll + * @covers ::searchForOpenEMRRecords + * @dataProvider searchParameterPatientReferenceDataProvider + */ + public function testGetAllPatientReference($parameterName, $parameterValue) + { + $pubpid = FixtureManager::PATIENT_FIXTURE_PUBPID_PREFIX . "%"; + $select = "SELECT `lists`.`pid`,`patient_data`.`uuid` FROM `lists` INNER JOIN `patient_data` ON `patient_data`.`pid` = " + . "`lists`.`pid` WHERE `type`='allergy' and `patient_data`.`pubpid` LIKE ? LIMIT 2"; + $records = QueryUtils::fetchTableColumn($select, 'uuid', [$pubpid]); + $uuids = array_map(function ($v) { + return UuidRegistry::uuidToString($v); + }, $records); + list($uuidPatient1, $uuidPatient2) = $uuids; + + // replace any values that we will use for searching + $parameterValue = str_replace(":uuid1", $uuidPatient1, $parameterValue); + $parameterValue = str_replace(":uuid2", $uuidPatient2, $parameterValue); + + $fhirSearchParameters = [$parameterName => $parameterValue]; + $processingResult = $this->fhirService->getAll($fhirSearchParameters); + $this->assertGetAllSearchResults($processingResult); + } + + /** + * Tests getAll queries for the _id search parameter. Since we can't combine a dataProvider with our test fixture + * installation, we run this test separately + * @covers ::getAll + * @covers ::searchForOpenEMRRecords + */ + public function testGetAllWithUuid() + { + $select = "SELECT `uuid` FROM `lists` WHERE `type`='allergy' LIMIT 1"; + $allergy_uuid = QueryUtils::fetchSingleValue($select, 'uuid'); + $fhirSearchParameters = ['_id' => UuidRegistry::uuidToString($allergy_uuid)]; + $processingResult = $this->fhirService->getAll($fhirSearchParameters); + $this->assertGetAllSearchResults($processingResult); + } + + /** + * Uses the getAll method so we can't pass unless that is working. + * @covers ::getOne + */ + public function testGetOne() + { + $actualResult = $this->fhirService->getAll([]); + $this->assertNotEmpty($actualResult->getData(), "Get All should have returned a result"); + + $this->assertInstanceOf(FHIRAllergyIntolerance::class, $actualResult->getData()[0], "Instance returned should have been the correct AllergyIntolerance class"); + $expectedId = $actualResult->getData()[0]->getId()->getValue(); + + $actualResult = $this->fhirService->getOne($expectedId); + $this->assertGreaterThan(0, count($actualResult->getData()), "Data array should have at least one record"); + $actualId = $actualResult->getData()[0]->getId()->getValue(); + + $this->assertEquals($expectedId, $actualId); + } + + /** + * @covers ::getOne with an invalid uuid + */ + public function testGetOneInvalidUuid() + { + $actualResult = $this->fhirService->getOne('not-a-uuid'); + $this->assertGreaterThan(0, count($actualResult->getValidationMessages())); + $this->assertEquals(0, count($actualResult->getInternalErrors())); + $this->assertEquals(0, count($actualResult->getData())); + } +} diff --git a/tests/Tests/Services/FHIR/FhirPatientServiceMappingTest.php b/tests/Tests/Services/FHIR/FhirPatientServiceMappingTest.php index 9541bd76dc4..ceee2ea9c27 100644 --- a/tests/Tests/Services/FHIR/FhirPatientServiceMappingTest.php +++ b/tests/Tests/Services/FHIR/FhirPatientServiceMappingTest.php @@ -47,8 +47,8 @@ protected function setUp(): void private function assertFhirPatientResource(FHIRPatient $fhirPatientResource, $sourcePatientRecord) { $this->assertEquals($sourcePatientRecord['uuid'], $fhirPatientResource->getId()); - $this->assertEquals(1, $fhirPatientResource->getMeta()['versionId']); - $this->assertNotEmpty($fhirPatientResource->getMeta()['lastUpdated']); + $this->assertEquals(1, $fhirPatientResource->getMeta()->getVersionId()); + $this->assertNotEmpty($fhirPatientResource->getMeta()->getLastUpdated()); $this->assertEquals('generated', $fhirPatientResource->getText()['status']); $this->assertNotEmpty($fhirPatientResource->getText()['div']); diff --git a/tests/Tests/Services/FHIR/FhirPatientServiceQueryTest.php b/tests/Tests/Services/FHIR/FhirPatientServiceQueryTest.php index 4239b2f740d..757c241a855 100644 --- a/tests/Tests/Services/FHIR/FhirPatientServiceQueryTest.php +++ b/tests/Tests/Services/FHIR/FhirPatientServiceQueryTest.php @@ -2,6 +2,7 @@ namespace OpenEMR\Tests\Services\FHIR; +use OpenEMR\Common\Uuid\UuidRegistry; use PHPUnit\Framework\TestCase; use OpenEMR\Tests\Fixtures\FixtureManager; use OpenEMR\Services\FHIR\FhirPatientService; @@ -22,6 +23,10 @@ class FhirPatientServiceQueryTest extends TestCase private $fixtureManager; private $patientFixture; private $fhirPatientFixture; + + /** + * @var FhirPatientService + */ private $fhirPatientService; protected function setUp(): void @@ -57,15 +62,81 @@ private function assertGetAllSearchResults($processingResult, $isExpectedToHaveA */ public function searchParameterDataProvider() { + return [ - ['address', 'Avenue'], - ['address', '90210'], - ['address', 'San Diego'], - ['address', 'CA'], - ['address-city', 'San Diego'], - ['address-postalcode', '90210'], - ['address-state', 'CA'], - ['birthdate', '1960-01-01'], + ['identifier', 'test-fixture-789456'], + ['gender', 'male'], + ['gender', 'female'], + ['gender', 'unknown'], // handle unknown gender's + + // need to do a bunch of identifier tests to make sure our token searching is working. + + ['address:contains', 'Avenue'], + ['address:prefix', '789'], + ['address', '789'], // default is :prefix + ['address:contains', 'Diego'], + ['address:exact', '400 West Broadway'], + + // name searches + ['name', 'Ilias'], // first name + ['name', 'Ilias'], + ['name', 'Johnny'], + ['name', 'Jenane'], + ['name', 'Mr.'], // title + + // if someone does a full timestamp birthdate, this tests the full timestamp parser even though + // birthdate is just a date not a datetime + ['birthdate', '1960-01-01T13:25:60'], + + // now combinations of birthdates + ['birthdate', '1945'], // search by year + ['birthdate', '1945-02'], // search by year, month + ['birthdate', '1945-02-14'], // search by year, month, day + + // now let's do it with our equality search which should be the same + ['birthdate', 'eq1945'], // search by year + ['birthdate', 'eq1945-02'], // search by year, month + ['birthdate', 'eq1945-02-14'], // search by year, month, day + + // now inequality search + ['birthdate', 'ne1945'], // search by year + ['birthdate', 'ne1945-02'], // search by year, month + ['birthdate', 'ne1945-02-14'], // search by year, month, day + + // now we will do less than, only 1 patient in data set has DOB of 1933-03-22 + ['birthdate', 'lt1934'], // search by year + ['birthdate', 'lt1933-04'], // search by year, month + ['birthdate', 'lt1933-03-23'], // search by year, month, day + + // now we will do ends before, only 1 patient in data set has DOB of 1933-03-22 + ['birthdate', 'eb1934'], // search by year + ['birthdate', 'eb1933-04'], // search by year, month + ['birthdate', 'eb1933-03-23'], // search by year, month, day + + // now we will do greater than, only 1 patient in data set has DOB of 1977-05-02 + ['birthdate', 'gt1976'], // search by year + ['birthdate', 'gt1977-04'], // search by year, month + ['birthdate', 'gt1977-05-01'], // search by year, month, day + + // now we will do starts after, only 1 patient in data set has DOB of 1977-05-02 + ['birthdate', 'sa1976'], // search by year + ['birthdate', 'sa1977-04'], // search by year, month + ['birthdate', 'sa1977-05-01'], // search by year, month, day + + // now we will do less than or equal to, only 1 patient in data set has DOB of 1933-03-22 + ['birthdate', 'le1933'], // search by year + ['birthdate', 'le1933-03'], // search by year, month + ['birthdate', 'le1933-03-22'], // search by year, month, day + + // now we will do greater than or equal to, only 1 patient in data set has DOB of 1977-05-02 + ['birthdate', 'ge1977'], // search by year + ['birthdate', 'ge1977-05'], // search by year, month + ['birthdate', 'ge1977-05-02'], // search by year, month, day + + + + // range searches for dates. + ['email', 'info@pennfirm.com'], ['family', 'Moses'], ['gender', 'male'], @@ -84,6 +155,22 @@ public function searchParameterDataProvider() ]; } + /** + * PHPUnit Data Provider for FHIR patient searches + */ + public function searchParameterCompoundDataProvider() + { + return [ + ['birthdate', 'le1960-01-01', 'name:contains', 'lias'], // check operators and comparators work combined + ['birthdate', '1945', 'name', 'Moses'], // check defaults work + ['birthdate', '1945', 'family', 'Moses'], // check birthdate+family works + ['name', 'Ilias', 'birthdate', '1933-03'], // check name+birthdate work + ['gender', 'female', 'name', 'Ilias'], // check gender+name works + ['birthdate', '1933-03', 'gender', 'female'], // check birthdate+gender works + ['name', 'Moses', 'gender', 'male'], + ]; + } + /** * Tests getAll queries * @covers ::getAll @@ -98,18 +185,49 @@ public function testGetAll($parameterName, $parameterValue) } /** + * Tests getAll queries for the _id search parameter. Since we can't combine a dataProvider with our test fixture + * installation, we run this test separately + * @covers ::getAll + * @covers ::searchForOpenEMRRecords + */ + public function testGetAllWithUuid() + { + $select = "SELECT `uuid` FROM `patient_data` WHERE `pubpid`=?"; + $result = sqlStatement($select, ['test-fixture-789456']); + $patient = sqlFetchArray($result); + $fhirSearchParameters = ['_id' => UuidRegistry::uuidToString($patient['uuid'])]; + $processingResult = $this->fhirPatientService->getAll($fhirSearchParameters); + $this->assertGetAllSearchResults($processingResult); + } + + /** + * Tests getAll compound search queries + * @covers ::getAll + * @covers ::searchForOpenEMRRecords + * @dataProvider searchParameterCompoundDataProvider + */ + public function testGetAllCompound($parameter1, $parameter1Value, $parameter2, $parameter2Value) + { + $fhirSearchParameters = [$parameter1 => $parameter1Value, $parameter2 => $parameter2Value]; + $processingResult = $this->fhirPatientService->getAll($fhirSearchParameters); + $this->assertGetAllSearchResults($processingResult); + } + + /** + * Uses the getAll method so we can't pass unless that is working. * @covers ::getOne */ public function testGetOne() { - $actualResult = $this->fhirPatientService->getAll(['state' => 'CA']); - $this->assertGreaterThan(0, $actualResult->getData()); + $actualResult = $this->fhirPatientService->getAll([]); + $this->assertNotEmpty($actualResult->getData(), "Get All should have returned a result"); - $expectedId = $actualResult->getData()[0]->getId(); + $this->assertInstanceOf(FhirPatient::class, $actualResult->getData()[0], "Instance returned should have been the correct patient class"); + $expectedId = $actualResult->getData()[0]->getId()->getValue(); $actualResult = $this->fhirPatientService->getOne($expectedId); $this->assertGreaterThan(0, $actualResult->getData()); - $actualId = $actualResult->getData()[0]->getId(); + $actualId = $actualResult->getData()[0]->getId()->getValue(); $this->assertEquals($expectedId, $actualId); } diff --git a/tests/Tests/Services/PractitionerServiceTest.php b/tests/Tests/Services/PractitionerServiceTest.php index 33c3685d21d..7de49149c24 100644 --- a/tests/Tests/Services/PractitionerServiceTest.php +++ b/tests/Tests/Services/PractitionerServiceTest.php @@ -19,7 +19,11 @@ */ class PractitionerServiceTest extends TestCase { + /** + * @var PractitionerService + */ private $practitionerService; + private $fixtureManager; protected function setUp(): void diff --git a/tests/Tests/Unit/Common/Acl/AclMainTest.php b/tests/Tests/Unit/Common/Acl/AclMainTest.php index 1d56c673897..d8404ca4408 100644 --- a/tests/Tests/Unit/Common/Acl/AclMainTest.php +++ b/tests/Tests/Unit/Common/Acl/AclMainTest.php @@ -37,7 +37,7 @@ public function testAclCheckCore() AclMain::clearGaclCache(); // we assume in our unit tests that our admin user will have access to certain parts of the database - $adminUsername = 'admin'; + $adminUsername = getenv("OE_USER", true) ?: "admin"; $userService = new UserService(); $admin = $userService->getUserByUsername($adminUsername);