1 /* 2 * Copyright (C) 2022 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.request; 18 19 import static android.health.connect.datatypes.AggregationType.AVG; 20 import static android.health.connect.datatypes.AggregationType.COUNT; 21 import static android.health.connect.datatypes.AggregationType.MAX; 22 import static android.health.connect.datatypes.AggregationType.MIN; 23 import static android.health.connect.datatypes.AggregationType.SUM; 24 25 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.APP_INFO_ID_COLUMN_NAME; 26 27 import android.annotation.NonNull; 28 import android.database.Cursor; 29 import android.health.connect.AggregateResult; 30 import android.health.connect.Constants; 31 import android.health.connect.LocalTimeRangeFilter; 32 import android.health.connect.TimeRangeFilter; 33 import android.health.connect.TimeRangeFilterHelper; 34 import android.health.connect.datatypes.AggregationType; 35 import android.util.ArrayMap; 36 import android.util.Pair; 37 import android.util.Slog; 38 39 import com.android.server.healthconnect.storage.TransactionManager; 40 import com.android.server.healthconnect.storage.datatypehelpers.AppInfoHelper; 41 import com.android.server.healthconnect.storage.datatypehelpers.RecordHelper; 42 import com.android.server.healthconnect.storage.datatypehelpers.aggregation.PriorityRecordsAggregator; 43 import com.android.server.healthconnect.storage.utils.OrderByClause; 44 import com.android.server.healthconnect.storage.utils.SqlJoin; 45 import com.android.server.healthconnect.storage.utils.StorageUtils; 46 import com.android.server.healthconnect.storage.utils.WhereClauses; 47 48 import java.time.Duration; 49 import java.time.LocalDateTime; 50 import java.time.Period; 51 import java.util.ArrayList; 52 import java.util.List; 53 import java.util.Map; 54 55 /** 56 * A request for {@link TransactionManager} to query the DB for aggregation results 57 * 58 * @hide 59 */ 60 public class AggregateTableRequest { 61 private static final String TAG = "HealthConnectAggregate"; 62 private static final String GROUP_BY_COLUMN_NAME = "category"; 63 64 private final long DEFAULT_TIME = -1; 65 private final String mTableName; 66 private final List<String> mColumnNamesToAggregate; 67 private final AggregationType<?> mAggregationType; 68 private final RecordHelper<?> mRecordHelper; 69 private final Map<Integer, AggregateResult<?>> mAggregateResults = new ArrayMap<>(); 70 private final String mTimeColumnName; 71 // Additional column used for time filtering. End time for interval records, 72 // null for other records. 73 private final String mEndTimeColumnName; 74 private final SqlJoin mSqlJoin; 75 private List<Long> mPackageFilters; 76 private long mStartTime = DEFAULT_TIME; 77 private long mEndTime = DEFAULT_TIME; 78 private String mPackageColumnName; 79 private String mGroupByColumnName; 80 private int mGroupBySize = 1; 81 private final List<String> mAdditionalColumnsToFetch; 82 private final AggregateParams.PriorityAggregationExtraParams mPriorityParams; 83 private final boolean mUseLocalTime; 84 private List<Long> mTimeSplits; 85 AggregateTableRequest( AggregateParams params, AggregationType<?> aggregationType, RecordHelper<?> recordHelper, boolean useLocalTime)86 public AggregateTableRequest( 87 AggregateParams params, 88 AggregationType<?> aggregationType, 89 RecordHelper<?> recordHelper, 90 boolean useLocalTime) { 91 mTableName = params.getTableName(); 92 mColumnNamesToAggregate = params.getColumnsToFetch(); 93 mTimeColumnName = params.getTimeColumnName(); 94 mAggregationType = aggregationType; 95 mRecordHelper = recordHelper; 96 mSqlJoin = params.getJoin(); 97 mPriorityParams = params.getPriorityAggregationExtraParams(); 98 mEndTimeColumnName = params.getExtraTimeColumnName(); 99 mAdditionalColumnsToFetch = new ArrayList<>(); 100 mAdditionalColumnsToFetch.add(params.getTimeOffsetColumnName()); 101 mAdditionalColumnsToFetch.add(mTimeColumnName); 102 if (mEndTimeColumnName != null) { 103 mAdditionalColumnsToFetch.add(mEndTimeColumnName); 104 } 105 mUseLocalTime = useLocalTime; 106 } 107 108 /** 109 * @return {@link AggregationType} for this request 110 */ getAggregationType()111 public AggregationType<?> getAggregationType() { 112 return mAggregationType; 113 } 114 115 /** 116 * @return {@link RecordHelper} for this request 117 */ getRecordHelper()118 public RecordHelper<?> getRecordHelper() { 119 return mRecordHelper; 120 } 121 122 /** 123 * @return results fetched after performing aggregate operation for this class. 124 * <p>Note: Only available after the call to {@link 125 * TransactionManager#populateWithAggregation} has been made 126 */ getAggregateResults()127 public List<AggregateResult<?>> getAggregateResults() { 128 List<AggregateResult<?>> aggregateResults = new ArrayList<>(mGroupBySize); 129 for (int i = 0; i < mGroupBySize; i++) { 130 aggregateResults.add(mAggregateResults.get(i)); 131 } 132 133 return aggregateResults; 134 } 135 136 /** Returns SQL statement to get data origins for the aggregation operation */ getCommandToFetchAggregateMetadata()137 public String getCommandToFetchAggregateMetadata() { 138 final StringBuilder builder = new StringBuilder("SELECT DISTINCT "); 139 builder.append(APP_INFO_ID_COLUMN_NAME).append(", "); 140 return appendAggregateCommand(builder, /* isMetadata= */ true); 141 } 142 143 /** Returns name of the main time column (start time for Interval, time for Instant records) */ getTimeColumnName()144 public String getTimeColumnName() { 145 return mTimeColumnName; 146 } 147 148 /** Returns whether request is using local time instead of physical one. */ getUseLocalTime()149 public boolean getUseLocalTime() { 150 return mUseLocalTime; 151 } 152 153 /** Returns SQL statement to perform aggregation operation */ 154 @NonNull getAggregationCommand()155 public String getAggregationCommand() { 156 final StringBuilder builder = new StringBuilder("SELECT "); 157 String aggCommand; 158 boolean usingPriority = 159 StorageUtils.supportsPriority( 160 mRecordHelper.getRecordIdentifier(), 161 mAggregationType.getAggregateOperationType()) 162 || StorageUtils.isDerivedType(mRecordHelper.getRecordIdentifier()); 163 if (usingPriority) { 164 for (String columnName : mColumnNamesToAggregate) { 165 builder.append(columnName).append(", "); 166 } 167 } else { 168 aggCommand = getSqlCommandFor(mAggregationType.getAggregateOperationType()); 169 170 for (String columnName : mColumnNamesToAggregate) { 171 builder.append(aggCommand) 172 .append("(") 173 .append(columnName) 174 .append(")") 175 .append(" as ") 176 .append(columnName) 177 .append(", "); 178 } 179 } 180 181 if (mAdditionalColumnsToFetch != null) { 182 for (String additionalColumnToFetch : mAdditionalColumnsToFetch) { 183 builder.append(additionalColumnToFetch).append(", "); 184 } 185 } 186 187 return appendAggregateCommand(builder, usingPriority); 188 } 189 setPackageFilter( List<Long> packageFilters, String packageColumnName)190 public AggregateTableRequest setPackageFilter( 191 List<Long> packageFilters, String packageColumnName) { 192 mPackageFilters = packageFilters; 193 mPackageColumnName = packageColumnName; 194 195 return this; 196 } 197 198 /** Sets time filter for table request. */ setTimeFilter(long startTime, long endTime)199 public AggregateTableRequest setTimeFilter(long startTime, long endTime) { 200 // Return if the params will result in no impact on the query 201 if (startTime < 0 || endTime < startTime) { 202 return this; 203 } 204 205 mStartTime = startTime; 206 mEndTime = endTime; 207 mTimeSplits = List.of(mStartTime, mEndTime); 208 return this; 209 } 210 211 /** Sets group by fields. */ setGroupBy( String columnName, Period period, Duration duration, TimeRangeFilter timeRangeFilter)212 public void setGroupBy( 213 String columnName, Period period, Duration duration, TimeRangeFilter timeRangeFilter) { 214 mGroupByColumnName = columnName; 215 if (period != null) { 216 mTimeSplits = getGroupSplitsForPeriod(timeRangeFilter, period); 217 } else if (duration != null) { 218 mTimeSplits = getGroupSplitsForDuration(timeRangeFilter, duration); 219 } else { 220 throw new IllegalArgumentException( 221 "Either aggregation period or duration should be not null"); 222 } 223 mGroupBySize = mTimeSplits.size() - 1; 224 225 if (Constants.DEBUG) { 226 Slog.d( 227 TAG, 228 "Group aggregation splits: " 229 + mTimeSplits 230 + " number of groups: " 231 + mGroupBySize); 232 } 233 } 234 onResultsFetched(Cursor cursor, Cursor metaDataCursor)235 public void onResultsFetched(Cursor cursor, Cursor metaDataCursor) { 236 if (StorageUtils.isDerivedType(mRecordHelper.getRecordIdentifier())) { 237 deriveAggregate(cursor); 238 } else if (StorageUtils.supportsPriority( 239 mRecordHelper.getRecordIdentifier(), 240 mAggregationType.getAggregateOperationType())) { 241 processPriorityRequest(cursor); 242 } else { 243 processNoPrioritiesRequest(cursor); 244 } 245 246 updateResultWithDataOriginPackageNames(metaDataCursor); 247 } 248 processPriorityRequest(Cursor cursor)249 private void processPriorityRequest(Cursor cursor) { 250 List<Long> priorityList = 251 StorageUtils.getAppIdPriorityList(mRecordHelper.getRecordIdentifier()); 252 PriorityRecordsAggregator aggregator = 253 new PriorityRecordsAggregator( 254 mTimeSplits, 255 priorityList, 256 mAggregationType.getAggregationTypeIdentifier(), 257 mPriorityParams, 258 mUseLocalTime); 259 aggregator.calculateAggregation(cursor); 260 AggregateResult<?> result; 261 for (int groupNumber = 0; groupNumber < mGroupBySize; groupNumber++) { 262 if (aggregator.getResultForGroup(groupNumber) == null) { 263 continue; 264 } 265 266 if (mAggregationType.getAggregateResultClass() == Long.class) { 267 result = 268 new AggregateResult<>( 269 aggregator.getResultForGroup(groupNumber).longValue()); 270 } else { 271 result = new AggregateResult<>(aggregator.getResultForGroup(groupNumber)); 272 } 273 mAggregateResults.put( 274 groupNumber, 275 result.setZoneOffset(aggregator.getZoneOffsetForGroup(groupNumber))); 276 } 277 278 if (Constants.DEBUG) { 279 Slog.d(TAG, "Priority aggregation result: " + mAggregateResults); 280 } 281 } 282 processNoPrioritiesRequest(Cursor cursor)283 private void processNoPrioritiesRequest(Cursor cursor) { 284 while (cursor.moveToNext()) { 285 mAggregateResults.put( 286 StorageUtils.getCursorInt(cursor, GROUP_BY_COLUMN_NAME), 287 mRecordHelper.getAggregateResult(cursor, mAggregationType)); 288 } 289 } 290 getSqlCommandFor(@ggregationType.AggregateOperationType int type)291 private static String getSqlCommandFor(@AggregationType.AggregateOperationType int type) { 292 return switch (type) { 293 case MAX -> "MAX"; 294 case MIN -> "MIN"; 295 case AVG -> "AVG"; 296 case SUM -> "SUM"; 297 case COUNT -> "COUNT"; 298 default -> null; 299 }; 300 } 301 appendAggregateCommand(StringBuilder builder, boolean isMetadata)302 private String appendAggregateCommand(StringBuilder builder, boolean isMetadata) { 303 boolean useGroupBy = mGroupByColumnName != null && !isMetadata; 304 if (useGroupBy) { 305 builder.append(" CASE "); 306 int groupByIndex = 0; 307 for (int i = 0; i < mTimeSplits.size() - 1; i++) { 308 builder.append(" WHEN ") 309 .append(mTimeColumnName) 310 .append(" >= ") 311 .append(mTimeSplits.get(i)) 312 .append(" AND ") 313 .append(mTimeColumnName) 314 .append(" < ") 315 .append(mTimeSplits.get(i + 1)) 316 .append(" THEN ") 317 .append(groupByIndex++); 318 } 319 builder.append(" END " + GROUP_BY_COLUMN_NAME + " "); 320 } else { 321 builder.setLength(builder.length() - 2); // Remove the last 2 char i.e. ", " 322 } 323 324 builder.append(" FROM ").append(mTableName); 325 if (mSqlJoin != null) { 326 builder.append(mSqlJoin.getJoinCommand()); 327 } 328 329 builder.append(buildAggregationWhereCondition()); 330 331 if (useGroupBy) { 332 builder.append(" GROUP BY " + GROUP_BY_COLUMN_NAME); 333 } 334 335 OrderByClause orderByClause = new OrderByClause(); 336 orderByClause.addOrderByClause(mTimeColumnName, true); 337 builder.append(orderByClause.getOrderBy()); 338 339 if (Constants.DEBUG) { 340 Slog.d(TAG, "Aggregation origin query: " + builder); 341 } 342 343 return builder.toString(); 344 } 345 buildAggregationWhereCondition()346 private String buildAggregationWhereCondition() { 347 WhereClauses whereClauses = new WhereClauses(); 348 whereClauses.addWhereInLongsClause(mPackageColumnName, mPackageFilters); 349 350 if (mEndTimeColumnName != null) { 351 // Filter all records which overlap with time filter interval: 352 // recordStartTime < filterEndTime and recordEndTime >= filterStartTime 353 whereClauses.addWhereGreaterThanOrEqualClause(mEndTimeColumnName, mStartTime); 354 } else { 355 whereClauses.addWhereGreaterThanOrEqualClause(mTimeColumnName, mStartTime); 356 } 357 whereClauses.addWhereLessThanClause(mTimeColumnName, mEndTime); 358 359 return whereClauses.get(/* withWhereKeyword= */ true); 360 } 361 updateResultWithDataOriginPackageNames(Cursor metaDataCursor)362 private void updateResultWithDataOriginPackageNames(Cursor metaDataCursor) { 363 List<Long> packageIds = new ArrayList<>(); 364 while (metaDataCursor.moveToNext()) { 365 packageIds.add(StorageUtils.getCursorLong(metaDataCursor, APP_INFO_ID_COLUMN_NAME)); 366 } 367 List<String> packageNames = AppInfoHelper.getInstance().getPackageNames(packageIds); 368 369 mAggregateResults.replaceAll( 370 (n, v) -> mAggregateResults.get(n).setDataOrigins(packageNames)); 371 } 372 getGroupSplitIntervals()373 public List<Pair<Long, Long>> getGroupSplitIntervals() { 374 List<Pair<Long, Long>> groupIntervals = new ArrayList<>(); 375 long previous = mTimeSplits.get(0); 376 for (int i = 1; i < mTimeSplits.size(); i++) { 377 Pair<Long, Long> pair = new Pair<>(previous, mTimeSplits.get(i)); 378 groupIntervals.add(pair); 379 previous = mTimeSplits.get(i); 380 } 381 382 return groupIntervals; 383 } 384 getGroupSplitsForPeriod(TimeRangeFilter timeFilter, Period period)385 private List<Long> getGroupSplitsForPeriod(TimeRangeFilter timeFilter, Period period) { 386 LocalDateTime filterStart = ((LocalTimeRangeFilter) timeFilter).getStartTime(); 387 LocalDateTime filterEnd = ((LocalTimeRangeFilter) timeFilter).getEndTime(); 388 389 List<Long> splits = new ArrayList<>(); 390 splits.add(TimeRangeFilterHelper.getMillisOfLocalTime(filterStart)); 391 392 LocalDateTime currentEnd = filterStart.plus(period); 393 while (!currentEnd.isAfter(filterEnd)) { 394 splits.add(TimeRangeFilterHelper.getMillisOfLocalTime(currentEnd)); 395 currentEnd = currentEnd.plus(period); 396 } 397 398 // If the last group doesn't fit the rest of the window, we cut it up to filterEnd 399 if (splits.get(splits.size() - 1) < TimeRangeFilterHelper.getMillisOfLocalTime(filterEnd)) { 400 splits.add(TimeRangeFilterHelper.getMillisOfLocalTime(filterEnd)); 401 } 402 return splits; 403 } 404 getGroupSplitsForDuration( TimeRangeFilter timeRangeFilter, Duration duration)405 private List<Long> getGroupSplitsForDuration( 406 TimeRangeFilter timeRangeFilter, Duration duration) { 407 long groupByStart = TimeRangeFilterHelper.getFilterStartTimeMillis(timeRangeFilter); 408 long groupByEnd = TimeRangeFilterHelper.getFilterEndTimeMillis(timeRangeFilter); 409 long groupDurationMillis = duration.toMillis(); 410 411 List<Long> splits = new ArrayList<>(); 412 splits.add(groupByStart); 413 long currentEnd = groupByStart + groupDurationMillis; 414 while (currentEnd <= groupByEnd) { 415 splits.add(currentEnd); 416 currentEnd += groupDurationMillis; 417 } 418 419 // If the last group doesn't fit the rest of the window, we cut it up to filterEnd 420 if (splits.get(splits.size() - 1) < groupByEnd) { 421 splits.add(groupByEnd); 422 } 423 return splits; 424 } 425 deriveAggregate(Cursor cursor)426 private void deriveAggregate(Cursor cursor) { 427 double[] derivedAggregateArray = mRecordHelper.deriveAggregate(cursor, this); 428 int index = 0; 429 cursor.moveToFirst(); 430 for (double aggregate : derivedAggregateArray) { 431 mAggregateResults.put( 432 index, mRecordHelper.getAggregateResult(cursor, mAggregationType, aggregate)); 433 index++; 434 } 435 } 436 } 437