1 /* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.server.healthconnect.storage.datatypehelpers; 18 19 import static android.health.connect.Constants.DEFAULT_LONG; 20 import static android.health.connect.Constants.MAXIMUM_ALLOWED_CURSOR_COUNT; 21 import static android.health.connect.accesslog.AccessLog.OperationType.OPERATION_TYPE_DELETE; 22 import static android.health.connect.accesslog.AccessLog.OperationType.OPERATION_TYPE_READ; 23 import static android.health.connect.accesslog.AccessLog.OperationType.OPERATION_TYPE_UPSERT; 24 import static android.health.connect.datatypes.FhirVersion.parseFhirVersion; 25 26 import static com.android.server.healthconnect.storage.HealthConnectDatabase.createTable; 27 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalDataSourceHelper.getDataSourceUuidColumnName; 28 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalDataSourceHelper.getFhirVersionColumnName; 29 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalDataSourceHelper.getReadTableWhereClause; 30 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalResourceIndicesHelper.getCreateMedicalResourceIndicesTableRequest; 31 import static com.android.server.healthconnect.storage.datatypehelpers.MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName; 32 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.LAST_MODIFIED_TIME_COLUMN_NAME; 33 import static com.android.server.healthconnect.storage.utils.SqlJoin.INNER_QUERY_ALIAS; 34 import static com.android.server.healthconnect.storage.utils.SqlJoin.SQL_JOIN_INNER; 35 import static com.android.server.healthconnect.storage.utils.StorageUtils.DELIMITER; 36 import static com.android.server.healthconnect.storage.utils.StorageUtils.INTEGER_NOT_NULL; 37 import static com.android.server.healthconnect.storage.utils.StorageUtils.PRIMARY_AUTOINCREMENT; 38 import static com.android.server.healthconnect.storage.utils.StorageUtils.TEXT_NOT_NULL; 39 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorInt; 40 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorLong; 41 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorLongList; 42 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorString; 43 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorUUID; 44 import static com.android.server.healthconnect.storage.utils.StorageUtils.getListOfHexStrings; 45 import static com.android.server.healthconnect.storage.utils.WhereClauses.LogicalOperator.AND; 46 47 import static java.util.stream.Collectors.toMap; 48 import static java.util.stream.Collectors.toSet; 49 50 import android.annotation.Nullable; 51 import android.content.ContentValues; 52 import android.database.Cursor; 53 import android.database.sqlite.SQLiteDatabase; 54 import android.database.sqlite.SQLiteException; 55 import android.health.connect.Constants; 56 import android.health.connect.DeleteMedicalResourcesRequest; 57 import android.health.connect.MedicalResourceId; 58 import android.health.connect.ReadMedicalResourcesInitialRequest; 59 import android.health.connect.datatypes.FhirResource; 60 import android.health.connect.datatypes.FhirVersion; 61 import android.health.connect.datatypes.MedicalDataSource; 62 import android.health.connect.datatypes.MedicalResource; 63 import android.health.connect.datatypes.MedicalResource.MedicalResourceType; 64 import android.util.Pair; 65 import android.util.Slog; 66 67 import com.android.healthfitness.flags.Flags; 68 import com.android.internal.annotations.VisibleForTesting; 69 import com.android.server.healthconnect.fitness.aggregation.AggregateRecordRequest; 70 import com.android.server.healthconnect.phr.PhrPageTokenWrapper; 71 import com.android.server.healthconnect.phr.ReadMedicalResourcesInternalResponse; 72 import com.android.server.healthconnect.storage.TransactionManager; 73 import com.android.server.healthconnect.storage.TransactionManager.RunnableWithReturn; 74 import com.android.server.healthconnect.storage.request.CreateIndexRequest; 75 import com.android.server.healthconnect.storage.request.CreateTableRequest; 76 import com.android.server.healthconnect.storage.request.DeleteTableRequest; 77 import com.android.server.healthconnect.storage.request.ReadTableRequest; 78 import com.android.server.healthconnect.storage.request.UpsertMedicalResourceInternalRequest; 79 import com.android.server.healthconnect.storage.utils.OrderByClause; 80 import com.android.server.healthconnect.storage.utils.SqlJoin; 81 import com.android.server.healthconnect.storage.utils.StorageUtils; 82 import com.android.server.healthconnect.storage.utils.WhereClauses; 83 import com.android.server.healthconnect.utils.TimeSource; 84 85 import java.time.Instant; 86 import java.util.ArrayList; 87 import java.util.Collections; 88 import java.util.HashMap; 89 import java.util.HashSet; 90 import java.util.List; 91 import java.util.Map; 92 import java.util.Objects; 93 import java.util.Set; 94 import java.util.UUID; 95 import java.util.stream.Collectors; 96 97 /** 98 * Helper class for MedicalResource table. 99 * 100 * @hide 101 */ 102 public final class MedicalResourceHelper { 103 private static final String TAG = "MedicalResourceHelper"; 104 @VisibleForTesting static final String MEDICAL_RESOURCE_TABLE_NAME = "medical_resource_table"; 105 private static final String MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME = "medical_resource_row_id"; 106 @VisibleForTesting static final String FHIR_RESOURCE_TYPE_COLUMN_NAME = "fhir_resource_type"; 107 @VisibleForTesting static final String FHIR_DATA_COLUMN_NAME = "fhir_data"; 108 109 @VisibleForTesting static final String DATA_SOURCE_ID_COLUMN_NAME = "data_source_id"; 110 @VisibleForTesting static final String FHIR_RESOURCE_ID_COLUMN_NAME = "fhir_resource_id"; 111 private static final String LAST_MODIFIED_TIMESTAMP_MEDICAL_RESOURCE_ALIAS = 112 "medical_resource_last_modified_time"; 113 114 private static final String sLastModifiedTimeInInnerQuery = 115 String.format( 116 "%1$s.%2$s AS %3$s", 117 INNER_QUERY_ALIAS, 118 LAST_MODIFIED_TIME_COLUMN_NAME, 119 LAST_MODIFIED_TIMESTAMP_MEDICAL_RESOURCE_ALIAS); 120 121 private static final String sMedicalResourceLastModifiedTime = 122 String.format( 123 "%1$s.%2$s AS %3$s", 124 getMainTableName(), 125 LAST_MODIFIED_TIME_COLUMN_NAME, 126 LAST_MODIFIED_TIMESTAMP_MEDICAL_RESOURCE_ALIAS); 127 128 private static final List<String> sMedicalResourceColumns = 129 List.of( 130 MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME, 131 FHIR_RESOURCE_TYPE_COLUMN_NAME, 132 FHIR_RESOURCE_ID_COLUMN_NAME, 133 FHIR_DATA_COLUMN_NAME, 134 MedicalDataSourceHelper.getFhirVersionColumnName(), 135 MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName(), 136 MedicalDataSourceHelper.getDataSourceUuidColumnName()); 137 138 /** 139 * A block of SQL with a where clause to read based on the medical resource id composite key. 140 * 141 * <p>For it to be syntactically correct it needs to have the result from {@link 142 * #makeParametersAndArgs(List, Long)} appended to it. Both the resources table and the data 143 * sources table must be joined in the SELECT which uses this clause. 144 */ 145 private static final String SELECT_ON_IDS_WHERE_CLAUSE = 146 "(" 147 + MedicalDataSourceHelper.getMainTableName() 148 + "." 149 + MedicalDataSourceHelper.getDataSourceUuidColumnName() 150 + "," 151 + MEDICAL_RESOURCE_TABLE_NAME 152 + "." 153 + FHIR_RESOURCE_TYPE_COLUMN_NAME 154 + "," 155 + MEDICAL_RESOURCE_TABLE_NAME 156 + "." 157 + FHIR_RESOURCE_ID_COLUMN_NAME 158 + ") IN "; 159 160 /** 161 * A block of SQL with the inner select where clause for deleting based on the medical resource 162 * id. 163 * 164 * <p>For it to be syntactically correct it needs to have the result from {@link 165 * #makeParametersAndArgs} appended to it, followed by a ")" 166 */ 167 // The SQL here is made more complicated because: 168 // 1. The medical resource table has a 3 column composite primary key, on data source, 169 // resource type, and resource id. 170 // 2. Data source is a reference to another table, so a JOIN is needed 171 // 3. SQLite does not allow JOIN in the FROM clause of a delete. This means an inner 172 // SELECT is needed to reference the ids. 173 // 4. You can't bind a list to SQL to put a dynamic range of values into an "IN" list 174 // However, SQLite has a row_id value for every row which simplifies things. 175 // We end up with a select clause looking like: 176 // DELETE FROM resources WHERE medical_resource_row_id IN ( 177 // SELECT medical_resource_row_id FROM resources INNER JOIN datasources ON ... 178 // WHERE ..key columns.. IN ( (?,?,?), (?,?,?), ...) 179 private static final String DELETE_ON_IDS_WHERE_CLAUSE = 180 MEDICAL_RESOURCE_TABLE_NAME 181 + "." 182 + MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME 183 + " IN (" 184 + "SELECT " 185 + MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME 186 + " FROM " 187 + MEDICAL_RESOURCE_TABLE_NAME 188 + " INNER JOIN " 189 + MedicalDataSourceHelper.getMainTableName() 190 + " ON " 191 + MEDICAL_RESOURCE_TABLE_NAME 192 + "." 193 + DATA_SOURCE_ID_COLUMN_NAME 194 + "=" 195 + MedicalDataSourceHelper.getMainTableName() 196 + "." 197 + MedicalDataSourceHelper.getPrimaryColumnName() 198 + " WHERE (" 199 + MedicalDataSourceHelper.getMainTableName() 200 + "." 201 + MedicalDataSourceHelper.getDataSourceUuidColumnName() 202 + "," 203 + FHIR_RESOURCE_TYPE_COLUMN_NAME 204 + "," 205 + FHIR_RESOURCE_ID_COLUMN_NAME 206 + ") IN "; 207 208 /** 209 * An SQL string joining the three key tables for resource information - resources, data sources 210 * and the index with resource types. Suitable for using in a FROM clause in a select. 211 */ 212 private static final String RESOURCES_JOIN_DATA_SOURCES_JOIN_INDICES = 213 MEDICAL_RESOURCE_TABLE_NAME 214 + " INNER JOIN " 215 + MedicalResourceIndicesHelper.getTableName() 216 + " ON " 217 + MEDICAL_RESOURCE_TABLE_NAME 218 + "." 219 + MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME 220 + " = " 221 + MedicalResourceIndicesHelper.getTableName() 222 + "." 223 + MedicalResourceIndicesHelper.getParentColumnReference() 224 + " INNER JOIN " 225 + MedicalDataSourceHelper.getMainTableName() 226 + " ON " 227 + MEDICAL_RESOURCE_TABLE_NAME 228 + "." 229 + DATA_SOURCE_ID_COLUMN_NAME 230 + " = " 231 + MedicalDataSourceHelper.getMainTableName() 232 + "." 233 + MedicalDataSourceHelper.getPrimaryColumnName(); 234 235 private final TransactionManager mTransactionManager; 236 private final AppInfoHelper mAppInfoHelper; 237 private final MedicalDataSourceHelper mMedicalDataSourceHelper; 238 private final TimeSource mTimeSource; 239 private final AccessLogsHelper mAccessLogsHelper; 240 MedicalResourceHelper( TransactionManager transactionManager, AppInfoHelper appInfoHelper, MedicalDataSourceHelper medicalDataSourceHelper, TimeSource timeSource, AccessLogsHelper accessLogsHelper)241 public MedicalResourceHelper( 242 TransactionManager transactionManager, 243 AppInfoHelper appInfoHelper, 244 MedicalDataSourceHelper medicalDataSourceHelper, 245 TimeSource timeSource, 246 AccessLogsHelper accessLogsHelper) { 247 mTransactionManager = transactionManager; 248 mAppInfoHelper = appInfoHelper; 249 mMedicalDataSourceHelper = medicalDataSourceHelper; 250 mTimeSource = timeSource; 251 mAccessLogsHelper = accessLogsHelper; 252 } 253 getMainTableName()254 public static String getMainTableName() { 255 return MEDICAL_RESOURCE_TABLE_NAME; 256 } 257 getPrimaryColumn()258 public static String getPrimaryColumn() { 259 return MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME; 260 } 261 getDataSourceIdColumnName()262 public static String getDataSourceIdColumnName() { 263 return DATA_SOURCE_ID_COLUMN_NAME; 264 } 265 getMedicalResourceColumns()266 private static String getMedicalResourceColumns() { 267 List<String> medicalResourceColumns = new ArrayList<>(sMedicalResourceColumns); 268 medicalResourceColumns.add(sMedicalResourceLastModifiedTime); 269 return String.join(DELIMITER, medicalResourceColumns); 270 } 271 getColumnInfo()272 private static List<Pair<String, String>> getColumnInfo() { 273 return List.of( 274 Pair.create(MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME, PRIMARY_AUTOINCREMENT), 275 Pair.create(FHIR_RESOURCE_TYPE_COLUMN_NAME, INTEGER_NOT_NULL), 276 Pair.create(FHIR_RESOURCE_ID_COLUMN_NAME, TEXT_NOT_NULL), 277 Pair.create(FHIR_DATA_COLUMN_NAME, TEXT_NOT_NULL), 278 Pair.create(DATA_SOURCE_ID_COLUMN_NAME, INTEGER_NOT_NULL), 279 Pair.create(LAST_MODIFIED_TIME_COLUMN_NAME, INTEGER_NOT_NULL)); 280 } 281 282 // TODO(b/352010531): Remove the use of setChildTableRequests and upsert child table directly 283 // in {@code upsertMedicalResources} to improve readability. 284 getCreateTableRequest()285 public static CreateTableRequest getCreateTableRequest() { 286 return new CreateTableRequest(MEDICAL_RESOURCE_TABLE_NAME, getColumnInfo()) 287 .addForeignKey( 288 MedicalDataSourceHelper.getMainTableName(), 289 Collections.singletonList(DATA_SOURCE_ID_COLUMN_NAME), 290 Collections.singletonList(MedicalDataSourceHelper.getPrimaryColumnName())) 291 .createIndexOn(LAST_MODIFIED_TIME_COLUMN_NAME) 292 .setChildTableRequests( 293 Collections.singletonList(getCreateMedicalResourceIndicesTableRequest())); 294 } 295 296 /** Creates the medical_resource table. */ onInitialUpgrade(SQLiteDatabase db)297 public static void onInitialUpgrade(SQLiteDatabase db) { 298 createTable(db, getCreateTableRequest()); 299 // There are 3 equivalent ways we could add the (Datasource, type, id) triple as a primary 300 // key - primary key, unique index, or unique constraint. 301 // Primary Key and unique constraints cannot be altered after table creation. Indexes can be 302 // dropped later and added to. So it seems most flexible to add as a named index. 303 db.execSQL( 304 new CreateIndexRequest( 305 MEDICAL_RESOURCE_TABLE_NAME, 306 MEDICAL_RESOURCE_TABLE_NAME + "_fhir_idx", 307 /* isUnique= */ true, 308 List.of( 309 DATA_SOURCE_ID_COLUMN_NAME, 310 FHIR_RESOURCE_TYPE_COLUMN_NAME, 311 FHIR_RESOURCE_ID_COLUMN_NAME)) 312 .getCommand()); 313 } 314 315 /** Returns the total number of medical resources in HC database. */ getMedicalResourcesCount()316 public int getMedicalResourcesCount() { 317 ReadTableRequest readTableRequest = new ReadTableRequest(getMainTableName()); 318 return mTransactionManager.count(readTableRequest); 319 } 320 321 /** 322 * Reads the {@link MedicalResource}s stored in the HealthConnect database. 323 * 324 * @param medicalResourceIds a {@link MedicalResourceId}. 325 * @return List of {@link MedicalResource}s read from medical_resource table based on ids. 326 * @throws IllegalArgumentException if any of the ids has a data source id which is not valid 327 * (not a String form of a UUID) 328 */ readMedicalResourcesByIdsWithoutPermissionChecks( List<MedicalResourceId> medicalResourceIds)329 public List<MedicalResource> readMedicalResourcesByIdsWithoutPermissionChecks( 330 List<MedicalResourceId> medicalResourceIds) throws SQLiteException { 331 if (medicalResourceIds.isEmpty()) { 332 return List.of(); 333 } 334 Pair<String, String[]> paramsAndArgs = 335 makeParametersAndArgs(medicalResourceIds, /* appId= */ null); 336 String sql = 337 "SELECT " 338 + getMedicalResourceColumns() 339 + " FROM " 340 + RESOURCES_JOIN_DATA_SOURCES_JOIN_INDICES 341 + " WHERE " 342 + SELECT_ON_IDS_WHERE_CLAUSE 343 + paramsAndArgs.first; 344 List<MedicalResource> medicalResources; 345 try (Cursor cursor = mTransactionManager.rawQuery(sql, paramsAndArgs.second)) { 346 medicalResources = getMedicalResources(cursor); 347 } 348 return medicalResources; 349 } 350 351 /** 352 * Reads the {@link MedicalResource}s stored in the HealthConnect database filtering based on 353 * the {@code callingPackageName}'s permissions. 354 * 355 * @return List of {@link MedicalResource}s read from medical_resource table based on ids. 356 * @throws IllegalStateException if {@code hasWritePermission} is false and {@code 357 * grantedReadMedicalResourceTypes} is empty. 358 * @throws IllegalArgumentException if any of the ids has a data source id which is not valid 359 * (not a String form of a UUID) 360 */ readMedicalResourcesByIdsWithPermissionChecks( List<MedicalResourceId> medicalResourceIds, Set<Integer> grantedReadMedicalResourceTypes, String callingPackageName, boolean hasWritePermission, boolean isCalledFromBgWithoutBgRead)361 public List<MedicalResource> readMedicalResourcesByIdsWithPermissionChecks( 362 List<MedicalResourceId> medicalResourceIds, 363 Set<Integer> grantedReadMedicalResourceTypes, 364 String callingPackageName, 365 boolean hasWritePermission, 366 boolean isCalledFromBgWithoutBgRead) 367 throws SQLiteException { 368 369 Pair<String, String[]> sqlAndArgs = 370 getSqlAndArgsBasedOnPermissionFilters( 371 medicalResourceIds, 372 grantedReadMedicalResourceTypes, 373 callingPackageName, 374 hasWritePermission, 375 isCalledFromBgWithoutBgRead); 376 return mTransactionManager.runAsTransaction( 377 db -> { 378 List<MedicalResource> medicalResources; 379 try (Cursor cursor = db.rawQuery(sqlAndArgs.first, sqlAndArgs.second)) { 380 medicalResources = getMedicalResources(cursor); 381 } 382 // If the app is called from background but without background read permission, 383 // the most the app can do, is to read their own data. Same when the 384 // grantedReadMedicalResourceTypes is empty. And we don't need to add access 385 // logs when an app intends to access their own data. 386 // If medicalResources is empty, it means that we haven't read any resources 387 // out, so no need to add access logs either. 388 if (!isCalledFromBgWithoutBgRead 389 && !grantedReadMedicalResourceTypes.isEmpty() 390 && !medicalResources.isEmpty()) { 391 // We do this to get resourceTypes that are read due to the calling app 392 // having a read permission for it. If the resources returned, were read 393 // due to selfRead only, no access logs should be created. 394 // However if the resources read were written by the app itself, but the 395 // app also had read permissions for those resources, we don't record 396 // this as selfRead and access log is added. 397 Set<Integer> resourceTypes = 398 getIntersectionOfResourceTypesReadAndGrantedReadPermissions( 399 getResourceTypesRead(medicalResources), 400 grantedReadMedicalResourceTypes); 401 if (!resourceTypes.isEmpty()) { 402 mAccessLogsHelper.addAccessLog( 403 db, 404 callingPackageName, 405 resourceTypes, 406 OPERATION_TYPE_READ, 407 /* accessedMedicalDataSource= */ false); 408 } 409 } 410 return medicalResources; 411 }); 412 } 413 414 /** 415 * Reads from the storage and creates a map between {@link MedicalResourceType}s and all its 416 * contributing {@link MedicalDataSource}s. 417 * 418 * <p>This map does not guarantee to contain all the valid {@link MedicalResourceType}s we 419 * support, but only contain those we have data for in the storage. 420 */ 421 public Map<Integer, Set<MedicalDataSource>> getMedicalResourceTypeToContributingDataSourcesMap()422 getMedicalResourceTypeToContributingDataSourcesMap() { 423 return mTransactionManager.runAsTransaction( 424 db -> { 425 Map<Long, MedicalDataSource> allRowIdToDataSourceMap = 426 mMedicalDataSourceHelper.getAllRowIdToDataSourceMap(db); 427 Map<Integer, List<Long>> resourceTypeToDataSourceIdsMap = 428 getMedicalResourceTypeToDataSourceIdsMap(db); 429 return resourceTypeToDataSourceIdsMap.keySet().stream() 430 .collect( 431 toMap( 432 medicalResourceType -> medicalResourceType, 433 medicalResourceType -> 434 resourceTypeToDataSourceIdsMap 435 .getOrDefault( 436 medicalResourceType, List.of()) 437 .stream() 438 .map(allRowIdToDataSourceMap::get) 439 // This should not happen, but we 440 // filter out nulls for extra safe. 441 .filter(Objects::nonNull) 442 .collect(toSet()))); 443 }); 444 } 445 446 private Map<Integer, List<Long>> getMedicalResourceTypeToDataSourceIdsMap(SQLiteDatabase db) { 447 String readMainTableQuery = getReadQueryForMedicalResourceTypeToDataSourceIdsMap(); 448 Map<Integer, List<Long>> resourceTypeToDataSourceIdsMap = new HashMap<>(); 449 try (Cursor cursor = db.rawQuery(readMainTableQuery, /* selectionArgs= */ null)) { 450 if (cursor.moveToFirst()) { 451 do { 452 int medicalResourceType = 453 getCursorInt(cursor, getMedicalResourceTypeColumnName()); 454 List<Long> dataSourceIds = 455 getCursorLongList(cursor, DATA_SOURCE_ID_COLUMN_NAME, DELIMITER); 456 resourceTypeToDataSourceIdsMap.put(medicalResourceType, dataSourceIds); 457 } while (cursor.moveToNext()); 458 } 459 } 460 return resourceTypeToDataSourceIdsMap; 461 } 462 463 static Set<Integer> getIntersectionOfResourceTypesReadAndGrantedReadPermissions( 464 Set<Integer> resourceTypesRead, Set<Integer> grantedReadPerms) { 465 Set<Integer> intersection = new HashSet<>(resourceTypesRead); 466 intersection.retainAll(grantedReadPerms); 467 return intersection; 468 } 469 470 private static Set<Integer> getResourceTypesRead(List<MedicalResource> resources) { 471 return resources.stream().map(MedicalResource::getType).collect(Collectors.toSet()); 472 } 473 474 /** 475 * Returns an SQL query and the selection arguments for that query to get medical resources, 476 * based on permission values. 477 * 478 * @throws IllegalArgumentException if any of the ids has a data source id which is not valid 479 * (not a String form of a UUID) 480 */ 481 private Pair<String, String[]> getSqlAndArgsBasedOnPermissionFilters( 482 List<MedicalResourceId> medicalResourceIds, 483 Set<Integer> grantedReadMedicalResourceTypes, 484 String callingPackageName, 485 boolean hasWritePermission, 486 boolean isCalledFromBgWithoutBgRead) { 487 if (!hasWritePermission && grantedReadMedicalResourceTypes.isEmpty()) { 488 throw new IllegalStateException("no read or write permission"); 489 } 490 long appId = mAppInfoHelper.getAppInfoId(callingPackageName); 491 // App is calling the API from background without backgroundReadPermission. 492 if (isCalledFromBgWithoutBgRead) { 493 // App has writePermission. 494 // App can read all data they wrote themselves. 495 if (hasWritePermission) { 496 return readAllIdsWrittenByCallingPackage(medicalResourceIds, appId); 497 } 498 // App does not have writePermission. 499 // App has normal read permission for some medicalResourceTypes. 500 // App can read the ids that belong to those medicalResourceTypes and was written by the 501 // app itself. 502 return readResourcesByIdsAppIdResourceTypes( 503 medicalResourceIds, 504 appId, 505 LogicalOperator.AND, 506 grantedReadMedicalResourceTypes); 507 } 508 509 // App is in background with backgroundReadPermission or in foreground. 510 // App has writePermission. 511 if (hasWritePermission) { 512 // App does not have any read permissions for any medicalResourceType. 513 // App can read all data they wrote themselves. 514 if (grantedReadMedicalResourceTypes.isEmpty()) { 515 return readAllIdsWrittenByCallingPackage(medicalResourceIds, appId); 516 } 517 // App has some read permissions for medicalResourceTypes. 518 // App can read all data they wrote themselves and the medicalResourceTypes they have 519 // read permission for. 520 return readResourcesByIdsAppIdResourceTypes( 521 medicalResourceIds, appId, LogicalOperator.OR, grantedReadMedicalResourceTypes); 522 } 523 // App is in background with backgroundReadPermission or in foreground. 524 // App has some read permissions for medicalResourceTypes. 525 // App does not have writePermission. 526 // App can read all data of the granted medicalResourceType read permissions. 527 return readResourcesByIdsAppIdResourceTypes( 528 medicalResourceIds, 529 /* appId= */ null, 530 LogicalOperator.AND, 531 grantedReadMedicalResourceTypes); 532 } 533 534 private static Pair<String, String[]> readAllIdsWrittenByCallingPackage( 535 List<MedicalResourceId> medicalResourceIds, long appId) { 536 Pair<String, String[]> paramsAndArgs = makeParametersAndArgs(medicalResourceIds, appId); 537 return Pair.create( 538 "SELECT " 539 + getMedicalResourceColumns() 540 + " FROM " 541 + RESOURCES_JOIN_DATA_SOURCES_JOIN_INDICES 542 + " WHERE " 543 + SELECT_ON_IDS_WHERE_CLAUSE 544 + paramsAndArgs.first, 545 paramsAndArgs.second); 546 } 547 548 /** 549 * Reads the {@link MedicalResource}s stored in the HealthConnect database by {@code request}. 550 * 551 * @param pageTokenWrapper a {@link PhrPageTokenWrapper}. 552 * @return a {@link ReadMedicalResourcesInternalResponse}. 553 */ 554 public ReadMedicalResourcesInternalResponse 555 readMedicalResourcesByRequestWithoutPermissionChecks( 556 PhrPageTokenWrapper pageTokenWrapper, int pageSize) { 557 ReadTableRequest request = 558 getReadTableRequestUsingRequestFilters(pageTokenWrapper, pageSize); 559 560 return mTransactionManager.runAsTransaction( 561 (db) -> { 562 return getMedicalResources(db, request, pageTokenWrapper, pageSize); 563 }); 564 } 565 566 /** 567 * Reads the {@link MedicalResource}s stored in the HealthConnect database by {@code request} 568 * filtering based on {@code callingPackageName}'s permissions. 569 * 570 * @return a {@link ReadMedicalResourcesInternalResponse}. 571 */ 572 // TODO(b/360352345): Add cts tests for access logs being created per API call. 573 574 public ReadMedicalResourcesInternalResponse readMedicalResourcesByRequestWithPermissionChecks( 575 PhrPageTokenWrapper pageTokenWrapper, 576 int pageSize, 577 String callingPackageName, 578 boolean enforceSelfRead) { 579 ReadMedicalResourcesInitialRequest request = pageTokenWrapper.getRequest(); 580 if (request == null) { 581 throw new IllegalStateException("The pageTokenWrapper's request can not be null."); 582 } 583 return mTransactionManager.runAsTransaction( 584 db -> { 585 ReadMedicalResourcesInternalResponse response; 586 ReadTableRequest readTableRequest = 587 getReadTableRequestUsingRequestBasedOnPermissionFilters( 588 pageTokenWrapper, 589 pageSize, 590 callingPackageName, 591 enforceSelfRead); 592 response = 593 getMedicalResources(db, readTableRequest, pageTokenWrapper, pageSize); 594 if (!enforceSelfRead) { 595 mAccessLogsHelper.addAccessLog( 596 db, 597 callingPackageName, 598 Set.of(request.getMedicalResourceType()), 599 OPERATION_TYPE_READ, 600 /* accessedMedicalDataSource= */ false); 601 } 602 return response; 603 }); 604 } 605 606 private ReadTableRequest getReadTableRequestUsingRequestBasedOnPermissionFilters( 607 PhrPageTokenWrapper pageTokenWrapper, 608 int pageSize, 609 String callingPackageName, 610 boolean enforceSelfRead) { 611 // If this is true, app can only read its own data of the given filters set in the request. 612 if (enforceSelfRead) { 613 long appId = mAppInfoHelper.getAppInfoId(callingPackageName); 614 return getReadTableRequestUsingRequestFiltersAndAppId( 615 pageTokenWrapper, pageSize, appId); 616 } 617 // Otherwise, app can read all data of the given filters. 618 return getReadTableRequestUsingRequestFilters(pageTokenWrapper, pageSize); 619 } 620 621 /** Creates {@link ReadTableRequest} for the given {@link PhrPageTokenWrapper}. */ 622 public static ReadTableRequest getReadTableRequestUsingRequestFilters( 623 PhrPageTokenWrapper pageTokenWrapper, int pageSize) { 624 // The INNER_QUERY_ALIAS refers to the medical_resource_table. 625 List<String> allColumns = new ArrayList<>(sMedicalResourceColumns); 626 allColumns.add(sLastModifiedTimeInInnerQuery); 627 ReadTableRequest readTableRequest = 628 getReadTableRequestUsingPageSizeAndLastRowId( 629 pageSize, pageTokenWrapper.getLastRowId()) 630 .setColumnNames(allColumns); 631 ReadMedicalResourcesInitialRequest request = pageTokenWrapper.getRequest(); 632 SqlJoin joinClause; 633 if (request == null) { 634 // If request is null, it means the request is to read out all the data without 635 // any filters applied. So we just join the tables without any filtering on them. 636 joinClause = 637 joinWithMedicalResourceIndicesTable() 638 .attachJoin(joinWithMedicalDataSourceTable()); 639 } else if (request.getDataSourceIds().isEmpty()) { 640 joinClause = 641 getJoinWithIndicesAndDataSourceTablesFilterOnMedicalResourceTypes( 642 Set.of(request.getMedicalResourceType())); 643 } else { 644 List<UUID> dataSourceUuids = StorageUtils.toUuids(request.getDataSourceIds()); 645 joinClause = 646 getJoinWithIndicesAndDataSourceTablesFilterOnMedicalResourceTypesAndSourceIds( 647 Set.of(request.getMedicalResourceType()), dataSourceUuids); 648 } 649 return readTableRequest.setJoinClause(joinClause); 650 } 651 652 /** 653 * Creates {@link ReadTableRequest} for the given {@link PhrPageTokenWrapper} and {@code 654 * callingPackageName}. 655 */ 656 private static ReadTableRequest getReadTableRequestUsingRequestFiltersAndAppId( 657 PhrPageTokenWrapper pageTokenWrapper, int pageSize, long appId) { 658 ReadMedicalResourcesInitialRequest request = pageTokenWrapper.getRequest(); 659 if (request == null) { 660 throw new IllegalArgumentException("Request can't be null when doing a filtered read."); 661 } 662 List<String> allColumns = new ArrayList<>(sMedicalResourceColumns); 663 allColumns.add(sLastModifiedTimeInInnerQuery); 664 ReadTableRequest readTableRequest = 665 getReadTableRequestUsingPageSizeAndLastRowId( 666 pageSize, pageTokenWrapper.getLastRowId()) 667 .setColumnNames(allColumns); 668 SqlJoin joinClause; 669 if (request.getDataSourceIds().isEmpty()) { 670 joinClause = 671 getJoinWithIndicesAndDataSourceTablesFilterOnMedicalResourceTypesAndAppId( 672 Set.of(request.getMedicalResourceType()), appId); 673 } else { 674 List<UUID> dataSourceUuids = StorageUtils.toUuids(request.getDataSourceIds()); 675 joinClause = 676 getJoinWithIndicesAndDataSourceTablesFilterOnTypesAndSourceIdsAndAppId( 677 Set.of(request.getMedicalResourceType()), dataSourceUuids, appId); 678 } 679 return readTableRequest.setJoinClause(joinClause); 680 } 681 682 private static ReadTableRequest getReadTableRequestUsingPageSizeAndLastRowId( 683 int pageSize, long lastRowId) { 684 // The limit is set to pageSize + 1, so that we know if there are more resources 685 // than the pageSize for creating the pageToken. 686 ReadTableRequest request = 687 new ReadTableRequest(getMainTableName()) 688 .setWhereClause(getReadByLastRowIdWhereClause(lastRowId)); 689 690 if (Flags.phrReadMedicalResourcesFixQueryLimit()) { 691 request.setFinalOrderBy(getOrderByClause()).setFinalLimit(pageSize + 1); 692 } else { 693 request.setOrderBy(getOrderByClause()).setLimit(pageSize + 1); 694 } 695 696 return request; 697 } 698 699 static ReadTableRequest getReadRequestForDistinctResourceTypesBelongingToDataSourceIds( 700 List<UUID> dataSourceIds) { 701 return new ReadTableRequest(getMainTableName()) 702 .setDistinctClause(true) 703 .setColumnNames( 704 List.of(MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName())) 705 .setJoinClause( 706 getJoinWithMedicalDataSourceFilterOnDataSourceIds( 707 dataSourceIds, joinWithMedicalResourceIndicesTable())); 708 } 709 710 @VisibleForTesting 711 static ReadTableRequest getFilteredReadRequestForDistinctResourceTypes( 712 List<UUID> dataSourceIds, Set<Integer> medicalResourceTypes, long appId) { 713 return new ReadTableRequest(getMainTableName()) 714 .setDistinctClause(true) 715 .setColumnNames( 716 List.of(MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName())) 717 .setJoinClause( 718 getJoinWithMedicalDataSourceFilterOnDataSourceIdsAndAppId( 719 dataSourceIds, 720 appId, 721 getJoinWithIndicesTableFilterOnMedicalResourceTypes( 722 medicalResourceTypes))); 723 } 724 725 /** 726 * Creates raw SQL query for {@link 727 * MedicalResourceHelper#getMedicalResourceTypeToDataSourceIdsMap}. 728 * 729 * <p>"GROUP BY" is not supported in {@link ReadTableRequest} and should be achieved via {@link 730 * AggregateRecordRequest}. But the {@link AggregateRecordRequest} is too complicated for our 731 * simple use case here (requiring {@link RecordHelper}). Thus we just build and return raw SQL 732 * query which appends the "GROUP BY" clause directly. 733 */ 734 @VisibleForTesting 735 static String getReadQueryForMedicalResourceTypeToDataSourceIdsMap() { 736 ReadTableRequest readDistinctResourceTypeToDataSourceIdRequest = 737 new ReadTableRequest(getMainTableName()) 738 .setDistinctClause(true) 739 .setColumnNames( 740 List.of( 741 getMedicalResourceTypeColumnName(), 742 DATA_SOURCE_ID_COLUMN_NAME)) 743 .setJoinClause(joinWithMedicalResourceIndicesTable()); 744 745 return String.format( 746 "SELECT %1$s, GROUP_CONCAT(%2$s, '%3$s') AS %4$s FROM (%5$s) GROUP BY %6$s", 747 /* 1 */ getMedicalResourceTypeColumnName(), 748 /* 2 */ DATA_SOURCE_ID_COLUMN_NAME, 749 /* 3 */ DELIMITER, 750 /* 4 */ DATA_SOURCE_ID_COLUMN_NAME, 751 /* 5 */ readDistinctResourceTypeToDataSourceIdRequest.getReadCommand(), 752 /* 6 */ getMedicalResourceTypeColumnName()); 753 } 754 755 /** 756 * Creates {@link SqlJoin} that is an inner join from medical_resource_table to 757 * medical_resource_indices_table followed by another inner join from medical_resource_table to 758 * medical_data_source_table. 759 */ 760 private static SqlJoin getJoinWithIndicesAndDataSourceTables() { 761 return joinWithMedicalResourceIndicesTable().attachJoin(joinWithMedicalDataSourceTable()); 762 } 763 764 /** 765 * Creates {@link SqlJoin} that is an inner join from medical_resource_table to 766 * medical_resource_indices_table filtering on {@code medicalResourceTypes} followed by another 767 * inner join from medical_resource_table to medical_data_source_table. 768 */ 769 private static SqlJoin getJoinWithIndicesAndDataSourceTablesFilterOnMedicalResourceTypes( 770 Set<Integer> medicalResourceTypes) { 771 return getJoinWithIndicesTableFilterOnMedicalResourceTypes(medicalResourceTypes) 772 .attachJoin(joinWithMedicalDataSourceTable()); 773 } 774 775 /** 776 * Creates {@link SqlJoin} that is an inner join from medical_resource_table to 777 * medical_resource_indices_table filtering on {@code medicalResourceTypes} followed by another 778 * inner join from medical_resource_table to medical_data_source_table filtering on appId. 779 */ 780 private static SqlJoin 781 getJoinWithIndicesAndDataSourceTablesFilterOnMedicalResourceTypesAndAppId( 782 Set<Integer> medicalResourceTypes, long appId) { 783 return getJoinWithIndicesTableFilterOnMedicalResourceTypes(medicalResourceTypes) 784 .attachJoin(joinWithMedicalDataSourceTableFilterOnAppId(appId)); 785 } 786 787 /** 788 * Creates {@link SqlJoin} that is an inner join from medical_resource_table to 789 * medical_resource_indices_table filtering on {@code medicalResourceTypes} followed by another 790 * inner join from medical_resource_table to medical_data_source_table filtering on {@code 791 * dataSourceIds}. 792 */ 793 private static SqlJoin 794 getJoinWithIndicesAndDataSourceTablesFilterOnMedicalResourceTypesAndSourceIds( 795 Set<Integer> medicalResourceTypes, List<UUID> dataSourceUuids) { 796 return getJoinWithIndicesTableFilterOnMedicalResourceTypes(medicalResourceTypes) 797 .attachJoin(joinWithMedicalDataSourceTableFilterOnDataSourceIds(dataSourceUuids)); 798 } 799 800 /** 801 * Creates {@link SqlJoin} that is an inner join from medical_resource_table to 802 * medical_resource_indices_table filtering on {@code medicalResourceTypes} followed by another 803 * inner join from medical_resource_table to medical_data_source_table filtering on {@code 804 * dataSourceIds} and appId. 805 */ 806 private static SqlJoin getJoinWithIndicesAndDataSourceTablesFilterOnTypesAndSourceIdsAndAppId( 807 Set<Integer> medicalResourceTypes, List<UUID> dataSourceUuids, long appId) { 808 return getJoinWithIndicesTableFilterOnMedicalResourceTypes(medicalResourceTypes) 809 .attachJoin( 810 joinWithMedicalDataSourceTableFilterOnDataSourceIdsAndAppId( 811 dataSourceUuids, appId)); 812 } 813 814 /** 815 * Creates {@link SqlJoin} that is an inner join from medical_resource_table to 816 * medical_resource_indices_table filtering on {@code medicalResourceTypes} followed by {@code 817 * extraJoin} attached to it. 818 * 819 * <p>If the list of {@code medicalResourceTypes} is empty, then the {@link WhereClauses} will 820 * be empty. 821 */ 822 static SqlJoin getJoinWithIndicesTableFilterOnMedicalResourceTypes( 823 Set<Integer> medicalResourceTypes) { 824 WhereClauses medicalResourceTypeWhereClause = 825 new WhereClauses(AND) 826 .addWhereInIntsClause( 827 getMedicalResourceTypeColumnName(), 828 new ArrayList<>(medicalResourceTypes)); 829 return joinWithMedicalResourceIndicesTable() 830 .setSecondTableWhereClause(medicalResourceTypeWhereClause); 831 } 832 833 static SqlJoin getJoinWithMedicalDataSourceFilterOnDataSourceIdsAndAppId( 834 List<UUID> dataSourceIds, long appId, SqlJoin extraJoin) { 835 return joinWithMedicalDataSourceTable() 836 .setSecondTableWhereClause( 837 getDataSourceIdsAndAppIdWhereClause(dataSourceIds, appId)) 838 .attachJoin(extraJoin); 839 } 840 841 static SqlJoin getJoinWithMedicalDataSourceFilterOnDataSourceIds( 842 List<UUID> dataSourceIds, SqlJoin extraJoin) { 843 return joinWithMedicalDataSourceTable() 844 .setSecondTableWhereClause(getDataSourceIdsWhereClause(dataSourceIds)) 845 .attachJoin(extraJoin); 846 } 847 848 static SqlJoin joinWithMedicalResourceIndicesTable() { 849 return new SqlJoin( 850 MEDICAL_RESOURCE_TABLE_NAME, 851 MedicalResourceIndicesHelper.getTableName(), 852 MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME, 853 MedicalResourceIndicesHelper.getParentColumnReference()) 854 .setJoinType(SQL_JOIN_INNER); 855 } 856 857 private static SqlJoin joinWithMedicalDataSourceTable() { 858 return new SqlJoin( 859 MEDICAL_RESOURCE_TABLE_NAME, 860 MedicalDataSourceHelper.getMainTableName(), 861 DATA_SOURCE_ID_COLUMN_NAME, 862 MedicalDataSourceHelper.getPrimaryColumnName()) 863 .setJoinType(SQL_JOIN_INNER); 864 } 865 866 private static SqlJoin joinWithMedicalDataSourceTableFilterOnAppId(long appId) { 867 SqlJoin join = joinWithMedicalDataSourceTable(); 868 join.setSecondTableWhereClause(getAppIdWhereClause(appId)); 869 return join; 870 } 871 872 private static SqlJoin joinWithMedicalDataSourceTableFilterOnDataSourceIds( 873 List<UUID> dataSourceUuids) { 874 SqlJoin join = joinWithMedicalDataSourceTable(); 875 join.setSecondTableWhereClause(getReadTableWhereClause(dataSourceUuids)); 876 return join; 877 } 878 879 private static SqlJoin joinWithMedicalDataSourceTableFilterOnDataSourceIdsAndAppId( 880 List<UUID> dataSourceUuids, long appId) { 881 SqlJoin join = joinWithMedicalDataSourceTable(); 882 join.setSecondTableWhereClause( 883 getReadTableWhereClause(dataSourceUuids) 884 .addWhereEqualsClause( 885 MedicalDataSourceHelper.getAppInfoIdColumnName(), 886 String.valueOf(appId))); 887 return join; 888 } 889 890 private static WhereClauses getAppIdWhereClause(long appId) { 891 return new WhereClauses(AND) 892 .addWhereEqualsClause( 893 MedicalDataSourceHelper.getAppInfoIdColumnName(), String.valueOf(appId)); 894 } 895 896 private static WhereClauses getDataSourceIdsAndAppIdWhereClause( 897 List<UUID> dataSourceIds, long appId) { 898 WhereClauses whereClauses = getAppIdWhereClause(appId); 899 whereClauses.addWhereInClauseWithoutQuotes( 900 getDataSourceUuidColumnName(), StorageUtils.getListOfHexStrings(dataSourceIds)); 901 return whereClauses; 902 } 903 904 private static WhereClauses getDataSourceIdsWhereClause(List<UUID> dataSourceIds) { 905 return new WhereClauses(AND) 906 .addWhereInClauseWithoutQuotes( 907 getDataSourceUuidColumnName(), getListOfHexStrings(dataSourceIds)); 908 } 909 910 static WhereClauses getAppIdsWhereClause(Set<Long> appIds) { 911 return new WhereClauses(AND) 912 .addWhereInLongsClause( 913 MedicalDataSourceHelper.getAppInfoIdColumnName(), appIds.stream().toList()); 914 } 915 916 private static OrderByClause getOrderByClause() { 917 return new OrderByClause() 918 .addOrderByClause(MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME, /* isAscending= */ true); 919 } 920 921 private static WhereClauses getReadByLastRowIdWhereClause(long lastRowId) { 922 WhereClauses whereClauses = new WhereClauses(AND); 923 924 if (lastRowId == DEFAULT_LONG) { 925 return whereClauses; 926 } 927 928 whereClauses.addWhereGreaterThanClause(MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME, lastRowId); 929 return whereClauses; 930 } 931 932 /** 933 * Upserts (insert/update) a list of {@link MedicalResource}s created based on the given list of 934 * {@link UpsertMedicalResourceInternalRequest}s into the HealthConnect database. 935 * 936 * @param upsertMedicalResourceInternalRequests a list of {@link 937 * UpsertMedicalResourceInternalRequest}. 938 * @return List of {@link MedicalResource}s that were upserted into the database, in the same 939 * order as their associated {@link UpsertMedicalResourceInternalRequest}s. 940 * @throws IllegalArgumentException if the data source id does not exist, or if a resource's 941 * FHIR version does not match the data source's FHIR version. 942 */ 943 public List<MedicalResource> upsertMedicalResources( 944 String callingPackageName, 945 List<UpsertMedicalResourceInternalRequest> upsertMedicalResourceInternalRequests) 946 throws SQLiteException { 947 if (Constants.DEBUG) { 948 Slog.d( 949 TAG, 950 "Upserting " 951 + upsertMedicalResourceInternalRequests.size() 952 + " " 953 + UpsertMedicalResourceInternalRequest.class.getSimpleName() 954 + "(s)."); 955 } 956 return mTransactionManager.runAsTransaction( 957 (RunnableWithReturn<List<MedicalResource>, RuntimeException>) 958 db -> 959 readDataSourcesAndUpsertMedicalResources( 960 db, 961 callingPackageName, 962 upsertMedicalResourceInternalRequests)); 963 } 964 965 private List<MedicalResource> readDataSourcesAndUpsertMedicalResources( 966 SQLiteDatabase db, 967 String callingPackageName, 968 List<UpsertMedicalResourceInternalRequest> upsertRequests) { 969 List<String> dataSourceUuids = 970 upsertRequests.stream() 971 .map(UpsertMedicalResourceInternalRequest::getDataSourceId) 972 .toList(); 973 long appInfoIdRestriction = mAppInfoHelper.getAppInfoId(callingPackageName); 974 Map<String, Pair<Long, FhirVersion>> dataSourceUuidToRowIdAndVersion = 975 mMedicalDataSourceHelper.getUuidToRowIdAndVersionMap( 976 db, appInfoIdRestriction, StorageUtils.toUuids(dataSourceUuids)); 977 978 // Standard Upsert code cannot be used as it uses a query with inline values to look for 979 // existing data. The FHIR id is a user supplied string, and so vulnerable to SQL injection. 980 // The Insert itself uses ContentValues (and so is safe) but there is also a read which is 981 // not. 982 // SQLite supports UPSERT https://www.sqlite.org/lang_upsert.html with ON CONFLICT DO UPDATE 983 // This was added in SQLite version 3.24.0. This has been supported since Android API 30. 984 // https://developer.android.com/reference/android/database/sqlite/package-summary.html 985 // So we use this. 986 for (UpsertMedicalResourceInternalRequest upsertRequest : upsertRequests) { 987 Pair<Long, FhirVersion> dataSourceRowIdAndVersion = 988 dataSourceUuidToRowIdAndVersion.get(upsertRequest.getDataSourceId()); 989 if (dataSourceRowIdAndVersion == null) { 990 throw new IllegalArgumentException( 991 "Invalid data source id: " + upsertRequest.getDataSourceId()); 992 } 993 Long dataSourceRowId = dataSourceRowIdAndVersion.first; 994 String dataSourceFhirVersion = dataSourceRowIdAndVersion.second.toString(); 995 if (!upsertRequest.getFhirVersion().equals(dataSourceFhirVersion)) { 996 throw new IllegalArgumentException( 997 "Invalid fhir version: " 998 + upsertRequest.getFhirVersion() 999 + ". It did not match the data source's fhir version"); 1000 } 1001 ContentValues contentValues = 1002 getContentValues(dataSourceRowId, upsertRequest, mTimeSource.getInstantNow()); 1003 long rowId = 1004 db.insertWithOnConflict( 1005 MEDICAL_RESOURCE_TABLE_NAME, 1006 /* nullColumnHack= */ null, 1007 contentValues, 1008 SQLiteDatabase.CONFLICT_REPLACE); 1009 int medicalResourceType = upsertRequest.getMedicalResourceType(); 1010 db.insertWithOnConflict( 1011 MedicalResourceIndicesHelper.getTableName(), 1012 /* nullColumnHack= */ null, 1013 MedicalResourceIndicesHelper.getContentValues(rowId, medicalResourceType), 1014 SQLiteDatabase.CONFLICT_REPLACE); 1015 } 1016 1017 List<MedicalResource> upsertedMedicalResources = new ArrayList<>(); 1018 Set<Integer> resourceTypes = new HashSet<>(); 1019 for (UpsertMedicalResourceInternalRequest upsertMedicalResourceInternalRequest : 1020 upsertRequests) { 1021 MedicalResource medicalResource = 1022 buildMedicalResource(upsertMedicalResourceInternalRequest); 1023 resourceTypes.add(medicalResource.getType()); 1024 upsertedMedicalResources.add(medicalResource); 1025 } 1026 1027 mAccessLogsHelper.addAccessLog( 1028 db, 1029 callingPackageName, 1030 resourceTypes, 1031 OPERATION_TYPE_UPSERT, 1032 /* accessedMedicalDataSource= */ false); 1033 1034 return upsertedMedicalResources; 1035 } 1036 1037 @VisibleForTesting 1038 static ContentValues getContentValues( 1039 long dataSourceRowId, 1040 UpsertMedicalResourceInternalRequest upsertMedicalResourceInternalRequest, 1041 Instant instant) { 1042 ContentValues resourceContentValues = new ContentValues(); 1043 resourceContentValues.put(DATA_SOURCE_ID_COLUMN_NAME, dataSourceRowId); 1044 resourceContentValues.put( 1045 FHIR_DATA_COLUMN_NAME, upsertMedicalResourceInternalRequest.getData()); 1046 resourceContentValues.put( 1047 FHIR_RESOURCE_TYPE_COLUMN_NAME, 1048 upsertMedicalResourceInternalRequest.getFhirResourceType()); 1049 resourceContentValues.put( 1050 FHIR_RESOURCE_ID_COLUMN_NAME, 1051 upsertMedicalResourceInternalRequest.getFhirResourceId()); 1052 resourceContentValues.put(LAST_MODIFIED_TIME_COLUMN_NAME, instant.toEpochMilli()); 1053 return resourceContentValues; 1054 } 1055 1056 /** 1057 * Create {@link ContentValues} for the given {@code dataSourceRowId}, {@code lastModifiedTime}, 1058 * {@code appInfoId} and {@link MedicalResource}. 1059 * 1060 * <p>This is only used in DatabaseMerger code, where we want to provide a lastModifiedTimestamp 1061 * from the source database rather than based on the current time. 1062 */ 1063 public static ContentValues getContentValues( 1064 long dataSourceRowId, long lastModifiedTime, MedicalResource resource) { 1065 FhirResource fhirResource = resource.getFhirResource(); 1066 ContentValues resourceContentValues = new ContentValues(); 1067 resourceContentValues.put(DATA_SOURCE_ID_COLUMN_NAME, dataSourceRowId); 1068 resourceContentValues.put(FHIR_DATA_COLUMN_NAME, fhirResource.getData()); 1069 resourceContentValues.put(FHIR_RESOURCE_TYPE_COLUMN_NAME, fhirResource.getType()); 1070 resourceContentValues.put(FHIR_RESOURCE_ID_COLUMN_NAME, fhirResource.getId()); 1071 resourceContentValues.put(LAST_MODIFIED_TIME_COLUMN_NAME, lastModifiedTime); 1072 return resourceContentValues; 1073 } 1074 1075 /** 1076 * Creates a {@link MedicalResource} for the given {@code uuid} and {@link 1077 * UpsertMedicalResourceInternalRequest}. 1078 */ 1079 private static MedicalResource buildMedicalResource( 1080 UpsertMedicalResourceInternalRequest internalRequest) { 1081 FhirResource fhirResource = 1082 new FhirResource.Builder( 1083 internalRequest.getFhirResourceType(), 1084 internalRequest.getFhirResourceId(), 1085 internalRequest.getData()) 1086 .build(); 1087 return new MedicalResource.Builder( 1088 internalRequest.getMedicalResourceType(), 1089 internalRequest.getDataSourceId(), 1090 parseFhirVersion(internalRequest.getFhirVersion()), 1091 fhirResource) 1092 .build(); 1093 } 1094 1095 /** 1096 * Returns a {@link ReadMedicalResourcesInternalResponse}. 1097 * 1098 * <p>This should be run within a transaction as it does multiple requests using the db passed 1099 * for the transaction. 1100 * 1101 * @param request the specification for the rows to read 1102 * @param pageSize the number of results to return in this page 1103 * @param pageTokenWrapper the page token for the query 1104 * @throws IllegalArgumentException if the cursor contains more than @link 1105 * MAXIMUM_ALLOWED_CURSOR_COUNT} records. 1106 */ 1107 public static ReadMedicalResourcesInternalResponse getMedicalResources( 1108 SQLiteDatabase db, 1109 ReadTableRequest request, 1110 PhrPageTokenWrapper pageTokenWrapper, 1111 int pageSize) { 1112 ReadMedicalResourcesInternalResponse response; 1113 // Get the count from a requests with no limit, 1114 int totalRowCount; 1115 if (Flags.phrReadMedicalResourcesFixQueryLimit()) { 1116 Integer originalLimit = request.getFinalLimit(); 1117 request.setFinalLimit(null); 1118 totalRowCount = TransactionManager.count(db, request); 1119 request.setFinalLimit(originalLimit); 1120 } else { 1121 Integer originalLimit = request.getLimit(); 1122 request.setLimit(null); 1123 totalRowCount = TransactionManager.count(db, request); 1124 request.setLimit(originalLimit); 1125 } 1126 try (Cursor cursor = db.rawQuery(request.getReadCommand(), null)) { 1127 response = getMedicalResources(cursor, pageTokenWrapper, pageSize, totalRowCount); 1128 } 1129 return response; 1130 } 1131 1132 /** 1133 * Returns a {@link ReadMedicalResourcesInternalResponse}. 1134 * 1135 * @param pageSize the number of results to return in this page 1136 * @param totalRowCount the number of rows that would have been returned if this query was 1137 * executed with no limit 1138 * @throws IllegalArgumentException if the cursor contains more than @link 1139 * MAXIMUM_ALLOWED_CURSOR_COUNT} records. 1140 */ 1141 private static ReadMedicalResourcesInternalResponse getMedicalResources( 1142 Cursor cursor, PhrPageTokenWrapper pageTokenWrapper, int pageSize, int totalRowCount) { 1143 // TODO(b/356613483): remove these checks in the helpers and instead validate pageSize 1144 // in the service. 1145 if (cursor.getCount() > MAXIMUM_ALLOWED_CURSOR_COUNT) { 1146 throw new IllegalArgumentException( 1147 "Too many resources in the cursor. Max allowed: " 1148 + MAXIMUM_ALLOWED_CURSOR_COUNT); 1149 } 1150 List<MedicalResource> medicalResources = new ArrayList<>(); 1151 String nextPageToken = null; 1152 long lastRowId = DEFAULT_LONG; 1153 if (cursor.moveToFirst()) { 1154 do { 1155 if (medicalResources.size() >= pageSize) { 1156 nextPageToken = pageTokenWrapper.cloneWithNewLastRowId(lastRowId).encode(); 1157 break; 1158 } 1159 medicalResources.add(getMedicalResource(cursor)); 1160 lastRowId = getCursorLong(cursor, MEDICAL_RESOURCE_PRIMARY_COLUMN_NAME); 1161 } while (cursor.moveToNext()); 1162 } 1163 1164 int remainingCount = totalRowCount - medicalResources.size(); 1165 return new ReadMedicalResourcesInternalResponse( 1166 medicalResources, nextPageToken, remainingCount); 1167 } 1168 1169 /** 1170 * Returns List of {@code MedicalResource}s from the cursor. If the cursor contains more than 1171 * {@link Constants#MAXIMUM_ALLOWED_CURSOR_COUNT} records, it throws {@link 1172 * IllegalArgumentException}. 1173 */ 1174 private static List<MedicalResource> getMedicalResources(Cursor cursor) { 1175 if (cursor.getCount() > MAXIMUM_ALLOWED_CURSOR_COUNT) { 1176 throw new IllegalArgumentException( 1177 "Too many resources in the cursor. Max allowed: " 1178 + MAXIMUM_ALLOWED_CURSOR_COUNT); 1179 } 1180 List<MedicalResource> medicalResources = new ArrayList<>(); 1181 if (cursor.moveToFirst()) { 1182 do { 1183 medicalResources.add(getMedicalResource(cursor)); 1184 } while (cursor.moveToNext()); 1185 } 1186 cursor.close(); 1187 return medicalResources; 1188 } 1189 1190 /** 1191 * Deletes a list of {@link MedicalResource}s created based on the given list of {@link 1192 * MedicalResourceId}s into the HealthConnect database. 1193 * 1194 * @param medicalResourceIds list of {@link MedicalResourceId} to delete 1195 */ 1196 public void deleteMedicalResourcesByIdsWithoutPermissionChecks( 1197 List<MedicalResourceId> medicalResourceIds) { 1198 if (medicalResourceIds.isEmpty()) { 1199 throw new IllegalArgumentException("Nothing to delete specified"); 1200 } 1201 Pair<String, String[]> paramsAndArgs = 1202 makeParametersAndArgs(medicalResourceIds, /* appId= */ null); 1203 String whereClause = DELETE_ON_IDS_WHERE_CLAUSE + paramsAndArgs.first + ")"; 1204 mTransactionManager.runAsTransaction( 1205 db -> { 1206 db.delete(MEDICAL_RESOURCE_TABLE_NAME, whereClause, paramsAndArgs.second); 1207 }); 1208 } 1209 1210 /** 1211 * Deletes a list of {@link MedicalResource}s created based on the given list of {@link 1212 * MedicalResourceId}s into the HealthConnect database. 1213 * 1214 * @param medicalResourceIds list of {@link MedicalResourceId} to delete 1215 * @param callingPackageName Only allows deletions of resources whose owning datasource belongs 1216 * to the given appInfoId. 1217 * @throws IllegalArgumentException if no appId exists for the given {@code packageName} in the 1218 * {@link AppInfoHelper#TABLE_NAME}. 1219 */ 1220 public void deleteMedicalResourcesByIdsWithPermissionChecks( 1221 List<MedicalResourceId> medicalResourceIds, String callingPackageName) 1222 throws SQLiteException { 1223 1224 long appId = mAppInfoHelper.getAppInfoId(callingPackageName); 1225 if (appId == Constants.DEFAULT_LONG) { 1226 throw new IllegalArgumentException( 1227 "Deletion not permitted as app has inserted no data."); 1228 } 1229 1230 Pair<String, String[]> paramsAndArgs = makeParametersAndArgs(medicalResourceIds, appId); 1231 String whereClause = DELETE_ON_IDS_WHERE_CLAUSE + paramsAndArgs.first + ")"; 1232 String[] args = paramsAndArgs.second; 1233 1234 mTransactionManager.runAsTransaction( 1235 db -> { 1236 // Getting the distinct resource types that will be deleted, to add 1237 // access logs. 1238 Set<Integer> resourcesTypes = 1239 readMedicalResourcesTypes(db, medicalResourceIds, appId); 1240 1241 db.delete(MEDICAL_RESOURCE_TABLE_NAME, whereClause, args); 1242 1243 if (!resourcesTypes.isEmpty()) { 1244 mAccessLogsHelper.addAccessLog( 1245 db, 1246 callingPackageName, 1247 resourcesTypes, 1248 OPERATION_TYPE_DELETE, 1249 /* accessedMedicalDataSource= */ false); 1250 } 1251 }); 1252 } 1253 1254 private Set<Integer> readMedicalResourcesTypes( 1255 SQLiteDatabase db, List<MedicalResourceId> medicalResourceIds, long appId) { 1256 Pair<String, String[]> paramsAndArgs = makeParametersAndArgs(medicalResourceIds, appId); 1257 String sql = 1258 "SELECT DISTINCT " 1259 + MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName() 1260 + " FROM " 1261 + RESOURCES_JOIN_DATA_SOURCES_JOIN_INDICES 1262 + " WHERE " 1263 + SELECT_ON_IDS_WHERE_CLAUSE 1264 + paramsAndArgs.first; 1265 Set<Integer> resourceTypes = new HashSet<>(); 1266 try (Cursor cursor = db.rawQuery(sql, paramsAndArgs.second)) { 1267 if (cursor.moveToFirst()) { 1268 do { 1269 resourceTypes.add(getCursorInt(cursor, getMedicalResourceTypeColumnName())); 1270 } while (cursor.moveToNext()); 1271 } 1272 } 1273 return resourceTypes; 1274 } 1275 1276 /** 1277 * Deletes all {@link MedicalResource}s that are part of the given datasource. 1278 * 1279 * <p>No error occurs if any of the ids are not present because the ids are just a part of the 1280 * filters. 1281 * 1282 * @param request which resources to delete. 1283 */ 1284 public void deleteMedicalResourcesByRequestWithoutPermissionChecks( 1285 DeleteMedicalResourcesRequest request) throws SQLiteException { 1286 Set<String> dataSourceIds = request.getDataSourceIds(); 1287 Set<Integer> medicalResourceTypes = request.getMedicalResourceTypes(); 1288 List<UUID> dataSourceUuids = StorageUtils.toUuids(dataSourceIds); 1289 if (dataSourceUuids.isEmpty() && !dataSourceIds.isEmpty()) { 1290 // The request came in with no valid UUIDs. Do nothing. 1291 return; 1292 } 1293 mTransactionManager.delete( 1294 getFilteredDeleteRequest(dataSourceUuids, medicalResourceTypes, /* appId= */ null)); 1295 } 1296 1297 /** 1298 * Deletes all {@link MedicalResource}s that are part of the given datasource. 1299 * 1300 * <p>No error occurs if any of the ids are not present because the ids are just a part of the 1301 * filters. 1302 * 1303 * @param request which resources to delete. 1304 * @param callingPackageName only allows deletions of data sources belonging to the given app 1305 * @throws IllegalArgumentException if the {@code callingPackageName} does not exist in the 1306 * {@link AppInfoHelper#TABLE_NAME}. This can happen if the app has never written any data 1307 * sources. 1308 */ 1309 public void deleteMedicalResourcesByRequestWithPermissionChecks( 1310 DeleteMedicalResourcesRequest request, String callingPackageName) 1311 throws SQLiteException { 1312 Set<String> dataSourceIds = request.getDataSourceIds(); 1313 Set<Integer> medicalResourceTypes = request.getMedicalResourceTypes(); 1314 List<UUID> dataSourceUuids = StorageUtils.toUuids(dataSourceIds); 1315 if (dataSourceUuids.isEmpty() && !dataSourceIds.isEmpty()) { 1316 // The request came in with no valid UUIDs. Do nothing. 1317 return; 1318 } 1319 1320 long appId = mAppInfoHelper.getAppInfoId(callingPackageName); 1321 if (appId == Constants.DEFAULT_LONG) { 1322 throw new IllegalArgumentException( 1323 "Deletion not permitted as app has inserted no data."); 1324 } 1325 1326 mTransactionManager.runAsTransaction( 1327 db -> { 1328 // Getting the distinct resource types that will be deleted, to add 1329 // access logs. 1330 ReadTableRequest readRequest = 1331 getFilteredReadRequestForDistinctResourceTypes( 1332 dataSourceUuids, medicalResourceTypes, appId); 1333 Set<Integer> resourceTypes = 1334 readMedicalResourcesTypesByReadRequest(db, readRequest); 1335 1336 mTransactionManager.delete( 1337 db, 1338 getFilteredDeleteRequest(dataSourceUuids, medicalResourceTypes, appId)); 1339 1340 if (!resourceTypes.isEmpty()) { 1341 mAccessLogsHelper.addAccessLog( 1342 db, 1343 callingPackageName, 1344 resourceTypes, 1345 OPERATION_TYPE_DELETE, 1346 /* accessedMedicalDataSource= */ false); 1347 } 1348 }); 1349 } 1350 1351 private Set<Integer> readMedicalResourcesTypesByReadRequest( 1352 SQLiteDatabase db, ReadTableRequest request) { 1353 Set<Integer> resourceTypes = new HashSet<>(); 1354 try (Cursor cursor = mTransactionManager.read(db, request)) { 1355 if (cursor.moveToFirst()) { 1356 do { 1357 resourceTypes.add(getCursorInt(cursor, getMedicalResourceTypeColumnName())); 1358 } while (cursor.moveToNext()); 1359 } 1360 } 1361 return resourceTypes; 1362 } 1363 1364 private DeleteTableRequest getFilteredDeleteRequest( 1365 List<UUID> dataSourceUuids, Set<Integer> medicalResourceTypes, @Nullable Long appId) { 1366 /* 1367 SQLite does not allow deletes with joins. So the following code does a select with 1368 appropriate joins, and then deletes the result. This is doing the following SQL code: 1369 1370 DELETE FROM medical_resource_table 1371 WHERE medical_resource_row_id IN ( 1372 SELECT medical_resource_row_id FROM medical_resource_table 1373 JOIN medical_indices_table ... 1374 JOIN medical_datasource_table ... 1375 WHERE data_source_uuid IN (uuid1, uuid2, ...) 1376 AND app_info_id IN (id1, id2, ...) 1377 ) 1378 1379 The ReadTableRequest does the inner select, and the DeleteTableRequest does the outer 1380 delete. The foreign key between medical_resource_table and medical_data_source_table is 1381 (datasource) PRIMARY_COLUMN_NAME = (resource) DATA_SOURCE_ID_COLUMN_NAME. 1382 */ 1383 1384 WhereClauses dataSourceWhereClauses = 1385 MedicalDataSourceHelper.getWhereClauses(dataSourceUuids, appId); 1386 SqlJoin dataSourceJoin = joinWithMedicalDataSourceTable(); 1387 dataSourceJoin.setSecondTableWhereClause(dataSourceWhereClauses); 1388 1389 SqlJoin indexJoin = 1390 getJoinWithIndicesTableFilterOnMedicalResourceTypes(medicalResourceTypes); 1391 indexJoin.attachJoin(dataSourceJoin); 1392 1393 ReadTableRequest innerRead = 1394 new ReadTableRequest(getMainTableName()) 1395 .setJoinClause(indexJoin) 1396 .setColumnNames(List.of(getPrimaryColumn())); 1397 1398 return new DeleteTableRequest(getMainTableName()) 1399 .addExtraWhereClauses( 1400 new WhereClauses(AND) 1401 .addWhereInSQLRequestClause(getPrimaryColumn(), innerRead)); 1402 } 1403 1404 private static MedicalResource getMedicalResource(Cursor cursor) { 1405 int fhirResourceTypeInt = getCursorInt(cursor, FHIR_RESOURCE_TYPE_COLUMN_NAME); 1406 FhirResource fhirResource = 1407 new FhirResource.Builder( 1408 fhirResourceTypeInt, 1409 getCursorString(cursor, FHIR_RESOURCE_ID_COLUMN_NAME), 1410 getCursorString(cursor, FHIR_DATA_COLUMN_NAME)) 1411 .build(); 1412 FhirVersion fhirVersion = 1413 parseFhirVersion(getCursorString(cursor, getFhirVersionColumnName())); 1414 long lastModifiedTimestamp = 1415 getCursorLong(cursor, LAST_MODIFIED_TIMESTAMP_MEDICAL_RESOURCE_ALIAS); 1416 return new MedicalResource( 1417 getCursorInt(cursor, getMedicalResourceTypeColumnName()), 1418 getCursorUUID(cursor, getDataSourceUuidColumnName()).toString(), 1419 fhirVersion, 1420 fhirResource, 1421 lastModifiedTimestamp); 1422 } 1423 1424 /** 1425 * Creates sql and arguments suitable for appending to a WHERE clause specifying medical 1426 * resource ids. 1427 * 1428 * @param medicalResourceIds a non-empty list of ids to specify in the where clause. 1429 * @param appId if not null an app id which should be AND combined in the where clause. 1430 * @return a pair where the first element is SQL that can be appended to a relevant where clause 1431 * with some values parameterised. The second element is the values for those parameters 1432 * @throws IllegalArgumentException if any of the ids have a data source id that is not valid 1433 * (not a String form of a UUID) 1434 */ 1435 private static Pair<String, String[]> makeParametersAndArgs( 1436 List<MedicalResourceId> medicalResourceIds, @Nullable Long appId) { 1437 if (medicalResourceIds.isEmpty()) { 1438 throw new IllegalArgumentException("No ids provided"); 1439 } 1440 StringBuilder parameters = new StringBuilder(); 1441 // Data source id is not passed as a parameter as the rawQuery API does not allow 1442 // BLOBs as strings. So unfortunately we inline the datasource id. This means there 1443 // are only 2 parameters per medical resource id, not 3. 1444 // One potential future improvement is to keep a Data source id map in memory as is 1445 // done for App id. 1446 String[] selectionArgs = 1447 new String[2 * medicalResourceIds.size() + (appId == null ? 0 : 1)]; 1448 int index = 0; 1449 parameters.append('('); 1450 for (MedicalResourceId id : medicalResourceIds) { 1451 index = appendMedicalResourceId(id, parameters, selectionArgs, index); 1452 } 1453 // replace a trailing comma with a ) 1454 parameters.setCharAt(parameters.length() - 1, ')'); 1455 if (appId != null) { 1456 parameters 1457 .append(" AND ") 1458 .append(MedicalDataSourceHelper.getAppInfoIdColumnName()) 1459 .append("=?"); 1460 selectionArgs[index] = String.valueOf(appId); 1461 } 1462 1463 return new Pair<>(parameters.toString(), selectionArgs); 1464 } 1465 1466 /** 1467 * Creates SQL and selection arguments for the given {@link MedicalResourceId}s joining with 1468 * medical_resource_indices table and medical_data_source table and filtering on appId of the 1469 * {@code callingPackageName} and {@code medicalResourceTypes}. 1470 * 1471 * @param medicalResourceTypes a non-empty set of medical resource types to include. 1472 * @param appId an app id to filter to, or if null all app ids will be included 1473 * @throws IllegalArgumentException if any of the medical resource ids is not valid (has a data 1474 * source if which is not a valid string form of a UUID) 1475 */ 1476 private static Pair<String, String[]> readResourcesByIdsAppIdResourceTypes( 1477 List<MedicalResourceId> medicalResourceIds, 1478 @Nullable Long appId, 1479 LogicalOperator howToCombineAppIdAndResourceTypes, 1480 Set<Integer> medicalResourceTypes) { 1481 StringBuilder sql = 1482 new StringBuilder( 1483 "SELECT " 1484 + getMedicalResourceColumns() 1485 + " FROM " 1486 + RESOURCES_JOIN_DATA_SOURCES_JOIN_INDICES 1487 + " WHERE " 1488 + SELECT_ON_IDS_WHERE_CLAUSE); 1489 1490 String[] selectionArgs = 1491 new String 1492 [2 * medicalResourceIds.size() 1493 + (appId == null ? 0 : 1) 1494 + medicalResourceTypes.size()]; 1495 int index = 0; 1496 sql.append('('); 1497 for (MedicalResourceId id : medicalResourceIds) { 1498 index = appendMedicalResourceId(id, sql, selectionArgs, index); 1499 } 1500 // replace a trailing comma with a ) 1501 sql.setCharAt(sql.length() - 1, ')'); 1502 1503 sql.append(" AND ("); 1504 if (appId != null) { 1505 sql.append(MedicalDataSourceHelper.getAppInfoIdColumnName()) 1506 .append("=?") 1507 .append( 1508 howToCombineAppIdAndResourceTypes.equals(LogicalOperator.AND) 1509 ? " AND " 1510 : " OR "); 1511 selectionArgs[index++] = String.valueOf(appId); 1512 } 1513 sql.append(MedicalResourceIndicesHelper.getMedicalResourceTypeColumnName()).append(" IN ("); 1514 1515 for (Integer type : medicalResourceTypes) { 1516 sql.append("?,"); 1517 selectionArgs[index++] = String.valueOf(type); 1518 } 1519 // Replace closing comma with closing bracket for IN 1520 sql.setCharAt(sql.length() - 1, ')'); 1521 sql.append(")"); 1522 return new Pair<>(sql.toString(), selectionArgs); 1523 } 1524 1525 /** 1526 * Appends a medical resource id to both an SQL {@code StringBuilder} as a parameter and to an 1527 * array of arguments. 1528 * 1529 * @param id the id to append 1530 * @param sql the SQL string being built 1531 * @param selectionArgs the array holding the arguments for the SQL parameters 1532 * @param index the index to put the argument into {@code selectionArgs} 1533 * @return the new index for the next insert 1534 * @throws IllegalArgumentException if the data source id is not a valid UUID 1535 */ 1536 private static int appendMedicalResourceId( 1537 MedicalResourceId id, StringBuilder sql, String[] selectionArgs, int index) { 1538 // Data source id is not passed as a parameter as the rawQuery API does not allow 1539 // BLOBs as strings. So unfortunately we inline the datasource id. 1540 // One potential future improvement is to keep a Data source id map in memory as is 1541 // done for App id. 1542 sql.append("(") 1543 .append(StorageUtils.getHexString(UUID.fromString(id.getDataSourceId()))) 1544 .append(",?,?),"); 1545 selectionArgs[index++] = String.valueOf(id.getFhirResourceType()); 1546 selectionArgs[index++] = id.getFhirResourceId(); 1547 return index; 1548 } 1549 1550 private enum LogicalOperator { 1551 AND, 1552 OR 1553 } 1554 } 1555