Skip to content

Instantly share code, notes, and snippets.

@thekid
Last active June 16, 2024 17:58
Show Gist options
  • Save thekid/d6ebd435998e0b8bd70a85c9dcc517c6 to your computer and use it in GitHub Desktop.
Save thekid/d6ebd435998e0b8bd70a85c9dcc517c6 to your computer and use it in GitHub Desktop.
Atlas Search
composer.lock
vendor/
static/
<?php
use com\mongodb\Collection;
use util\profiling\Timer;
use web\Application;
use web\frontend\{Frontend, AssetsFrom, Handlebars, Get, View, Param};
class Atlas extends Application {
use Connection;
/** @return [:web.Handler] */
public function routes() {
$db= self::connect($this->environment->variable('MONGO_URI'), $this->environment->variable('MONGO_DB') ?? 'search');
$impl= new class($db->collection(self::$COLLECTION), self::$INDEX) {
public function __construct(private Collection $collection, private string $index) { }
#[Get]
public function search(#[Param('q')] $query= '') {
$view= View::named('search');
if ('' === $query) return $view;
// Ranking: 1) Direct hit on title, 2) Phrase in title, 3) Phrase in overview, 4) Fuzzy matching
$search= [
'should' => [
['text' => ['query' => $query, 'path' => 'title', 'score' => ['boost' => ['value' => 5.0]]]],
['phrase' => ['query' => $query, 'path' => 'title', 'score' => ['boost' => ['value' => 2.0]]]],
['phrase' => ['query' => $query, 'path' => 'overview', 'score' => ['boost' => ['value' => 1.0]]]],
['text' => [
'query' => $query,
'path' => ['title', 'overview', 'genres'],
'fuzzy' => ['maxEdits' => 1],
'score' => ['boost' => ['value' => 0.5]],
]],
],
];
$timer= (new Timer())->start();
$results= $this->collection->aggregate([
['$search' => ['index' => $this->index, 'compound' => $search]],
['$limit' => 20],
['$addFields' => ['meta' => '$$SEARCH_META']],
]);
$timer->stop();
return $view->with([
'meta' => ['elapsed' => (int)($timer->elapsedTime() * 1000)] + ($results->first()['meta'] ?? []),
'results' => $results->all(),
]);
}
};
return [
'/static' => new AssetsFrom($this->environment->webroot()),
'/' => new Frontend($impl, new Handlebars('.', [[
'date' => fn($node, $context, $options) => date($options[1] ?? 'Y-m-d', $options[0]),
]]))
];
}
}
{
"require": {
"xp-forge/handlebars-templates": "^3.1",
"xp-forge/frontend": "^6.3",
"xp-forge/mongodb": "^2.3",
"php": ">=8.0.0"
},
"scripts": {
"dev": "xp -supervise web -m develop Atlas",
"serve": "xp -supervise web Atlas",
"post-update-cmd": "xp bundle && cp icon.svg static/search.svg"
}
}
<?php
use com\mongodb\{MongoConnection, Database};
trait Connection {
private static $COLLECTION= 'movies', $INDEX= 'full';
private static function connect(string $uri, string $database): Database {
return (new MongoConnection($uri))->database($database);
}
}
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
<?php
use lang\Environment;
use peer\http\HttpConnection;
use text\json\StreamInput;
use util\cmd\Console;
class ImportMovies {
use Connection;
/** @param string[] $args */
public static function main($args): int {
$db= self::connect(Environment::variable('MONGO_URI'), Environment::variable('MONGO_DB', 'search'));
$collection= $db->collection(self::$COLLECTION);
$source= $args[0] ?? 'https://www.meilisearch.com/movies.json';
Console::writeLine('Importing ', $source, ' -> ', $collection);
$response= (new HttpConnection($source))->get();
if (200 !== $response->statusCode()) {
Console::writeLine('Unexpected ', $response);
return 1;
}
// Stream response, upserting movies in collection while going
$input= new StreamInput($response->in(), 'utf-8');
$records= 0;
try {
foreach ($input->elements() as $element) {
$element['_id']= $element['id'];
unset($element['id']);
$collection->upsert($element['_id'], $element);
Console::writef("\e[2K\r=> [%07d] \e[34m%s\e[0m%s", $element['_id'], $element['title'], $records % 1000 ? '' : "\n");
$records++;
}
Console::writeLine();
} finally {
$input->close();
}
Console::writeLine('Finished, ', $records, ' record(s) imported');
return 0;
}
}
{
"dependencies": {
"htmx.org": "^1.9"
},
"bundles": {
"vendor": {"htmx.org": ["dist/htmx.min.js"], "fonts://display=swap": "Montserrat:wght@400"}
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Movie search">
<title>{{#if meta}}{{request.params.q}} - {{/if}}Search</title>
<link href="/static/vendor.css" rel="stylesheet">
<link href="/static/search.svg" rel="icon">
<style type="text/css">
*, *::before, *::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body, h1, h2, h3, h4, p, figure, blockquote, dl, dd {
margin: 0;
}
body {
font-family: 'Montserrat', sans-serif;
background-color: #F6F8FC;
}
header {
padding: 1rem;
border-bottom: 1px solid #C8CCD8;
box-shadow: 0 .5rem .5rem rgb(128 128 128 / .1);
background-color: white;
}
form {
display: flex;
gap: .5rem;
border: 2px solid #C8CCD8;
border-radius: .35rem;
padding: .5rem;
width: fit-content;
align-items: center;
img {
width: 1.25rem;
height: 1.25rem;
}
input {
font: inherit;
border: 0;
font-size: larger;
outline: none;
width: 100%;
}
&:focus-within {
border: 2px solid #9FBCFC;
}
}
main {
padding: .5rem;
margin-top: 1rem;
}
#metrics {
color: #444444;
margin-bottom: 1rem;
.detail {
font-weight: bold;
}
}
#results {
display: flex;
flex-direction: column;
gap: 1rem;
article {
background-color: white;
border-radius: .5rem;
padding: 1rem;
display: flex;
box-shadow: 0 .25rem .25rem rgb(192 192 192 / .1);
img {
width: 9rem;
height: fit-content;
min-height: 12rem;
object-fit: cover;
border-radius: .5rem;
margin-right: 1rem;
}
h2 {
margin: 0;
}
p.date {
font-size: smaller;
font-weight: bold;
}
ul {
display: flex;
flex-wrap: wrap;
list-style: none;
margin: .25rem 0 .5rem 0;
padding: 0;
gap: .5rem;
}
li {
background-color: #D3E4FC;
border-radius: .25rem;
padding: .25rem .5rem;
}
p {
color: #444444;
line-height: 1.7;
}
}
}
</style>
</head>
<body {{#with request.values.token}}hx-headers='{"X-CSRF-Token": "{{.}}"}'{{/with}}>
<header>
<form action="/" method="GET">
<img src="/static/search.svg" alt="Search icon" width="18" height="18">
<input name="q" type="text" placeholder="Search..." size="50" value="{{request.params.q}}">
</form>
</header>
<main>
{{#with meta}}
<p id="metrics">
{{#with count}}<span class="detail">~{{lowerBound}}</span>{{else}}No{{/with}} results
in <span class="detail">{{elapsed}}</span> ms
</p>
<div id="results">
{{#each results}}
<article>
<img src="{{poster}}" alt="Movie poster of {{title}}">
<section>
<h2>{{title}}</h2>
<p class="date">{{date release_date 'Y'}}</p>
<ul>{{#each genres}}<li>{{.}}</li>{{/each}}</ul>
<p>{{overview}}</p>
</section>
</article>
{{/each}}
</div>
{{/with}}
</main>
<script src="/static/vendor.js"></script>
<script type="module">
const $input = document.forms[0].elements['q'];
$input.focus();
$input.setSelectionRange($input.value.length, $input.value.length);
</script>
</body>
</html>
<?php
use com\mongodb\MongoConnection;
use lang\Environment;
use util\cmd\Console;
class SearchIndex {
use Connection;
/** @param string[] $args */
public static function main($args) {
$db= self::connect(Environment::variable('MONGO_URI'), Environment::variable('MONGO_DB', 'search'));
$collection= $db->collection(self::$COLLECTION);
// Quoting https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/:
// This command can only be run on a deployment hosted on MongoDB Atlas, and requires an
// Atlas cluster tier of at least M10. In the free tier, go to https://cloud.mongodb.com/
// and create the indexes using the frontend
$result= $collection->run('createSearchIndexes', ['indexes' => [[
'name' => self::$INDEX,
'definition' => ['mappings' => ['dynamic' => true]],
]]]);
Console::writeLine('Created index => ', $result);
}
}
@thekid
Copy link
Author

thekid commented Jun 16, 2024

Setup instructions after cloning:

# Install dependencies
$ composer up

# Point environment variable to Atlas URI
$ export MONGO_URI="mongodb+srv://user:pass@cluster.id.mongodb.net/?readPreference=nearest"
$ export MONGO_DB=search

# Import movies
$ xp ImportMovies

# Create search index (requires Atlas M10 tier, in free tier, you need to do this using the frontend!)
$ xp SearchIndex

Finally, run xp serve (or xp dev for development mode) and open http://localhost:8080/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment