Add a search engine into Plume (#324)

* Add search engine to the model

Add a Tantivy based search engine to the model
Implement most required functions for it

* Implement indexing and plm subcommands

Implement indexation on insert, update and delete
Modify func args to get the indexer where required
Add subcommand to initialize, refill and unlock search db

* Move to a new threadpool engine allowing scheduling

* Autocommit search index every half an hour

* Implement front part of search

Add default fields for search
Add new routes and templates for search and result
Implement FromFormValue for Page to reuse it on search result pagination
Add optional query parameters to paginate template's macro
Update to newer rocket_csrf, don't get csrf token on GET forms

* Handle process termination to release lock

Handle process termination
Add tests to search

* Add proper support for advanced search

Add an advanced search form to /search, in template and route
Modify Tantivy schema, add new tokenizer for some properties
Create new String query parser
Create Tantivy query AST from our own

* Split search.rs, add comment and tests

Split search.rs into multiple submodules
Add comments and tests for Query
Make user@domain be treated as one could assume
This commit is contained in:
fdb-hiroshima
2018-12-02 17:37:51 +01:00
committed by GitHub
parent 9714bafded
commit 449641d158
29 changed files with 1613 additions and 143 deletions
+3
View File
@@ -10,14 +10,17 @@ bcrypt = "0.2"
canapi = "0.1"
guid-create = "0.1"
heck = "0.3.0"
itertools = "0.7.8"
lazy_static = "*"
openssl = "0.10.11"
reqwest = "0.9"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
tantivy = "0.7.1"
url = "1.7"
webfinger = "0.3.1"
whatlang = "0.5.0"
[dependencies.chrono]
features = ["serde"]
+8 -5
View File
@@ -24,6 +24,7 @@ use plume_common::activity_pub::{
use posts::Post;
use safe_string::SafeString;
use schema::blogs;
use search::Searcher;
use users::User;
use {Connection, BASE_URL, USE_HTTPS};
@@ -411,9 +412,9 @@ impl Blog {
json
}
pub fn delete(&self, conn: &Connection) {
pub fn delete(&self, conn: &Connection, searcher: &Searcher) {
for post in Post::get_for_blog(conn, &self) {
post.delete(conn);
post.delete(&(conn, searcher));
}
diesel::delete(self)
.execute(conn)
@@ -509,6 +510,7 @@ pub(crate) mod tests {
use instance::tests as instance_tests;
use tests::db;
use users::tests as usersTests;
use search::tests::get_searcher;
use Connection as Conn;
pub(crate) fn fill_database(conn: &Conn) -> Vec<Blog> {
@@ -756,7 +758,7 @@ pub(crate) mod tests {
conn.test_transaction::<_, (), _>(|| {
let blogs = fill_database(conn);
blogs[0].delete(conn);
blogs[0].delete(conn, &get_searcher());
assert!(Blog::get(conn, blogs[0].id).is_none());
Ok(())
@@ -767,6 +769,7 @@ pub(crate) mod tests {
fn delete_via_user() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let searcher = get_searcher();
let user = usersTests::fill_database(conn);
fill_database(conn);
@@ -818,10 +821,10 @@ pub(crate) mod tests {
},
);
user[0].delete(conn);
user[0].delete(conn, &searcher);
assert!(Blog::get(conn, blog[0].id).is_some());
assert!(Blog::get(conn, blog[1].id).is_none());
user[1].delete(conn);
user[1].delete(conn, &searcher);
assert!(Blog::get(conn, blog[0].id).is_none());
Ok(())
+5 -28
View File
@@ -10,6 +10,7 @@ extern crate chrono;
extern crate diesel;
extern crate guid_create;
extern crate heck;
extern crate itertools;
#[macro_use]
extern crate lazy_static;
extern crate openssl;
@@ -22,8 +23,11 @@ extern crate serde;
extern crate serde_derive;
#[macro_use]
extern crate serde_json;
#[macro_use]
extern crate tantivy;
extern crate url;
extern crate webfinger;
extern crate whatlang;
#[cfg(test)]
#[macro_use]
@@ -145,34 +149,6 @@ macro_rules! insert {
};
}
/// Adds a function to a model to save changes to a model.
/// The model should derive diesel::AsChangeset.
///
/// # Usage
///
/// ```rust
/// impl Model {
/// update!(model_table);
/// }
///
/// // Update and save changes
/// let m = Model::get(connection, 1);
/// m.foo = 42;
/// m.update(connection);
/// ```
macro_rules! update {
($table:ident) => {
pub fn update(&self, conn: &crate::Connection) -> Self {
diesel::update(self)
.set(self)
.execute(conn)
.expect(concat!("macro::update: Error updating ", stringify!($table)));
Self::get(conn, self.id)
.expect(concat!("macro::update: ", stringify!($table), " we just updated doesn't exist anymore???"))
}
};
}
/// Returns the last row of a table.
///
/// # Usage
@@ -284,6 +260,7 @@ pub mod post_authors;
pub mod posts;
pub mod reshares;
pub mod safe_string;
pub mod search;
pub mod schema;
pub mod tags;
pub mod users;
+41 -18
View File
@@ -25,6 +25,7 @@ use plume_common::{
use post_authors::*;
use reshares::Reshare;
use safe_string::SafeString;
use search::Searcher;
use schema::posts;
use std::collections::HashSet;
use tags::Tag;
@@ -64,11 +65,11 @@ pub struct NewPost {
pub cover_id: Option<i32>,
}
impl<'a> Provider<(&'a Connection, Option<i32>)> for Post {
impl<'a> Provider<(&'a Connection, &'a Searcher, Option<i32>)> for Post {
type Data = PostEndpoint;
fn get(
(conn, user_id): &(&'a Connection, Option<i32>),
(conn, _search, user_id): &(&'a Connection, &Searcher, Option<i32>),
id: i32,
) -> Result<PostEndpoint, Error> {
if let Some(post) = Post::get(conn, id) {
@@ -90,7 +91,7 @@ impl<'a> Provider<(&'a Connection, Option<i32>)> for Post {
}
fn list(
(conn, user_id): &(&'a Connection, Option<i32>),
(conn, _searcher, user_id): &(&'a Connection, &Searcher, Option<i32>),
filter: PostEndpoint,
) -> Vec<PostEndpoint> {
let mut query = posts::table.into_boxed();
@@ -123,37 +124,57 @@ impl<'a> Provider<(&'a Connection, Option<i32>)> for Post {
}
fn create(
(_conn, _user_id): &(&'a Connection, Option<i32>),
(_conn, _searcher, _user_id): &(&'a Connection, &Searcher, Option<i32>),
_query: PostEndpoint,
) -> Result<PostEndpoint, Error> {
unimplemented!()
}
fn update(
(_conn, _user_id): &(&'a Connection, Option<i32>),
(_conn, _searcher, _user_id): &(&'a Connection, &Searcher, Option<i32>),
_id: i32,
_new_data: PostEndpoint,
) -> Result<PostEndpoint, Error> {
unimplemented!()
}
fn delete((conn, user_id): &(&'a Connection, Option<i32>), id: i32) {
fn delete((conn, searcher, user_id): &(&'a Connection, &Searcher, Option<i32>), id: i32) {
let user_id = user_id.expect("Post as Provider::delete: not authenticated");
if let Some(post) = Post::get(conn, id) {
if post.is_author(conn, user_id) {
post.delete(conn);
post.delete(&(conn, searcher));
}
}
}
}
impl Post {
insert!(posts, NewPost);
get!(posts);
update!(posts);
find_by!(posts, find_by_slug, slug as &str, blog_id as i32);
find_by!(posts, find_by_ap_url, ap_url as &str);
last!(posts);
pub fn insert(conn: &Connection, new: NewPost, searcher: &Searcher) -> Self {
diesel::insert_into(posts::table)
.values(new)
.execute(conn)
.expect("Post::insert: Error saving in posts");
let post = Self::last(conn);
searcher.add_document(conn, &post);
post
}
pub fn update(&self, conn: &Connection, searcher: &Searcher) -> Self {
diesel::update(self)
.set(self)
.execute(conn)
.expect("Post::update: Error updating posts");
let post = Self::get(conn, self.id)
.expect("macro::update: posts we just updated doesn't exist anymore???");
searcher.update_document(conn, &post);
post
}
pub fn list_by_tag(conn: &Connection, tag: String, (min, max): (i32, i32)) -> Vec<Post> {
use schema::tags;
@@ -560,7 +581,7 @@ impl Post {
act
}
pub fn handle_update(conn: &Connection, updated: &Article) {
pub fn handle_update(conn: &Connection, updated: &Article, searcher: &Searcher) {
let id = updated
.object_props
.id_string()
@@ -620,7 +641,7 @@ impl Post {
post.update_hashtags(conn, hashtags);
}
post.update(conn);
post.update(conn, searcher);
}
pub fn update_mentions(&self, conn: &Connection, mentions: Vec<link::Mention>) {
@@ -765,8 +786,8 @@ impl Post {
}
}
impl FromActivity<Article, Connection> for Post {
fn from_activity(conn: &Connection, article: Article, _actor: Id) -> Post {
impl<'a> FromActivity<Article, (&'a Connection, &'a Searcher)> for Post {
fn from_activity((conn, searcher): &(&'a Connection, &'a Searcher), article: Article, _actor: Id) -> Post {
if let Some(post) = Post::find_by_ap_url(
conn,
&article.object_props.id_string().unwrap_or_default(),
@@ -838,6 +859,7 @@ impl FromActivity<Article, Connection> for Post {
.content,
cover_id: cover,
},
searcher,
);
for author in authors {
@@ -877,8 +899,8 @@ impl FromActivity<Article, Connection> for Post {
}
}
impl Deletable<Connection, Delete> for Post {
fn delete(&self, conn: &Connection) -> Delete {
impl<'a> Deletable<(&'a Connection, &'a Searcher), Delete> for Post {
fn delete(&self, (conn, searcher): &(&Connection, &Searcher)) -> Delete {
let mut act = Delete::default();
act.delete_props
.set_actor_link(self.get_authors(conn)[0].clone().into_id())
@@ -904,12 +926,13 @@ impl Deletable<Connection, Delete> for Post {
m.delete(conn);
}
diesel::delete(self)
.execute(conn)
.execute(*conn)
.expect("Post::delete: DB error");
searcher.delete_document(self);
act
}
fn delete_id(id: &str, actor_id: &str, conn: &Connection) {
fn delete_id(id: &str, actor_id: &str, (conn, searcher): &(&Connection, &Searcher)) {
let actor = User::find_by_ap_url(conn, actor_id);
let post = Post::find_by_ap_url(conn, id);
let can_delete = actor
@@ -919,7 +942,7 @@ impl Deletable<Connection, Delete> for Post {
})
.unwrap_or(false);
if can_delete {
post.map(|p| p.delete(conn));
post.map(|p| p.delete(&(conn, searcher)));
}
}
}
+167
View File
@@ -0,0 +1,167 @@
mod searcher;
mod query;
mod tokenizer;
pub use self::searcher::*;
pub use self::query::PlumeQuery as Query;
#[cfg(test)]
pub(crate) mod tests {
use super::{Query, Searcher};
use std::env::temp_dir;
use diesel::Connection;
use plume_common::activity_pub::inbox::Deletable;
use plume_common::utils::random_hex;
use blogs::tests::fill_database;
use posts::{NewPost, Post};
use post_authors::*;
use safe_string::SafeString;
use tests::db;
pub(crate) fn get_searcher() -> Searcher {
let dir = temp_dir().join("plume-test");
if dir.exists() {
Searcher::open(&dir)
} else {
Searcher::create(&dir)
}.unwrap()
}
#[test]
fn get_first_token() {
let vector = vec![
("+\"my token\" other", ("+\"my token\"", " other")),
("-\"my token\" other", ("-\"my token\"", " other")),
(" \"my token\" other", ("\"my token\"", " other")),
("\"my token\" other", ("\"my token\"", " other")),
("+my token other", ("+my", " token other")),
("-my token other", ("-my", " token other")),
(" my token other", ("my", " token other")),
("my token other", ("my", " token other")),
("+\"my token other", ("+\"my token other", "")),
("-\"my token other", ("-\"my token other", "")),
(" \"my token other", ("\"my token other", "")),
("\"my token other", ("\"my token other", "")),
];
for (source, res) in vector {
assert_eq!(Query::get_first_token(source), res);
}
}
#[test]
fn from_str() {
let vector = vec![
("", ""),
("a query", "a query"),
("\"a query\"", "\"a query\""),
("+a -\"query\"", "+a -query"),
("title:\"something\" a query", "a query title:something"),
("-title:\"something\" a query", "a query -title:something"),
("author:user@domain", "author:user@domain"),
("-author:@user@domain", "-author:user@domain"),
("before:2017-11-05 before:2018-01-01", "before:2017-11-05"),
("after:2017-11-05 after:2018-01-01", "after:2018-01-01"),
];
for (source, res) in vector {
assert_eq!(&Query::from_str(source).to_string(), res);
assert_eq!(Query::new().parse_query(source).to_string(), res);
}
}
#[test]
fn setters() {
let vector = vec![
("something", "title:something"),
("+something", "+title:something"),
("-something", "-title:something"),
("+\"something\"", "+title:something"),
("+some thing", "+title:\"some thing\""),
];
for (source, res) in vector {
assert_eq!(&Query::new().title(source, None).to_string(), res);
}
let vector = vec![
("something", "author:something"),
("+something", "+author:something"),
("-something", "-author:something"),
("+\"something\"", "+author:something"),
("+@someone@somewhere", "+author:someone@somewhere"),
];
for (source, res) in vector {
assert_eq!(&Query::new().author(source, None).to_string(), res);
}
}
#[test]
fn open() {
{get_searcher()};//make sure $tmp/plume-test-tantivy exist
let dir = temp_dir().join("plume-test");
Searcher::open(&dir).unwrap();
}
#[test]
fn create() {
let dir = temp_dir().join(format!("plume-test-{}", random_hex()));
assert!(Searcher::open(&dir).is_err());
{Searcher::create(&dir).unwrap();}
Searcher::open(&dir).unwrap();//verify it's well created
}
#[test]
fn search() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let searcher = get_searcher();
let blog = &fill_database(conn)[0];
let author = &blog.list_authors(conn)[0];
let title = random_hex()[..8].to_owned();
let mut post = Post::insert(conn, NewPost {
blog_id: blog.id,
slug: title.clone(),
title: title.clone(),
content: SafeString::new(""),
published: true,
license: "CC-BY-SA".to_owned(),
ap_url: "".to_owned(),
creation_date: None,
subtitle: "".to_owned(),
source: "".to_owned(),
cover_id: None,
}, &searcher);
PostAuthor::insert(conn, NewPostAuthor {
post_id: post.id,
author_id: author.id,
});
searcher.commit();
assert_eq!(searcher.search_document(conn, Query::from_str(&title), (0,1))[0].id, post.id);
let newtitle = random_hex()[..8].to_owned();
post.title = newtitle.clone();
post.update(conn, &searcher);
searcher.commit();
assert_eq!(searcher.search_document(conn, Query::from_str(&newtitle), (0,1))[0].id, post.id);
assert!(searcher.search_document(conn, Query::from_str(&title), (0,1)).is_empty());
post.delete(&(conn, &searcher));
searcher.commit();
assert!(searcher.search_document(conn, Query::from_str(&newtitle), (0,1)).is_empty());
Ok(())
});
}
#[test]
fn drop_writer() {
let searcher = get_searcher();
searcher.drop_writer();
get_searcher();
}
}
+343
View File
@@ -0,0 +1,343 @@
use chrono::{Datelike, naive::NaiveDate, offset::Utc};
use tantivy::{query::*, schema::*, Term};
use std::{cmp,ops::Bound};
use search::searcher::Searcher;
//Generate functions for advanced search
macro_rules! gen_func {
( $($field:ident),*; strip: $($strip:ident),* ) => {
$( //most fields go here, it's kinda the "default" way
pub fn $field(&mut self, mut val: &str, occur: Option<Occur>) -> &mut Self {
if !val.trim_matches(&[' ', '"', '+', '-'][..]).is_empty() {
let occur = if let Some(occur) = occur {
occur
} else {
if val.get(0..1).map(|v| v=="+").unwrap_or(false) {
val = &val[1..];
Occur::Must
} else if val.get(0..1).map(|v| v=="-").unwrap_or(false) {
val = &val[1..];
Occur::MustNot
} else {
Occur::Should
}
};
self.$field.push((occur, val.trim_matches(&[' ', '"'][..]).to_owned()));
}
self
}
)*
$( // blog and author go here, leading @ get dismissed
pub fn $strip(&mut self, mut val: &str, occur: Option<Occur>) -> &mut Self {
if !val.trim_matches(&[' ', '"', '+', '-'][..]).is_empty() {
let occur = if let Some(occur) = occur {
occur
} else {
if val.get(0..1).map(|v| v=="+").unwrap_or(false) {
val = &val[1..];
Occur::Must
} else if val.get(0..1).map(|v| v=="-").unwrap_or(false) {
val = &val[1..];
Occur::MustNot
} else {
Occur::Should
}
};
self.$strip.push((occur, val.trim_matches(&[' ', '"', '@'][..]).to_owned()));
}
self
}
)*
}
}
//generate the parser for advanced query from string
macro_rules! gen_parser {
( $self:ident, $query:ident, $occur:ident; normal: $($field:ident),*; date: $($date:ident),*) => {
$( // most fields go here
if $query.starts_with(concat!(stringify!($field), ':')) {
let new_query = &$query[concat!(stringify!($field), ':').len()..];
let (token, rest) = Self::get_first_token(new_query);
$query = rest;
$self.$field(token, Some($occur));
} else
)*
$( // dates (before/after) got here
if $query.starts_with(concat!(stringify!($date), ':')) {
let new_query = &$query[concat!(stringify!($date), ':').len()..];
let (token, rest) = Self::get_first_token(new_query);
$query = rest;
if let Ok(token) = NaiveDate::parse_from_str(token, "%Y-%m-%d") {
$self.$date(&token);
}
} else
)* // fields without 'fieldname:' prefix are considered bare words, and will be searched in title, subtitle and content
{
let (token, rest) = Self::get_first_token($query);
$query = rest;
$self.text(token, Some($occur));
}
}
}
// generate the to_string, giving back a textual query from a PlumeQuery
macro_rules! gen_to_string {
( $self:ident, $result:ident; normal: $($field:ident),*; date: $($date:ident),*) => {
$(
for (occur, val) in &$self.$field {
if val.contains(' ') {
$result.push_str(&format!("{}{}:\"{}\" ", Self::occur_to_str(&occur), stringify!($field), val));
} else {
$result.push_str(&format!("{}{}:{} ", Self::occur_to_str(&occur), stringify!($field), val));
}
}
)*
$(
for val in &$self.$date {
$result.push_str(&format!("{}:{} ", stringify!($date), NaiveDate::from_num_days_from_ce(*val as i32).format("%Y-%m-%d")));
}
)*
}
}
// convert PlumeQuery to Tantivy's Query
macro_rules! gen_to_query {
( $self:ident, $result:ident; normal: $($normal:ident),*; oneoff: $($oneoff:ident),*) => {
$( // classic fields
for (occur, token) in $self.$normal {
$result.push((occur, Self::token_to_query(&token, stringify!($normal))));
}
)*
$( // fields where having more than on Must make no sense in general, so it's considered a Must be one of these instead.
// Those fields are instance, author, blog, lang and license
let mut subresult = Vec::new();
for (occur, token) in $self.$oneoff {
match occur {
Occur::Must => subresult.push((Occur::Should, Self::token_to_query(&token, stringify!($oneoff)))),
occur => $result.push((occur, Self::token_to_query(&token, stringify!($oneoff)))),
}
}
if !subresult.is_empty() {
$result.push((Occur::Must, Box::new(BooleanQuery::from(subresult))));
}
)*
}
}
#[derive(Default)]
pub struct PlumeQuery {
text: Vec<(Occur, String)>,
title: Vec<(Occur, String)>,
subtitle: Vec<(Occur, String)>,
content: Vec<(Occur, String)>,
tag: Vec<(Occur, String)>,
instance: Vec<(Occur, String)>,
author: Vec<(Occur, String)>,
blog: Vec<(Occur, String)>,
lang: Vec<(Occur, String)>,
license: Vec<(Occur, String)>,
before: Option<i64>,
after: Option<i64>,
}
impl PlumeQuery {
/// Create a new empty Query
pub fn new() -> Self {
Default::default()
}
/// Create a new Query from &str
/// Same as doing
/// ```rust
/// # extern crate plume_models;
/// # use plume_models::search::Query;
/// let mut q = Query::new();
/// q.parse_query("some query");
/// ```
pub fn from_str(query: &str) -> Self {
let mut res: Self = Default::default();
res.from_str_req(&query.trim());
res
}
/// Parse a query string into this Query
pub fn parse_query(&mut self, query: &str) -> &mut Self {
self.from_str_req(&query.trim())
}
/// Convert this Query to a Tantivy Query
pub fn into_query(self) -> BooleanQuery {
let mut result: Vec<(Occur, Box<Query>)> = Vec::new();
gen_to_query!(self, result; normal: title, subtitle, content, tag;
oneoff: instance, author, blog, lang, license);
for (occur, token) in self.text { // text entries need to be added as multiple Terms
match occur {
Occur::Must => { // a Must mean this must be in one of title subtitle or content, not in all 3
let subresult = vec![
(Occur::Should, Self::token_to_query(&token, "title")),
(Occur::Should, Self::token_to_query(&token, "subtitle")),
(Occur::Should, Self::token_to_query(&token, "content")),
];
result.push((Occur::Must, Box::new(BooleanQuery::from(subresult))));
},
occur => {
result.push((occur, Self::token_to_query(&token, "title")));
result.push((occur, Self::token_to_query(&token, "subtitle")));
result.push((occur, Self::token_to_query(&token, "content")));
},
}
}
if self.before.is_some() || self.after.is_some() { // if at least one range bound is provided
let after = self.after.unwrap_or_else(|| i64::from(NaiveDate::from_ymd(2000, 1, 1).num_days_from_ce()));
let before = self.before.unwrap_or_else(|| i64::from(Utc::today().num_days_from_ce()));
let field = Searcher::schema().get_field("creation_date").unwrap();
let range = RangeQuery::new_i64_bounds(field, Bound::Included(after), Bound::Included(before));
result.push((Occur::Must, Box::new(range)));
}
result.into()
}
//generate most setters functions
gen_func!(text, title, subtitle, content, tag, instance, lang, license; strip: author, blog);
// documents newer than the provided date will be ignored
pub fn before<D: Datelike>(&mut self, date: &D) -> &mut Self {
let before = self.before.unwrap_or_else(|| i64::from(Utc::today().num_days_from_ce()));
self.before = Some(cmp::min(before, i64::from(date.num_days_from_ce())));
self
}
// documents older than the provided date will be ignored
pub fn after<D: Datelike>(&mut self, date: &D) -> &mut Self {
let after = self.after.unwrap_or_else(|| i64::from(NaiveDate::from_ymd(2000, 1, 1).num_days_from_ce()));
self.after = Some(cmp::max(after, i64::from(date.num_days_from_ce())));
self
}
// split a string into a token and a rest
pub fn get_first_token<'a>(mut query: &'a str) -> (&'a str, &'a str) {
query = query.trim();
if query.is_empty() {
("", "")
} else {
if query.get(0..1).map(|v| v=="\"").unwrap_or(false) {
if let Some(index) = query[1..].find('"') {
query.split_at(index+2)
} else {
(query, "")
}
} else if query.get(0..2).map(|v| v=="+\"" || v=="-\"").unwrap_or(false) {
if let Some(index) = query[2..].find('"') {
query.split_at(index+3)
} else {
(query, "")
}
} else {
if let Some(index) = query.find(' ') {
query.split_at(index)
} else {
(query, "")
}
}
}
}
// map each Occur state to a prefix
fn occur_to_str(occur: &Occur) -> &'static str {
match occur {
Occur::Should => "",
Occur::Must => "+",
Occur::MustNot => "-",
}
}
// recursive parser for query string
fn from_str_req(&mut self, mut query: &str) -> &mut Self {
query = query.trim_left();
if query.is_empty() {
self
} else {
let occur = if query.get(0..1).map(|v| v=="+").unwrap_or(false) {
query = &query[1..];
Occur::Must
} else if query.get(0..1).map(|v| v=="-").unwrap_or(false) {
query = &query[1..];
Occur::MustNot
} else {
Occur::Should
};
gen_parser!(self, query, occur; normal: title, subtitle, content, tag,
instance, author, blog, lang, license;
date: after, before);
self.from_str_req(query)
}
}
// map a token and it's field to a query
fn token_to_query(token: &str, field_name: &str) -> Box<Query> {
let token = token.to_lowercase();
let token = token.as_str();
let field = Searcher::schema().get_field(field_name).unwrap();
if token.contains('@') && (field_name=="author" || field_name=="blog") {
let pos = token.find('@').unwrap();
let user_term = Term::from_field_text(field, &token[..pos]);
let instance_term = Term::from_field_text(Searcher::schema().get_field("instance").unwrap(), &token[pos+1..]);
Box::new(BooleanQuery::from(vec![
(Occur::Must, Box::new(TermQuery::new(user_term, if field_name=="author" { IndexRecordOption::Basic }
else { IndexRecordOption::WithFreqsAndPositions }
)) as Box<dyn Query + 'static>),
(Occur::Must, Box::new(TermQuery::new(instance_term, IndexRecordOption::Basic))),
]))
} else if token.contains(' ') { // phrase query
match field_name {
"instance" | "author" | "tag" => // phrase query are not available on these fields, treat it as multiple Term queries
Box::new(BooleanQuery::from(token.split_whitespace()
.map(|token| {
let term = Term::from_field_text(field, token);
(Occur::Should, Box::new(TermQuery::new(term, IndexRecordOption::Basic))
as Box<dyn Query + 'static>)
})
.collect::<Vec<_>>())),
_ => Box::new(PhraseQuery::new(token.split_whitespace()
.map(|token| Term::from_field_text(field, token))
.collect()))
}
} else { // Term Query
let term = Term::from_field_text(field, token);
let index_option = match field_name {
"instance" | "author" | "tag" => IndexRecordOption::Basic,
_ => IndexRecordOption::WithFreqsAndPositions,
};
Box::new(TermQuery::new(term, index_option))
}
}
}
impl ToString for PlumeQuery {
fn to_string(&self) -> String {
let mut result = String::new();
for (occur, val) in &self.text {
if val.contains(' ') {
result.push_str(&format!("{}\"{}\" ", Self::occur_to_str(&occur), val));
} else {
result.push_str(&format!("{}{} ", Self::occur_to_str(&occur), val));
}
}
gen_to_string!(self, result; normal: title, subtitle, content, tag,
instance, author, blog, lang, license;
date: before, after);
result.pop();// remove trailing ' '
result
}
}
+203
View File
@@ -0,0 +1,203 @@
use instance::Instance;
use posts::Post;
use tags::Tag;
use Connection;
use chrono::Datelike;
use itertools::Itertools;
use tantivy::{
collector::TopCollector, directory::MmapDirectory,
schema::*, tokenizer::*, Index, IndexWriter, Term
};
use whatlang::{detect as detect_lang, Lang};
use std::{cmp, fs::create_dir_all, path::Path, sync::Mutex};
use search::query::PlumeQuery;
use super::tokenizer;
#[derive(Debug)]
pub enum SearcherError{
IndexCreationError,
WriteLockAcquisitionError,
IndexOpeningError,
IndexEditionError,
}
pub struct Searcher {
index: Index,
writer: Mutex<Option<IndexWriter>>,
}
impl Searcher {
pub fn schema() -> Schema {
let tag_indexing = TextOptions::default()
.set_indexing_options(TextFieldIndexing::default()
.set_tokenizer("whitespace_tokenizer")
.set_index_option(IndexRecordOption::Basic));
let content_indexing = TextOptions::default()
.set_indexing_options(TextFieldIndexing::default()
.set_tokenizer("content_tokenizer")
.set_index_option(IndexRecordOption::WithFreqsAndPositions));
let property_indexing = TextOptions::default()
.set_indexing_options(TextFieldIndexing::default()
.set_tokenizer("property_tokenizer")
.set_index_option(IndexRecordOption::WithFreqsAndPositions));
let mut schema_builder = SchemaBuilder::default();
schema_builder.add_i64_field("post_id", INT_STORED | INT_INDEXED);
schema_builder.add_i64_field("creation_date", INT_INDEXED);
schema_builder.add_text_field("instance", tag_indexing.clone());
schema_builder.add_text_field("author", tag_indexing.clone());//todo move to a user_indexing with user_tokenizer function
schema_builder.add_text_field("tag", tag_indexing);
schema_builder.add_text_field("blog", content_indexing.clone());
schema_builder.add_text_field("content", content_indexing.clone());
schema_builder.add_text_field("subtitle", content_indexing.clone());
schema_builder.add_text_field("title", content_indexing);
schema_builder.add_text_field("lang", property_indexing.clone());
schema_builder.add_text_field("license", property_indexing);
schema_builder.build()
}
pub fn create(path: &AsRef<Path>) -> Result<Self,SearcherError> {
let whitespace_tokenizer = tokenizer::WhitespaceTokenizer
.filter(LowerCaser);
let content_tokenizer = SimpleTokenizer
.filter(RemoveLongFilter::limit(40))
.filter(LowerCaser);
let property_tokenizer = NgramTokenizer::new(2, 8, false)
.filter(LowerCaser);
let schema = Self::schema();
create_dir_all(path).map_err(|_| SearcherError::IndexCreationError)?;
let index = Index::create(MmapDirectory::open(path).map_err(|_| SearcherError::IndexCreationError)?, schema).map_err(|_| SearcherError::IndexCreationError)?;
{
let tokenizer_manager = index.tokenizers();
tokenizer_manager.register("whitespace_tokenizer", whitespace_tokenizer);
tokenizer_manager.register("content_tokenizer", content_tokenizer);
tokenizer_manager.register("property_tokenizer", property_tokenizer);
}//to please the borrow checker
Ok(Self {
writer: Mutex::new(Some(index.writer(50_000_000).map_err(|_| SearcherError::WriteLockAcquisitionError)?)),
index
})
}
pub fn open(path: &AsRef<Path>) -> Result<Self, SearcherError> {
let whitespace_tokenizer = tokenizer::WhitespaceTokenizer
.filter(LowerCaser);
let content_tokenizer = SimpleTokenizer
.filter(RemoveLongFilter::limit(40))
.filter(LowerCaser);
let property_tokenizer = NgramTokenizer::new(2, 8, false)
.filter(LowerCaser);
let index = Index::open(MmapDirectory::open(path).map_err(|_| SearcherError::IndexOpeningError)?).map_err(|_| SearcherError::IndexOpeningError)?;
{
let tokenizer_manager = index.tokenizers();
tokenizer_manager.register("whitespace_tokenizer", whitespace_tokenizer);
tokenizer_manager.register("content_tokenizer", content_tokenizer);
tokenizer_manager.register("property_tokenizer", property_tokenizer);
}//to please the borrow checker
let mut writer = index.writer(50_000_000).map_err(|_| SearcherError::WriteLockAcquisitionError)?;
writer.garbage_collect_files().map_err(|_| SearcherError::IndexEditionError)?;
Ok(Self {
writer: Mutex::new(Some(writer)),
index,
})
}
pub fn add_document(&self, conn: &Connection, post: &Post) {
let schema = self.index.schema();
let post_id = schema.get_field("post_id").unwrap();
let creation_date = schema.get_field("creation_date").unwrap();
let instance = schema.get_field("instance").unwrap();
let author = schema.get_field("author").unwrap();
let tag = schema.get_field("tag").unwrap();
let blog_name = schema.get_field("blog").unwrap();
let content = schema.get_field("content").unwrap();
let subtitle = schema.get_field("subtitle").unwrap();
let title = schema.get_field("title").unwrap();
let lang = schema.get_field("lang").unwrap();
let license = schema.get_field("license").unwrap();
let mut writer = self.writer.lock().unwrap();
let writer = writer.as_mut().unwrap();
writer.add_document(doc!(
post_id => i64::from(post.id),
author => post.get_authors(conn).into_iter().map(|u| u.get_fqn(conn)).join(" "),
creation_date => i64::from(post.creation_date.num_days_from_ce()),
instance => Instance::get(conn, post.get_blog(conn).instance_id).unwrap().public_domain.clone(),
tag => Tag::for_post(conn, post.id).into_iter().map(|t| t.tag).join(" "),
blog_name => post.get_blog(conn).title,
content => post.content.get().clone(),
subtitle => post.subtitle.clone(),
title => post.title.clone(),
lang => detect_lang(post.content.get()).and_then(|i| if i.is_reliable() { Some(i.lang()) } else {None} ).unwrap_or(Lang::Eng).name(),
license => post.license.clone(),
));
}
pub fn delete_document(&self, post: &Post) {
let schema = self.index.schema();
let post_id = schema.get_field("post_id").unwrap();
let doc_id = Term::from_field_i64(post_id, i64::from(post.id));
let mut writer = self.writer.lock().unwrap();
let writer = writer.as_mut().unwrap();
writer.delete_term(doc_id);
}
pub fn update_document(&self, conn: &Connection, post: &Post) {
self.delete_document(post);
self.add_document(conn, post);
}
pub fn search_document(&self, conn: &Connection, query: PlumeQuery, (min, max): (i32, i32)) -> Vec<Post>{
let schema = self.index.schema();
let post_id = schema.get_field("post_id").unwrap();
let mut collector = TopCollector::with_limit(cmp::max(1,max) as usize);
let searcher = self.index.searcher();
searcher.search(&query.into_query(), &mut collector).unwrap();
collector.docs().get(min as usize..).unwrap_or(&[])
.into_iter()
.filter_map(|doc_add| {
let doc = searcher.doc(*doc_add).ok()?;
let id = doc.get_first(post_id)?;
Post::get(conn, id.i64_value() as i32)
//borrow checker don't want me to use filter_map or and_then here
})
.collect()
}
pub fn commit(&self) {
let mut writer = self.writer.lock().unwrap();
writer.as_mut().unwrap().commit().unwrap();
self.index.load_searchers().unwrap();
}
pub fn drop_writer(&self) {
self.writer.lock().unwrap().take();
}
}
+67
View File
@@ -0,0 +1,67 @@
use std::str::CharIndices;
use tantivy::tokenizer::{Token, TokenStream, Tokenizer};
/// Tokenize the text by splitting on whitespaces. Pretty much a copy of Tantivy's SimpleTokenizer,
/// but not splitting on punctuation
#[derive(Clone)]
pub struct WhitespaceTokenizer;
pub struct WhitespaceTokenStream<'a> {
text: &'a str,
chars: CharIndices<'a>,
token: Token,
}
impl<'a> Tokenizer<'a> for WhitespaceTokenizer {
type TokenStreamImpl = WhitespaceTokenStream<'a>;
fn token_stream(&self, text: &'a str) -> Self::TokenStreamImpl {
WhitespaceTokenStream {
text,
chars: text.char_indices(),
token: Token::default(),
}
}
}
impl<'a> WhitespaceTokenStream<'a> {
// search for the end of the current token.
fn search_token_end(&mut self) -> usize {
(&mut self.chars)
.filter(|&(_, ref c)| c.is_whitespace())
.map(|(offset, _)| offset)
.next()
.unwrap_or_else(|| self.text.len())
}
}
impl<'a> TokenStream for WhitespaceTokenStream<'a> {
fn advance(&mut self) -> bool {
self.token.text.clear();
self.token.position = self.token.position.wrapping_add(1);
loop {
match self.chars.next() {
Some((offset_from, c)) => {
if !c.is_whitespace() {
let offset_to = self.search_token_end();
self.token.offset_from = offset_from;
self.token.offset_to = offset_to;
self.token.text.push_str(&self.text[offset_from..offset_to]);
return true;
}
}
None => {
return false;
}
}
}
}
fn token(&self) -> &Token {
&self.token
}
fn token_mut(&mut self) -> &mut Token {
&mut self.token
}
}
+6 -4
View File
@@ -41,6 +41,7 @@ use posts::Post;
use reshares::Reshare;
use safe_string::SafeString;
use schema::users;
use search::Searcher;
use {ap_url, Connection, BASE_URL, USE_HTTPS};
pub type CustomPerson = CustomObject<ApSignature, Person>;
@@ -104,13 +105,13 @@ impl User {
.expect("User::one_by_instance: loading error")
}
pub fn delete(&self, conn: &Connection) {
pub fn delete(&self, conn: &Connection, searcher: &Searcher) {
use schema::post_authors;
Blog::find_for_author(conn, self)
.iter()
.filter(|b| b.list_authors(conn).len() <= 1)
.for_each(|b| b.delete(conn));
.for_each(|b| b.delete(conn, searcher));
// delete the posts if they is the only author
let all_their_posts_ids: Vec<i32> = post_authors::table
.filter(post_authors::author_id.eq(self.id))
@@ -129,7 +130,7 @@ impl User {
if !has_other_authors {
Post::get(conn, post_id)
.expect("User::delete: post not found error")
.delete(conn);
.delete(&(conn, searcher));
}
}
@@ -981,6 +982,7 @@ pub(crate) mod tests {
use super::*;
use diesel::Connection;
use instance::{tests as instance_tests, Instance};
use search::tests::get_searcher;
use tests::db;
use Connection as Conn;
@@ -1077,7 +1079,7 @@ pub(crate) mod tests {
let inserted = fill_database(conn);
assert!(User::get(conn, inserted[0].id).is_some());
inserted[0].delete(conn);
inserted[0].delete(conn, &get_searcher());
assert!(User::get(conn, inserted[0].id).is_none());
Ok(())