Retour aux articles

Trouver les marchés autour de moi : la recherche géographique avec SQL Server, EF Core et NetTopologySuite

Trouver les marchés autour de moi : la recherche géographique avec SQL Server, EF Core et NetTopologySuite

Sur Où est le marché ?, la question la plus fréquente des visiteurs tient en quelques mots : « quels marchés autour de moi ? ». Avec plus de 5 000 marchés en France, impossible de faire défiler une liste. Il faut une vraie recherche géographique : à partir d'une position, trouver les marchés les plus proches, triés par distance.

Bonne nouvelle : SQL Server sait faire ça très bien, et EF Core aussi, grâce à NetTopologySuite. Voici comment je l'ai mis en place, avec les quelques pièges dans lesquels je suis tombé.

TL;DR

  • Stockez vos coordonnées dans une colonne SQL Server de type geography, mappée sur un Point NetTopologySuite.
  • Attention à l'ordre : un Point prend (longitude, latitude), pas l'inverse.
  • Les distances sont en mètres.
  • Sans index spatial, chaque recherche parcourt toute la table.
  • Une seule coordonnée invalide peut faire échouer toute la requête : vérifiez-les avant.

Mise en place

Le paquet NuGet à ajouter au projet qui contient le DbContext :

dotnet add package Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite

Puis on l'active dans la configuration d'EF Core :

services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString, sql => sql.UseNetTopologySuite()));

Dans l'entité, la position est un simple Point :

using NetTopologySuite.Geometries;

public class Market
{
    public long Id { get; set; }
    public string Name { get; set; } = string.Empty;

    // Colonne SQL Server de type geography
    public Point GpsCoordinates { get; set; } = null!;
}

EF Core génère une colonne de type geography : c'est le type qui calcule les distances sur la surface de la Terre, en mètres. (L'autre type spatial, geometry, travaille sur un plan : parfait pour un plan d'usine, pas pour la France.)

Créer un point : le piège de l'ordre

Le piège classique, dans lequel tout le monde tombe une fois :

// ❌ Faux : latitude en premier
var point = new Point(48.11, -1.68);

// ✅ Juste : X = longitude, Y = latitude
var point = new Point(-1.68, 48.11) { SRID = 4326 };

Un Point est un point mathématique : X d'abord, puis Y. Et sur une carte, X c'est la longitude. Si vous inversez, Rennes se retrouve quelque part dans l'océan Indien... et aucune erreur ne vous prévient.

Le SRID 4326, c'est le système de coordonnées du GPS (WGS 84). Sans lui, SQL Server ne sait pas comment interpréter vos coordonnées.

La requête : les marchés les plus proches

Voici la version simplifiée de ma méthode :

public async Task<List<ClosestMarketDto>> GetClosestMarkets(Point point, double maxDistanceKm, int nbResults = 30)
{
    point.SRID = 4326;
    var maxDistanceMeters = maxDistanceKm * 1000; // geography travaille en mètres

    return await _dbContext.Markets
        .Where(m => m.Status == MarketStatus.Published)
        // La distance est calculée une seule fois, puis réutilisée pour filtrer et trier
        .Select(m => new { Market = m, Distance = m.GpsCoordinates.Distance(point) })
        .Where(x => x.Distance < maxDistanceMeters)
        .OrderBy(x => x.Distance)
        .Take(nbResults)
        .Select(x => new ClosestMarketDto
        {
            Id = x.Market.Id,
            Name = x.Market.Name,
            DistanceKm = x.Distance / 1000
        })
        .ToListAsync();
}

EF Core traduit Distance() en STDistance() côté SQL Server. Tout se passe dans la base : on ne ramène que les 30 marchés utiles, déjà triés.

Petit détail qui compte : la distance est calculée une seule fois dans un Select, puis réutilisée par le Where et le OrderBy. Ma première version appelait Distance() trois fois.

L'index spatial : de « ça marche » à « ça marche vite »

Sans index, SQL Server calcule la distance entre votre point et chaque marché de la table, à chaque recherche. Avec 5 000 lignes, ça passe encore. Avec plus, ou sur un hébergement mutualisé, ça se sent.

EF Core n'a pas d'API pour créer un index spatial. On l'ajoute donc dans une migration, en SQL :

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.Sql(@"
        CREATE SPATIAL INDEX [SIDX_Markets_GpsCoordinates]
        ON [dbo].[Markets] ([GpsCoordinates])
        USING GEOGRAPHY_AUTO_GRID;
    ");
}

protected override void Down(MigrationBuilder migrationBuilder)
{
    migrationBuilder.Sql("DROP INDEX IF EXISTS [SIDX_Markets_GpsCoordinates] ON [dbo].[Markets];");
}

GEOGRAPHY_AUTO_GRID laisse SQL Server calibrer l'index tout seul : pas besoin de jouer avec les niveaux de grille.

Le piège des coordonnées invalides

Celui-là m'a valu quelques erreurs en production. Certaines communes, notamment en outre-mer, avaient des coordonnées corrompues lors d'un import : latitude et longitude inversées, ou valeurs hors limites. Résultat : SQL Server refuse le calcul, avec une erreur du type « instance geography non valide »... et c'est toute la requête qui échoue, pas seulement la ligne fautive.

La parade : vérifier le point avant d'interroger la base.

// Une latitude est entre -90 et 90, une longitude entre -180 et 180
if (double.IsNaN(point.Y) || double.IsNaN(point.X) ||
    point.Y < -90 || point.Y > 90 || point.X < -180 || point.X > 180)
{
    return [];
}

Et bien sûr, corriger les données à la source. Mais ce garde-fou évite qu'une seule ligne abîmée fasse planter une page.

Bonus : mettre le résultat en cache

Sur une page de ville, la liste des « marchés à proximité » ne change quasiment jamais, mais elle est recalculée à chaque visite, y compris pour les robots des moteurs de recherche. Je la garde donc en mémoire quelques heures, dans un petit cache dédié et limité en taille (mon hébergement mutualisé compte la mémoire). La requête spatiale ne tourne plus qu'une fois de temps en temps par ville.

En résumé

  • geography + NetTopologySuite + UseNetTopologySuite() : trois lignes pour faire de la géographie avec EF Core.
  • new Point(longitude, latitude) { SRID = 4326 } : dans cet ordre, toujours.
  • Les distances sont en mètres.
  • Un index spatial, ajouté en SQL dans une migration.
  • Des coordonnées vérifiées avant la requête.

Vous voulez voir le résultat ? Allez chercher les marchés autour de chez vous sur ouestlemarche.fr 😉

Une question sur la mise en place ? N'hésitez pas à me contacter ou à laisser un commentaire !

Commentaires (0)

Aucun commentaire pour le moment. Soyez le premier à commenter !

Laisser un commentaire

Votre e-mail ne sera pas publié.
An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please reload the page.