├── .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 |
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 |
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 |

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 |
--------------------------------------------------------------------------------