Skip to content

Commit 92dc495

Browse files
authored
Add precise PHPStan types for search and vector search index definitions (#3234)
1 parent 6160526 commit 92dc495

3 files changed

Lines changed: 334 additions & 11 deletions

File tree

phpstan.neon.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ parameters:
66

77
paths:
88
- src
9+
- tests/PHPStan
910

1011
level: 2
1112

src/Schema/Blueprint.php

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,104 @@
1818
use function is_string;
1919
use function key;
2020

21-
/** @property Connection $connection */
21+
/**
22+
* @property Connection $connection
23+
* @link https://www.mongodb.com/docs/atlas/atlas-search/define-field-mappings/#std-label-fts-field-mappings
24+
* @phpstan-type TypeSearchIndexField array{
25+
* type: 'boolean'|'date'|'dateFacet'|'objectId'|'stringFacet'|'uuid',
26+
* } | array{
27+
* type: 'autocomplete',
28+
* analyzer?: string,
29+
* maxGrams?: int,
30+
* minGrams?: int,
31+
* tokenization?: 'edgeGram'|'rightEdgeGram'|'nGram',
32+
* foldDiacritics?: bool,
33+
* similarity?: array{type: 'bm25'|'boolean'|'stableTfl'},
34+
* } | array{
35+
* type: 'document'|'embeddedDocuments',
36+
* dynamic?: bool,
37+
* fields: array<string, array<mixed>>,
38+
* } | array{
39+
* type: 'geo',
40+
* indexShapes?: bool,
41+
* } | array{
42+
* type: 'number'|'numberFacet',
43+
* representation?: 'int64'|'double',
44+
* indexIntegers?: bool,
45+
* indexDoubles?: bool,
46+
* } | array{
47+
* type: 'token',
48+
* normalizer?: 'lowercase'|'none',
49+
* } | array{
50+
* type: 'string',
51+
* analyzer?: string,
52+
* searchAnalyzer?: string,
53+
* indexOptions?: 'docs'|'freqs'|'positions'|'offsets',
54+
* store?: bool,
55+
* ignoreAbove?: int,
56+
* multi?: array<string, array<string, mixed>>,
57+
* norms?: 'include'|'omit',
58+
* similarity?: array{type: 'bm25'|'boolean'|'stableTfl'},
59+
* }
60+
* @link https://www.mongodb.com/docs/atlas/atlas-search/analyzers/character-filters/
61+
* @phpstan-type TypeSearchIndexCharFilter array{
62+
* type: 'icuNormalize'|'persian',
63+
* } | array{
64+
* type: 'htmlStrip',
65+
* ignoredTags?: string[],
66+
* } | array{
67+
* type: 'mapping',
68+
* mappings?: array<string, string>,
69+
* }
70+
* @link https://www.mongodb.com/docs/atlas/atlas-search/analyzers/token-filters/
71+
* @phpstan-type TypeSearchIndexTokenFilter array{type: string, ...}
72+
* @link https://www.mongodb.com/docs/atlas/atlas-search/analyzers/custom/
73+
* @phpstan-type TypeSearchIndexAnalyzer array{
74+
* name: string,
75+
* charFilters?: TypeSearchIndexCharFilter[],
76+
* tokenizer: array{type: string},
77+
* tokenFilters?: TypeSearchIndexTokenFilter[],
78+
* }
79+
* @link https://www.mongodb.com/docs/atlas/atlas-search/stored-source-definition/#std-label-fts-stored-source-definition
80+
* @phpstan-type TypeSearchIndexStoredSource bool | array{
81+
* include: array<string>,
82+
* } | array{
83+
* exclude: array<string>,
84+
* }
85+
* @link https://www.mongodb.com/docs/atlas/atlas-search/synonyms/#std-label-synonyms-ref
86+
* @phpstan-type TypeSearchIndexSynonyms array{
87+
* analyzer: string,
88+
* name: string,
89+
* source?: array{collection: string},
90+
* }
91+
* @link https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-search-index-definition-create
92+
* @phpstan-type TypeSearchIndexDefinition array{
93+
* analyzer?: string,
94+
* analyzers?: TypeSearchIndexAnalyzer[],
95+
* searchAnalyzer?: string,
96+
* mappings: array{dynamic: true} | array{dynamic?: bool|array{typeSet: string}, fields: array<string, TypeSearchIndexField|TypeSearchIndexField[]>},
97+
* storedSource?: TypeSearchIndexStoredSource,
98+
* synonyms?: TypeSearchIndexSynonyms[],
99+
* typeSets?: array<array{name: string, types: array<array{type: string, ...}>}>,
100+
* }
101+
* @link https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-type/#atlas-vector-search-index-fields
102+
* @phpstan-type TypeVectorSearchIndexField array{
103+
* type: 'vector',
104+
* path: string,
105+
* numDimensions: int,
106+
* similarity: 'euclidean'|'cosine'|'dotProduct',
107+
* quantization?: 'none'|'scalar'|'binary',
108+
* indexingMethod?: 'flat'|'hnsw',
109+
* hnswOptions?: array{maxEdges?: int, numEdgeCandidates?: int},
110+
* } | array{
111+
* type: 'filter',
112+
* path: string,
113+
* }
114+
* @link https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-type/#atlas-vector-search-index-fields
115+
* @phpstan-type TypeVectorSearchIndexDefinition array{
116+
* fields: TypeVectorSearchIndexField[],
117+
* }
118+
*/
22119
class Blueprint extends BaseBlueprint
23120
{
24121
// Import $connection property and constructor for Laravel 12 compatibility
@@ -319,15 +416,7 @@ public function sparse_and_unique($columns = null, $options = [])
319416
*
320417
* @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-search-index-definition-create
321418
*
322-
* @phpstan-param array{
323-
* analyzer?: string,
324-
* analyzers?: list<array>,
325-
* searchAnalyzer?: string,
326-
* mappings: array{dynamic: true} | array{dynamic?: bool, fields: array<string, array>},
327-
* storedSource?: bool|array,
328-
* synonyms?: list<array>,
329-
* ...
330-
* } $definition
419+
* @phpstan-param TypeSearchIndexDefinition $definition
331420
*/
332421
public function searchIndex(array $definition, string $name = 'default'): static
333422
{
@@ -341,7 +430,7 @@ public function searchIndex(array $definition, string $name = 'default'): static
341430
*
342431
* @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-vector-search-index-definition-create
343432
*
344-
* @phpstan-param array{fields: array<string, array{type: string, ...}>} $definition
433+
* @phpstan-param TypeVectorSearchIndexDefinition $definition
345434
*/
346435
public function vectorSearchIndex(array $definition, string $name = 'default'): static
347436
{

tests/PHPStan/SearchIndexTypes.php

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MongoDB\Laravel\Tests\PHPStan;
6+
7+
use MongoDB\Laravel\Schema\Blueprint;
8+
9+
/**
10+
* PHPStan type-level tests for search index definitions.
11+
* These functions are never executed at runtime — they exist to let PHPStan
12+
* validate that complex index definitions match the declared @phpstan-type shapes.
13+
*
14+
* Examples are taken verbatim from the MongoDB Atlas documentation:
15+
*
16+
* @link https://www.mongodb.com/docs/atlas/atlas-search/define-field-mappings.md
17+
* @link https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-type.md
18+
*
19+
* @phpstan-import-type TypeSearchIndexDefinition from Blueprint
20+
* @phpstan-import-type TypeVectorSearchIndexDefinition from Blueprint
21+
*/
22+
final class SearchIndexTypes
23+
{
24+
/** @phpstan-param TypeSearchIndexDefinition $definition */
25+
public static function assertSearchIndexDefinition(array $definition): void
26+
{
27+
}
28+
29+
/** @phpstan-param TypeVectorSearchIndexDefinition $definition */
30+
public static function assertVectorSearchIndexDefinition(array $definition): void
31+
{
32+
}
33+
34+
public static function searchIndexExamples(): void
35+
{
36+
// Static mapping with nested document, multi-analyzer string, ignoreAbove
37+
// Source: https://www.mongodb.com/docs/atlas/atlas-search/define-field-mappings.md
38+
self::assertSearchIndexDefinition([
39+
'analyzer' => 'lucene.standard',
40+
'searchAnalyzer' => 'lucene.standard',
41+
'mappings' => [
42+
'dynamic' => false,
43+
'fields' => [
44+
'awards' => [
45+
'type' => 'document',
46+
'fields' => [
47+
'wins' => ['type' => 'number'],
48+
'nominations' => ['type' => 'number', 'representation' => 'int64'],
49+
'text' => ['type' => 'string', 'analyzer' => 'lucene.english', 'ignoreAbove' => 255],
50+
],
51+
],
52+
'title' => [
53+
'type' => 'string',
54+
'analyzer' => 'lucene.whitespace',
55+
'multi' => [
56+
'mySecondaryAnalyzer' => ['type' => 'string', 'analyzer' => 'lucene.french'],
57+
],
58+
],
59+
'genres' => ['type' => 'string', 'analyzer' => 'lucene.standard'],
60+
],
61+
],
62+
]);
63+
64+
// Synonyms
65+
// Source: https://www.mongodb.com/docs/atlas/atlas-search/synonyms.md
66+
self::assertSearchIndexDefinition([
67+
'mappings' => [
68+
'dynamic' => false,
69+
'fields' => [
70+
'plot' => ['type' => 'string', 'analyzer' => 'lucene.english'],
71+
],
72+
],
73+
'synonyms' => [
74+
[
75+
'analyzer' => 'lucene.english',
76+
'name' => 'my_synonyms',
77+
'source' => ['collection' => 'synonymous_terms'],
78+
],
79+
],
80+
]);
81+
82+
// storedSource with include
83+
// Source: https://www.mongodb.com/docs/atlas/atlas-search/stored-source-definition.md
84+
self::assertSearchIndexDefinition([
85+
'mappings' => ['dynamic' => true],
86+
'storedSource' => ['include' => ['title', 'awards.wins']],
87+
]);
88+
89+
// storedSource with exclude
90+
// Source: https://www.mongodb.com/docs/atlas/atlas-search/stored-source-definition.md
91+
self::assertSearchIndexDefinition([
92+
'mappings' => ['dynamic' => true],
93+
'storedSource' => ['exclude' => ['directors', 'imdb.rating']],
94+
]);
95+
96+
// Dynamic typeSet-based mapping
97+
// Source: https://www.mongodb.com/docs/atlas/atlas-search/define-field-mappings.md
98+
self::assertSearchIndexDefinition([
99+
'analyzer' => 'lucene.standard',
100+
'searchAnalyzer' => 'lucene.standard',
101+
'mappings' => [
102+
'dynamic' => ['typeSet' => 'indexedTypes'],
103+
'fields' => [
104+
'plot' => [],
105+
],
106+
],
107+
'typeSets' => [
108+
[
109+
'name' => 'indexedTypes',
110+
'types' => [
111+
['type' => 'token'],
112+
['type' => 'number'],
113+
],
114+
],
115+
],
116+
]);
117+
118+
// typeSets with autocomplete and multi-analyzer string
119+
// Source: https://www.mongodb.com/docs/atlas/atlas-search/define-field-mappings.md
120+
self::assertSearchIndexDefinition([
121+
'analyzer' => 'lucene.standard',
122+
'searchAnalyzer' => 'lucene.standard',
123+
'mappings' => [
124+
'dynamic' => false,
125+
'fields' => [
126+
'awards' => ['type' => 'document', 'fields' => []],
127+
],
128+
],
129+
'typeSets' => [
130+
[
131+
'name' => 'movieAwards',
132+
'types' => [
133+
[
134+
'type' => 'string',
135+
'multi' => [
136+
'english' => ['type' => 'string', 'analyzer' => 'lucene.english'],
137+
'french' => ['type' => 'string', 'analyzer' => 'lucene.french'],
138+
],
139+
],
140+
['type' => 'number'],
141+
[
142+
'type' => 'autocomplete',
143+
'analyzer' => 'lucene.standard',
144+
'tokenization' => 'edgeGram',
145+
'minGrams' => 3,
146+
'maxGrams' => 5,
147+
'foldDiacritics' => false,
148+
],
149+
],
150+
],
151+
],
152+
]);
153+
154+
// String field with all options including similarity
155+
// Source: https://www.mongodb.com/docs/atlas/atlas-search/field-types/string-type.md
156+
self::assertSearchIndexDefinition([
157+
'mappings' => [
158+
'dynamic' => false,
159+
'fields' => [
160+
'plot' => [
161+
'type' => 'string',
162+
'analyzer' => 'lucene.english',
163+
'searchAnalyzer' => 'lucene.standard',
164+
'indexOptions' => 'offsets',
165+
'store' => true,
166+
'ignoreAbove' => 255,
167+
'norms' => 'omit',
168+
'similarity' => ['type' => 'bm25'],
169+
],
170+
],
171+
],
172+
]);
173+
174+
// Autocomplete field with all options including similarity
175+
// Source: https://www.mongodb.com/docs/atlas/atlas-search/field-types/autocomplete-type.md
176+
self::assertSearchIndexDefinition([
177+
'mappings' => [
178+
'dynamic' => false,
179+
'fields' => [
180+
'title' => [
181+
'type' => 'autocomplete',
182+
'analyzer' => 'lucene.standard',
183+
'tokenization' => 'edgeGram',
184+
'minGrams' => 2,
185+
'maxGrams' => 15,
186+
'foldDiacritics' => true,
187+
'similarity' => ['type' => 'stableTfl'],
188+
],
189+
],
190+
],
191+
]);
192+
}
193+
194+
public static function vectorSearchIndexExamples(): void
195+
{
196+
// Vector + quantization + HNSW + two filter fields
197+
// Source: https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-type.md
198+
self::assertVectorSearchIndexDefinition([
199+
'fields' => [
200+
[
201+
'type' => 'vector',
202+
'path' => 'plot_embedding_voyage_3_large',
203+
'numDimensions' => 2048,
204+
'similarity' => 'dotProduct',
205+
'quantization' => 'scalar',
206+
'indexingMethod' => 'hnsw',
207+
],
208+
['type' => 'filter', 'path' => 'genres'],
209+
['type' => 'filter', 'path' => 'year'],
210+
],
211+
]);
212+
213+
// Vector with hnswOptions (full syntax from docs)
214+
// Source: https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-type.md
215+
self::assertVectorSearchIndexDefinition([
216+
'fields' => [
217+
[
218+
'type' => 'vector',
219+
'path' => 'plot_embedding',
220+
'numDimensions' => 1536,
221+
'similarity' => 'cosine',
222+
'quantization' => 'none',
223+
'indexingMethod' => 'hnsw',
224+
'hnswOptions' => [
225+
'maxEdges' => 32,
226+
'numEdgeCandidates' => 200,
227+
],
228+
],
229+
['type' => 'filter', 'path' => 'genres'],
230+
],
231+
]);
232+
}
233+
}

0 commit comments

Comments
 (0)