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