How to Implement a Full-Text Search with Prisma

How to Implement a Full-Text Search with Prisma

David Herbert's photo
David Herbert
·Nov 3, 2022·

10 min read

Subscribe to our newsletter and never miss any upcoming articles

The search functionality is an indispensable feature in any website that is meant to help users find the correct information or content more quickly and easily. Furthermore, it can help improve user engagement on a website, thereby reducing bounce rates.

As a result, it is a feature that is heavily used by search engines and web applications that support some form of database queries. As such, the search functionality is now a feature that users expect in their everyday web applications.

This tutorial aims to explore the full-text search functionality, its benefits, and how to implement this full-text search with Prisma.

A full-text search refers to the technique of running full-text search queries quickly against some text or character-based data stored in a database. It is a more advanced way of exploring a database with string values. Unlike the traditional text search, which searches a substring from an entire string, a full-text search will return results that contain some or all of the words from a query.

It is a handy feature that is applicable to a wide range of business scenarios, like matching stored resumes with job descriptions, finding code repositories with a particular set of technologies, blog post searches, or even everyday searches on your favorite search engines.

In recent times, there has been an exponential increase in the amount of data generated, which makes it rather difficult for users to find relevant information quickly. As a result, it is crucial to assist users on your platform in finding appropriate information as quickly as possible, thereby improving user experience.

In our day-to-day use of the web, we see and make use of the Full-Text search functionality in various applications, especially data-driven ones. The full-text search is an advanced query functionality that enables you optimize your application's search feature to be as precise as possible when querying a database.

How does Full-Text Search work?

In a full-text search, the basic unit of a Full-Text search is referred to as a token that is mapped to a word. And unlike the traditional string search query that checks the sequence of characters in a larger string or text, a full-text search will match only a complete word, not just part of a string.

Let's demonstrate how this works using a typical SQL query syntax to illustrate:

ID Body
1 Add more notes as per instructions
2 Check the completeness of the paperwork
3 Find the percentage.

In a typical search feature, a normal string query on this data table for the string “per” can be something like this.

SELECT * FROM `todo` WHERE `body` LIKE '%per%';

The SQL statement above will fetch the three rows in the table since the character sequence "per" exists for each row. While in the case of the full-text search, it matches full words against exact full-text queries. Hence, a full-text query for the word "per" will return only one todo, i.e., "Add more notes as per instructions," as the word "per" is not part of a word but is considered a complete token (full-text).

Note: The key to the full-text feature is a technique called indexing.

About Indexing?

A database index uses various in-memory & on-disk data structures to map search keys to associated data information on the disk. As such, an index speeds up the search by limiting the number of records to look up. However, this has some performance implications on write/modify operations as the dataset's text fields are examined during the index creation process.

First, it will remove the glyphs for each word and stop words like "of," "a," "is," "the," etc. These words are filtered out because they are insignificant. After that, it modifies the casing to only utilize capital or lowercase letters, thus, producing a token. Finally, the index is created by adding each word alongside a reference to the document(s) it can be found in.

Implementing a Full-Text Search with Prisma

We will create a simple to-do app with a full-text search feature to keep everything simple. This application will have an input field that accepts the full-text search query, which queries the database and returns the full-text search results. So let’s jump right in. We will be using the node express framework and basic HTML.

Prerequisite

  • Basic understanding of Javascript and SQL
  • Nodejs and NPM

Setting up the Application

We’ll start by initializing an npm project, then install the necessary dependencies for our application.

npm init -y
npm install express

Then, we’ll install Prisma as a dev dependency.

npm install prisma --save-dev

Finally, we’ll run the Prisma CLI command to setup Prisma.

npx prisma init --datasource-provider mysql

This will create a folder with a schema file to configure our data sources, a client to use, and model definitions. Finally, it generates a .env file at the root of our application. We will need to edit these files to suit our needs.

Setting up the Database

Our application will have just one simple model called Todo with an id and body field to contain the todo text. This is to be added to the schema.prisma file.

model Todo {
 id  Int @id @default(autoincrement())
 body  String @db.Text

 @@fulltext([body]) // needed for mysql
}

As seen above, we used the @@fulltext attribute to specify what field(s) we want to be indexed and used during the search; this field is required if the database in use is MySQL, while the models represent tables and are used to generate the Prisma client.

At the time of writing, the full-text search API is a preview feature. Hence, we have to enable the fullTextSearch feature flag in the Prisma client definition in our schema.prisma file.

generator client {
 provider        = "prisma-client-js"
 previewFeatures = ["fullTextSearch", "fullTextIndex"]
}

This enables the full-text search feature in Prisma. Note that for MySQL, the second value, “fullTextIndex,” is needed but can be optional for PostgreSQL.

Next, we run a migration to sync the changes we’ve made so far.

npx prisma migrate dev --name init

This creates a new migration with the name init. The database migrations keep track of minute modifications to our database schema. Next, we add some data. We can do this by writing code to generate the data or manually entering inputs into the database.

Prisma CLI provides a GUI tool to view, edit and even insert data into the database. We can access it with the following command.

npx prisma studio

Now we add in some random todos.

todos data

Writing the Application Logic

Now that the database has been set up and filled with some data, let's build our application. We'll create an index.js at the root of the project directory and then populate it with the code below.

const express = require("express");
const { PrismaClient } = require("@prisma/client");

// Initialize prisma client
const prisma = new PrismaClient();


// Initialize express app
const app = express();


// serve files in specified folder
app.use(express.static(__dirname + "/static"));


app.get('/feed', async (req, res) => {
 // get and return all todos
 const todos = await prisma.todo.findMany();
 res.json(todos);
});


app.listen(3000, () => console.log("Server started"));

In the above code, we imported and initialized an express app and the Prisma client, set the static content path to serve files from a folder named static, and defined a route ‘/feed’ to fetch and return all todos from the database.

To query the database using the full-text search feature, we do this using the findMany method tied to each model from the Prisma client. The findMany function returns a list of rows from the specified table, todo table, in our case, as it runs queries against our database for the searched term. The function accepts an object as an argument used to describe or filter rows it should return. The where property is one of the properties used to filter returned rows and specify conditions the returned rows should meet based on the column values. For example, in a table of users, finding users with a particular user role - admin would be:

await prisma.user.findMany({
  where: {
    role: "admin",    // 
  },
});

Since the full-text feature is enabled, a new field, search, will be added to string fields available in our models or text fields specified in the @@fulltext attribute. See the example below.

const todos = await prisma.todo.findMany({
   where: {
     body: {
       search: "notes", // search term
     },
   },
 });

From the above example we utilize the search field to search for rows in the todo table where the body column contains the word “notes”.

We can do same in our application using the example above, by replacing the string “notes” with data submitted to the “/search” route. This query returns the rows where the body column contains the search term submitted to the server.

Next, we define a route to fetch todos based on a search. We write this just before calling the app.listen() method.

app.get('/search', async (req, res) => {
 const todos = await prisma.todo.findMany({
   where: {
     body: {
       search: req.query.q, // get search term from the query string q
     },
   },
 });
 res.json(todos);
});

In the code above, we use the findMany function provided by Prisma to get rows from the todo table where the body column contains a string in the query string i.e submitted data via URL e.g /search?q=search_term. Hence, req.query.q = search_term

Adding our UI

Now for the UI of our app, we'll create a basic HTML markup and simply add a title and form with an input box to collect the search query.

<body>
   <div class="container">
     <main>
       <h1 class="title">Full-Text Search</h1>
       <form action="#">
         <input
           type="search"
           name="search"
           id="search"
           placeholder="Enter search..."
         />
         <button type="submit">Search</button>
       </form>
       <ul class="todos"></ul>
     </main>
   </div>

   <script src="/app.js"></script>
 </body>

For the javascript, we separate actions into functions to fetch todos and update the dom. The form element is given a submit event handler to prevent default form submission, and then we fetch the todos based on the form input. If the input form is empty we fetch all todos on /feed else we fetch todos based on the input value.

async function fetchTodo(search) {
 if (!search) {
   try {
     // fetch all todos
     const res = await fetch("/feed");
     const data = await res.json();
     // place fetched todos in DOM
     syncDOM(data);
   } catch (err) {
     console.error("Couldn't get feed");
   }
 } else {
   try {
     // get todos based on search
     const res = await fetch("/search?q=" + search.trim());
     const data = await res.json();
     // place fetched todos in DOM
     syncDOM(data);
   } catch (err) {
     console.error("Couldn't get search");
   }
 }
}

The fetchTodo function takes in a string as the search term to submit to the server and present the result to the user. It does this by using the fetch function to get results from the server and presenting the result using the SyncDOM function, which takes an array of todo objects, then selects the HTML element with the .todos class present in the HTML. And finally, replaces the element’s children using the replaceChildren method while spreading the liElements array as parameter values. The liElements array contains li elements created with text content being the body of each todo.

function syncDOM(data = []) {
 // select todo ul element and replace its child elements with elements created with passed in data
 const elemContainer = document.querySelector(".todos");
 const liElements = data.map(todo => Object.assign(document.createElement("li"), { textContent: todo.body }));
 elemContainer.replaceChildren(...liElements);
}

const form = document.querySelector("form");
form.addEventListener("submit", (e) => {
 e.preventDefault();
 // get search input element
 const searchQuery = form.querySelector("[name=search]");
 // fetch todos using query
 fetchTodo(searchQuery?.value);
});

// initialize app
fetchTodo();

Finally, if we run the index.js file using the node command below.

$ node index.js

We are now able to perform a full-text search in our application as shown below.

Preview 1

Preview 2

Preview 3

Source Code: Github

Conclusion

As we have discussed, implementing a full-text search on your website can improve user experience and accessibility by making it easier for users to find relevant information. It is crucial that you make your search function as accurate as possible if you wish to make it effective. And in this tutorial, we explored the concept of full-text search and how to implement it using Prisma.

 
Share this