Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 198 additions & 15 deletions crates/onenote-index/src/index.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::document::{documents, ObjectDocument, PageDocument};
use crate::model::{
IndexProgress, MatchedField, SearchHit, SearchQuery, SourceStatus, TextRange, TextSnippet,
IndexProfile, IndexProgress, IndexUpdate, MatchedField, SearchHit, SearchQuery, SourceStatus,
TextRange, TextSnippet,
};
use crate::query::{prepare_query, PreparedQuery};
use crate::{Error, Result, SCHEMA_VERSION};
Expand Down Expand Up @@ -47,17 +48,48 @@ impl SearchIndex {
let version: u32 = connection
.query_row("PRAGMA user_version", [], |row| row.get(0))
.map_err(Error::from)?;
if version == 0 {
create_schema(&connection)?;
} else if version != SCHEMA_VERSION {
return Err(Error::IncompatibleSchema {
found: version,
expected: SCHEMA_VERSION,
});
match version {
0 => create_schema(&connection)?,
1 => migrate_schema_v1(&connection)?,
SCHEMA_VERSION => {}
found => {
return Err(Error::IncompatibleSchema {
found,
expected: SCHEMA_VERSION,
});
}
}
Ok(Self { connection })
}

/// Reuse a compatible source generation or publish a replacement.
///
/// Reuse requires an exact source identity, fingerprint, projection
/// version, and caller configuration match. The reuse path performs no
/// writes. Replacements have the same transactional and cancellation
/// guarantees as [`Self::replace_source`].
///
/// # Errors
///
/// Returns a cancellation or database error. A failed replacement leaves
/// the previously published generation available.
pub fn ensure_source(
&mut self,
notebook: &Notebook,
profile: &IndexProfile,
cancel: &AtomicBool,
progress: impl FnMut(IndexProgress),
) -> Result<IndexUpdate> {
if cancel.load(Ordering::Acquire) {
return Err(Error::Cancelled);
}
if self.source_matches(notebook, profile)? {
return Ok(IndexUpdate::Reused);
}
self.replace_source_with_profile(notebook, profile, cancel, progress)?;
Ok(IndexUpdate::Rebuilt)
}

/// Transactionally replace one complete source generation.
///
/// Progress callbacks execute on the calling thread after each page is
Expand All @@ -72,6 +104,24 @@ impl SearchIndex {
&mut self,
notebook: &Notebook,
cancel: &AtomicBool,
progress: impl FnMut(IndexProgress),
) -> Result<()> {
self.replace_source_with_profile(notebook, &IndexProfile::unversioned(), cancel, progress)
}

/// Transactionally replace one source and record its validity profile.
///
/// Prefer [`Self::ensure_source`] when unchanged generations may be reused.
///
/// # Errors
///
/// Returns a cancellation or database error. No partial generation is
/// visible after either failure.
pub fn replace_source_with_profile(
&mut self,
notebook: &Notebook,
profile: &IndexProfile,
cancel: &AtomicBool,
mut progress: impl FnMut(IndexProgress),
) -> Result<()> {
let documents = documents(notebook);
Expand All @@ -85,11 +135,15 @@ impl SearchIndex {
.map_err(Error::from)?;
transaction
.execute(
"INSERT INTO sources(source_id, fingerprint, notebook_name, page_count)
VALUES (?1, ?2, ?3, ?4)",
"INSERT INTO sources(
source_id, fingerprint, projection_version, projection_profile,
notebook_name, page_count
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
notebook.source_id.as_str(),
notebook.fingerprint.as_str(),
i64::from(profile.projection_version),
profile.configuration,
notebook.name,
usize_to_i64(total),
],
Expand All @@ -113,6 +167,25 @@ impl SearchIndex {
transaction.commit().map_err(Error::from)
}

fn source_matches(&self, notebook: &Notebook, profile: &IndexProfile) -> Result<bool> {
self.connection
.query_row(
"SELECT 1 FROM sources
WHERE source_id = ?1 AND fingerprint = ?2
AND projection_version = ?3 AND projection_profile = ?4",
params![
notebook.source_id.as_str(),
notebook.fingerprint.as_str(),
i64::from(profile.projection_version),
profile.configuration,
],
|_| Ok(()),
)
.optional()
.map(|matched| matched.is_some())
.map_err(Error::from)
}

/// Remove one source and its derived documents transactionally.
///
/// # Errors
Expand All @@ -138,7 +211,8 @@ impl SearchIndex {
let mut statement = self
.connection
.prepare(
"SELECT source_id, fingerprint, notebook_name, page_count
"SELECT source_id, fingerprint, projection_version, projection_profile,
notebook_name, page_count
FROM sources ORDER BY notebook_name, source_id",
)
.map_err(Error::from)?;
Expand All @@ -147,8 +221,9 @@ impl SearchIndex {
Ok(SourceStatus {
source_id: SourceId::new(row.get::<_, String>(0)?),
fingerprint: SourceFingerprint::new(row.get::<_, String>(1)?),
notebook_name: row.get(2)?,
page_count: i64_to_usize(row.get(3)?),
profile: IndexProfile::from_stored(row.get(2)?, row.get(3)?),
notebook_name: row.get(4)?,
page_count: i64_to_usize(row.get(5)?),
})
})
.map_err(Error::from)?
Expand Down Expand Up @@ -320,6 +395,8 @@ fn create_schema(connection: &Connection) -> Result<()> {
CREATE TABLE sources (
source_id TEXT PRIMARY KEY NOT NULL,
fingerprint TEXT NOT NULL,
projection_version INTEGER NOT NULL CHECK(projection_version >= 0),
projection_profile TEXT NOT NULL,
notebook_name TEXT NOT NULL,
page_count INTEGER NOT NULL CHECK(page_count >= 0)
);
Expand Down Expand Up @@ -379,7 +456,21 @@ fn create_schema(connection: &Connection) -> Result<()> {
END;
CREATE INDEX pages_source ON pages(source_id);
CREATE INDEX pages_section ON pages(section_id);
PRAGMA user_version = 1;
PRAGMA user_version = 2;
COMMIT;
",
)
.map_err(Error::from)
}

fn migrate_schema_v1(connection: &Connection) -> Result<()> {
connection
.execute_batch(
"
BEGIN IMMEDIATE;
ALTER TABLE sources ADD COLUMN projection_version INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sources ADD COLUMN projection_profile TEXT NOT NULL DEFAULT '';
PRAGMA user_version = 2;
COMMIT;
",
)
Expand Down Expand Up @@ -602,14 +693,106 @@ fn i64_to_usize(value: i64) -> usize {
#[cfg(test)]
mod tests {
use super::SearchIndex;
use crate::{Error, MatchedField, SearchQuery};
use crate::{Error, IndexProfile, IndexUpdate, MatchedField, SearchQuery, SCHEMA_VERSION};
use onenote_core::{
MathExpression, MathNode, MathSpan, Notebook, NotebookEntry, ObjectId, ObjectKind, Outline,
OutlineElement, Page, PageId, PageObject, PageObjectRole, Rect, Section, SectionId,
SourceFingerprint, SourceId, TextAlignment, TextBlock, TextLink, TextLinkOrigin, TextStyle,
};
use std::sync::atomic::AtomicBool;

#[test]
fn reuses_only_an_exact_source_and_projection_match_without_writes() {
let mut index = SearchIndex::open_in_memory().expect("index");
let notebook = notebook("source", "fingerprint", "Notebook");
let profile = IndexProfile::new("plain-text-links=enabled");
let cancel = AtomicBool::new(false);

assert_eq!(
index
.ensure_source(&notebook, &profile, &cancel, |_| {})
.expect("initial generation"),
IndexUpdate::Rebuilt
);
let changes = index.connection.total_changes();
assert_eq!(
index
.ensure_source(&notebook, &profile, &cancel, |_| {})
.expect("reuse generation"),
IndexUpdate::Reused
);
assert_eq!(index.connection.total_changes(), changes);

let changed_profile = IndexProfile::new("plain-text-links=disabled");
assert_eq!(
index
.ensure_source(&notebook, &changed_profile, &cancel, |_| {})
.expect("changed projection"),
IndexUpdate::Rebuilt
);
let mut changed_source = notebook;
changed_source.fingerprint = SourceFingerprint::new("new-fingerprint");
assert_eq!(
index
.ensure_source(&changed_source, &changed_profile, &cancel, |_| {})
.expect("changed source"),
IndexUpdate::Rebuilt
);
}

#[test]
fn cancelled_profiled_replacement_preserves_the_last_good_generation() {
let mut index = SearchIndex::open_in_memory().expect("index");
let notebook = notebook("source", "old", "Notebook");
let old_profile = IndexProfile::new("old-profile");
index
.ensure_source(&notebook, &old_profile, &AtomicBool::new(false), |_| {})
.expect("initial generation");

let mut replacement = notebook;
replacement.fingerprint = SourceFingerprint::new("new");
let error = index
.ensure_source(
&replacement,
&IndexProfile::new("new-profile"),
&AtomicBool::new(true),
|_| {},
)
.expect_err("cancel replacement");
assert_eq!(error, Error::Cancelled);
let published = &index.sources().expect("sources")[0];
assert_eq!(published.fingerprint.as_str(), "old");
assert_eq!(published.profile, old_profile);
}

#[test]
fn migrates_schema_one_sources_as_non_reusable_generations() {
let connection = rusqlite::Connection::open_in_memory().expect("database");
connection
.execute_batch(
"
CREATE TABLE sources (
source_id TEXT PRIMARY KEY NOT NULL,
fingerprint TEXT NOT NULL,
notebook_name TEXT NOT NULL,
page_count INTEGER NOT NULL CHECK(page_count >= 0)
);
INSERT INTO sources VALUES ('source', 'fingerprint', 'Notebook', 3);
PRAGMA user_version = 1;
",
)
.expect("schema one database");

let index = SearchIndex::initialize(connection).expect("migrate database");
let version: u32 = index
.connection
.query_row("PRAGMA user_version", [], |row| row.get(0))
.expect("schema version");
assert_eq!(version, SCHEMA_VERSION);
let source = &index.sources().expect("sources")[0];
assert_eq!(source.profile, IndexProfile::unversioned());
}

#[test]
fn indexes_queries_filters_and_removes_multiple_sources() {
let mut index = SearchIndex::open_in_memory().expect("index");
Expand Down
13 changes: 10 additions & 3 deletions crates/onenote-index/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,19 @@ mod query;
pub use error::{Error, Result};
pub use index::SearchIndex;
pub use model::{
IndexProgress, MatchedField, SearchFilters, SearchHit, SearchQuery, SourceStatus, TextRange,
TextSnippet,
IndexProfile, IndexProgress, IndexUpdate, MatchedField, SearchFilters, SearchHit, SearchQuery,
SourceStatus, TextRange, TextSnippet,
};

/// The crate API version during the pre-1.0 implementation phase.
pub const API_VERSION: u32 = onenote_core::API_VERSION;

/// Current private `SQLite` schema version.
pub const SCHEMA_VERSION: u32 = 1;
pub const SCHEMA_VERSION: u32 = 2;

/// Version of the model-to-search-document projection.
///
/// Increment this when the same loaded notebook model would produce different
/// indexed documents. Caller-selected loader behavior belongs in
/// [`IndexProfile::configuration`], not in this constant.
pub const INDEX_PROJECTION_VERSION: u32 = 1;
42 changes: 42 additions & 0 deletions crates/onenote-index/src/model.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,46 @@
use onenote_core::{ObjectId, PageId, Rect, SectionId, SourceFingerprint, SourceId};
use serde::{Deserialize, Serialize};

/// Inputs, other than source bytes, that determine indexed documents.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct IndexProfile {
/// Version of the index library's document projection.
pub projection_version: u32,
/// Stable caller-defined description of relevant loader/model options.
pub configuration: String,
}

impl IndexProfile {
/// Construct a profile for the current document projection.
pub fn new(configuration: impl Into<String>) -> Self {
Self {
projection_version: crate::INDEX_PROJECTION_VERSION,
configuration: configuration.into(),
}
}

pub(crate) fn from_stored(projection_version: u32, configuration: String) -> Self {
Self {
projection_version,
configuration,
}
}

pub(crate) fn unversioned() -> Self {
Self::from_stored(0, String::new())
}
}

/// Result of validating and, when necessary, rebuilding one source.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IndexUpdate {
/// The published generation already matched every validity input.
Reused,
/// A new generation was published transactionally.
Rebuilt,
}

/// Progress emitted synchronously while indexing a source.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct IndexProgress {
Expand All @@ -19,6 +59,8 @@ pub struct SourceStatus {
pub source_id: SourceId,
/// Fingerprint of the fully published generation.
pub fingerprint: SourceFingerprint,
/// Projection and caller configuration used by this generation.
pub profile: IndexProfile,
/// Notebook display name.
pub notebook_name: String,
/// Indexed pages.
Expand Down
Loading
Loading