blob: 286fc9fc15bc6548c9096e1079865a590231304f [file] [log] [blame]
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "components/history/core/browser/url_database.h"
#include <limits>
#include <string>
#include <vector>
#include "base/i18n/case_conversion.h"
#include "base/stl_util.h"
#include "base/strings/utf_string_conversions.h"
#include "components/history/core/browser/keyword_search_term.h"
#include "components/url_formatter/url_formatter.h"
#include "sql/statement.h"
#include "url/gurl.h"
namespace history {
const char URLDatabase::kURLRowFields[] = HISTORY_URL_ROW_FIELDS;
const int URLDatabase::kNumURLRowFields = 9;
URLDatabase::URLEnumeratorBase::URLEnumeratorBase()
: initialized_(false) {
}
URLDatabase::URLEnumeratorBase::~URLEnumeratorBase() {
}
URLDatabase::URLEnumerator::URLEnumerator() {
}
bool URLDatabase::URLEnumerator::GetNextURL(URLRow* r) {
if (statement_.Step()) {
FillURLRow(statement_, r);
return true;
}
return false;
}
URLDatabase::URLDatabase()
: has_keyword_search_terms_(false) {
}
URLDatabase::~URLDatabase() {
}
// static
std::string URLDatabase::GURLToDatabaseURL(const GURL& gurl) {
// TODO(brettw): do something fancy here with encoding, etc.
// Strip username and password from URL before sending to DB.
GURL::Replacements replacements;
replacements.ClearUsername();
replacements.ClearPassword();
return (gurl.ReplaceComponents(replacements)).spec();
}
// Convenience to fill a URLRow. Must be in sync with the fields in
// kURLRowFields.
void URLDatabase::FillURLRow(const sql::Statement& s, URLRow* i) {
DCHECK(i);
i->set_id(s.ColumnInt64(0));
i->set_url(GURL(s.ColumnString(1)));
i->set_title(s.ColumnString16(2));
i->set_visit_count(s.ColumnInt(3));
i->set_typed_count(s.ColumnInt(4));
i->set_last_visit(base::Time::FromInternalValue(s.ColumnInt64(5)));
i->set_hidden(s.ColumnInt(6) != 0);
}
bool URLDatabase::GetURLRow(URLID url_id, URLRow* info) {
// TODO(brettw) We need check for empty URLs to handle the case where
// there are old URLs in the database that are empty that got in before
// we added any checks. We should eventually be able to remove it
// when all inputs are using GURL (which prohibit empty input).
sql::Statement statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"SELECT" HISTORY_URL_ROW_FIELDS "FROM urls WHERE id=?"));
statement.BindInt64(0, url_id);
if (statement.Step()) {
FillURLRow(statement, info);
return true;
}
return false;
}
URLID URLDatabase::GetRowForURL(const GURL& url, URLRow* info) {
sql::Statement statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"SELECT" HISTORY_URL_ROW_FIELDS "FROM urls WHERE url=?"));
std::string url_string = GURLToDatabaseURL(url);
statement.BindString(0, url_string);
if (!statement.Step())
return 0; // no data
if (info)
FillURLRow(statement, info);
return statement.ColumnInt64(0);
}
bool URLDatabase::UpdateURLRow(URLID url_id, const URLRow& info) {
sql::Statement statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"UPDATE urls SET title=?,visit_count=?,typed_count=?,last_visit_time=?,"
"hidden=?"
"WHERE id=?"));
statement.BindString16(0, info.title());
statement.BindInt(1, info.visit_count());
statement.BindInt(2, info.typed_count());
statement.BindInt64(3, info.last_visit().ToInternalValue());
statement.BindInt(4, info.hidden() ? 1 : 0);
statement.BindInt64(5, url_id);
return statement.Run() && GetDB().GetLastChangeCount() > 0;
}
URLID URLDatabase::AddURLInternal(const URLRow& info, bool is_temporary) {
// This function is used to insert into two different tables, so we have to
// do some shuffling. Unfortinately, we can't use the macro
// HISTORY_URL_ROW_FIELDS because that specifies the table name which is
// invalid in the insert syntax.
#define ADDURL_COMMON_SUFFIX \
" (url, title, visit_count, typed_count, "\
"last_visit_time, hidden) "\
"VALUES (?,?,?,?,?,?)"
size_t statement_line;
const char* statement_sql;
if (is_temporary) {
statement_line = __LINE__;
statement_sql = "INSERT INTO temp_urls" ADDURL_COMMON_SUFFIX;
} else {
statement_line = __LINE__;
statement_sql = "INSERT INTO urls" ADDURL_COMMON_SUFFIX;
}
#undef ADDURL_COMMON_SUFFIX
sql::Statement statement(GetDB().GetCachedStatement(
sql::StatementID(__FILE__, statement_line), statement_sql));
statement.BindString(0, GURLToDatabaseURL(info.url()));
statement.BindString16(1, info.title());
statement.BindInt(2, info.visit_count());
statement.BindInt(3, info.typed_count());
statement.BindInt64(4, info.last_visit().ToInternalValue());
statement.BindInt(5, info.hidden() ? 1 : 0);
if (!statement.Run()) {
VLOG(0) << "Failed to add url " << info.url().possibly_invalid_spec()
<< " to table history.urls.";
return 0;
}
return GetDB().GetLastInsertRowId();
}
bool URLDatabase::URLTableContainsAutoincrement() {
// sqlite_master has columns:
// type - "index" or "table".
// name - name of created element.
// tbl_name - name of element, or target table in case of index.
// rootpage - root page of the element in database file.
// sql - SQL to create the element.
sql::Statement statement(GetDB().GetUniqueStatement(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'urls'"));
// urls table does not exist.
if (!statement.Step())
return false;
std::string urls_schema = statement.ColumnString(0);
// We check if the whole schema contains "AUTOINCREMENT", since
// "AUTOINCREMENT" only can be used for "INTEGER PRIMARY KEY", so we assume no
// other columns could cantain "AUTOINCREMENT".
return urls_schema.find("AUTOINCREMENT") != std::string::npos;
}
bool URLDatabase::InsertOrUpdateURLRowByID(const URLRow& info) {
// SQLite does not support INSERT OR UPDATE, however, it does have INSERT OR
// REPLACE, which is feasible to use, because of the following.
// * Before INSERTing, REPLACE will delete all pre-existing rows that cause
// constraint violations. Here, we only have a PRIMARY KEY constraint, so
// the only row that might get deleted is an old one with the same ID.
// * Another difference between the two flavors is that the latter actually
// deletes the old row, and thus the old values are lost in columns which
// are not explicitly assigned new values. This is not an issue, however,
// as we assign values to all columns.
// * When rows are deleted due to constraint violations, the delete triggers
// may not be invoked. As of now, we do not have any delete triggers.
// For more details, see: http://www.sqlite.org/lang_conflict.html.
sql::Statement statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"INSERT OR REPLACE INTO urls "
"(id, url, title, visit_count, typed_count, last_visit_time, hidden) "
"VALUES (?, ?, ?, ?, ?, ?, ?)"));
statement.BindInt64(0, info.id());
statement.BindString(1, GURLToDatabaseURL(info.url()));
statement.BindString16(2, info.title());
statement.BindInt(3, info.visit_count());
statement.BindInt(4, info.typed_count());
statement.BindInt64(5, info.last_visit().ToInternalValue());
statement.BindInt(6, info.hidden() ? 1 : 0);
return statement.Run();
}
bool URLDatabase::DeleteURLRow(URLID id) {
sql::Statement statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"DELETE FROM urls WHERE id = ?"));
statement.BindInt64(0, id);
if (!statement.Run())
return false;
// And delete any keyword visits.
return !has_keyword_search_terms_ || DeleteKeywordSearchTermForURL(id);
}
bool URLDatabase::CreateTemporaryURLTable() {
return CreateURLTable(true);
}
bool URLDatabase::CommitTemporaryURLTable() {
// See the comments in the header file as well as
// HistoryBackend::DeleteAllHistory() for more information on how this works
// and why it does what it does.
// Swap the url table out and replace it with the temporary one.
if (!GetDB().Execute("DROP TABLE urls")) {
NOTREACHED() << GetDB().GetErrorMessage();
return false;
}
if (!GetDB().Execute("ALTER TABLE temp_urls RENAME TO urls")) {
NOTREACHED() << GetDB().GetErrorMessage();
return false;
}
// Re-create the index over the now permanent URLs table -- this was not there
// for the temporary table.
return CreateMainURLIndex();
}
bool URLDatabase::InitURLEnumeratorForEverything(URLEnumerator* enumerator) {
DCHECK(!enumerator->initialized_);
std::string sql("SELECT ");
sql.append(kURLRowFields);
sql.append(" FROM urls");
enumerator->statement_.Assign(GetDB().GetUniqueStatement(sql.c_str()));
enumerator->initialized_ = enumerator->statement_.is_valid();
return enumerator->statement_.is_valid();
}
bool URLDatabase::InitURLEnumeratorForSignificant(URLEnumerator* enumerator) {
DCHECK(!enumerator->initialized_);
std::string sql("SELECT ");
sql.append(kURLRowFields);
sql.append(
" FROM urls WHERE hidden = 0 AND "
"(last_visit_time >= ? OR visit_count >= ? OR typed_count >= ?)");
sql.append(
" ORDER BY typed_count DESC, last_visit_time DESC, visit_count "
"DESC");
enumerator->statement_.Assign(GetDB().GetUniqueStatement(sql.c_str()));
enumerator->statement_.BindInt64(
0, AutocompleteAgeThreshold().ToInternalValue());
enumerator->statement_.BindInt(1, kLowQualityMatchVisitLimit);
enumerator->statement_.BindInt(2, kLowQualityMatchTypedLimit);
enumerator->initialized_ = enumerator->statement_.is_valid();
return enumerator->statement_.is_valid();
}
bool URLDatabase::AutocompleteForPrefix(const std::string& prefix,
size_t max_results,
bool typed_only,
URLRows* results) {
// NOTE: this query originally sorted by starred as the second parameter. But
// as bookmarks is no longer part of the db we no longer include the order
// by clause.
results->clear();
const char* sql;
size_t line;
if (typed_only) {
sql = "SELECT" HISTORY_URL_ROW_FIELDS "FROM urls "
"WHERE url >= ? AND url < ? AND hidden = 0 AND typed_count > 0 "
"ORDER BY typed_count DESC, visit_count DESC, last_visit_time DESC "
"LIMIT ?";
line = __LINE__;
} else {
sql = "SELECT" HISTORY_URL_ROW_FIELDS "FROM urls "
"WHERE url >= ? AND url < ? AND hidden = 0 "
"ORDER BY typed_count DESC, visit_count DESC, last_visit_time DESC "
"LIMIT ?";
line = __LINE__;
}
sql::Statement statement(
GetDB().GetCachedStatement(sql::StatementID(__FILE__, line), sql));
// We will find all strings between "prefix" and this string, which is prefix
// followed by the maximum character size. Use 8-bit strings for everything
// so we can be sure sqlite is comparing everything in 8-bit mode. Otherwise,
// it will have to convert strings either to UTF-8 or UTF-16 for comparison.
std::string end_query(prefix);
end_query.push_back(std::numeric_limits<unsigned char>::max());
statement.BindString(0, prefix);
statement.BindString(1, end_query);
statement.BindInt(2, static_cast<int>(max_results));
while (statement.Step()) {
URLRow info;
FillURLRow(statement, &info);
if (info.url().is_valid())
results->push_back(info);
}
return !results->empty();
}
bool URLDatabase::IsTypedHost(const std::string& host, std::string* scheme) {
const char* schemes[] = {
url::kHttpScheme,
url::kHttpsScheme,
url::kFtpScheme
};
URLRows dummy;
for (size_t i = 0; i < base::size(schemes); ++i) {
std::string scheme_and_host(schemes[i]);
scheme_and_host += url::kStandardSchemeSeparator + host;
if (AutocompleteForPrefix(scheme_and_host + '/', 1, true, &dummy) ||
AutocompleteForPrefix(scheme_and_host + ':', 1, true, &dummy)) {
if (scheme != nullptr)
*scheme = schemes[i];
return true;
}
}
return false;
}
bool URLDatabase::FindShortestURLFromBase(const std::string& base,
const std::string& url,
int min_visits,
int min_typed,
bool allow_base,
URLRow* info) {
// Select URLs that start with |base| and are prefixes of |url|. All parts
// of this query except the substr() call can be done using the index. We
// could do this query with a couple of LIKE or GLOB statements as well, but
// those wouldn't use the index, and would run into problems with "wildcard"
// characters that appear in URLs (% for LIKE, or *, ? for GLOB).
std::string sql("SELECT ");
sql.append(kURLRowFields);
sql.append(" FROM urls WHERE url ");
sql.append(allow_base ? ">=" : ">");
sql.append(" ? AND url < :end AND url = substr(:end, 1, length(url)) "
"AND hidden = 0 AND visit_count >= ? AND typed_count >= ? "
"ORDER BY url LIMIT 1");
sql::Statement statement(GetDB().GetUniqueStatement(sql.c_str()));
statement.BindString(0, base);
statement.BindString(1, url); // :end
statement.BindInt(2, min_visits);
statement.BindInt(3, min_typed);
if (!statement.Step())
return false;
DCHECK(info);
FillURLRow(statement, info);
return true;
}
bool URLDatabase::GetTextMatches(const base::string16& query,
URLRows* results) {
return GetTextMatchesWithAlgorithm(
query, query_parser::MatchingAlgorithm::DEFAULT, results);
}
bool URLDatabase::GetTextMatchesWithAlgorithm(
const base::string16& query,
query_parser::MatchingAlgorithm algorithm,
URLRows* results) {
query_parser::QueryNodeVector query_nodes;
query_parser_.ParseQueryNodes(query, algorithm, &query_nodes);
results->clear();
sql::Statement statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"SELECT" HISTORY_URL_ROW_FIELDS "FROM urls WHERE hidden = 0"));
while (statement.Step()) {
query_parser::QueryWordVector query_words;
base::string16 url = base::i18n::ToLower(statement.ColumnString16(1));
query_parser_.ExtractQueryWords(url, &query_words);
GURL gurl(url);
if (gurl.is_valid()) {
// Decode punycode to match IDN.
base::string16 ascii = base::ASCIIToUTF16(gurl.host());
base::string16 utf = url_formatter::IDNToUnicode(gurl.host());
if (ascii != utf)
query_parser_.ExtractQueryWords(utf, &query_words);
}
base::string16 title = base::i18n::ToLower(statement.ColumnString16(2));
query_parser_.ExtractQueryWords(title, &query_words);
if (query_parser_.DoesQueryMatch(query_words, query_nodes)) {
URLResult info;
FillURLRow(statement, &info);
if (info.url().is_valid())
results->push_back(info);
}
}
return !results->empty();
}
bool URLDatabase::InitKeywordSearchTermsTable() {
has_keyword_search_terms_ = true;
if (!GetDB().DoesTableExist("keyword_search_terms")) {
if (!GetDB().Execute("CREATE TABLE keyword_search_terms ("
"keyword_id INTEGER NOT NULL," // ID of the TemplateURL.
"url_id INTEGER NOT NULL," // ID of the url.
"lower_term LONGVARCHAR NOT NULL," // The search term, in lower case.
"term LONGVARCHAR NOT NULL)")) // The actual search term.
return false;
}
return true;
}
bool URLDatabase::CreateKeywordSearchTermsIndices() {
// For searching.
if (!GetDB().Execute(
"CREATE INDEX IF NOT EXISTS keyword_search_terms_index1 ON "
"keyword_search_terms (keyword_id, lower_term)")) {
return false;
}
// For deletion.
if (!GetDB().Execute(
"CREATE INDEX IF NOT EXISTS keyword_search_terms_index2 ON "
"keyword_search_terms (url_id)")) {
return false;
}
// For query or deletion by term.
if (!GetDB().Execute(
"CREATE INDEX IF NOT EXISTS keyword_search_terms_index3 ON "
"keyword_search_terms (term)")) {
return false;
}
return true;
}
bool URLDatabase::DropKeywordSearchTermsTable() {
// This will implicitly delete the indices over the table.
return GetDB().Execute("DROP TABLE keyword_search_terms");
}
bool URLDatabase::SetKeywordSearchTermsForURL(URLID url_id,
KeywordID keyword_id,
const base::string16& term) {
DCHECK(url_id && keyword_id && !term.empty());
sql::Statement exist_statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"SELECT term FROM keyword_search_terms "
"WHERE keyword_id = ? AND url_id = ?"));
exist_statement.BindInt64(0, keyword_id);
exist_statement.BindInt64(1, url_id);
if (exist_statement.Step())
return true; // Term already exists, no need to add it.
if (!exist_statement.Succeeded())
return false;
sql::Statement statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"INSERT INTO keyword_search_terms (keyword_id, url_id, lower_term, term) "
"VALUES (?,?,?,?)"));
statement.BindInt64(0, keyword_id);
statement.BindInt64(1, url_id);
statement.BindString16(2, base::i18n::ToLower(term));
statement.BindString16(3, term);
return statement.Run();
}
bool URLDatabase::GetKeywordSearchTermRow(URLID url_id,
KeywordSearchTermRow* row) {
DCHECK(url_id);
sql::Statement statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"SELECT keyword_id, term FROM keyword_search_terms WHERE url_id=?"));
statement.BindInt64(0, url_id);
if (!statement.Step())
return false;
if (row) {
row->url_id = url_id;
row->keyword_id = statement.ColumnInt64(0);
row->term = statement.ColumnString16(1);
}
return true;
}
bool URLDatabase::GetKeywordSearchTermRows(
const base::string16& term,
std::vector<KeywordSearchTermRow>* rows) {
sql::Statement statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"SELECT keyword_id, url_id FROM keyword_search_terms WHERE term=?"));
statement.BindString16(0, term);
if (!statement.is_valid())
return false;
while (statement.Step()) {
KeywordSearchTermRow row;
row.url_id = statement.ColumnInt64(1);
row.keyword_id = statement.ColumnInt64(0);
row.term = term;
rows->push_back(row);
}
return true;
}
void URLDatabase::DeleteAllSearchTermsForKeyword(
KeywordID keyword_id) {
DCHECK(keyword_id);
sql::Statement statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"DELETE FROM keyword_search_terms WHERE keyword_id=?"));
statement.BindInt64(0, keyword_id);
statement.Run();
}
void URLDatabase::GetMostRecentKeywordSearchTerms(
KeywordID keyword_id,
const base::string16& prefix,
int max_count,
std::vector<KeywordSearchTermVisit>* matches) {
// NOTE: the keyword_id can be zero if on first run the user does a query
// before the TemplateURLService has finished loading. As the chances of this
// occurring are small, we ignore it.
if (!keyword_id)
return;
DCHECK(!prefix.empty());
sql::Statement statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"SELECT DISTINCT kv.term, u.visit_count, u.last_visit_time "
"FROM keyword_search_terms kv "
"JOIN urls u ON kv.url_id = u.id "
"WHERE kv.keyword_id = ? AND kv.lower_term >= ? AND kv.lower_term < ? "
"ORDER BY u.last_visit_time DESC LIMIT ?"));
// NOTE: Keep this ToLower() call in sync with search_provider.cc.
base::string16 lower_prefix = base::i18n::ToLower(prefix);
// This magic gives us a prefix search.
base::string16 next_prefix = lower_prefix;
next_prefix.back() = next_prefix.back() + 1;
statement.BindInt64(0, keyword_id);
statement.BindString16(1, lower_prefix);
statement.BindString16(2, next_prefix);
statement.BindInt(3, max_count);
KeywordSearchTermVisit visit;
while (statement.Step()) {
visit.term = statement.ColumnString16(0);
visit.visits = statement.ColumnInt(1);
visit.time = base::Time::FromInternalValue(statement.ColumnInt64(2));
matches->push_back(visit);
}
}
bool URLDatabase::DeleteKeywordSearchTerm(const base::string16& term) {
sql::Statement statement(GetDB().GetCachedStatement(SQL_FROM_HERE,
"DELETE FROM keyword_search_terms WHERE term=?"));
statement.BindString16(0, term);
return statement.Run();
}
bool URLDatabase::DeleteKeywordSearchTermForURL(URLID url_id) {
sql::Statement statement(GetDB().GetCachedStatement(
SQL_FROM_HERE, "DELETE FROM keyword_search_terms WHERE url_id=?"));
statement.BindInt64(0, url_id);
return statement.Run();
}
bool URLDatabase::DropStarredIDFromURLs() {
if (!GetDB().DoesColumnExist("urls", "starred_id"))
return true; // urls is already updated, no need to continue.
return RecreateURLTableWithAllContents();
}
bool URLDatabase::CreateURLTable(bool is_temporary) {
const char* name = is_temporary ? "temp_urls" : "urls";
if (GetDB().DoesTableExist(name))
return true;
// Note: revise implementation for InsertOrUpdateURLRowByID() if you add any
// new constraints to the schema.
std::string sql;
sql.append("CREATE TABLE ");
sql.append(name);
sql.append(
"("
// The id uses AUTOINCREMENT is for sync propose. Sync uses this |id| as
// an unique key to identify the URLs. If here did not use AUTOINCREMENT,
// and Sync was not working somehow, a ROWID could be deleted and re-used
// during this period. Once Sync come back, Sync would use ROWIDs and
// timestamps to see if there are any updates need to be synced. And sync
// will only see the new URL, but missed the deleted URL.
//
// IMPORTANT NOTE: Currently new tables are created with AUTOINCREMENT
// but the migration code is disabled. This means that you will not
// be able to count on AUTOINCREMENT behavior without adding
// additional migration steps.
//
// Along with this, an unused favicon_id column will exist for tables
// without AUTOINCREMENT. This should be removed everywhere.
//
// TODO(https://crbug.com/736136) figure out how to update users to use
// AUTOINCREMENT and remove the favicon_id column consistently.
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"url LONGVARCHAR,"
"title LONGVARCHAR,"
"visit_count INTEGER DEFAULT 0 NOT NULL,"
"typed_count INTEGER DEFAULT 0 NOT NULL,"
"last_visit_time INTEGER NOT NULL,"
"hidden INTEGER DEFAULT 0 NOT NULL)");
// IMPORTANT: If you change the colums, also update in_memory_database.cc
// where the values are copied (InitFromDisk).
return GetDB().Execute(sql.c_str());
}
bool URLDatabase::CreateMainURLIndex() {
return GetDB().Execute(
"CREATE INDEX IF NOT EXISTS urls_url_index ON urls (url)");
}
bool URLDatabase::RecreateURLTableWithAllContents() {
// Create a temporary table to contain the new URLs table.
if (!CreateTemporaryURLTable()) {
NOTREACHED();
return false;
}
// Copy the contents.
if (!GetDB().Execute(
"INSERT INTO temp_urls (id, url, title, visit_count, typed_count, "
"last_visit_time, hidden) "
"SELECT id, url, title, visit_count, typed_count, last_visit_time, "
"hidden FROM urls")) {
NOTREACHED() << GetDB().GetErrorMessage();
return false;
}
// Rename/commit the tmp table.
return CommitTemporaryURLTable();
}
const int kLowQualityMatchTypedLimit = 1;
const int kLowQualityMatchVisitLimit = 4;
const int kLowQualityMatchAgeLimitInDays = 3;
base::Time AutocompleteAgeThreshold() {
return (base::Time::Now() -
base::TimeDelta::FromDays(kLowQualityMatchAgeLimitInDays));
}
bool RowQualifiesAsSignificant(const URLRow& row,
const base::Time& threshold) {
if (row.hidden())
return false;
const base::Time& real_threshold =
threshold.is_null() ? AutocompleteAgeThreshold() : threshold;
return (row.typed_count() >= kLowQualityMatchTypedLimit) ||
(row.visit_count() >= kLowQualityMatchVisitLimit) ||
(row.last_visit() >= real_threshold);
}
} // namespace history