db: Track app launch history with sqlite db

Add support for sqlite3 database. Track the application launches'
metrics by inserting new rows into the database.

Test: manual (launch app and then check /data/misc/iorapd/sqlite.db)
Bug: 137786053
Change-Id: I0fa337926a4aad05495b00ebee6a0b3e815d6af7
diff --git a/src/db/models.h b/src/db/models.h
new file mode 100644
index 0000000..ed6c16d
--- /dev/null
+++ b/src/db/models.h
@@ -0,0 +1,760 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef IORAP_SRC_DB_MODELS_H_
+#define IORAP_SRC_DB_MODELS_H_
+
+#include <android-base/logging.h>
+
+#include <optional>
+#include <ostream>
+#include <string>
+#include <sstream>
+#include <type_traits>
+
+#include <sqlite3.h>
+
+namespace iorap::db {
+
+struct SqliteDbDeleter {
+  void operator()(sqlite3* db) {
+    if (db != nullptr) {
+      LOG(VERBOSE) << "sqlite3_close for: " << db;
+      sqlite3_close(db);
+    }
+  }
+};
+
+class DbHandle {
+ public:
+  // Take over ownership of sqlite3 db.
+  explicit DbHandle(sqlite3* db)
+    : db_{std::shared_ptr<sqlite3>{db, SqliteDbDeleter{}}},
+      mutex_{std::make_shared<std::mutex>()} {
+  }
+
+  sqlite3* get() {
+    return db_.get();
+  }
+
+  operator sqlite3*() {
+    return db_.get();
+  }
+
+  std::mutex& mutex() {
+    return *mutex_.get();
+  }
+
+ private:
+  std::shared_ptr<sqlite3> db_;
+  std::shared_ptr<std::mutex> mutex_;
+};
+
+class ScopedLockDb {
+ public:
+  ScopedLockDb(std::mutex& mutex) : mutex(mutex) {
+    mutex.lock();
+  }
+
+  ScopedLockDb(DbHandle& handle) : ScopedLockDb(handle.mutex()) {
+  }
+
+  ~ScopedLockDb() {
+    mutex.unlock();
+  }
+ private:
+  std::mutex& mutex;
+};
+
+class DbStatement {
+ public:
+  template <typename ... Args>
+  static DbStatement Prepare(DbHandle db, const std::string& sql, Args&&... args) {
+    return Prepare(db, sql.c_str(), std::forward<Args>(args)...);
+  }
+
+  template <typename ... Args>
+  static DbStatement Prepare(DbHandle db, const char* sql, Args&&... args) {
+    DCHECK(db.get() != nullptr);
+    DCHECK(sql != nullptr);
+
+    // LOG(VERBOSE) << "Prepare DB=" << db.get();
+
+    sqlite3_stmt* stmt = nullptr;
+    int rc = sqlite3_prepare_v2(db.get(), sql, -1, /*out*/&stmt, nullptr);
+
+    DbStatement db_stmt{db, stmt};
+    DCHECK(db_stmt.CheckOk(rc)) << sql;
+    db_stmt.BindAll(std::forward<Args>(args)...);
+
+    return db_stmt;
+  }
+
+  DbStatement(DbHandle db, sqlite3_stmt* stmt) : db_(db), stmt_(stmt) {
+  }
+
+  sqlite3_stmt* get() {
+    return stmt_;
+  }
+
+  DbHandle db() {
+    return db_;
+  }
+
+  template <typename T, typename ... Args>
+  void BindAll(T&& arg, Args&&... args) {
+    Bind(std::forward<T>(arg));
+    BindAll(std::forward<Args>(args)...);
+  }
+
+  void BindAll() {}
+
+  template <typename T>
+  void Bind(const std::optional<T>& value) {
+    if (value) {
+      Bind(*value);
+    } else {
+      BindNull();
+    }
+  }
+
+  void Bind(bool value) {
+    CheckOk(sqlite3_bind_int(stmt_, bind_counter_++, value));
+  }
+
+  void Bind(int value) {
+    CheckOk(sqlite3_bind_int(stmt_, bind_counter_++, value));
+  }
+
+  void Bind(uint64_t value) {
+    CheckOk(sqlite3_bind_int64(stmt_, bind_counter_++, static_cast<int64_t>(value)));
+  }
+
+  void Bind(const char* value) {
+    if (value != nullptr) {
+      //sqlite3_bind_text(stmt_, /*index*/bind_counter_++, value, -1, SQLITE_STATIC);
+      CheckOk(sqlite3_bind_text(stmt_, /*index*/bind_counter_++, value, -1, SQLITE_TRANSIENT));
+    } else {
+      BindNull();
+    }
+  }
+
+  void Bind(const std::string& value) {
+    Bind(value.c_str());
+  }
+
+  template <typename E, typename = std::enable_if_t<std::is_enum_v<E>>>
+  void Bind(E value) {
+    Bind(static_cast<std::underlying_type_t<E>>(value));
+  }
+
+  void BindNull() {
+    CheckOk(sqlite3_bind_null(stmt_, bind_counter_++));
+  }
+
+  int Step() {
+    ++step_counter_;
+    return sqlite3_step(stmt_);
+  }
+
+  bool Step(int expected) {
+    int rc = Step();
+    if (rc != expected) {
+      LOG(ERROR) << "SQLite error: " << sqlite3_errmsg(db_.get());
+      return false;
+    }
+    return true;
+  }
+
+  template <typename T, typename ... Args>
+  void ColumnAll(T& first, Args&... rest) {
+    Column(first);
+    ColumnAll(rest...);
+  }
+
+  void ColumnAll() {}
+
+  template <typename T>
+  void Column(std::optional<T>& value) {
+    T tmp;
+    Column(/*out*/tmp);
+
+    if (!tmp) {  // disambiguate 0 and NULL
+      const unsigned char* text = sqlite3_column_text(stmt_, column_counter_ - 1);
+      if (text == nullptr) {
+        value = std::nullopt;
+        return;
+      }
+    }
+    value = std::move(tmp);
+  }
+
+  template <typename E, typename = std::enable_if_t<std::is_enum_v<E>>>
+  void Column(E& value) {
+    std::underlying_type_t<E> tmp;
+    Column(/*out*/tmp);
+    value = static_cast<E>(tmp);
+  }
+
+  void Column(bool& value) {
+    value = sqlite3_column_int(stmt_, column_counter_++);
+  }
+
+  void Column(int& value) {
+    value = sqlite3_column_int(stmt_, column_counter_++);
+  }
+
+  void Column(uint64_t& value) {
+    value = static_cast<uint64_t>(sqlite3_column_int64(stmt_, column_counter_++));
+  }
+
+  void Column(std::string& value) {
+    const unsigned char* text = sqlite3_column_text(stmt_, column_counter_++);
+    value = std::string{reinterpret_cast<const char*>(text)};
+  }
+
+  const char* ExpandedSql() {
+    char* p = sqlite3_expanded_sql(stmt_);
+    if (p == nullptr) {
+      return "(nullptr)";
+    }
+    return p;
+  }
+
+  const char* Sql() {
+    const char* p = sqlite3_sql(stmt_);
+    if (p == nullptr) {
+      return "(nullptr)";
+    }
+    return p;
+  }
+
+
+  DbStatement(DbStatement&& other)
+    : db_{other.db_}, stmt_{other.stmt_}, bind_counter_{other.bind_counter_},
+      step_counter_{other.step_counter_} {
+    other.db_ = DbHandle{nullptr};
+    other.stmt_ = nullptr;
+  }
+
+  ~DbStatement() {
+    if (stmt_ != nullptr) {
+      DCHECK_GT(step_counter_, 0) << " forgot to call Step()?";
+      sqlite3_finalize(stmt_);
+    }
+  }
+
+ private:
+  bool CheckOk(int rc, int expected = SQLITE_OK) {
+    if (rc != expected) {
+      LOG(ERROR) << "Got error for SQL query: '" << Sql() << "'"
+                 << ", expanded: '" << ExpandedSql() << "'";
+      LOG(ERROR) << "Failed SQLite api call (" << rc << "): " << sqlite3_errstr(rc);
+    }
+    return rc == expected;
+  }
+
+  DbHandle db_;
+  sqlite3_stmt* stmt_;
+  int bind_counter_ = 1;
+  int step_counter_ = 0;
+  int column_counter_ = 0;
+};
+
+class DbQueryBuilder {
+ public:
+  // Returns the row ID that was inserted last.
+  template <typename... Args>
+  static std::optional<int> Insert(DbHandle db, const std::string& sql, Args&&... args) {
+    ScopedLockDb lock{db};
+
+    sqlite3_int64 last_rowid = sqlite3_last_insert_rowid(db.get());
+    DbStatement stmt = DbStatement::Prepare(db, sql, std::forward<Args>(args)...);
+
+    if (!stmt.Step(SQLITE_DONE)) {
+      return std::nullopt;
+    }
+
+    last_rowid = sqlite3_last_insert_rowid(db.get());
+    DCHECK_GT(last_rowid, 0);
+
+    return static_cast<int>(last_rowid);
+  }
+
+  template <typename... Args>
+  static bool SelectOnce(DbStatement& stmt, Args&... args) {
+    int rc = stmt.Step();
+    switch (rc) {
+      case SQLITE_ROW:
+        stmt.ColumnAll(/*out*/args...);
+        return true;
+      case SQLITE_DONE:
+        return false;
+      default:
+        LOG(ERROR) << "Failed to step (" << rc << "): " << sqlite3_errmsg(stmt.db());
+        return false;
+    }
+  }
+};
+
+class Model {
+ public:
+  DbHandle db() const {
+    return db_;
+  }
+
+  Model(DbHandle db) : db_{db} {
+  }
+
+ private:
+  DbHandle db_;
+};
+
+class SchemaModel : public Model {
+ public:
+  static SchemaModel GetOrCreate(std::string location) {
+    int rc = sqlite3_config(SQLITE_CONFIG_LOG, ErrorLogCallback, /*data*/nullptr);
+
+    if (rc != SQLITE_OK) {
+      LOG(FATAL) << "Failed to configure logging";
+    }
+
+    sqlite3* db = nullptr;
+    if (location != ":memory:") {
+      // Try to open DB if it already exists.
+      rc = sqlite3_open_v2(location.c_str(), /*out*/&db, SQLITE_OPEN_READWRITE, /*vfs*/nullptr);
+
+      if (rc == SQLITE_OK) {
+        LOG(INFO) << "Opened existing database at '" << location << "'";
+        return SchemaModel{DbHandle{db}, location};
+      }
+    }
+
+    // Create a new DB if one didn't exist already.
+    rc = sqlite3_open(location.c_str(), /*out*/&db);
+
+    if (rc != SQLITE_OK) {
+      LOG(FATAL) << "Failed to open DB: " << sqlite3_errmsg(db);
+    }
+
+    SchemaModel schema{DbHandle{db}, location};
+    schema.Reinitialize();
+    // TODO: migrate versions upwards when we rev the schema version
+
+    int old_version = schema.Version();
+    LOG(VERBOSE) << "Loaded schema version: " << old_version;
+
+    return schema;
+  }
+
+  void MarkSingleton() {
+    s_singleton_ = db();
+  }
+
+  static DbHandle GetSingleton() {
+    DCHECK(s_singleton_.has_value());
+    return *s_singleton_;
+  }
+
+  void Reinitialize() {
+    const char* sql_to_initialize = R"SQLC0D3(
+        DROP TABLE IF EXISTS schema_versions;
+        DROP TABLE IF EXISTS packages;
+        DROP TABLE IF EXISTS activities;
+        DROP TABLE IF EXISTS app_launch_histories;
+        DROP TABLE IF EXISTS raw_traces;
+        DROP TABLE IF EXISTS prefetch_files;
+)SQLC0D3";
+    char* err_msg = nullptr;
+    int rc = sqlite3_exec(db().get(),
+                          sql_to_initialize,
+                          /*callback*/nullptr,
+                          /*arg*/0,
+                          /*out*/&err_msg);
+    if (rc != SQLITE_OK) {
+      LOG(FATAL) << "Failed to drop tables: " << err_msg ? err_msg : "nullptr";
+    }
+
+    CreateSchema();
+    LOG(INFO) << "Reinitialized database at '" << location_ << "'";
+  }
+
+  int Version() {
+    std::string query = "SELECT MAX(version) FROM schema_versions;";
+    DbStatement stmt = DbStatement::Prepare(db(), query);
+
+    int return_value = 0;
+    if (!DbQueryBuilder::SelectOnce(stmt, /*out*/return_value)) {
+      LOG(ERROR) << "Failed to query schema version";
+      return -1;
+    }
+
+    return return_value;
+  }
+
+ protected:
+  SchemaModel(DbHandle db, std::string location) : Model{db}, location_(location) {
+  }
+
+ private:
+  static std::optional<DbHandle> s_singleton_;
+
+  void CreateSchema() {
+    const char* sql_to_initialize = R"SQLC0D3(
+        CREATE TABLE schema_versions(
+            version INTEGER NOT NULL
+        );
+        INSERT INTO schema_versions VALUES(1);
+
+        CREATE TABLE packages(
+            id INTEGER NOT NULL,
+            name TEXT NOT NULL,
+            version INTEGER,
+
+            PRIMARY KEY(id)
+        );
+
+        CREATE TABLE activities(
+            id INTEGER NOT NULL,
+            name TEXT NOT NULL,
+            package_id INTEGER NOT NULL,
+
+            PRIMARY KEY(id),
+            FOREIGN KEY (package_id) REFERENCES packages (id)
+        );
+
+        CREATE TABLE app_launch_histories(
+            id INTEGER NOT NULL PRIMARY KEY,
+            activity_id INTEGER NOT NULL,
+            -- 1:Cold, 2:Warm, 3:Hot
+            temperature INTEGER CHECK (temperature IN (1, 2, 3)) NOT NULL,
+            trace_enabled INTEGER CHECK(trace_enabled in (TRUE, FALSE)) NOT NULL,
+            readahead_enabled INTEGER CHECK(trace_enabled in (TRUE, FALSE)) NOT NULL,
+            -- absolute timestamp since epoch
+            intent_started_ns INTEGER CHECK(intent_started_ns IS NULL or intent_started_ns >= 0),
+            -- absolute timestamp since epoch
+            total_time_ns INTEGER CHECK(total_time_ns IS NULL or total_time_ns >= 0),
+            -- absolute timestamp since epoch
+            report_fully_drawn_ns INTEGER CHECK(report_fully_drawn_ns IS NULL or report_fully_drawn_ns >= 0),
+
+            FOREIGN KEY (activity_id) REFERENCES activities (id)
+        );
+
+        CREATE TABLE raw_traces(
+            id INTEGER NOT NULL PRIMARY KEY,
+            history_id INTEGER NOT NULL,
+            file_path TEXT NOT NULL,
+
+            FOREIGN KEY (history_id) REFERENCES app_launch_histories (id)
+        );
+
+        CREATE TABLE prefetch_files(
+          id INTEGER NOT NULL PRIMARY KEY,
+          activity_id INTEGER NOT NULL,
+          file_path TEXT NOT NULL,
+
+          FOREIGN KEY (activity_id) REFERENCES activities (id)
+        );
+)SQLC0D3";
+
+    char* err_msg = nullptr;
+    int rc = sqlite3_exec(db().get(),
+                          sql_to_initialize,
+                          /*callback*/nullptr,
+                          /*arg*/0,
+                          /*out*/&err_msg);
+
+    if (rc != SQLITE_OK) {
+      LOG(FATAL) << "Failed to create tables: " << err_msg ? err_msg : "nullptr";
+    }
+  }
+
+  static void ErrorLogCallback(void *pArg, int iErrCode, const char *zMsg) {
+    LOG(ERROR) << "SQLite error (" << iErrCode << "): " << zMsg;
+  }
+
+  std::string location_;
+};
+
+class PackageModel : public Model {
+ protected:
+  PackageModel(DbHandle db) : Model{db} {
+  }
+
+ public:
+  static std::optional<PackageModel> SelectById(DbHandle db, int id) {
+    ScopedLockDb lock{db};
+    int original_id = id;
+
+    std::string query = "SELECT * FROM packages WHERE id = ?1 LIMIT 1;";
+    DbStatement stmt = DbStatement::Prepare(db, query, id);
+
+    PackageModel p{db};
+    if (!DbQueryBuilder::SelectOnce(stmt, p.id, p.name, p.version)) {
+      return std::nullopt;
+    }
+
+    return p;
+  }
+
+  static std::optional<PackageModel> SelectByName(DbHandle db, const char* name) {
+    ScopedLockDb lock{db};
+
+    std::string query = "SELECT * FROM packages WHERE name = ?1 LIMIT 1;";
+    DbStatement stmt = DbStatement::Prepare(db, query, name);
+
+    PackageModel p{db};
+    if (!DbQueryBuilder::SelectOnce(stmt, p.id, p.name, p.version)) {
+      return std::nullopt;
+    }
+
+    return p;
+  }
+
+  static std::optional<PackageModel> Insert(DbHandle db,
+                                            std::string name,
+                                            std::optional<int> version) {
+    const char* sql = "INSERT INTO packages (name, version) VALUES (?1, ?2);";
+
+    std::optional<int> inserted_row_id =
+        DbQueryBuilder::Insert(db, sql, name, version);
+    if (!inserted_row_id) {
+      return std::nullopt;
+    }
+
+    PackageModel p{db};
+    p.name = name;
+    p.version = version;
+    p.id = *inserted_row_id;
+
+    return p;
+  }
+
+  int id;
+  std::string name;
+  std::optional<int> version;
+};
+
+inline std::ostream& operator<<(std::ostream& os, const PackageModel& p) {
+  os << "PackageModel{id=" << p.id << ",name=" << p.name << ",";
+  os << "version=";
+  if (p.version) {
+    os << *p.version;
+  } else {
+    os << "(nullopt)";
+  }
+  os << "}";
+  return os;
+}
+
+class ActivityModel : public Model {
+ protected:
+  ActivityModel(DbHandle db) : Model{db} {
+  }
+
+ public:
+  static std::optional<ActivityModel> SelectById(DbHandle db, int id) {
+    ScopedLockDb lock{db};
+    int original_id = id;
+
+    std::string query = "SELECT * FROM activities WHERE id = ? LIMIT 1;";
+    DbStatement stmt = DbStatement::Prepare(db, query, id);
+
+    ActivityModel p{db};
+    if (!DbQueryBuilder::SelectOnce(stmt, p.id, p.name, p.package_id)) {
+      return std::nullopt;
+    }
+
+    return p;
+  }
+
+  static std::optional<ActivityModel> SelectByNameAndPackageId(DbHandle db,
+                                                               const char* name,
+                                                               int package_id) {
+    ScopedLockDb lock{db};
+
+    std::string query = "SELECT * FROM activities WHERE name = ? AND package_id = ? LIMIT 1;";
+    DbStatement stmt = DbStatement::Prepare(db, query, name, package_id);
+
+    ActivityModel p{db};
+    if (!DbQueryBuilder::SelectOnce(stmt, p.id, p.name, p.package_id)) {
+      return std::nullopt;
+    }
+
+    return p;
+  }
+
+  static std::optional<ActivityModel> Insert(DbHandle db,
+                                             std::string name,
+                                             int package_id) {
+    const char* sql = "INSERT INTO activities (name, package_id) VALUES (?1, ?2);";
+
+    std::optional<int> inserted_row_id =
+        DbQueryBuilder::Insert(db, sql, name, package_id);
+    if (!inserted_row_id) {
+      return std::nullopt;
+    }
+
+    ActivityModel p{db};
+    p.id = *inserted_row_id;
+    p.name = name;
+    p.package_id = package_id;
+
+    return p;
+  }
+
+  // Try to select by package_name+activity_name, otherwise insert into both tables.
+  // Package version is ignored for selects.
+  static std::optional<ActivityModel> SelectOrInsert(
+      DbHandle db,
+      std::string package_name,
+      std::optional<int> package_version,
+      std::string activity_name) {
+    std::optional<PackageModel> package = PackageModel::SelectByName(db, package_name.c_str());
+    if (!package) {
+      package = PackageModel::Insert(db, package_name, package_version);
+      DCHECK(package.has_value());
+    }
+
+    std::optional<ActivityModel> activity =
+        ActivityModel::SelectByNameAndPackageId(db,
+                                                activity_name.c_str(),
+                                                package->id);
+    if (!activity) {
+      activity = Insert(db, activity_name, package->id);
+      // XX: should we really return an optional here? This feels like it should never fail.
+    }
+
+    return activity;
+  }
+
+  int id;
+  std::string name;
+  int package_id;  // PackageModel::id
+};
+
+inline std::ostream& operator<<(std::ostream& os, const ActivityModel& p) {
+  os << "ActivityModel{id=" << p.id << ",name=" << p.name << ",";
+  os << "package_id=" << p.package_id << "}";
+  return os;
+}
+
+class AppLaunchHistoryModel : public Model {
+ protected:
+  AppLaunchHistoryModel(DbHandle db) : Model{db} {
+  }
+
+ public:
+  enum class Temperature : int32_t {
+    kUninitialized = -1,  // Note: Not a valid SQL value.
+    kCold = 1,
+    kWarm = 2,
+    kHot = 3,
+  };
+
+  static std::optional<AppLaunchHistoryModel> SelectById(DbHandle db, int id) {
+    ScopedLockDb lock{db};
+    int original_id = id;
+
+    std::string query = "SELECT * FROM app_launch_histories WHERE id = ? LIMIT 1;";
+    DbStatement stmt = DbStatement::Prepare(db, query, id);
+
+    AppLaunchHistoryModel p{db};
+    if (!DbQueryBuilder::SelectOnce(stmt,
+                                    p.id,
+                                    p.activity_id,
+                                    p.temperature,
+                                    p.trace_enabled,
+                                    p.readahead_enabled,
+                                    p.total_time_ns,
+                                    p.report_fully_drawn_ns)) {
+      return std::nullopt;
+    }
+
+    return p;
+  }
+
+  static std::optional<AppLaunchHistoryModel> Insert(DbHandle db,
+                                                     int activity_id,
+                                                     AppLaunchHistoryModel::Temperature temperature,
+                                                     bool trace_enabled,
+                                                     bool readahead_enabled,
+                                                     std::optional<uint64_t> total_time_ns,
+                                                     std::optional<uint64_t> report_fully_drawn_ns)
+  {
+    const char* sql = "INSERT INTO app_launch_histories (activity_id, temperature, trace_enabled, "
+                                                        "readahead_enabled, total_time_ns, "
+                                                        "report_fully_drawn_ns) "
+                      "VALUES (?1, ?2, ?3, ?4, ?5, ?6);";
+
+    std::optional<int> inserted_row_id =
+        DbQueryBuilder::Insert(db,
+                               sql,
+                               activity_id,
+                               temperature,
+                               trace_enabled,
+                               readahead_enabled,
+                               total_time_ns,
+                               report_fully_drawn_ns);
+    if (!inserted_row_id) {
+      return std::nullopt;
+    }
+
+    AppLaunchHistoryModel p{db};
+    p.id = *inserted_row_id;
+    p.activity_id = activity_id;
+    p.temperature = temperature;
+    p.trace_enabled = trace_enabled;
+    p.readahead_enabled = readahead_enabled;
+    p.total_time_ns = total_time_ns;
+    p.report_fully_drawn_ns = report_fully_drawn_ns;
+
+    return p;
+  }
+
+  int id;
+  int activity_id;  // ActivityModel::id
+  Temperature temperature = Temperature::kUninitialized;
+  bool trace_enabled;
+  bool readahead_enabled;
+  std::optional<uint64_t> total_time_ns;
+  std::optional<uint64_t> report_fully_drawn_ns;
+};
+
+inline std::ostream& operator<<(std::ostream& os, const AppLaunchHistoryModel& p) {
+  os << "AppLaunchHistoryModel{id=" << p.id << ","
+     << "activity_id=" << p.activity_id << ","
+     << "temperature=" << static_cast<int>(p.temperature) << ","
+     << "trace_enabled=" << p.trace_enabled << ","
+     << "readahead_enabled=" << p.readahead_enabled << ","
+     << "total_time_ns=";
+  if (p.total_time_ns) {
+    os << *p.total_time_ns;
+  } else {
+    os << "(nullopt)";
+  }
+  os << ",";
+  os << "report_fully_drawn_ns=";
+  if (p.report_fully_drawn_ns) {
+    os << *p.report_fully_drawn_ns;
+  } else {
+    os << "(nullopt)";
+  }
+  os << "}";
+  return os;
+}
+
+}  // namespace iorap::db
+
+#endif  // IORAP_SRC_DB_MODELS_H_