Wed, 31 Dec 2014 07:22:50 +0100
Correct previous dual key logic pending first delivery installment.
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 package org.mozilla.gecko.background.healthreport;
7 import java.io.File;
8 import java.util.Collection;
9 import java.util.HashMap;
10 import java.util.concurrent.ConcurrentHashMap;
11 import java.util.concurrent.Executor;
12 import java.util.concurrent.Executors;
14 import org.json.JSONObject;
15 import org.mozilla.gecko.background.common.DateUtils;
16 import org.mozilla.gecko.background.common.log.Logger;
17 import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields.FieldSpec;
19 import android.content.ContentValues;
20 import android.content.Context;
21 import android.content.ContextWrapper;
22 import android.database.Cursor;
23 import android.database.SQLException;
24 import android.database.sqlite.SQLiteConstraintException;
25 import android.database.sqlite.SQLiteDatabase;
26 import android.database.sqlite.SQLiteOpenHelper;
27 import android.os.Build;
28 import android.util.SparseArray;
30 /**
31 * <code>HealthReportDatabaseStorage</code> provides an interface on top of
32 * SQLite storage for Health Report data. It exposes methods for management of
33 * environments, measurements, fields, and values, and a cursor-based API for
34 * querying.
35 *
36 * Health Report data is structured as follows.
37 *
38 * Records are primarily broken down by date, at day granularity. Each day's data
39 * is then split according to environment. An environment is a collection of
40 * constant attributes, such as version and processor; if one of these attributes
41 * changes, a new environment becomes active.
42 *
43 * Environments are identified by a stable hash of their attributes.
44 *
45 * The database includes a persistent numeric identifier for each environment. Create
46 * or fetch this identifier via:
47 *
48 * <pre>
49 * final Environment environment = storage.getEnvironment();
50 * // Init the environment now.
51 * String envHash = environment.getHash();
52 * int env = environment.register();
53 * </pre>
54 *
55 * You can safely cache this environment identifier for the life of the database.
56 *
57 * Orthogonal to environments are measurements. Each measurement is a named and
58 * versioned scope for a collection of fields. It is assumed that if a measurement
59 * with the given name and version is known to the database, then all of its fields
60 * are also known; if you change the collection of fields in the measurement, the
61 * measurement's version must be incremented, too.
62 *
63 * As with environments, measurements have an internal numeric identifier.
64 *
65 * Calling code should initialize its measurements as follows:
66 *
67 * <pre>
68 * public static class FooFieldsV1 implements MeasurementFields {
69 * {@literal @}Override
70 * public Iterable<String> getFields() {
71 * ArrayList<String> fields = new ArrayList<String>();
72 * fields.add("bar");
73 * fields.add("baz");
74 * return fields;
75 * }
76 * }
77 *
78 * storage.beginInitialization();
79 *
80 * try {
81 * storage.ensureMeasurementInitialized("org.mozilla.fooProvider.fooMeasurement",
82 * 1, new FooFieldsV1());
83 * storage.finishInitialization();
84 * } catch (Exception e) {
85 * storage.abortInitialization();
86 * }
87 * </pre>
88 *
89 * Measurements have fields. Fields can conceptually be divided into "daily last"
90 * (we only care about the last value), "daily counter" (increments per day),
91 * "daily discrete" (multiple records per day). Simply call the correct method for each.
92 *
93 * To do so you need a field ID, to avoid constant costly string lookups. You can get
94 * this value from storage:
95 *
96 * <pre>
97 * Field field = storage.getField("org.mozilla.fooProvider.fooMeasurement", 1, "bar");
98 * int fieldID = field.getID();
99 * </pre>
100 *
101 * This lookup is cached, and so is relatively inexpensive.
102 *
103 * You can then do something like the following:
104 *
105 * <pre>
106 * storage.recordDailyLast(storage.getDay(), env, fieldID, "last value");
107 * </pre>
108 *
109 * or equivalently for numeric values, discrete or counters, etc.
110 *
111 * To retrieve values, use {@link #getRawEventsSince(long)}.
112 *
113 * For safety, perform operations on the storage executor thread:
114 *
115 * <pre>
116 * storage.enqueueOperation(runnable);
117 * </pre>
118 */
119 public class HealthReportDatabaseStorage implements HealthReportStorage {
121 private static final String WHERE_DATE_AND_ENV_AND_FIELD = "date = ? AND env = ? AND field = ?";
123 public static final String[] COLUMNS_HASH = new String[] {"hash"};
124 public static final String[] COLUMNS_DATE_ENV_FIELD_VALUE = new String[] {"date", "env", "field", "value"};
125 public static final String[] COLUMNS_DATE_ENVSTR_M_MV_F_VALUE = new String[] {
126 "date", "environment", "measurement_name", "measurement_version",
127 "field_name", "field_flags", "value"
128 };
130 private static final String[] COLUMNS_ENVIRONMENT_DETAILS = new String[] {
131 "id", "version", "hash",
132 "profileCreation", "cpuCount", "memoryMB",
134 "isBlocklistEnabled", "isTelemetryEnabled", "extensionCount",
135 "pluginCount", "themeCount",
137 "architecture", "sysName", "sysVersion", "vendor", "appName", "appID",
138 "appVersion", "appBuildID", "platformVersion", "platformBuildID", "os",
139 "xpcomabi", "updateChannel",
141 "distribution", "osLocale", "appLocale", "acceptLangSet",
143 // Joined to the add-ons table.
144 "addonsBody"
145 };
147 public static final String[] COLUMNS_MEASUREMENT_DETAILS = new String[] {"id", "name", "version"};
148 public static final String[] COLUMNS_MEASUREMENT_AND_FIELD_DETAILS =
149 new String[] {"measurement_name", "measurement_id", "measurement_version",
150 "field_name", "field_id", "field_flags"};
152 private static final String[] COLUMNS_VALUE = new String[] {"value"};
153 private static final String[] COLUMNS_ID = new String[] {"id"};
155 private static final String EVENTS_TEXTUAL = "events_textual";
156 private static final String EVENTS_INTEGER = "events_integer";
158 protected static final String DB_NAME = "health.db";
160 private static final String LOG_TAG = "HealthReportStorage";
162 private final Executor executor = Executors.newSingleThreadExecutor();
164 @Override
165 public void enqueueOperation(Runnable runnable) {
166 executor.execute(runnable);
167 }
169 public HealthReportDatabaseStorage(final Context context,
170 final File profileDirectory) {
171 this.helper = new HealthReportSQLiteOpenHelper(context, profileDirectory,
172 DB_NAME);
173 executor.execute(new Runnable() {
174 @Override
175 public void run() {
176 Logger.setThreadLogTag(HealthReportConstants.GLOBAL_LOG_TAG);
177 Logger.debug(LOG_TAG, "Creating HealthReportDatabaseStorage.");
178 }
179 });
180 }
182 @Override
183 public void close() {
184 this.helper.close();
185 this.fields.clear();
186 this.envs.clear();
187 this.measurementVersions.clear();
188 }
190 protected final HealthReportSQLiteOpenHelper helper;
192 public static class HealthReportSQLiteOpenHelper extends SQLiteOpenHelper {
193 public static final int CURRENT_VERSION = 6;
194 public static final String LOG_TAG = "HealthReportSQL";
196 /**
197 * A little helper to avoid SQLiteOpenHelper misbehaving on Android 2.1.
198 * Partly cribbed from
199 * <http://stackoverflow.com/questions/5332328/sqliteopenhelper-problem-with-fully-qualified-db-path-name>.
200 */
201 public static class AbsolutePathContext extends ContextWrapper {
202 private final File parent;
204 public AbsolutePathContext(Context base, File parent) {
205 super(base);
206 this.parent = parent;
207 }
209 @Override
210 public File getDatabasePath(String name) {
211 return new File(getAbsolutePath(parent, name));
212 }
214 // Won't be called after API v11, but we can't override the version that
215 // *is* called and still support v8.
216 // Instead we check the version code in the HealthReportSQLiteOpenHelper
217 // constructor, and only use this workaround if we need to.
218 @Override
219 public SQLiteDatabase openOrCreateDatabase(String name,
220 int mode,
221 SQLiteDatabase.CursorFactory factory) {
222 final File path = getDatabasePath(name);
223 Logger.pii(LOG_TAG, "Opening database through absolute path " + path.getAbsolutePath());
224 return SQLiteDatabase.openOrCreateDatabase(path, null);
225 }
226 }
228 public static String getAbsolutePath(File parent, String name) {
229 return parent.getAbsolutePath() + File.separator + name;
230 }
232 public static boolean CAN_USE_ABSOLUTE_DB_PATH = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO);
233 public HealthReportSQLiteOpenHelper(Context context, File profileDirectory, String name) {
234 this(context, profileDirectory, name, CURRENT_VERSION);
235 }
237 // For testing DBs of different versions.
238 public HealthReportSQLiteOpenHelper(Context context, File profileDirectory, String name, int version) {
239 super(
240 (CAN_USE_ABSOLUTE_DB_PATH ? context : new AbsolutePathContext(context, profileDirectory)),
241 (CAN_USE_ABSOLUTE_DB_PATH ? getAbsolutePath(profileDirectory, name) : name),
242 null,
243 version);
245 if (CAN_USE_ABSOLUTE_DB_PATH) {
246 Logger.pii(LOG_TAG, "Opening: " + getAbsolutePath(profileDirectory, name));
247 }
248 }
250 @Override
251 public void onCreate(SQLiteDatabase db) {
252 db.execSQL("CREATE TABLE addons (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
253 " body TEXT, " +
254 " UNIQUE (body) " +
255 ")");
257 // N.B., hash collisions can occur across versions. In that case, the system
258 // is likely to persist the original environment version.
259 db.execSQL("CREATE TABLE environments (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
260 " version INTEGER, " +
261 " hash TEXT, " +
262 " profileCreation INTEGER, " +
263 " cpuCount INTEGER, " +
264 " memoryMB INTEGER, " +
265 " isBlocklistEnabled INTEGER, " +
266 " isTelemetryEnabled INTEGER, " +
267 " extensionCount INTEGER, " +
268 " pluginCount INTEGER, " +
269 " themeCount INTEGER, " +
270 " architecture TEXT, " +
271 " sysName TEXT, " +
272 " sysVersion TEXT, " +
273 " vendor TEXT, " +
274 " appName TEXT, " +
275 " appID TEXT, " +
276 " appVersion TEXT, " +
277 " appBuildID TEXT, " +
278 " platformVersion TEXT, " +
279 " platformBuildID TEXT, " +
280 " os TEXT, " +
281 " xpcomabi TEXT, " +
282 " updateChannel TEXT, " +
284 " distribution TEXT, " +
285 " osLocale TEXT, " +
286 " appLocale TEXT, " +
287 " acceptLangSet INTEGER, " +
289 " addonsID INTEGER, " +
290 " FOREIGN KEY (addonsID) REFERENCES addons(id) ON DELETE RESTRICT, " +
291 " UNIQUE (hash) " +
292 ")");
294 db.execSQL("CREATE TABLE measurements (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
295 " name TEXT, " +
296 " version INTEGER, " +
297 " UNIQUE (name, version) " +
298 ")");
300 db.execSQL("CREATE TABLE fields (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
301 " measurement INTEGER, " +
302 " name TEXT, " +
303 " flags INTEGER, " +
304 " FOREIGN KEY (measurement) REFERENCES measurements(id) ON DELETE CASCADE, " +
305 " UNIQUE (measurement, name)" +
306 ")");
308 db.execSQL("CREATE TABLE " + EVENTS_INTEGER + "(" +
309 " date INTEGER, " +
310 " env INTEGER, " +
311 " field INTEGER, " +
312 " value INTEGER, " +
313 " FOREIGN KEY (field) REFERENCES fields(id) ON DELETE CASCADE, " +
314 " FOREIGN KEY (env) REFERENCES environments(id) ON DELETE CASCADE" +
315 ")");
317 db.execSQL("CREATE TABLE " + EVENTS_TEXTUAL + "(" +
318 " date INTEGER, " +
319 " env INTEGER, " +
320 " field INTEGER, " +
321 " value TEXT, " +
322 " FOREIGN KEY (field) REFERENCES fields(id) ON DELETE CASCADE, " +
323 " FOREIGN KEY (env) REFERENCES environments(id) ON DELETE CASCADE" +
324 ")");
326 db.execSQL("CREATE INDEX idx_events_integer_date_env_field ON events_integer (date, env, field)");
327 db.execSQL("CREATE INDEX idx_events_textual_date_env_field ON events_textual (date, env, field)");
329 db.execSQL("CREATE VIEW events AS " +
330 "SELECT date, env, field, value FROM " + EVENTS_INTEGER + " " +
331 "UNION ALL " +
332 "SELECT date, env, field, value FROM " + EVENTS_TEXTUAL);
334 db.execSQL("CREATE VIEW named_events AS " +
335 "SELECT date, " +
336 " environments.hash AS environment, " +
337 " measurements.name AS measurement_name, " +
338 " measurements.version AS measurement_version, " +
339 " fields.name AS field_name, " +
340 " fields.flags AS field_flags, " +
341 " value FROM " +
342 "events JOIN environments ON events.env = environments.id " +
343 " JOIN fields ON events.field = fields.id " +
344 " JOIN measurements ON fields.measurement = measurements.id");
346 db.execSQL("CREATE VIEW named_fields AS " +
347 "SELECT measurements.name AS measurement_name, " +
348 " measurements.id AS measurement_id, " +
349 " measurements.version AS measurement_version, " +
350 " fields.name AS field_name, " +
351 " fields.id AS field_id, " +
352 " fields.flags AS field_flags " +
353 "FROM fields JOIN measurements ON fields.measurement = measurements.id");
355 db.execSQL("CREATE VIEW current_measurements AS " +
356 "SELECT name, MAX(version) AS version FROM measurements GROUP BY name");
358 createAddonsEnvironmentsView(db);
359 }
361 @Override
362 public void onOpen(SQLiteDatabase db) {
363 if (!db.isReadOnly()) {
364 db.execSQL("PRAGMA foreign_keys=ON;");
365 }
366 }
368 private void createAddonsEnvironmentsView(SQLiteDatabase db) {
369 db.execSQL("CREATE VIEW environments_with_addons AS " +
370 "SELECT e.id AS id, " +
371 " e.version AS version, " +
372 " e.hash AS hash, " +
373 " e.profileCreation AS profileCreation, " +
374 " e.cpuCount AS cpuCount, " +
375 " e.memoryMB AS memoryMB, " +
376 " e.isBlocklistEnabled AS isBlocklistEnabled, " +
377 " e.isTelemetryEnabled AS isTelemetryEnabled, " +
378 " e.extensionCount AS extensionCount, " +
379 " e.pluginCount AS pluginCount, " +
380 " e.themeCount AS themeCount, " +
381 " e.architecture AS architecture, " +
382 " e.sysName AS sysName, " +
383 " e.sysVersion AS sysVersion, " +
384 " e.vendor AS vendor, " +
385 " e.appName AS appName, " +
386 " e.appID AS appID, " +
387 " e.appVersion AS appVersion, " +
388 " e.appBuildID AS appBuildID, " +
389 " e.platformVersion AS platformVersion, " +
390 " e.platformBuildID AS platformBuildID, " +
391 " e.os AS os, " +
392 " e.xpcomabi AS xpcomabi, " +
393 " e.updateChannel AS updateChannel, " +
394 " e.distribution AS distribution, " +
395 " e.osLocale AS osLocale, " +
396 " e.appLocale AS appLocale, " +
397 " e.acceptLangSet AS acceptLangSet, " +
398 " addons.body AS addonsBody " +
399 "FROM environments AS e, addons " +
400 "WHERE e.addonsID = addons.id");
401 }
403 private void upgradeDatabaseFrom2To3(SQLiteDatabase db) {
404 db.execSQL("CREATE TABLE addons (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
405 " body TEXT, " +
406 " UNIQUE (body) " +
407 ")");
409 db.execSQL("ALTER TABLE environments ADD COLUMN addonsID INTEGER REFERENCES addons(id) ON DELETE RESTRICT");
411 createAddonsEnvironmentsView(db);
412 }
414 private void upgradeDatabaseFrom3To4(SQLiteDatabase db) {
415 // Update search measurements to use a different type.
416 db.execSQL("UPDATE OR IGNORE fields SET flags = " + Field.TYPE_COUNTED_STRING_DISCRETE +
417 " WHERE measurement IN (SELECT id FROM measurements WHERE name = 'org.mozilla.searches.counts')");
418 }
420 private void upgradeDatabaseFrom4to5(SQLiteDatabase db) {
421 // Delete NULL in addons.body, which appeared as a result of Bug 886156. Note that the
422 // foreign key constraint, "ON DELETE RESTRICT", may be violated, but since onOpen() is
423 // called after this method, foreign keys are not yet enabled and constraints can be broken.
424 db.delete("addons", "body IS NULL", null);
426 // Purge any data inconsistent with foreign key references (which may have appeared before
427 // foreign keys were enabled in Bug 900289).
428 db.delete("fields", "measurement NOT IN (SELECT id FROM measurements)", null);
429 db.delete("environments", "addonsID NOT IN (SELECT id from addons)", null);
430 db.delete(EVENTS_INTEGER, "env NOT IN (SELECT id FROM environments)", null);
431 db.delete(EVENTS_TEXTUAL, "env NOT IN (SELECT id FROM environments)", null);
432 db.delete(EVENTS_INTEGER, "field NOT IN (SELECT id FROM fields)", null);
433 db.delete(EVENTS_TEXTUAL, "field NOT IN (SELECT id FROM fields)", null);
434 }
436 private void upgradeDatabaseFrom5to6(SQLiteDatabase db) {
437 db.execSQL("DROP VIEW environments_with_addons");
439 // Add version to environment (default to 1).
440 db.execSQL("ALTER TABLE environments ADD COLUMN version INTEGER DEFAULT 1");
442 // Add fields to environment (default to empty string).
443 db.execSQL("ALTER TABLE environments ADD COLUMN distribution TEXT DEFAULT ''");
444 db.execSQL("ALTER TABLE environments ADD COLUMN osLocale TEXT DEFAULT ''");
445 db.execSQL("ALTER TABLE environments ADD COLUMN appLocale TEXT DEFAULT ''");
446 db.execSQL("ALTER TABLE environments ADD COLUMN acceptLangSet INTEGER DEFAULT 0");
448 // Recreate view.
449 createAddonsEnvironmentsView(db);
450 }
452 @Override
453 public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
454 if (oldVersion >= newVersion) {
455 return;
456 }
458 Logger.info(LOG_TAG, "onUpgrade: from " + oldVersion + " to " + newVersion + ".");
459 try {
460 switch (oldVersion) {
461 case 2:
462 upgradeDatabaseFrom2To3(db);
463 case 3:
464 upgradeDatabaseFrom3To4(db);
465 case 4:
466 upgradeDatabaseFrom4to5(db);
467 case 5:
468 upgradeDatabaseFrom5to6(db);
469 }
470 } catch (Exception e) {
471 Logger.error(LOG_TAG, "Failure in onUpgrade.", e);
472 throw new RuntimeException(e);
473 }
474 }
476 public void deleteEverything() {
477 final SQLiteDatabase db = this.getWritableDatabase();
479 Logger.info(LOG_TAG, "Deleting everything.");
480 db.beginTransaction();
481 try {
482 // Cascade will clear the rest.
483 db.delete("measurements", null, null);
484 db.delete("environments", null, null);
485 db.delete("addons", null, null);
486 db.setTransactionSuccessful();
487 Logger.info(LOG_TAG, "Deletion successful.");
488 } finally {
489 db.endTransaction();
490 }
491 }
492 }
494 public class DatabaseField extends Field {
495 public DatabaseField(String mName, int mVersion, String fieldName) {
496 this(mName, mVersion, fieldName, UNKNOWN_TYPE_OR_FIELD_ID, UNKNOWN_TYPE_OR_FIELD_ID);
497 }
499 public DatabaseField(String mName, int mVersion, String fieldName, int flags) {
500 this(mName, mVersion, fieldName, UNKNOWN_TYPE_OR_FIELD_ID, flags);
501 }
503 public DatabaseField(String mName, int mVersion, String fieldName, int fieldID, int flags) {
504 super(mName, mVersion, fieldName, flags);
505 this.fieldID = fieldID;
506 }
508 private void loadFlags() {
509 if (this.flags == UNKNOWN_TYPE_OR_FIELD_ID) {
510 if (this.fieldID == UNKNOWN_TYPE_OR_FIELD_ID) {
511 this.getID();
512 }
513 this.flags = integerQuery("fields", "flags", "id = ?", new String[] { Integer.toString(this.fieldID, 10) }, -1);
514 }
515 }
517 @Override
518 public synchronized boolean isIntegerField() {
519 loadFlags();
520 return super.isIntegerField();
521 }
523 @Override
524 public synchronized boolean isStringField() {
525 loadFlags();
526 return super.isStringField();
527 }
529 @Override
530 public synchronized boolean isDiscreteField() {
531 loadFlags();
532 return super.isDiscreteField();
533 }
535 @Override
536 public synchronized int getID() throws IllegalStateException {
537 if (this.fieldID == UNKNOWN_TYPE_OR_FIELD_ID) {
538 this.fieldID = integerQuery("named_fields", "field_id",
539 "measurement_name = ? AND measurement_version = ? AND field_name = ?",
540 new String[] {measurementName, measurementVersion, fieldName},
541 UNKNOWN_TYPE_OR_FIELD_ID);
542 if (this.fieldID == UNKNOWN_TYPE_OR_FIELD_ID) {
543 throw new IllegalStateException("No field with name " + fieldName +
544 " (" + measurementName + ", " + measurementVersion + ")");
545 }
546 }
547 return this.fieldID;
548 }
549 }
551 // `envs` and `fields` look similar, but they are touched differently and
552 // store differently stable kinds of data, hence type difference.
553 // Note that we don't pre-populate the environment cache. We'll typically only
554 // handle one per session.
555 //
556 // protected for testing purposes only.
557 protected final ConcurrentHashMap<String, Integer> envs = new ConcurrentHashMap<String, Integer>();
559 /**
560 * An {@link Environment} that knows how to persist to and from our database.
561 */
562 public static class DatabaseEnvironment extends Environment {
563 protected final HealthReportDatabaseStorage storage;
565 @Override
566 public int register() {
567 final String h = getHash();
568 if (storage.envs.containsKey(h)) {
569 this.id = storage.envs.get(h);
570 return this.id;
571 }
573 // Otherwise, add data and hash to the DB.
574 ContentValues v = new ContentValues();
575 v.put("version", version);
576 v.put("hash", h);
577 v.put("profileCreation", profileCreation);
578 v.put("cpuCount", cpuCount);
579 v.put("memoryMB", memoryMB);
580 v.put("isBlocklistEnabled", isBlocklistEnabled);
581 v.put("isTelemetryEnabled", isTelemetryEnabled);
582 v.put("extensionCount", extensionCount);
583 v.put("pluginCount", pluginCount);
584 v.put("themeCount", themeCount);
585 v.put("architecture", architecture);
586 v.put("sysName", sysName);
587 v.put("sysVersion", sysVersion);
588 v.put("vendor", vendor);
589 v.put("appName", appName);
590 v.put("appID", appID);
591 v.put("appVersion", appVersion);
592 v.put("appBuildID", appBuildID);
593 v.put("platformVersion", platformVersion);
594 v.put("platformBuildID", platformBuildID);
595 v.put("os", os);
596 v.put("xpcomabi", xpcomabi);
597 v.put("updateChannel", updateChannel);
598 v.put("distribution", distribution);
599 v.put("osLocale", osLocale);
600 v.put("appLocale", appLocale);
601 v.put("acceptLangSet", acceptLangSet);
603 final SQLiteDatabase db = storage.helper.getWritableDatabase();
605 // If we're not already, we want all of our inserts to be in a transaction.
606 boolean newTransaction = !db.inTransaction();
608 // Insert, with a little error handling to populate the cache in case of
609 // omission and consequent collision.
610 //
611 // We would like to hang a trigger off a view here, and just use that for
612 // inserts. But triggers don't seem to correctly update the last inserted
613 // ID, so Android's insertOrThrow method returns -1.
614 //
615 // Instead, we go without the trigger, simply running the inserts ourselves.
616 //
617 // insertWithOnConflict doesn't work as documented: <http://stackoverflow.com/questions/11328877/android-sqllite-on-conflict-ignore-is-ignored-in-ics/11424150>.
618 // So we do this the hard way.
619 // We presume that almost every get will hit the cache (except for the first, obviously), so we
620 // bias here towards inserts for the environments.
621 // For add-ons we assume that different environments will share add-ons, so we query first.
623 final String addonsJSON = getNormalizedAddonsJSON();
624 if (newTransaction) {
625 db.beginTransaction();
626 }
628 try {
629 int addonsID = ensureAddons(db, addonsJSON);
630 v.put("addonsID", addonsID);
632 try {
633 int inserted = (int) db.insertOrThrow("environments", null, v);
634 Logger.debug(LOG_TAG, "Inserted ID: " + inserted + " for hash " + h);
635 if (inserted == -1) {
636 throw new SQLException("Insert returned -1!");
637 }
638 this.id = inserted;
639 storage.envs.put(h, this.id);
640 if (newTransaction) {
641 db.setTransactionSuccessful();
642 }
643 return inserted;
644 } catch (SQLException e) {
645 // The inserter should take care of updating `envs`. But if it
646 // doesn't...
647 Cursor c = db.query("environments", COLUMNS_ID, "hash = ?",
648 new String[] { h }, null, null, null);
649 try {
650 if (!c.moveToFirst()) {
651 throw e;
652 }
653 this.id = (int) c.getLong(0);
654 Logger.debug(LOG_TAG, "Found " + this.id + " for hash " + h);
655 storage.envs.put(h, this.id);
656 if (newTransaction) {
657 db.setTransactionSuccessful();
658 }
659 return this.id;
660 } finally {
661 c.close();
662 }
663 }
664 } finally {
665 if (newTransaction) {
666 db.endTransaction();
667 }
668 }
669 }
671 protected static int ensureAddons(SQLiteDatabase db, String json) {
672 Cursor c = db.query("addons", COLUMNS_ID, "body = ?",
673 new String[] { (json == null) ? "null" : json }, null, null, null);
674 try {
675 if (c.moveToFirst()) {
676 return c.getInt(0);
677 }
678 ContentValues values = new ContentValues();
679 values.put("body", json);
680 return (int) db.insert("addons", null, values);
681 } finally {
682 c.close();
683 }
684 }
686 public void init(ContentValues v) {
687 version = v.containsKey("version") ? v.getAsInteger("version") : Environment.CURRENT_VERSION;
688 profileCreation = v.getAsInteger("profileCreation");
689 cpuCount = v.getAsInteger("cpuCount");
690 memoryMB = v.getAsInteger("memoryMB");
692 isBlocklistEnabled = v.getAsInteger("isBlocklistEnabled");
693 isTelemetryEnabled = v.getAsInteger("isTelemetryEnabled");
694 extensionCount = v.getAsInteger("extensionCount");
695 pluginCount = v.getAsInteger("pluginCount");
696 themeCount = v.getAsInteger("themeCount");
698 architecture = v.getAsString("architecture");
699 sysName = v.getAsString("sysName");
700 sysVersion = v.getAsString("sysVersion");
701 vendor = v.getAsString("vendor");
702 appName = v.getAsString("appName");
703 appID = v.getAsString("appID");
704 appVersion = v.getAsString("appVersion");
705 appBuildID = v.getAsString("appBuildID");
706 platformVersion = v.getAsString("platformVersion");
707 platformBuildID = v.getAsString("platformBuildID");
708 os = v.getAsString("os");
709 xpcomabi = v.getAsString("xpcomabi");
710 updateChannel = v.getAsString("updateChannel");
712 distribution = v.getAsString("distribution");
713 osLocale = v.getAsString("osLocale");
714 appLocale = v.getAsString("appLocale");
715 acceptLangSet = v.getAsInteger("acceptLangSet");
717 try {
718 setJSONForAddons(v.getAsString("addonsBody"));
719 } catch (Exception e) {
720 // Nothing we can do.
721 }
723 this.hash = null;
724 this.id = -1;
725 }
727 /**
728 * Fill ourselves with data from the DB, then advance the cursor.
729 *
730 * @param cursor a {@link Cursor} pointing at a record to load.
731 * @return true if the cursor was successfully advanced.
732 */
733 public boolean init(Cursor cursor) {
734 int i = 0;
735 this.id = cursor.getInt(i++);
736 this.version = cursor.getInt(i++);
737 this.hash = cursor.getString(i++);
739 profileCreation = cursor.getInt(i++);
740 cpuCount = cursor.getInt(i++);
741 memoryMB = cursor.getInt(i++);
743 isBlocklistEnabled = cursor.getInt(i++);
744 isTelemetryEnabled = cursor.getInt(i++);
745 extensionCount = cursor.getInt(i++);
746 pluginCount = cursor.getInt(i++);
747 themeCount = cursor.getInt(i++);
749 architecture = cursor.getString(i++);
750 sysName = cursor.getString(i++);
751 sysVersion = cursor.getString(i++);
752 vendor = cursor.getString(i++);
753 appName = cursor.getString(i++);
754 appID = cursor.getString(i++);
755 appVersion = cursor.getString(i++);
756 appBuildID = cursor.getString(i++);
757 platformVersion = cursor.getString(i++);
758 platformBuildID = cursor.getString(i++);
759 os = cursor.getString(i++);
760 xpcomabi = cursor.getString(i++);
761 updateChannel = cursor.getString(i++);
763 distribution = cursor.getString(i++);
764 osLocale = cursor.getString(i++);
765 appLocale = cursor.getString(i++);
766 acceptLangSet = cursor.getInt(i++);
768 try {
769 setJSONForAddons(cursor.getBlob(i++));
770 } catch (Exception e) {
771 // Nothing we can do.
772 }
774 return cursor.moveToNext();
775 }
777 public DatabaseEnvironment(HealthReportDatabaseStorage storage, Class<? extends EnvironmentAppender> appender) {
778 super(appender);
779 this.storage = storage;
780 }
782 public DatabaseEnvironment(HealthReportDatabaseStorage storage) {
783 this.storage = storage;
784 }
785 }
787 /**
788 * Factory method. Returns a new {@link Environment} that callers can
789 * populate and then register.
790 */
791 @Override
792 public DatabaseEnvironment getEnvironment() {
793 return new DatabaseEnvironment(this);
794 }
796 @Override
797 public SparseArray<Environment> getEnvironmentRecordsByID() {
798 final SQLiteDatabase db = this.helper.getReadableDatabase();
799 Cursor c = db.query("environments_with_addons", COLUMNS_ENVIRONMENT_DETAILS, null, null, null, null, null);
800 try {
801 SparseArray<Environment> results = new SparseArray<Environment>();
802 if (!c.moveToFirst()) {
803 return results;
804 }
806 DatabaseEnvironment e = getEnvironment();
807 while (e.init(c)) {
808 results.put(e.id, e);
809 e = getEnvironment();
810 }
811 results.put(e.id, e);
812 return results;
813 } finally {
814 c.close();
815 }
816 }
818 /**
819 * Reverse lookup for an env. Only really used for tests: document generation
820 * fetches all environments at once, and insertion only uses the integer key
821 * that's returned during insertion.
822 *
823 * @param id
824 * the identifier for the environment.
825 * @return a cursor over its details.
826 */
827 @Override
828 public Cursor getEnvironmentRecordForID(int id) {
829 final SQLiteDatabase db = this.helper.getReadableDatabase();
830 return db.query("environments_with_addons", COLUMNS_ENVIRONMENT_DETAILS, "id = " + id, null, null, null, null);
831 }
833 @Override
834 public SparseArray<String> getEnvironmentHashesByID() {
835 final SQLiteDatabase db = this.helper.getReadableDatabase();
836 Cursor c = db.query("environments", new String[] {"id", "hash"}, null, null, null, null, null);
837 try {
838 SparseArray<String> results = new SparseArray<String>();
839 if (!c.moveToFirst()) {
840 return results;
841 }
843 while (!c.isAfterLast()) {
844 results.put(c.getInt(0), c.getString(1));
845 c.moveToNext();
846 }
847 return results;
848 } finally {
849 c.close();
850 }
851 }
853 /**
854 * Cache the lookup from measurement and field specifier to field instance.
855 * This allows us to memoize the field ID lookup, too.
856 */
857 private HashMap<String, Field> fields = new HashMap<String, Field>();
858 private boolean fieldsCacheUpdated = false;
860 private void invalidateFieldsCache() {
861 synchronized (this.fields) {
862 fieldsCacheUpdated = false;
863 }
864 }
866 private String getFieldKey(String mName, int mVersion, String fieldName) {
867 return mVersion + "." + mName + "/" + fieldName;
868 }
870 @Override
871 public Field getField(String mName, int mVersion, String fieldName) {
872 final String key = getFieldKey(mName, mVersion, fieldName);
873 synchronized (fields) {
874 if (fields.containsKey(key)) {
875 return fields.get(key);
876 }
877 Field f = new DatabaseField(mName, mVersion, fieldName);
878 fields.put(key, f);
879 return f;
880 }
881 }
883 private void populateFieldCache() {
884 synchronized (fields) {
885 if (fieldsCacheUpdated) {
886 return;
887 }
889 fields.clear();
890 Cursor c = getFieldVersions();
891 try {
892 if (!c.moveToFirst()) {
893 return;
894 }
895 do {
896 // We don't use the measurement ID here, so column 1 is unused.
897 final String mName = c.getString(0);
898 final int mVersion = c.getInt(2);
899 final String fieldName = c.getString(3);
900 final int fieldID = c.getInt(4);
901 final int flags = c.getInt(5);
902 final String key = getFieldKey(mName, mVersion, fieldName);
904 Field f = new DatabaseField(mName, mVersion, fieldName, fieldID, flags);
905 fields.put(key, f);
906 } while (c.moveToNext());
907 fieldsCacheUpdated = true;
908 } finally {
909 c.close();
910 }
911 }
912 }
914 /**
915 * Return mappings from field ID to Field instance. Do so by looking in the DB.
916 */
917 @Override
918 public SparseArray<Field> getFieldsByID() {
919 final SparseArray<Field> out = new SparseArray<Field>();
920 synchronized (fields) {
921 populateFieldCache();
922 Collection<Field> values = fields.values();
923 for (Field field : values) {
924 // Cache is up-to-date at this point, so we don't need to hit the DB.
925 out.put(field.getID(), field);
926 }
927 }
928 return out;
929 }
931 private final HashMap<String, Integer> measurementVersions = new HashMap<String, Integer>();
933 private void populateMeasurementVersionsCache(SQLiteDatabase db) {
934 HashMap<String, Integer> results = getIntegers(db, "current_measurements", "name", "version");
935 if (results == null) {
936 measurementVersions.clear();
937 return;
938 }
939 synchronized (measurementVersions) {
940 measurementVersions.clear();
941 measurementVersions.putAll(results);
942 }
943 }
945 /**
946 * Return the version of the measurement for which the DB is currently configured, or
947 * 0 if unknown.
948 * @param measurement String measurement identifier.
949 * @return Current version.
950 */
951 private int getMeasurementVersion(String measurement) {
952 synchronized (measurementVersions) {
953 if (measurementVersions.containsKey(measurement)) {
954 return measurementVersions.get(measurement);
955 }
957 // This should never be necessary, unless the measurement does not exist.
958 int value = integerQuery("measurements", "version", "name = ?", new String[] {measurement}, 0);
959 measurementVersions.put(measurement, value);
960 return value;
961 }
962 }
964 /**
965 * Inform the storage layer that fields for the given measurement have been updated
966 * to this version.
967 *
968 * This should be one of the final calls in a configuration transaction.
969 * Always call this inside a transaction.
970 */
971 private void notifyMeasurementVersionUpdated(String measurement, int version) {
972 Logger.info(LOG_TAG, "Measurement " + measurement + " now at " + version);
974 final SQLiteDatabase db = this.helper.getWritableDatabase();
975 final ContentValues values = new ContentValues();
976 values.put("name", measurement);
977 values.put("version", version);
979 synchronized (measurementVersions) {
980 measurementVersions.put(measurement, version);
981 }
983 db.insertWithOnConflict("measurements", null, values, SQLiteDatabase.CONFLICT_IGNORE);
984 }
986 /**
987 * Call in a transaction.
988 * This method could race with other accesses, but (a) it's within a transaction,
989 * (b) configuration should be single-threaded, (c) we initialize the cache up-front.
990 */
991 @Override
992 public void ensureMeasurementInitialized(String measurement, int version, MeasurementFields fields) {
993 final int currentVersion = getMeasurementVersion(measurement);
994 Logger.info(LOG_TAG, "Initializing measurement " + measurement + " to " +
995 version + " (current " + currentVersion + ")");
997 if (currentVersion == version) {
998 Logger.info(LOG_TAG, "Measurement " + measurement + " already at v" + version);
999 return;
1000 }
1002 final SQLiteDatabase db = this.helper.getWritableDatabase();
1003 if (!db.inTransaction()) {
1004 Logger.warn(LOG_TAG, "ensureMeasurementInitialized should be called within a transaction.");
1005 }
1007 final ContentValues mv = new ContentValues();
1008 mv.put("name", measurement);
1009 mv.put("version", version);
1011 final int measurementID = (int) db.insert("measurements", null, mv);
1013 final ContentValues v = new ContentValues();
1014 v.put("measurement", measurementID);
1015 for (FieldSpec field : fields.getFields()) {
1016 v.put("name", field.name);
1017 v.put("flags", field.type);
1018 Logger.debug(LOG_TAG, "M: " + measurementID + " F: " + field.name + " (" + field.type + ")");
1019 db.insert("fields", null, v);
1020 }
1022 notifyMeasurementVersionUpdated(measurement, version);
1024 // Let's be easy for now.
1025 invalidateFieldsCache();
1026 }
1028 /**
1029 * Return a cursor over the measurements and fields in the DB.
1030 * Columns are {@link HealthReportDatabaseStorage#COLUMNS_MEASUREMENT_AND_FIELD_DETAILS}.
1031 */
1032 @Override
1033 public Cursor getFieldVersions() {
1034 final SQLiteDatabase db = this.helper.getReadableDatabase();
1035 return db.query("named_fields", COLUMNS_MEASUREMENT_AND_FIELD_DETAILS,
1036 null, null, null, null, "measurement_name, measurement_version, field_name");
1037 }
1039 @Override
1040 public Cursor getFieldVersions(String measurement, int measurementVersion) {
1041 final SQLiteDatabase db = this.helper.getReadableDatabase();
1042 return db.query("named_fields", COLUMNS_MEASUREMENT_AND_FIELD_DETAILS,
1043 "measurement_name = ? AND measurement_version = ?",
1044 new String[] {measurement, Integer.toString(measurementVersion)},
1045 null, null, "field_name");
1046 }
1048 @Override
1049 public Cursor getMeasurementVersions() {
1050 final SQLiteDatabase db = this.helper.getReadableDatabase();
1051 return db.query("measurements", COLUMNS_MEASUREMENT_DETAILS,
1052 null, null, null, null, "name, version");
1053 }
1055 /**
1056 * A thin wrapper around the database transactional semantics. Clients can
1057 * use this to more efficiently ensure that measurements are initialized.
1058 *
1059 * Note that caches are also initialized here.
1060 */
1061 public void beginInitialization() {
1062 SQLiteDatabase db = this.helper.getWritableDatabase();
1063 db.beginTransaction();
1064 populateMeasurementVersionsCache(db);
1065 }
1067 public void finishInitialization() {
1068 SQLiteDatabase db = this.helper.getWritableDatabase();
1069 db.setTransactionSuccessful();
1070 db.endTransaction();
1071 }
1073 public void abortInitialization() {
1074 this.helper.getWritableDatabase().endTransaction();
1075 }
1077 protected int getIntFromQuery(final String sql, final String[] selectionArgs) {
1078 final SQLiteDatabase db = this.helper.getReadableDatabase();
1079 final Cursor c = db.rawQuery(sql, selectionArgs);
1080 try {
1081 if (!c.moveToFirst()) {
1082 throw new IllegalStateException("Cursor is empty.");
1083 }
1084 return c.getInt(0);
1085 } finally {
1086 c.close();
1087 }
1088 }
1090 @Override
1091 public int getDay(long time) {
1092 return DateUtils.getDay(time);
1093 }
1095 @Override
1096 public int getDay() {
1097 return this.getDay(System.currentTimeMillis());
1098 }
1100 private void recordDailyLast(int env, int day, int field, Object value, String table) {
1101 if (env == -1) {
1102 Logger.warn(LOG_TAG, "Refusing to record with environment = -1.");
1103 return;
1104 }
1106 final SQLiteDatabase db = this.helper.getWritableDatabase();
1108 final String envString = Integer.toString(env);
1109 final String fieldIDString = Integer.toString(field, 10);
1110 final String dayString = Integer.toString(day, 10);
1112 // Java, your capacity for abstraction leaves me wanting.
1113 final ContentValues v = new ContentValues();
1114 putValue(v, value);
1116 // If we used a separate table, such that we could have a
1117 // UNIQUE(env, field, day) constraint for daily-last values, then we could
1118 // use INSERT OR REPLACE.
1119 final int updated = db.update(table, v, WHERE_DATE_AND_ENV_AND_FIELD,
1120 new String[] {dayString, envString, fieldIDString});
1121 if (0 == updated) {
1122 v.put("env", env);
1123 v.put("field", field);
1124 v.put("date", day);
1125 try {
1126 db.insertOrThrow(table, null, v);
1127 } catch (SQLiteConstraintException e) {
1128 throw new IllegalStateException("Event did not reference existing an environment or field.", e);
1129 }
1130 }
1131 }
1133 @Override
1134 public void recordDailyLast(int env, int day, int field, JSONObject value) {
1135 this.recordDailyLast(env, day, field, value == null ? "null" : value.toString(), EVENTS_TEXTUAL);
1136 }
1138 @Override
1139 public void recordDailyLast(int env, int day, int field, String value) {
1140 this.recordDailyLast(env, day, field, value, EVENTS_TEXTUAL);
1141 }
1143 @Override
1144 public void recordDailyLast(int env, int day, int field, int value) {
1145 this.recordDailyLast(env, day, field, Integer.valueOf(value), EVENTS_INTEGER);
1146 }
1148 private void recordDailyDiscrete(int env, int day, int field, Object value, String table) {
1149 if (env == -1) {
1150 Logger.warn(LOG_TAG, "Refusing to record with environment = -1.");
1151 return;
1152 }
1154 final ContentValues v = new ContentValues();
1155 v.put("env", env);
1156 v.put("field", field);
1157 v.put("date", day);
1159 final SQLiteDatabase db = this.helper.getWritableDatabase();
1160 putValue(v, value);
1162 // Using SQLiteDatabase.insertOrThrow throws SQLiteConstraintException we cannot catch for
1163 // unknown reasons (bug 961526 comment 13). We believe these are thrown because we attempt to
1164 // record events using environment IDs removed from the database by the prune service. We
1165 // invalidate the currentEnvironment ID after pruning, preventing further propagation,
1166 // however, any event recording waiting for the prune service to complete on the background
1167 // thread may carry an invalid ID: we expect an insertion failure and drop these events here.
1168 final long res = db.insert(table, null, v);
1169 if (res == -1) {
1170 Logger.error(LOG_TAG, "Unable to record daily discrete event. Ignoring.");
1171 }
1172 }
1174 @Override
1175 public void recordDailyDiscrete(int env, int day, int field, JSONObject value) {
1176 this.recordDailyDiscrete(env, day, field, value == null ? "null" : value.toString(), EVENTS_TEXTUAL);
1177 }
1179 @Override
1180 public void recordDailyDiscrete(int env, int day, int field, String value) {
1181 this.recordDailyDiscrete(env, day, field, value, EVENTS_TEXTUAL);
1182 }
1184 @Override
1185 public void recordDailyDiscrete(int env, int day, int field, int value) {
1186 this.recordDailyDiscrete(env, day, field, value, EVENTS_INTEGER);
1187 }
1189 /**
1190 * Increment the specified field value by the specified amount. Counts start
1191 * at zero.
1192 *
1193 * Note that this method can misbehave or throw if not executed within a
1194 * transaction, because correct behavior involves querying then
1195 * insert-or-update, and a race condition can otherwise occur.
1196 *
1197 * @param env the environment ID
1198 * @param day the current day, in days since epoch
1199 * @param field the field ID
1200 * @param by how much to increment the counter.
1201 */
1202 @Override
1203 public void incrementDailyCount(int env, int day, int field, int by) {
1204 if (env == -1) {
1205 Logger.warn(LOG_TAG, "Refusing to record with environment = -1.");
1206 return;
1207 }
1209 final SQLiteDatabase db = this.helper.getWritableDatabase();
1210 final String envString = Integer.toString(env);
1211 final String fieldIDString = Integer.toString(field, 10);
1212 final String dayString = Integer.toString(day, 10);
1214 // Can't run a complex UPDATE and get the number of changed rows, so we'll
1215 // do this the hard way.
1216 // This relies on being called within a transaction.
1217 final String[] args = new String[] {dayString, envString, fieldIDString};
1218 final Cursor c = db.query(EVENTS_INTEGER,
1219 COLUMNS_VALUE,
1220 WHERE_DATE_AND_ENV_AND_FIELD,
1221 args, null, null, null, "1");
1223 boolean present = false;
1224 try {
1225 present = c.moveToFirst();
1226 } finally {
1227 c.close();
1228 }
1230 if (present) {
1231 // It's an int, so safe to concatenate. Avoids us having to mess with args.
1232 db.execSQL("UPDATE " + EVENTS_INTEGER + " SET value = value + " + by + " WHERE " +
1233 WHERE_DATE_AND_ENV_AND_FIELD,
1234 args);
1235 } else {
1236 final ContentValues v = new ContentValues();
1237 v.put("env", env);
1238 v.put("value", by);
1239 v.put("field", field);
1240 v.put("date", day);
1241 try {
1242 db.insertOrThrow(EVENTS_INTEGER, null, v);
1243 } catch (SQLiteConstraintException e) {
1244 throw new IllegalStateException("Event did not reference existing an environment or field.", e);
1245 }
1246 }
1247 }
1249 @Override
1250 public void incrementDailyCount(int env, int day, int field) {
1251 this.incrementDailyCount(env, day, field, 1);
1252 }
1254 /**
1255 * Are there events recorded on or after <code>time</code>?
1256 *
1257 * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
1258 * @return true if such events exist, false otherwise.
1259 */
1260 @Override
1261 public boolean hasEventSince(long time) {
1262 final int start = this.getDay(time);
1263 final SQLiteDatabase db = this.helper.getReadableDatabase();
1264 final String dayString = Integer.toString(start, 10);
1265 Cursor cur = db.query("events", COLUMNS_DATE_ENV_FIELD_VALUE,
1266 "date >= ?", new String[] {dayString}, null, null, null, "1");
1267 if (cur == null) {
1268 // Something is horribly wrong; let the caller who actually reads the
1269 // events deal with it.
1270 return true;
1271 }
1272 try {
1273 return cur.getCount() > 0;
1274 } finally {
1275 cur.close();
1276 }
1277 }
1279 /**
1280 * Returns a cursor over field events in the database. The results will be
1281 * strictly ordered first by date, then by environment, and finally by field.
1282 *
1283 * Each row includes columns in {@link #COLUMNS_DATE_ENV_FIELD_VALUE}:
1284 * "date", "env", "field", "value".
1285 *
1286 * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
1287 * @return a cursor. The caller is responsible for closing this.
1288 */
1289 @Override
1290 public Cursor getRawEventsSince(long time) {
1291 final int start = this.getDay(time);
1292 final SQLiteDatabase db = this.helper.getReadableDatabase();
1293 final String dayString = Integer.toString(start, 10);
1294 return db.query("events", COLUMNS_DATE_ENV_FIELD_VALUE,
1295 "date >= ?", new String[] {dayString}, null, null, "date, env, field");
1296 }
1298 /**
1299 * Returns a cursor over field events in the database. The results will be
1300 * strictly ordered first by date, then by environment, and finally by field.
1301 *
1302 * Each row includes columns in {@link #COLUMNS_DATE_ENVSTR_M_MV_F_VALUE}:
1303 * "date", "environment" (as a String), "measurement_name", "measurement_version",
1304 * "field_name", "field_flags", "value".
1305 *
1306 * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
1307 * @return a cursor. The caller is responsible for closing this.
1308 */
1309 @Override
1310 public Cursor getEventsSince(long time) {
1311 final int start = this.getDay(time);
1312 final SQLiteDatabase db = this.helper.getReadableDatabase();
1313 final String dayString = Integer.toString(start, 10);
1314 return db.query("named_events", COLUMNS_DATE_ENVSTR_M_MV_F_VALUE,
1315 "date >= ?", new String[] {dayString}, null, null,
1316 "date, environment, measurement_name, measurement_version, field_name");
1317 }
1319 public int getEventCount() {
1320 return getRowCount("events");
1321 }
1323 public int getEnvironmentCount() {
1324 return getRowCount("environments");
1325 }
1327 private int getRowCount(String table) {
1328 // table should be parameterized, but SQL throws a compilation error if the table in unknown
1329 // in advance.
1330 return getIntFromQuery("SELECT COUNT(*) from " + table, null);
1331 }
1333 /**
1334 * Deletes all environments, addons, and events from the database before the given time. If this
1335 * data does not have recorded dates but are no longer referenced by other fields, they are also
1336 * removed (with exception to the current environment).
1337 *
1338 * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
1339 * @param curEnv The ID of the current environment.
1340 * @return The number of environments and addon entries deleted.
1341 */
1342 public int deleteDataBefore(final long time, final int curEnv) {
1343 final SQLiteDatabase db = this.helper.getWritableDatabase();
1344 db.beginTransaction();
1345 int numRowsDeleted = 0;
1346 try {
1347 numRowsDeleted += deleteEnvAndEventsBefore(db, time, curEnv);
1348 numRowsDeleted += deleteOrphanedAddons(db);
1349 db.setTransactionSuccessful();
1350 } finally {
1351 db.endTransaction();
1352 }
1353 return numRowsDeleted;
1354 }
1356 /**
1357 * Deletes environments and their referring events recorded before the given time. Environments
1358 * referenced by no events are deleted, except for the current environment.
1359 *
1360 * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}.
1361 * @param curEnv The ID of the current environment.
1362 * @return The number of environments (not events) deleted.
1363 */
1364 protected int deleteEnvAndEventsBefore(final long time, final int curEnv) {
1365 final SQLiteDatabase db = this.helper.getWritableDatabase();
1366 return deleteEnvAndEventsBefore(db, time, curEnv);
1367 }
1369 // Called internally only to ensure the same db instance is used.
1370 protected int deleteEnvAndEventsBefore(final SQLiteDatabase db, final long time, final int curEnv) {
1371 // Delete environments only referenced by events occuring before the given time. Cascade
1372 // delete these events.
1373 String whereClause =
1374 "(SELECT COUNT(*) FROM events WHERE date >= ? " +
1375 " AND events.env = environments.id) = 0 " +
1376 "AND id IN (SELECT DISTINCT env FROM events WHERE date < ?)";
1377 final int day = this.getDay(time);
1378 final String dayString = Integer.toString(day, 10);
1379 String[] whereArgs = new String[] {dayString, dayString};
1381 int numEnvDeleted = 0;
1382 db.beginTransaction();
1383 try {
1384 numEnvDeleted += db.delete("environments", whereClause, whereArgs);
1385 numEnvDeleted += deleteOrphanedEnv(db, curEnv);
1386 // We can't get the number of events deleted through cascading deletions so we do not record
1387 // the number of events deleted here.
1388 deleteEventsBefore(db, dayString);
1389 db.setTransactionSuccessful();
1390 } finally {
1391 db.endTransaction();
1392 }
1393 return numEnvDeleted;
1394 }
1396 /**
1397 * Deletes environments not referenced by any events except for the given current environment.
1398 */
1399 protected int deleteOrphanedEnv(final int curEnv) {
1400 final SQLiteDatabase db = this.helper.getWritableDatabase();
1401 return deleteOrphanedEnv(db, curEnv);
1402 }
1404 // Called internally only to ensure the same db instance is used.
1405 @SuppressWarnings("static-method")
1406 protected int deleteOrphanedEnv(final SQLiteDatabase db, final int curEnv) {
1407 final String whereClause =
1408 "id != ? AND " +
1409 "id NOT IN (SELECT env FROM events)";
1410 final String[] whereArgs = new String[] {Integer.toString(curEnv)};
1411 return db.delete("environments", whereClause, whereArgs);
1412 }
1414 protected int deleteEventsBefore(final String dayString) {
1415 final SQLiteDatabase db = this.helper.getWritableDatabase();
1416 return deleteEventsBefore(db, dayString);
1417 }
1419 // Called internally only to ensure the same db instance is used.
1420 @SuppressWarnings("static-method")
1421 protected int deleteEventsBefore(final SQLiteDatabase db, final String dayString) {
1422 final String whereClause = "date < ?";
1423 final String[] whereArgs = new String[] {dayString};
1424 int numEventsDeleted = 0;
1425 db.beginTransaction();
1426 try {
1427 numEventsDeleted += db.delete("events_integer", whereClause, whereArgs);
1428 numEventsDeleted += db.delete("events_textual", whereClause, whereArgs);
1429 db.setTransactionSuccessful();
1430 } finally {
1431 db.endTransaction();
1432 }
1433 return numEventsDeleted;
1434 }
1436 /**
1437 * Deletes addons not referenced by any environments.
1438 */
1439 protected int deleteOrphanedAddons() {
1440 final SQLiteDatabase db = this.helper.getWritableDatabase();
1441 return deleteOrphanedAddons(db);
1442 }
1444 // Called internally only to ensure the same db instance is used.
1445 @SuppressWarnings("static-method")
1446 protected int deleteOrphanedAddons(final SQLiteDatabase db) {
1447 final String whereClause = "id NOT IN (SELECT addonsID FROM environments)";
1448 return db.delete("addons", whereClause, null);
1449 }
1451 /**
1452 * Retrieve a mapping from a table. Keys should be unique; only one key-value
1453 * pair will be returned for each key.
1454 */
1455 private static HashMap<String, Integer> getIntegers(SQLiteDatabase db, String table, String columnA, String columnB) {
1456 Cursor c = db.query(table, new String[] {columnA, columnB}, null, null, null, null, null);
1457 try {
1458 if (!c.moveToFirst()) {
1459 return null;
1460 }
1462 HashMap<String, Integer> results = new HashMap<String, Integer>();
1463 while (!c.isAfterLast()) {
1464 results.put(c.getString(0), c.getInt(1));
1465 c.moveToNext();
1466 }
1467 return results;
1468 } finally {
1469 c.close();
1470 }
1471 }
1473 /**
1474 * Retrieve a single value from a mapping table.
1475 */
1476 private int integerQuery(String table, String column, String where, String[] args, int defaultValue) {
1477 final SQLiteDatabase db = this.helper.getReadableDatabase();
1478 Cursor c = db.query(table, new String[] {column}, where, args, null, null, column + " DESC", "1");
1479 try {
1480 if (!c.moveToFirst()) {
1481 return defaultValue;
1482 }
1483 return c.getInt(0);
1484 } finally {
1485 c.close();
1486 }
1487 }
1489 /**
1490 * Helper to allow us to avoid excessive code duplication.
1491 *
1492 * @param v
1493 * the destination <code>ContentValues</code>.
1494 * @param value
1495 * either a <code>String</code> or an <code>Integer</code>. No type
1496 * checking is performed.
1497 */
1498 private static final void putValue(final ContentValues v, Object value) {
1499 if (value instanceof String) {
1500 v.put("value", (String) value);
1501 } else {
1502 v.put("value", (Integer) value);
1503 }
1504 }
1506 @Override
1507 public void deleteEverything() {
1508 this.helper.deleteEverything();
1509 }
1511 @Override
1512 public void deleteEnvironments() {
1513 final SQLiteDatabase db = this.helper.getWritableDatabase();
1514 db.beginTransaction();
1515 try {
1516 // Cascade will clear the rest.
1517 db.delete("environments", null, null);
1518 db.setTransactionSuccessful();
1519 } finally {
1520 db.endTransaction();
1521 }
1522 }
1524 @Override
1525 public void deleteMeasurements() {
1526 final SQLiteDatabase db = this.helper.getWritableDatabase();
1527 db.beginTransaction();
1528 try {
1529 // Cascade will clear the rest.
1530 db.delete("measurements", null, null);
1532 // Clear measurements and fields cache, because some of their IDs are now invalid.
1533 invalidateFieldsCache(); // Let it repopulate on its own.
1534 populateMeasurementVersionsCache(db); // Performed at Storage init so repopulate now.
1536 db.setTransactionSuccessful();
1537 } finally {
1538 db.endTransaction();
1539 }
1540 }
1542 /**
1543 * Prunes the given number of least-recently used environments. Note that orphaned environments
1544 * are not removed and the environment cache is cleared.
1545 */
1546 public void pruneEnvironments(final int numToPrune) {
1547 final SQLiteDatabase db = this.helper.getWritableDatabase();
1548 db.beginTransaction();
1549 try {
1550 db.delete("environments",
1551 "id in (SELECT env " +
1552 " FROM events " +
1553 " GROUP BY env " +
1554 " ORDER BY MAX(date), env " +
1555 " LIMIT " + numToPrune + ")",
1556 null);
1557 db.setTransactionSuccessful();
1559 // Clear environment cache, because some of their IDs are now invalid.
1560 this.envs.clear();
1561 } finally {
1562 db.endTransaction();
1563 }
1564 }
1566 /**
1567 * Prunes up to a maximum of the given number of the oldest events. While it is more correct to
1568 * prune the exact given amount, there is no unique identifier among events so we cannot be so
1569 * precise. Instead, we prune on date, deleting all events up to the day before our count of
1570 * events reaches the given maximum. Note that this technicality means this method cannot be
1571 * used to delete all events.
1572 */
1573 public void pruneEvents(final int maxNumToPrune) {
1574 final SQLiteDatabase db = this.helper.getWritableDatabase();
1576 final Cursor c = db.rawQuery(
1577 "SELECT MAX(date) " +
1578 "FROM (SELECT date " +
1579 " FROM events " +
1580 " ORDER BY date " +
1581 " LIMIT " + maxNumToPrune + ")",
1582 null);
1583 long pruneDate = -1;
1584 try {
1585 if (!c.moveToFirst()) {
1586 Logger.debug(LOG_TAG, "No max date found in events: table is likely empty. Not pruning " +
1587 "events.");
1588 return;
1589 }
1590 pruneDate = c.getLong(0);
1591 } finally {
1592 c.close();
1593 }
1595 final String selection = "date < " + pruneDate;
1596 db.beginTransaction();
1597 try {
1598 db.delete(EVENTS_INTEGER, selection, null);
1599 db.delete(EVENTS_TEXTUAL, selection, null);
1600 db.setTransactionSuccessful();
1601 } finally {
1602 db.endTransaction();
1603 }
1604 }
1606 public void vacuum() {
1607 final SQLiteDatabase db = this.helper.getWritableDatabase();
1608 db.execSQL("vacuum");
1609 }
1611 /**
1612 * Disables auto_vacuuming. Changes may only take effect after a "vacuum" command.
1613 */
1614 public void disableAutoVacuuming() {
1615 final SQLiteDatabase db = this.helper.getWritableDatabase();
1616 db.execSQL("PRAGMA auto_vacuum=0");
1617 }
1618 }