loading...

Practical Rust Web Development - Searching

werner profile image Werner Echezuría ・3 min read

Our application should list products for sale and behave like Amazon or Ebay, in order to do so, we need to implement some features like searching, but, in this case we need a way to search over long texts like products descriptions and names.

We can achieve it by a feature PostgreSQL provide, call Full Text Search, there are several products available, like Elastic Search, however we can use the one provided by PostgreSQL, this way we can have one resource to maintain.

Migrations

We need a couple of things first, we need a description field, missing from the previous implementations, so, let's create a migration for that.

diesel migration generate add_description_to_products

migrations/2019-06-25-102903_add_description_to_products/up.sql:

ALTER TABLE products ADD COLUMN description VARCHAR;

migrations/2019-06-25-102903_add_description_to_products/down.sql:

ALTER TABLE products DROP COLUMN description;

Then, we are going to need a migration for our Full text search configuration, we need a new column of type tsvector, this new column will have the document over the search will perform, we need an index and a trigger that keeps updated our new column every time a new record is created in the database.

diesel migration generate add_tsvector_column_to_products

migrations/2019-06-25-112356_add_tsvector_column_to_products/up.sql:

ALTER TABLE products ADD COLUMN text_searchable_product_col tsvector NOT NULL;

UPDATE products SET text_searchable_product_col = 
    to_tsvector('english', name || ' ' || coalesce(description, ''));

CREATE INDEX textsearch_idx ON products USING GIN (text_searchable_product_col);

CREATE TRIGGER tsvectorupdateproducts BEFORE INSERT OR UPDATE
ON products FOR EACH ROW EXECUTE PROCEDURE
tsvector_update_trigger(text_searchable_product_col, 
'pg_catalog.english', 
name, 
description);

migrations/2019-06-25-112356_add_tsvector_column_to_products/down.sql:

ALTER TABLE products DROP COLUMN text_searchable_product_col;

DROP TRIGGER tsvectorupdateproducts ON products;

Search implementation

There is a crate that will help us with the diesel types required for full text search, It's called diesel_full_text_search, let's add it to Cargo.toml.

Cargo.toml:

diesel_full_text_search = "1.0.1"

src/lib.rs:

extern crate diesel_full_text_search;

We need to modify our schema manually to add the full text search column, however we're going to ignore it in our model, because the updates will be done by our database trigger.

src/schema.rs:

 table! {
    use diesel_full_text_search::TsVector;
    use diesel::sql_types::Int4;
    use diesel::sql_types::VarChar;
    use diesel::sql_types::Float8;
    use diesel::sql_types::Nullable;
     products (id) {
        id -> Int4,
        name -> VarChar,
        stock -> Float8,
        price -> Nullable<Int4>,
        description -> Nullable<VarChar>,
        text_searchable_product_col -> TsVector,
     }
 }

In the model we need to tell diesel we need only a few fields from the table, that's why we're going to need a constant that will tell Rust to bring all columns, except the tsvector, then we need a new modifications in the code to insert the filter required.

src/models/product.rs:

pub struct Product {
    pub id: i32,
    pub name: String,
    pub stock: f64,
    pub price: Option<i32>,
    pub description: Option<String>
}

type ProductColumns = (
    products::id,
    products::name,
    products::stock,
    products::price,
    products::description
);

const PRODUCT_COLUMNS: ProductColumns = (
    products::id,
    products::name,
    products::stock,
    products::price,
    products::description
);

#[derive(Insertable, Deserialize, Serialize, AsChangeset, Debug, Clone, PartialEq)]
#[table_name="products"]
pub struct NewProduct {
    pub name: Option<String>,
    pub stock: Option<f64>,
    pub price: Option<i32>,
    pub description: Option<String>
}

impl ProductList {
    pub fn list(connection: &PgConnection, search: &str) -> Self {
        use diesel::RunQueryDsl;
        use diesel::QueryDsl;
        use diesel::pg::Pg;
        use crate::schema::products::dsl::*;
        use crate::schema;
        use diesel_full_text_search::{plainto_tsquery, TsVectorExtensions};

        let mut query = schema::products::table.into_boxed::<Pg>();

        if !search.is_empty() {
            query = query
                .filter(text_searchable_product_col.matches(plainto_tsquery(search)));
        } 
        let result = query
            .select(PRODUCT_COLUMNS)
            .limit(10)
            .load::<Product>(connection)
            .expect("Error loading products");

        ProductList(result)
    }
}

impl NewProduct {
    pub fn create(&self, connection: &PgConnection) -> Result<Product, diesel::result::Error> {
        use diesel::RunQueryDsl;

        diesel::insert_into(products::table)
            .values(self)
            .on_conflict_do_nothing()
            .returning(PRODUCT_COLUMNS)
            .get_result::<Product>(connection)
    }
}

impl Product {
    pub fn find(id: &i32, connection: &PgConnection) -> Result<Product, diesel::result::Error> {
        use diesel::QueryDsl;
        use diesel::RunQueryDsl;

        products::table.find(id).select(PRODUCT_COLUMNS).first(connection)
    }

...

impl PartialEq<Product> for NewProduct {
    fn eq(&self, other: &Product) -> bool {
        let new_product = self.clone();
        let product = other.clone();
        new_product.name == Some(product.name) &&
        new_product.stock == Some(product.stock) &&
        new_product.price == product.price &&
        new_product.description == product.description
    }
}

As you can see in the previous code, we're telling diesel in the select method which columns will be needed to return.

Finally we need to modify the products index handler to introduce our search.

#[derive(Deserialize)]
pub struct ProductSearch{ 
    pub search: String
}

pub fn index(_user: LoggedUser, pool: web::Data<PgPool>, product_search: web::Query<ProductSearch>) -> Result<HttpResponse> {
     let pg_pool = pg_pool_handler(pool)?;
    let search = &product_search.search;
    Ok(HttpResponse::Ok().json(ProductList::list(&pg_pool, search)))
}

To test it you need to pass the search in the query string, even if you don't want to search, like this: http://localhost:8088/products?search=

You can see the source code here.

Posted on by:

werner profile

Werner Echezuría

@werner

Ruby on Rails developer and Rust enthusiast.

Discussion

markdown guide