Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ parameters:

paths:
- src
- tests/PHPStan

level: 2

Expand Down
111 changes: 100 additions & 11 deletions src/Schema/Blueprint.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,104 @@
use function is_string;
use function key;

/** @property Connection $connection */
/**
* @property Connection $connection
* @link https://www.mongodb.com/docs/atlas/atlas-search/define-field-mappings/#std-label-fts-field-mappings
* @phpstan-type TypeSearchIndexField array{
* type: 'boolean'|'date'|'dateFacet'|'objectId'|'stringFacet'|'uuid',
* } | array{
* type: 'autocomplete',
* analyzer?: string,
* maxGrams?: int,
* minGrams?: int,
* tokenization?: 'edgeGram'|'rightEdgeGram'|'nGram',
* foldDiacritics?: bool,
* similarity?: array{type: 'bm25'|'boolean'|'stableTfl'},
* } | array{
* type: 'document'|'embeddedDocuments',
* dynamic?: bool,
* fields: array<string, array<mixed>>,
* } | array{
* type: 'geo',
* indexShapes?: bool,
* } | array{
* type: 'number'|'numberFacet',
* representation?: 'int64'|'double',
* indexIntegers?: bool,
* indexDoubles?: bool,
* } | array{
* type: 'token',
* normalizer?: 'lowercase'|'none',
* } | array{
* type: 'string',
* analyzer?: string,
* searchAnalyzer?: string,
* indexOptions?: 'docs'|'freqs'|'positions'|'offsets',
* store?: bool,
* ignoreAbove?: int,
* multi?: array<string, array<string, mixed>>,
* norms?: 'include'|'omit',
* similarity?: array{type: 'bm25'|'boolean'|'stableTfl'},
* }
* @link https://www.mongodb.com/docs/atlas/atlas-search/analyzers/character-filters/
* @phpstan-type TypeSearchIndexCharFilter array{
* type: 'icuNormalize'|'persian',
* } | array{
* type: 'htmlStrip',
* ignoredTags?: string[],
* } | array{
* type: 'mapping',
* mappings?: array<string, string>,
* }
* @link https://www.mongodb.com/docs/atlas/atlas-search/analyzers/token-filters/
* @phpstan-type TypeSearchIndexTokenFilter array{type: string, ...}
* @link https://www.mongodb.com/docs/atlas/atlas-search/analyzers/custom/
* @phpstan-type TypeSearchIndexAnalyzer array{
* name: string,
* charFilters?: TypeSearchIndexCharFilter[],
* tokenizer: array{type: string},
* tokenFilters?: TypeSearchIndexTokenFilter[],
* }
* @link https://www.mongodb.com/docs/atlas/atlas-search/stored-source-definition/#std-label-fts-stored-source-definition
* @phpstan-type TypeSearchIndexStoredSource bool | array{
* include: array<string>,
* } | array{
* exclude: array<string>,
* }
* @link https://www.mongodb.com/docs/atlas/atlas-search/synonyms/#std-label-synonyms-ref
* @phpstan-type TypeSearchIndexSynonyms array{
* analyzer: string,
* name: string,
* source?: array{collection: string},
* }
* @link https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-search-index-definition-create
* @phpstan-type TypeSearchIndexDefinition array{
* analyzer?: string,
* analyzers?: TypeSearchIndexAnalyzer[],
* searchAnalyzer?: string,
* mappings: array{dynamic: true} | array{dynamic?: bool|array{typeSet: string}, fields: array<string, TypeSearchIndexField|TypeSearchIndexField[]>},
* storedSource?: TypeSearchIndexStoredSource,
* synonyms?: TypeSearchIndexSynonyms[],
* typeSets?: array<array{name: string, types: array<array{type: string, ...}>}>,
* }
* @link https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-type/#atlas-vector-search-index-fields
* @phpstan-type TypeVectorSearchIndexField array{
* type: 'vector',
* path: string,
* numDimensions: int,
* similarity: 'euclidean'|'cosine'|'dotProduct',
* quantization?: 'none'|'scalar'|'binary',
* indexingMethod?: 'flat'|'hnsw',
* hnswOptions?: array{maxEdges?: int, numEdgeCandidates?: int},
* } | array{
* type: 'filter',
* path: string,
* }
* @link https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-type/#atlas-vector-search-index-fields
* @phpstan-type TypeVectorSearchIndexDefinition array{
* fields: TypeVectorSearchIndexField[],
* }
*/
class Blueprint extends BaseBlueprint
{
// Import $connection property and constructor for Laravel 12 compatibility
Expand Down Expand Up @@ -319,15 +416,7 @@ public function sparse_and_unique($columns = null, $options = [])
*
* @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-search-index-definition-create
*
* @phpstan-param array{
* analyzer?: string,
* analyzers?: list<array>,
* searchAnalyzer?: string,
* mappings: array{dynamic: true} | array{dynamic?: bool, fields: array<string, array>},
* storedSource?: bool|array,
* synonyms?: list<array>,
* ...
* } $definition
* @phpstan-param TypeSearchIndexDefinition $definition
*/
public function searchIndex(array $definition, string $name = 'default'): static
{
Expand All @@ -341,7 +430,7 @@ public function searchIndex(array $definition, string $name = 'default'): static
*
* @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-vector-search-index-definition-create
*
* @phpstan-param array{fields: array<string, array{type: string, ...}>} $definition
* @phpstan-param TypeVectorSearchIndexDefinition $definition
*/
public function vectorSearchIndex(array $definition, string $name = 'default'): static
{
Expand Down
233 changes: 233 additions & 0 deletions tests/PHPStan/SearchIndexTypes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
<?php

declare(strict_types=1);

namespace MongoDB\Laravel\Tests\PHPStan;

use MongoDB\Laravel\Schema\Blueprint;

/**
* PHPStan type-level tests for search index definitions.
* These functions are never executed at runtime — they exist to let PHPStan
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Claude, but this feels like kind of a given lol

* validate that complex index definitions match the declared @phpstan-type shapes.
*
* Examples are taken verbatim from the MongoDB Atlas documentation:
*
* @link https://www.mongodb.com/docs/atlas/atlas-search/define-field-mappings.md
* @link https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-type.md
*
* @phpstan-import-type TypeSearchIndexDefinition from Blueprint
* @phpstan-import-type TypeVectorSearchIndexDefinition from Blueprint
*/
final class SearchIndexTypes
{
/** @phpstan-param TypeSearchIndexDefinition $definition */
public static function assertSearchIndexDefinition(array $definition): void
{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a comment in the body clarifying that the @phpstan-param constitutes the assertion and no other test logic is expected here?

}

/** @phpstan-param TypeVectorSearchIndexDefinition $definition */
public static function assertVectorSearchIndexDefinition(array $definition): void
{
}

public static function searchIndexExamples(): void
{
// Static mapping with nested document, multi-analyzer string, ignoreAbove
// Source: https://www.mongodb.com/docs/atlas/atlas-search/define-field-mappings.md
self::assertSearchIndexDefinition([
'analyzer' => 'lucene.standard',
'searchAnalyzer' => 'lucene.standard',
'mappings' => [
'dynamic' => false,
'fields' => [
'awards' => [
'type' => 'document',
'fields' => [
'wins' => ['type' => 'number'],
'nominations' => ['type' => 'number', 'representation' => 'int64'],
'text' => ['type' => 'string', 'analyzer' => 'lucene.english', 'ignoreAbove' => 255],
],
],
'title' => [
'type' => 'string',
'analyzer' => 'lucene.whitespace',
'multi' => [
'mySecondaryAnalyzer' => ['type' => 'string', 'analyzer' => 'lucene.french'],
],
],
'genres' => ['type' => 'string', 'analyzer' => 'lucene.standard'],
],
],
]);

// Synonyms
// Source: https://www.mongodb.com/docs/atlas/atlas-search/synonyms.md
self::assertSearchIndexDefinition([
'mappings' => [
'dynamic' => false,
'fields' => [
'plot' => ['type' => 'string', 'analyzer' => 'lucene.english'],
],
],
'synonyms' => [
[
'analyzer' => 'lucene.english',
'name' => 'my_synonyms',
'source' => ['collection' => 'synonymous_terms'],
],
],
]);

// storedSource with include
// Source: https://www.mongodb.com/docs/atlas/atlas-search/stored-source-definition.md
self::assertSearchIndexDefinition([
'mappings' => ['dynamic' => true],
'storedSource' => ['include' => ['title', 'awards.wins']],
]);

// storedSource with exclude
// Source: https://www.mongodb.com/docs/atlas/atlas-search/stored-source-definition.md
self::assertSearchIndexDefinition([
'mappings' => ['dynamic' => true],
'storedSource' => ['exclude' => ['directors', 'imdb.rating']],
]);

// Dynamic typeSet-based mapping
// Source: https://www.mongodb.com/docs/atlas/atlas-search/define-field-mappings.md
self::assertSearchIndexDefinition([
'analyzer' => 'lucene.standard',
'searchAnalyzer' => 'lucene.standard',
'mappings' => [
'dynamic' => ['typeSet' => 'indexedTypes'],
'fields' => [
'plot' => [],
],
],
'typeSets' => [
[
'name' => 'indexedTypes',
'types' => [
['type' => 'token'],
['type' => 'number'],
],
],
],
]);

// typeSets with autocomplete and multi-analyzer string
// Source: https://www.mongodb.com/docs/atlas/atlas-search/define-field-mappings.md
self::assertSearchIndexDefinition([
'analyzer' => 'lucene.standard',
'searchAnalyzer' => 'lucene.standard',
'mappings' => [
'dynamic' => false,
'fields' => [
'awards' => ['type' => 'document', 'fields' => []],
],
],
'typeSets' => [
[
'name' => 'movieAwards',
'types' => [
[
'type' => 'string',
'multi' => [
'english' => ['type' => 'string', 'analyzer' => 'lucene.english'],
'french' => ['type' => 'string', 'analyzer' => 'lucene.french'],
],
],
['type' => 'number'],
[
'type' => 'autocomplete',
'analyzer' => 'lucene.standard',
'tokenization' => 'edgeGram',
'minGrams' => 3,
'maxGrams' => 5,
'foldDiacritics' => false,
],
],
],
],
]);

// String field with all options including similarity
// Source: https://www.mongodb.com/docs/atlas/atlas-search/field-types/string-type.md
self::assertSearchIndexDefinition([
'mappings' => [
'dynamic' => false,
'fields' => [
'plot' => [
'type' => 'string',
'analyzer' => 'lucene.english',
'searchAnalyzer' => 'lucene.standard',
'indexOptions' => 'offsets',
'store' => true,
'ignoreAbove' => 255,
'norms' => 'omit',
'similarity' => ['type' => 'bm25'],
],
],
],
]);

// Autocomplete field with all options including similarity
// Source: https://www.mongodb.com/docs/atlas/atlas-search/field-types/autocomplete-type.md
self::assertSearchIndexDefinition([
'mappings' => [
'dynamic' => false,
'fields' => [
'title' => [
'type' => 'autocomplete',
'analyzer' => 'lucene.standard',
'tokenization' => 'edgeGram',
'minGrams' => 2,
'maxGrams' => 15,
'foldDiacritics' => true,
'similarity' => ['type' => 'stableTfl'],
],
],
],
]);
}

public static function vectorSearchIndexExamples(): void
{
// Vector + quantization + HNSW + two filter fields
// Source: https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-type.md
self::assertVectorSearchIndexDefinition([
'fields' => [
[
'type' => 'vector',
'path' => 'plot_embedding_voyage_3_large',
'numDimensions' => 2048,
'similarity' => 'dotProduct',
'quantization' => 'scalar',
'indexingMethod' => 'hnsw',
],
['type' => 'filter', 'path' => 'genres'],
['type' => 'filter', 'path' => 'year'],
],
]);

// Vector with hnswOptions (full syntax from docs)
// Source: https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-type.md
self::assertVectorSearchIndexDefinition([
'fields' => [
[
'type' => 'vector',
'path' => 'plot_embedding',
'numDimensions' => 1536,
'similarity' => 'cosine',
'quantization' => 'none',
'indexingMethod' => 'hnsw',
'hnswOptions' => [
'maxEdges' => 32,
'numEdgeCandidates' => 200,
],
],
['type' => 'filter', 'path' => 'genres'],
],
]);
}
}
Loading