├── .gitignore ├── client ├── .gitignore ├── .eslintrc.json ├── styles │ └── globals.css ├── postcss.config.js ├── next.config.js ├── pages │ ├── _app.tsx │ ├── _document.tsx │ └── index.tsx ├── next-env.d.ts ├── tailwind.config.js ├── tsconfig.json └── package.json ├── .github ├── FUNDING.yml └── preview.gif ├── .vscode ├── settings.json └── extensions.json ├── .env.local ├── .env.atlas ├── tsconfig.json ├── util.ts ├── package.json ├── api.rest ├── fake-data.ts ├── docker-compose.yml ├── self-deployed-api.ts ├── README.md └── atlas-api.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.next/ 3 | -------------------------------------------------------------------------------- /client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: flolu 2 | custom: https://flolu.de/support 3 | -------------------------------------------------------------------------------- /.github/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flolu/mongo-search/HEAD/.github/preview.gif -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["flolu", "Florian", "Ludewig", "urllib"] 3 | } 4 | -------------------------------------------------------------------------------- /client/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /.env.local: -------------------------------------------------------------------------------- 1 | MONGODB_HOST=mongodb://localhost:27017 2 | MONGODB_USERNAME=admin 3 | MONGODB_PASSWORD=password 4 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /.env.atlas: -------------------------------------------------------------------------------- 1 | MONGODB_HOST= 2 | MONGODB_USERNAME= 3 | MONGODB_PASSWORD= 4 | MONGODB_ATLAS_PROJECT_ID= 5 | MONGODB_ATLAS_CLUSTER= 6 | MONGODB_ATLAS_PUBLIC_KEY= 7 | MONGODB_ATLAS_PRIVATE_KEY= 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-docker", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode", 6 | "humao.rest-client" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["node"], 4 | "target": "ES2022", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "moduleResolution": "node" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css' 2 | import type {AppProps} from 'next/app' 3 | 4 | export default function App({Component, pageProps}: AppProps) { 5 | return 6 | } 7 | -------------------------------------------------------------------------------- /client/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /client/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./app/**/*.{js,ts,jsx,tsx}", 5 | "./pages/**/*.{js,ts,jsx,tsx}", 6 | "./components/**/*.{js,ts,jsx,tsx}", 7 | 8 | // Or if using `src` directory: 9 | "./src/**/*.{js,ts,jsx,tsx}", 10 | ], 11 | theme: { 12 | extend: {}, 13 | }, 14 | plugins: [], 15 | } 16 | -------------------------------------------------------------------------------- /util.ts: -------------------------------------------------------------------------------- 1 | import * as mongodb from 'mongodb' 2 | 3 | export const MONGODB_DATABASE = 'tutorial' 4 | export const MONGODB_COLLECTION = 'users' 5 | 6 | const MONGODB_HOST = process.env.MONGODB_HOST! 7 | const MONGODB_USER = process.env.MONGODB_USERNAME 8 | const MONGODB_PASS = process.env.MONGODB_PASSWORD 9 | 10 | export const mongoClient = new mongodb.MongoClient(MONGODB_HOST, { 11 | auth: {username: MONGODB_USER, password: MONGODB_PASS}, 12 | }) 13 | 14 | export interface User { 15 | userId: string 16 | fullName: string 17 | email: string 18 | avatar: string 19 | registeredAt: Date 20 | country: string 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "prettier": { 4 | "singleQuote": true, 5 | "bracketSpacing": false, 6 | "semi": false, 7 | "printWidth": 110 8 | }, 9 | "devDependencies": { 10 | "@faker-js/faker": "^7.6.0", 11 | "@types/cors": "^2.8.13", 12 | "@types/express": "^4.17.16", 13 | "@types/node": "^18.11.18", 14 | "prettier": "^2.8.3", 15 | "ts-node": "^10.9.1", 16 | "typescript": "^4.9.4" 17 | }, 18 | "dependencies": { 19 | "cors": "^2.8.5", 20 | "dotenv": "^16.0.3", 21 | "express": "^4.18.2", 22 | "mongodb": "^4.13.0", 23 | "urllib": "^3.10.1" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -p 4000", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@next/font": "13.1.5", 13 | "@types/node": "18.11.18", 14 | "@types/react": "18.0.27", 15 | "@types/react-dom": "18.0.10", 16 | "axios": "^1.2.5", 17 | "debounce": "^1.2.1", 18 | "eslint": "8.32.0", 19 | "eslint-config-next": "13.1.5", 20 | "next": "13.1.5", 21 | "react": "18.2.0", 22 | "react-dom": "18.2.0", 23 | "react-hook-form": "^7.42.1", 24 | "sass": "^1.57.1", 25 | "typescript": "4.9.4" 26 | }, 27 | "devDependencies": { 28 | "@types/debounce": "^1.2.1", 29 | "autoprefixer": "^10.4.13", 30 | "postcss": "^8.4.21", 31 | "tailwindcss": "^3.2.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /api.rest: -------------------------------------------------------------------------------- 1 | # Self Deployed MongoDB API 2 | ### search for gilbert 3 | GET http://localhost:3000/search?query=gilbert 4 | ### gilbert or kutch 5 | GET http://localhost:3000/search?query=gilbert%20murphy 6 | ### exclude kutch 7 | GET http://localhost:3000/search?query=gilbert%20-murphy 8 | ### self-deployed is not fuzzy! 9 | GET http://localhost:3000/search?query=gilber%20murph 10 | ### gilbart and country=RO 11 | GET http://localhost:3000/search?query=gilbert&country=RO 12 | ### 13 | 14 | # MongoDB Atlas API 15 | ### search for gilbert 16 | GET http://localhost:3001/search?query=gilbert 17 | ### gilbert or kutch 18 | GET http://localhost:3001/search?query=gilbert%20murphy 19 | ### fuzzy search works on atlas 20 | GET http://localhost:3001/search?query=gilber%20murph 21 | ### gilbart and country=RO 22 | GET http://localhost:3001/search?query=gilbert&country=RO 23 | ### autocomplete 24 | GET http://localhost:3001/autocomplete?query=gil 25 | -------------------------------------------------------------------------------- /fake-data.ts: -------------------------------------------------------------------------------- 1 | import {config} from 'dotenv' 2 | import {faker} from '@faker-js/faker' 3 | 4 | const envFile = process.argv[process.argv.length - 1] 5 | config({path: envFile}) 6 | 7 | import {mongoClient, MONGODB_COLLECTION, MONGODB_DATABASE, User} from './util' 8 | 9 | function createRandomUser() { 10 | return { 11 | userId: faker.datatype.uuid(), 12 | fullName: faker.name.fullName(), 13 | email: faker.internet.email(), 14 | avatar: faker.image.avatar(), 15 | registeredAt: faker.date.past(), 16 | country: faker.address.countryCode(), 17 | } 18 | } 19 | 20 | async function main() { 21 | try { 22 | const db = mongoClient.db(MONGODB_DATABASE) 23 | const collection = db.collection(MONGODB_COLLECTION) 24 | 25 | const users = Array.from({length: 10000}).map((_value, index) => { 26 | faker.seed(index) 27 | return createRandomUser() 28 | }) 29 | 30 | await collection.insertMany(users) 31 | } catch (err) { 32 | console.log(err) 33 | } finally { 34 | await mongoClient.close() 35 | } 36 | } 37 | 38 | main() 39 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | self-deployed-api: 4 | image: node:18 5 | container_name: mongo_search_self_deployed_api 6 | volumes: 7 | - ./self-deployed-api.ts:/self-deployed-api.ts 8 | - ./util.ts:/util.ts 9 | - ./node_modules:/node_modules 10 | - ./tsconfig.json:/tsconfig.json 11 | - ./package.json:/package.json 12 | ports: 13 | - 3000:3000 14 | command: npx ts-node self-deployed-api.ts 15 | env_file: .env.local 16 | environment: 17 | - MONGODB_HOST=mongodb://mongodb:27017 18 | depends_on: 19 | - mongodb 20 | 21 | mongodb: 22 | image: mongo:6 23 | container_name: mongo_search_mongodb 24 | ports: 25 | - 27017:27017 26 | environment: 27 | MONGO_INITDB_ROOT_USERNAME: admin 28 | MONGO_INITDB_ROOT_PASSWORD: password 29 | 30 | atlas-api: 31 | image: node:18 32 | container_name: mongo_search_atlas_api 33 | volumes: 34 | - ./atlas-api.ts:/atlas-api.ts 35 | - ./util.ts:/util.ts 36 | - ./node_modules:/node_modules 37 | - ./tsconfig.json:/tsconfig.json 38 | - ./package.json:/package.json 39 | ports: 40 | - 3001:3001 41 | command: npx ts-node atlas-api.ts 42 | env_file: .env.atlas 43 | depends_on: 44 | - mongodb 45 | -------------------------------------------------------------------------------- /self-deployed-api.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import {Filter} from 'mongodb' 3 | import cors from 'cors' 4 | import {mongoClient, MONGODB_COLLECTION} from './util' 5 | import {User} from './util' 6 | 7 | const app = express() 8 | 9 | app.use(cors({credentials: true, origin: 'http://localhost:4000'})) 10 | 11 | app.get('/search', async (req, res) => { 12 | const searchQuery = req.query.query as string 13 | const country = req.query.country as string 14 | 15 | if (!searchQuery || searchQuery.length < 2) { 16 | res.json([]) 17 | return 18 | } 19 | 20 | const db = mongoClient.db('tutorial') 21 | const collection = db.collection(MONGODB_COLLECTION) 22 | 23 | const filter: Filter = { 24 | $text: {$search: searchQuery, $caseSensitive: false, $diacriticSensitive: false}, 25 | } 26 | if (country) filter.country = country 27 | 28 | const result = await collection 29 | .find(filter) 30 | .project({score: {$meta: 'textScore'}, _id: 0}) 31 | .sort({score: {$meta: 'textScore'}}) 32 | .limit(10) 33 | 34 | const array = await result.toArray() 35 | 36 | res.json(array) 37 | }) 38 | 39 | async function main() { 40 | try { 41 | await mongoClient.connect() 42 | 43 | const db = mongoClient.db('tutorial') 44 | const collection = db.collection(MONGODB_COLLECTION) 45 | 46 | // Text index for searching fullName and email 47 | await collection.createIndexes([{name: 'fullName_email_text', key: {fullName: 'text', email: 'text'}}]) 48 | 49 | app.listen(3000, () => console.log('http://localhost:3000/search?query=gilbert')) 50 | } catch (err) { 51 | console.log(err) 52 | } 53 | 54 | process.on('SIGTERM', async () => { 55 | await mongoClient.close() 56 | process.exit(0) 57 | }) 58 | } 59 | 60 | main() 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |

MongoDB Text Search With Node.js

4 |

Fuzzy Text Search And Autocompletion With MongoDB And Node.js

5 | 6 |
7 | 8 | # Features 9 | 10 | - Exact match text search with self-deployed MongoDB 11 | - **Fuzzy text search** with MongoDB Atlas 12 | - Text **autocompletion** 13 | - **Sort search results** by score 14 | - Search through multiple text fields 15 | - Limit search results to specific country code 16 | - Populate database with fake users 17 | - Small user interface for searching 18 | 19 | # Tech Stack 20 | 21 | - [Node.js](https://nodejs.org) 22 | - [TypeScript](https://www.typescriptlang.org) 23 | - [Docker](https://www.docker.com) 24 | - Self-deployed [MongoDB](https://mongodb.com) or [MongoDB Atlas](https://www.mongodb.com/atlas/database) 25 | 26 | # Usage 27 | 28 | **Recommended OS**: Linux 29 | 30 | **Requirements**: Node.js, Docker 31 | 32 | **Setup** 33 | 34 | - `npm install` (Install NPM dependencies for server) 35 | 36 | **Self Deployed Search** 37 | 38 | - `docker-compose -f docker-compose.yml up --build` (Start services) 39 | - `npx ts-node fake-data.ts .env.local` (Add fake data to MongoDB) 40 | - `mongodb://admin:password@localhost:27017` (MongoDB Compass connection URI) 41 | - http://localhost:3000/search?query=gilbert (Basic search) 42 | 43 | **MongoDB Atlas Search** 44 | 45 | - Create [MongoDB Atlas](https://cloud.mongodb.com) cluster 46 | - Create a user with password and set `MONGODB_USERNAME` and `MONGODB_PASSWORD` in `.env.atlas` 47 | - Create API Key for your project 48 | 1. Go to Project Settings 49 | 2. Select Access Manager 50 | 3. Select API Keys 51 | 4. Create API Key 52 | 5. Set Project Permissions to Project Search Index Editor 53 | 6. Set `MONGODB_ATLAS_PUBLIC_KEY` and `MONGODB_ATLAS_PRIVATE_KEY` in `.env.atlas` 54 | - `docker-compose -f docker-compose.yml up --build` (Start services) 55 | - `npx ts-node fake-data.ts .env.atlas` (Add fake data to MongoDB) 56 | - http://localhost:3001/search?query=gilbert (Fuzzy search) 57 | 58 | **User interface** 59 | 60 | - `cd client && npm install` (Install NPM dependencies for client) 61 | - `cd client && npm run dev` (Start user interface in development mode, http://localhost:4000) 62 | 63 | **Cleanup** 64 | 65 | - `docker-compose -f docker-compose.yml rm -s -f -v` (Stop and remove Docker containers) 66 | 67 | # Codebase 68 | 69 | - [`self-deployed-api.ts`](self-deployed-api.ts) (Node.js API to fetch search results from self-deployed MongoDB) 70 | - [`atlas-api.ts`](atlas-api.ts) (Node.js API to fetch search results from MongoDB Atlas, which has more features) 71 | - [`fake-data.ts`](fake-data.ts) (Script to populate MongoDB with fake data) 72 | - [`util.ts`](util.ts) (Utilities for TypeScript and MongoDB) 73 | -------------------------------------------------------------------------------- /client/pages/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | 3 | import axios from 'axios' 4 | import Head from 'next/head' 5 | import {useForm} from 'react-hook-form' 6 | import debounce from 'debounce' 7 | import {ChangeEvent, KeyboardEvent, useState} from 'react' 8 | 9 | const classNames = (...classes: (string | boolean | undefined | null)[]) => { 10 | return classes.filter(Boolean).join(' ') 11 | } 12 | 13 | export default function Home() { 14 | const {register, handleSubmit, setValue} = useForm() 15 | const [currentValue, setCurrentValue] = useState('') 16 | const [autocompleteResults, setAutocompleteResults] = useState([]) 17 | const [selectedAutocompleteResultIndex, setSelectedAutocompleteResultIndex] = useState(null) 18 | const [searchResults, setSearchResults] = useState([]) 19 | const [isLoading, setLoading] = useState(false) 20 | 21 | const runSearch = async (query: string) => { 22 | setLoading(true) 23 | setAutocompleteResults([]) 24 | setValue('search', query) 25 | const response = await axios.get(`http://localhost:3001/search?query=${query}`) 26 | setSearchResults(response.data) 27 | setLoading(false) 28 | } 29 | 30 | const onFormSubmit = () => { 31 | if (selectedAutocompleteResultIndex !== null) { 32 | runSearch(autocompleteResults[selectedAutocompleteResultIndex]) 33 | } else { 34 | runSearch(currentValue) 35 | } 36 | } 37 | 38 | const onInputChange = async (event: ChangeEvent) => { 39 | if (searchResults.length) { 40 | setSearchResults([]) 41 | } 42 | setSelectedAutocompleteResultIndex(null) 43 | const query = event.target.value 44 | 45 | setCurrentValue(query) 46 | 47 | if (query) { 48 | const response = await axios.get(`http://localhost:3001/autocomplete?query=${query}`) 49 | setAutocompleteResults(response.data.map((u: any) => u.fullName)) 50 | } else { 51 | setAutocompleteResults([]) 52 | setSearchResults([]) 53 | } 54 | } 55 | 56 | const onInputKeypress = (event: KeyboardEvent) => { 57 | if (event.code === 'ArrowDown') { 58 | const current = selectedAutocompleteResultIndex === null ? -1 : selectedAutocompleteResultIndex 59 | if (current === autocompleteResults.length - 1) { 60 | setSelectedAutocompleteResultIndex(0) 61 | } else { 62 | setSelectedAutocompleteResultIndex(current + 1) 63 | } 64 | } 65 | if (event.code === 'ArrowUp') { 66 | const current = 67 | selectedAutocompleteResultIndex === null 68 | ? autocompleteResults.length 69 | : selectedAutocompleteResultIndex 70 | if (current === 0) { 71 | setSelectedAutocompleteResultIndex(autocompleteResults.length - 1) 72 | } else { 73 | setSelectedAutocompleteResultIndex(current - 1) 74 | } 75 | } 76 | } 77 | 78 | return ( 79 | <> 80 | 81 | MongoDB Text Search 82 | 83 | 84 |
85 |
86 | {/* Header */} 87 |
88 |

89 | MongoDB Text Search 90 |

91 | 96 |
97 | 98 |
99 | {/* Input field */} 100 |
101 | 107 |
108 | 109 | {/* Autocomplete suggestions */} 110 | {autocompleteResults.length >= 1 && ( 111 |
112 | {autocompleteResults.map((result, index) => { 113 | return ( 114 |
runSearch(result)} 117 | onMouseOver={() => setSelectedAutocompleteResultIndex(index)} 118 | onMouseOut={() => setSelectedAutocompleteResultIndex(null)} 119 | className={classNames( 120 | selectedAutocompleteResultIndex === index && 'bg-gray-100', 121 | 'px-4 py-2 border-b border-gray-100 cursor-pointer' 122 | )} 123 | > 124 | {result} 125 |
126 | ) 127 | })} 128 |
129 | )} 130 | 131 | {/* Loading indicator */} 132 | {isLoading &&
Loading...
} 133 | 134 | {/* Search results */} 135 |
136 | {searchResults.map((result, index) => { 137 | return ( 138 |
139 | avatar 140 |
141 |

{result.fullName}

142 |

{result.email}

143 |
144 |
145 | ) 146 | })} 147 |
148 |
149 | 150 | {/* Footer */} 151 |
152 | 157 |
158 |
159 | 160 | ) 161 | } 162 | -------------------------------------------------------------------------------- /atlas-api.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import {request} from 'urllib' 3 | import cors from 'cors' 4 | import {mongoClient, MONGODB_COLLECTION, MONGODB_DATABASE, User} from './util' 5 | 6 | const ATLAS_API_BASE_URL = 'https://cloud.mongodb.com/api/atlas/v1.0' 7 | const ATLAS_PROJECT_ID = process.env.MONGODB_ATLAS_PROJECT_ID 8 | const ATLAS_CLUSTER_NAME = process.env.MONGODB_ATLAS_CLUSTER 9 | const ATLAS_CLUSTER_API_URL = `${ATLAS_API_BASE_URL}/groups/${ATLAS_PROJECT_ID}/clusters/${ATLAS_CLUSTER_NAME}` 10 | const ATLAS_SEARCH_INDEX_API_URL = `${ATLAS_CLUSTER_API_URL}/fts/indexes` 11 | 12 | const ATLAS_API_PUBLIC_KEY = process.env.MONGODB_ATLAS_PUBLIC_KEY 13 | const ATLAS_API_PRIVATE_KEY = process.env.MONGODB_ATLAS_PRIVATE_KEY 14 | const DIGEST_AUTH = `${ATLAS_API_PUBLIC_KEY}:${ATLAS_API_PRIVATE_KEY}` 15 | 16 | const USER_SEARCH_INDEX_NAME = 'user_search' 17 | const USER_AUTOCOMPLETE_INDEX_NAME = 'user_autocomplete' 18 | 19 | const app = express() 20 | 21 | app.use(cors({credentials: true, origin: 'http://localhost:4000'})) 22 | 23 | app.get('/search', async (req, res) => { 24 | const searchQuery = req.query.query as string 25 | const country = req.query.country as string 26 | 27 | if (!searchQuery || searchQuery.length < 2) { 28 | res.json([]) 29 | return 30 | } 31 | 32 | const db = mongoClient.db('tutorial') 33 | const collection = db.collection(MONGODB_COLLECTION) 34 | 35 | const pipeline = [] 36 | 37 | if (country) { 38 | pipeline.push({ 39 | $search: { 40 | index: USER_SEARCH_INDEX_NAME, 41 | compound: { 42 | must: [ 43 | { 44 | text: { 45 | query: searchQuery, 46 | path: ['fullName', 'email'], 47 | fuzzy: {}, 48 | }, 49 | }, 50 | { 51 | text: { 52 | query: country, 53 | path: 'country', 54 | }, 55 | }, 56 | ], 57 | }, 58 | }, 59 | }) 60 | } else { 61 | pipeline.push({ 62 | $search: { 63 | index: USER_SEARCH_INDEX_NAME, 64 | text: { 65 | query: searchQuery, 66 | path: ['fullName', 'email'], 67 | fuzzy: {}, 68 | }, 69 | }, 70 | }) 71 | } 72 | 73 | pipeline.push({ 74 | $project: { 75 | _id: 0, 76 | score: {$meta: 'searchScore'}, 77 | userId: 1, 78 | fullName: 1, 79 | email: 1, 80 | avatar: 1, 81 | registeredAt: 1, 82 | country: 1, 83 | }, 84 | }) 85 | 86 | const result = await collection.aggregate(pipeline).sort({score: -1}).limit(10) 87 | const array = await result.toArray() 88 | res.json(array) 89 | }) 90 | 91 | app.get('/autocomplete', async (req, res) => { 92 | const searchQuery = req.query.query as string 93 | const country = req.query.country as string 94 | 95 | const db = mongoClient.db('tutorial') 96 | const collection = db.collection(MONGODB_COLLECTION) 97 | 98 | const pipeline = [] 99 | 100 | if (country) { 101 | pipeline.push({ 102 | $search: { 103 | index: USER_SEARCH_INDEX_NAME, 104 | compound: { 105 | must: [ 106 | { 107 | autocomplete: { 108 | query: searchQuery, 109 | path: 'fullName', 110 | fuzzy: {}, 111 | }, 112 | }, 113 | { 114 | text: { 115 | query: country, 116 | path: 'country', 117 | }, 118 | }, 119 | ], 120 | }, 121 | }, 122 | }) 123 | } else { 124 | pipeline.push({ 125 | $search: { 126 | index: USER_AUTOCOMPLETE_INDEX_NAME, 127 | // https://www.mongodb.com/docs/atlas/atlas-search/autocomplete/#options 128 | autocomplete: { 129 | query: searchQuery, 130 | path: 'fullName', 131 | tokenOrder: 'sequential', 132 | }, 133 | }, 134 | }) 135 | } 136 | 137 | pipeline.push({ 138 | $project: { 139 | _id: 0, 140 | score: {$meta: 'searchScore'}, 141 | userId: 1, 142 | fullName: 1, 143 | email: 1, 144 | avatar: 1, 145 | registeredAt: 1, 146 | country: 1, 147 | }, 148 | }) 149 | 150 | const result = await collection.aggregate(pipeline).sort({score: -1}).limit(10) 151 | const array = await result.toArray() 152 | res.json(array) 153 | }) 154 | 155 | async function findIndexByName(indexName: string) { 156 | const allIndexesResponse = await request( 157 | `${ATLAS_SEARCH_INDEX_API_URL}/${MONGODB_DATABASE}/${MONGODB_COLLECTION}`, 158 | { 159 | dataType: 'json', 160 | contentType: 'application/json', 161 | method: 'GET', 162 | digestAuth: DIGEST_AUTH, 163 | } 164 | ) 165 | 166 | return (allIndexesResponse.data as any[]).find((i) => i.name === indexName) 167 | } 168 | 169 | async function upsertSearchIndex() { 170 | const userSearchIndex = await findIndexByName(USER_SEARCH_INDEX_NAME) 171 | if (!userSearchIndex) { 172 | await request(ATLAS_SEARCH_INDEX_API_URL, { 173 | data: { 174 | name: USER_SEARCH_INDEX_NAME, 175 | database: MONGODB_DATABASE, 176 | collectionName: MONGODB_COLLECTION, 177 | // https://www.mongodb.com/docs/atlas/atlas-search/index-definitions/#syntax 178 | mappings: { 179 | dynamic: true, 180 | }, 181 | }, 182 | dataType: 'json', 183 | contentType: 'application/json', 184 | method: 'POST', 185 | digestAuth: DIGEST_AUTH, 186 | }) 187 | } 188 | } 189 | 190 | async function upsertAutocompleteIndex() { 191 | const userAutocompleteIndex = await findIndexByName(USER_AUTOCOMPLETE_INDEX_NAME) 192 | if (!userAutocompleteIndex) { 193 | await request(ATLAS_SEARCH_INDEX_API_URL, { 194 | data: { 195 | name: USER_AUTOCOMPLETE_INDEX_NAME, 196 | database: MONGODB_DATABASE, 197 | collectionName: MONGODB_COLLECTION, 198 | // https://www.mongodb.com/docs/atlas/atlas-search/autocomplete/#index-definition 199 | mappings: { 200 | dynamic: false, 201 | fields: { 202 | fullName: [ 203 | { 204 | foldDiacritics: false, 205 | maxGrams: 7, 206 | minGrams: 3, 207 | tokenization: 'edgeGram', 208 | type: 'autocomplete', 209 | }, 210 | ], 211 | }, 212 | }, 213 | }, 214 | dataType: 'json', 215 | contentType: 'application/json', 216 | method: 'POST', 217 | digestAuth: DIGEST_AUTH, 218 | }) 219 | } 220 | } 221 | 222 | async function main() { 223 | try { 224 | await mongoClient.connect() 225 | 226 | await upsertSearchIndex() 227 | await upsertAutocompleteIndex() 228 | 229 | app.listen(3001, () => console.log('http://localhost:3001/search?query=gilbert')) 230 | } catch (err) { 231 | console.log(err) 232 | } 233 | 234 | process.on('SIGTERM', async () => { 235 | await mongoClient.close() 236 | process.exit(0) 237 | }) 238 | } 239 | 240 | main() 241 | --------------------------------------------------------------------------------