PostgreSQL
 sql >> Base de Dados >  >> RDS >> PostgreSQL

Como posso obter resultados de uma entidade JPA ordenada por distância?


Esta é uma versão amplamente simplificada de uma função que uso em um aplicativo criado há cerca de 3 anos. Adaptado à questão em questão.

  • Encontra locais no perímetro de um ponto usando uma caixa . Pode-se fazer isso com um círculo para obter resultados mais precisos, mas isso é apenas uma aproximação para começar.

  • Ignora o fato de que o mundo não é plano. A minha candidatura destinava-se apenas a uma região local, com cerca de 100 quilómetros. E o perímetro de busca abrange apenas alguns quilômetros. Tornar o mundo plano é bom o suficiente para esse propósito. (Todo:Uma melhor aproximação para a relação lat/lon dependendo da geolocalização pode ajudar.)

  • Opera com geocódigos como você obtém do Google Maps.

  • Funciona com PostgreSQL padrão sem extensão (não requer PostGis), testado no PostgreSQL 9.1 e 9.2.

Sem índice, seria preciso calcular a distância para cada linha da tabela base e filtrar as mais próximas. Extremamente caro com grandes mesas.

Editar:
Verifiquei novamente e a implementação atual permite um índice GisT em pontos (Postgres 9.1 ou posterior). Simplificou o código de acordo.

O truque principal é usar um índice GiST funcional de caixas , mesmo que a coluna seja apenas um ponto. Isso torna possível usar a implementação GiST existente .

Com uma busca tão (muito rápida), podemos obter todos os locais dentro de uma caixa. O problema restante:sabemos o número de linhas, mas não sabemos o tamanho da caixa em que estão. É como saber parte da resposta, mas não a pergunta.

Eu uso uma pesquisa reversa semelhante abordagem descrita em mais detalhes em esta resposta relacionada em dba.SE . (Apenas, não estou usando índices parciais aqui - pode realmente funcionar também).

Itere através de uma série de etapas de pesquisa pré-definidas, desde muito pequenas até "grandes o suficiente para conter pelo menos locais suficientes". Significa que temos que executar algumas consultas (muito rápidas) para chegar ao tamanho da caixa de pesquisa.

Em seguida, pesquise a tabela base com esta caixa e calcule a distância real apenas para as poucas linhas retornadas do índice. Geralmente haverá algum excedente desde que encontramos a caixa com pelo menos locais suficientes. Ao pegar os mais próximos, efetivamente arredondamos os cantos da caixa. Você pode forçar este efeito tornando a caixa um entalhe maior (multiplique radius na função por sqrt(2) para ficar completamente preciso resultados, mas eu não faria tudo, já que isso está se aproximando para começar).

Isso seria ainda mais rápido e simples com um SP GiST index, disponível na última versão do PostgreSQL. Mas ainda não sei se isso é possível. Precisaríamos de uma implementação real para o tipo de dados e não tive tempo de mergulhar nisso. Se você encontrar uma maneira, prometa que relata!

Dada esta tabela simplificada com alguns valores de exemplo (adr .. Morada):
CREATE TABLE adr(adr_id int, adr text, geocode point);
INSERT INTO adr (adr_id, adr, geocode) VALUES
    (1,  'adr1', '(48.20117,16.294)'),
    (2,  'adr2', '(48.19834,16.302)'),
    (3,  'adr3', '(48.19755,16.299)'),
    (4,  'adr4', '(48.19727,16.303)'),
    (5,  'adr5', '(48.19796,16.304)'),
    (6,  'adr6', '(48.19791,16.302)'),
    (7,  'adr7', '(48.19813,16.304)'),
    (8,  'adr8', '(48.19735,16.299)'),
    (9,  'adr9', '(48.19746,16.297)');

O índice fica assim:
CREATE INDEX adr_geocode_gist_idx ON adr USING gist (geocode);

-> SQLfiddle

Você terá que ajustar a área da casa, as etapas e o fator de escala às suas necessidades. Contanto que você procure em caixas de alguns quilômetros ao redor de um ponto, uma terra plana é uma boa aproximação.

Você precisa entender bem o plpgsql para trabalhar com isso. Sinto que já fiz o bastante aqui.
CREATE OR REPLACE FUNCTION f_find_around(_lat double precision, _lon double precision, _limit bigint = 50)
  RETURNS TABLE(adr_id int, adr text, distance int) AS
$func$
DECLARE
   _homearea   CONSTANT box := '(49.05,17.15),(46.35,9.45)'::box;      -- box around legal area
-- 100m = 0.0008892                   250m, 340m, 450m, 700m,1000m,1500m,2000m,3000m,4500m,7000m
   _steps      CONSTANT real[] := '{0.0022,0.003,0.004,0.006,0.009,0.013,0.018,0.027,0.040,0.062}';  -- find optimum _steps by experimenting
   geo2m       CONSTANT integer := 73500;                              -- ratio geocode(lon) to meter (found by trial & error with google maps)
   lat2lon     CONSTANT real := 1.53;                                  -- ratio lon/lat (lat is worth more; found by trial & error with google maps in (Vienna)
   _radius     real;                                                   -- final search radius
   _area       box;                                                    -- box to search in
   _count      bigint := 0;                                            -- count rows
   _point      point := point($1,$2);                                  -- center of search
   _scalepoint point := point($1 * lat2lon, $2);                       -- lat scaled to adjust
BEGIN

 -- Optimize _radius
IF (_point <@ _homearea) THEN
   FOREACH _radius IN ARRAY _steps LOOP
      SELECT INTO _count  count(*) FROM adr a
      WHERE  a.geocode <@ box(point($1 - _radius, $2 - _radius * lat2lon)
                            , point($1 + _radius, $2 + _radius * lat2lon));

      EXIT WHEN _count >= _limit;
   END LOOP;
END IF;

IF _count = 0 THEN                                                     -- nothing found or not in legal area
   EXIT;
ELSE
   IF _radius IS NULL THEN
      _radius := _steps[array_upper(_steps,1)];                        --  max. _radius
   END IF;
   _area := box(point($1 - _radius, $2 - _radius * lat2lon)
              , point($1 + _radius, $2 + _radius * lat2lon));
END IF;

RETURN QUERY
SELECT a.adr_id
      ,a.adr
      ,((point (a.geocode[0] * lat2lon, a.geocode[1]) <-> _scalepoint) * geo2m)::int4 AS distance
FROM   adr a
WHERE  a.geocode <@ _area
ORDER  BY distance, a.adr, a.adr_id
LIMIT  _limit;

END
$func$  LANGUAGE plpgsql;

Ligar:
SELECT * FROM f_find_around (48.2, 16.3, 20);

Retorna uma lista de $3 locais, se houver o suficiente na área de pesquisa máxima definida.
Classificado pela distância real.

Mais melhorias


Construa uma função como:
CREATE OR REPLACE FUNCTION f_geo2m(double precision, double precision)
  RETURNS point AS
$BODY$
SELECT point($1 * 111200, $2 * 111400 * cos(radians($1)));
$BODY$
  LANGUAGE sql IMMUTABLE;

COMMENT ON FUNCTION f_geo2m(double precision, double precision)
IS 'Project geocode to approximate metric coordinates.
    SELECT f_geo2m(48.20872, 16.37263)  --';

As constantes globais (literalmente) 111200 e 111400 são otimizados para minha área (Áustria) a partir do Comprimento de um grau de longitude e O comprimento de um grau de latitude , mas basicamente só funciona em todo o mundo.

Use-o para adicionar um geocódigo dimensionado à tabela base, de preferência uma coluna gerada como descrito nesta resposta:
Como você data matemática que ignora o ano?
Consulte 3. Versão de magia negra onde eu acompanho você pelo processo.
Então você pode simplificar um pouco mais a função:Escale os valores de entrada uma vez e remova cálculos redundantes.