Database
 sql >> Database >  >> RDS >> Database

Ordine condizionale di

Uno scenario comune in molte applicazioni client-server consiste nel consentire all'utente finale di dettare l'ordinamento dei risultati. Alcune persone vogliono vedere prima gli articoli con il prezzo più basso, altri vogliono vedere prima gli articoli più recenti e alcuni vogliono vederli in ordine alfabetico. Questa è una cosa complessa da ottenere in Transact-SQL perché non puoi semplicemente dire:

CREATE PROCEDURE dbo.SortOnSomeTable
  @SortColumn    NVARCHAR(128) = N'key_col',
  @SortDirection VARCHAR(4)    = 'ASC'
AS
BEGIN
  ... ORDER BY @SortColumn;
 
  -- or
 
  ... ORDER BY @SortColumn @SortDirection;
END
GO

Questo perché T-SQL non consente variabili in queste posizioni. Se usi solo @SortColumn, ricevi:

Msg 1008, livello 16, stato 1, riga x
L'elemento SELECT identificato dal numero ORDER BY 1 contiene una variabile come parte dell'espressione che identifica una posizione di colonna. Le variabili sono consentite solo quando si ordina in base a un'espressione che fa riferimento al nome di una colonna.

(E quando il messaggio di errore dice "un'espressione che fa riferimento al nome di una colonna", potresti trovarlo ambiguo e sono d'accordo. Ma posso assicurarti che questo non significa che una variabile sia un'espressione adatta.)

Se provi ad aggiungere @SortDirection, il messaggio di errore è un po' più opaco:

Msg 102, livello 15, stato 1, riga x
Sintassi errata vicino a '@SortDirection'.

Ci sono alcuni modi per aggirare questo problema e il tuo primo istinto potrebbe essere quello di utilizzare SQL dinamico o di introdurre l'espressione CASE. Ma come con la maggior parte delle cose, ci sono complicazioni che possono costringerti a percorrere una strada o l'altra. Quindi quale dovresti usare? Esaminiamo come potrebbero funzionare queste soluzioni e confrontiamo l'impatto sulle prestazioni di alcuni approcci diversi.

Dati di esempio

Usando una vista catalogo che probabilmente tutti comprendiamo abbastanza bene, sys.all_objects, ho creato la seguente tabella basata su un cross join, limitando la tabella a 100.000 righe (volevo dati che riempissero molte pagine ma non ci voleva molto tempo per interrogare e prova):

CREATE DATABASE OrderBy;
GO
USE OrderBy;
GO
 
SELECT TOP (100000) 
  key_col = ROW_NUMBER() OVER (ORDER BY s1.[object_id]), -- a BIGINT with clustered index
  s1.[object_id],             -- an INT without an index
  name = s1.name              -- an NVARCHAR with a supporting index
              COLLATE SQL_Latin1_General_CP1_CI_AS,
  type_desc = s1.type_desc    -- an NVARCHAR(60) without an index
              COLLATE SQL_Latin1_General_CP1_CI_AS,
  s1.modify_date              -- a datetime without an index
INTO       dbo.sys_objects 
FROM       sys.all_objects AS s1 
CROSS JOIN sys.all_objects AS s2
ORDER BY   s1.[object_id];

(Il trucco COLLATE è dovuto al fatto che molte viste del catalogo hanno colonne diverse con regole di confronto diverse e ciò garantisce che le due colonne corrispondano ai fini di questa demo.)

Quindi ho creato una tipica coppia di indici cluster / non cluster che potrebbe esistere su una tabella del genere, prima dell'ottimizzazione (non posso usare object_id per la chiave, perché il cross join crea duplicati):

CREATE UNIQUE CLUSTERED INDEX key_col ON dbo.sys_objects(key_col);
 
CREATE INDEX name ON dbo.sys_objects(name);

Casi d'uso

Come accennato in precedenza, gli utenti potrebbero voler vedere questi dati ordinati in vari modi, quindi definiamo alcuni casi d'uso tipici che vogliamo supportare (e per supporto intendo dimostrare):

  • Ordinato per key_col crescente ** predefinito se all'utente non interessa
  • Ordinato per object_id (crescente/decrescente)
  • Ordinato per nome (crescente/decrescente)
  • Ordinato per type_desc (crescente/decrescente)
  • Ordinato per modify_date (crescente/decrescente)

Lasceremo l'ordine key_col come predefinito perché dovrebbe essere il più efficiente se l'utente non ha una preferenza; poiché key_col è un surrogato arbitrario che non dovrebbe significare nulla per l'utente (e potrebbe non essere nemmeno esposto ad esso), non c'è motivo per consentire l'ordinamento inverso su quella colonna.

Approcci che non funzionano

L'approccio più comune che vedo quando qualcuno inizia ad affrontare questo problema è l'introduzione della logica di controllo del flusso nella query. Si aspettano di poterlo fare:

SELECT key_col, [object_id], name, type_desc, modify_date
FROM dbo.sys_objects
ORDER BY 
IF @SortColumn = 'key_col'
    key_col
IF @SortColumn = 'object_id'
    [object_id]
IF @SortColumn = 'name'
    name
...
IF @SortDirection = 'ASC'
    ASC
ELSE
    DESC;

Questo ovviamente non funziona. Quindi vedo che CASE viene introdotto in modo errato, usando una sintassi simile:

SELECT key_col, [object_id], name, type_desc, modify_date
FROM dbo.sys_objects
ORDER BY CASE @SortColumn 
    WHEN 'key_col'   THEN key_col
    WHEN 'object_id' THEN [object_id]
    WHEN 'name'      THEN name
    ... 
    END CASE @SortDirection WHEN 'ASC' THEN ASC ELSE DESC END;

Questo è più vicino, ma fallisce per due motivi. Uno è che CASE è un'espressione che restituisce esattamente un valore di un tipo di dati specifico; questo unisce i tipi di dati che sono incompatibili e quindi interromperà l'espressione CASE. L'altro è che non c'è modo di applicare condizionalmente la direzione di ordinamento in questo modo senza utilizzare SQL dinamico.

Approcci che funzionano

I tre approcci principali che ho visto sono i seguenti:

Raggruppa i tipi e le indicazioni compatibili

Per utilizzare CASE con ORDER BY, deve esserci un'espressione distinta per ogni combinazione di tipi e direzioni compatibili. In questo caso dovremmo usare qualcosa del genere:

CREATE PROCEDURE dbo.Sort_CaseExpanded
  @SortColumn    NVARCHAR(128) = N'key_col',
  @SortDirection VARCHAR(4)    = 'ASC'
AS
BEGIN
  SET NOCOUNT ON;
 
  SELECT key_col, [object_id], name, type_desc, modify_date
  FROM dbo.sys_objects
  ORDER BY 
    CASE WHEN @SortDirection = 'ASC' THEN
      CASE @SortColumn 
        WHEN 'key_col'   THEN key_col
        WHEN 'object_id' THEN [object_id] 
      END
    END,
    CASE WHEN @SortDirection = 'DESC' THEN
      CASE @SortColumn 
        WHEN 'key_col'   THEN key_col
        WHEN 'object_id' THEN [object_id]
      END
    END DESC,
    CASE WHEN @SortDirection = 'ASC' THEN
      CASE @SortColumn 
        WHEN 'name'      THEN name
        WHEN 'type_desc' THEN type_desc 
      END
    END,
    CASE WHEN @SortDirection = 'DESC' THEN
      CASE @SortColumn 
        WHEN 'name'      THEN name
        WHEN 'type_desc' THEN type_desc 
      END
    END DESC,
    CASE WHEN @SortColumn = 'modify_date' 
      AND @SortDirection = 'ASC' THEN modify_date 
    END,
    CASE WHEN @SortColumn = 'modify_date' 
      AND @SortDirection = 'DESC' THEN modify_date 
    END DESC;
END

Potresti dire, wow, è un brutto pezzo di codice e sarei d'accordo con te. Penso che questo sia il motivo per cui molte persone memorizzano nella cache i propri dati sul front-end e lasciano che il livello di presentazione si occupi di destreggiarsi in ordini diversi. :-)

Puoi ridurre ulteriormente questa logica convertendo tutti i tipi non stringa in stringhe che verranno ordinate correttamente, ad es.

CREATE PROCEDURE dbo.Sort_CaseCollapsed
  @SortColumn    NVARCHAR(128) = N'key_col',
  @SortDirection VARCHAR(4)    = 'ASC'
AS
BEGIN
  SET NOCOUNT ON;
 
  SELECT key_col, [object_id], name, type_desc, modify_date
  FROM dbo.sys_objects
  ORDER BY 
    CASE WHEN @SortDirection = 'ASC' THEN
      CASE @SortColumn 
        WHEN 'key_col'     THEN RIGHT('000000000000' + RTRIM(key_col), 12)
        WHEN 'object_id'   THEN 
	  RIGHT(COALESCE(NULLIF(LEFT(RTRIM([object_id]),1),'-'),'0') 
	   + REPLICATE('0', 23) + RTRIM([object_id]), 24)
        WHEN 'name'        THEN name
        WHEN 'type_desc'   THEN type_desc 
	WHEN 'modify_date' THEN CONVERT(CHAR(19), modify_date, 120)
      END
    END,
    CASE WHEN @SortDirection = 'DESC' THEN
      CASE @SortColumn 
        WHEN 'key_col'     THEN RIGHT('000000000000' + RTRIM(key_col), 12)
        WHEN 'object_id'   THEN 
	  RIGHT(COALESCE(NULLIF(LEFT(RTRIM([object_id]),1),'-'),'0') 
	   + REPLICATE('0', 23) + RTRIM([object_id]), 24)
        WHEN 'name'      THEN name
        WHEN 'type_desc' THEN type_desc 
	WHEN 'modify_date' THEN CONVERT(CHAR(19), modify_date, 120)
    END
  END DESC;
END

Tuttavia, è un pasticcio piuttosto brutto e devi ripetere le espressioni due volte per affrontare le diverse direzioni di ordinamento. Sospetterei anche che l'utilizzo di OPTION RECOMPILE su quella query impedirebbe di essere punto dallo sniffing dei parametri. Tranne nel caso predefinito, non è che la maggior parte del lavoro svolto qui sarà una compilazione.

Applica un grado usando le funzioni della finestra

Ho scoperto questo trucco accurato da AndriyM, anche se è molto utile nei casi in cui tutte le potenziali colonne di ordinamento sono di tipi compatibili, altrimenti l'espressione usata per ROW_NUMBER() è ugualmente complessa. La parte più intelligente è che per passare dall'ordine crescente a quello decrescente, moltiplichiamo semplicemente ROW_NUMBER() per 1 o -1. Possiamo applicarlo in questa situazione come segue:

CREATE PROCEDURE dbo.Sort_RowNumber
  @SortColumn    NVARCHAR(128) = N'key_col',
  @SortDirection VARCHAR(4)    = 'ASC'
AS
BEGIN
  SET NOCOUNT ON;
 
  ;WITH x AS
  (
    SELECT key_col, [object_id], name, type_desc, modify_date,
      rn = ROW_NUMBER() OVER (
        ORDER BY CASE @SortColumn 
          WHEN 'key_col'     THEN RIGHT('000000000000' + RTRIM(key_col), 12)
          WHEN 'object_id'   THEN 
	    RIGHT(COALESCE(NULLIF(LEFT(RTRIM([object_id]),1),'-'),'0') 
             + REPLICATE('0', 23) + RTRIM([object_id]), 24)
          WHEN 'name'        THEN name
          WHEN 'type_desc'   THEN type_desc 
          WHEN 'modify_date' THEN CONVERT(CHAR(19), modify_date, 120)
      END
      ) * CASE @SortDirection WHEN 'ASC' THEN 1 ELSE -1 END
    FROM dbo.sys_objects
  )
  SELECT key_col, [object_id], name, type_desc, modify_date
  FROM x
  ORDER BY rn;
END
GO

Anche in questo caso, OPTION RECOMPILE può aiutare qui. Inoltre, potresti notare in alcuni di questi casi che i legami sono gestiti in modo diverso dai vari piani:quando ordini per nome, ad esempio, di solito vedrai key_col passare in ordine crescente all'interno di ogni serie di nomi duplicati, ma potresti anche vedere i valori si sono confusi. Per fornire un comportamento più prevedibile in caso di parità, puoi sempre aggiungere una clausola ORDER BY aggiuntiva. Nota che se dovessi aggiungere key_col al primo esempio, dovrai renderlo un'espressione in modo che key_col non sia elencato due volte in ORDER BY (puoi farlo usando key_col + 0, per esempio).

SQL dinamico

Molte persone hanno delle riserve sull'SQL dinamico:è impossibile da leggere, è un terreno fertile per l'iniezione di SQL, porta a pianificare il rigonfiamento della cache, vanifica lo scopo dell'utilizzo delle procedure archiviate... Alcuni di questi sono semplicemente falsi e alcuni di essi sono facili da mitigare. Ho aggiunto qui alcune convalide che potrebbero essere facilmente aggiunte a qualsiasi delle procedure precedenti:

CREATE PROCEDURE dbo.Sort_DynamicSQL
  @SortColumn    NVARCHAR(128) = N'key_col',
  @SortDirection VARCHAR(4)    = 'ASC'
AS
BEGIN
  SET NOCOUNT ON;
 
  -- reject any invalid sort directions:
  IF UPPER(@SortDirection) NOT IN ('ASC','DESC')
  BEGIN
    RAISERROR('Invalid parameter for @SortDirection: %s', 11, 1, @SortDirection);
    RETURN -1;
  END 
 
  -- reject any unexpected column names:
  IF LOWER(@SortColumn) NOT IN (N'key_col', N'object_id', N'name', N'type_desc', N'modify_date')
  BEGIN
    RAISERROR('Invalid parameter for @SortColumn: %s', 11, 1, @SortColumn);
    RETURN -1;
  END 
 
  SET @SortColumn = QUOTENAME(@SortColumn);
 
  DECLARE @sql NVARCHAR(MAX);
 
  SET @sql = N'SELECT key_col, [object_id], name, type_desc, modify_date
               FROM dbo.sys_objects
               ORDER BY ' + @SortColumn + ' ' + @SortDirection + ';';
 
  EXEC sp_executesql @sql;
END

Confronti delle prestazioni

Ho creato una stored procedure wrapper per ciascuna procedura precedente, in modo da poter testare facilmente tutti gli scenari. Le quattro procedure wrapper si presentano così, con il nome della procedura che varia ovviamente:

CREATE PROCEDURE dbo.Test_Sort_CaseExpanded
AS
BEGIN
	SET NOCOUNT ON;
 
	EXEC dbo.Sort_CaseExpanded; -- default
	EXEC dbo.Sort_CaseExpanded N'name',        'ASC';
	EXEC dbo.Sort_CaseExpanded N'name',        'DESC';
	EXEC dbo.Sort_CaseExpanded N'object_id',   'ASC';
	EXEC dbo.Sort_CaseExpanded N'object_id',   'DESC';
	EXEC dbo.Sort_CaseExpanded N'type_desc',   'ASC';
	EXEC dbo.Sort_CaseExpanded N'type_desc',   'DESC';
	EXEC dbo.Sort_CaseExpanded N'modify_date', 'ASC';
	EXEC dbo.Sort_CaseExpanded N'modify_date', 'DESC';
END

E quindi, utilizzando SQL Sentry Plan Explorer, ho generato piani di esecuzione effettivi (e le metriche a corredo) con le seguenti query e ripetuto il processo 10 volte per sommare la durata totale:

DBCC DROPCLEANBUFFERS;
DBCC FREEPROCCACHE;
EXEC dbo.Test_Sort_CaseExpanded;
--EXEC dbo.Test_Sort_CaseCollapsed;
--EXEC dbo.Test_Sort_RowNumber;
--EXEC dbo.Test_Sort_DynamicSQL;
GO 10

Ho anche testato i primi tre casi con OPTION RECOMPILE (non ha molto senso per il caso SQL dinamico, poiché sappiamo che sarà ogni volta un nuovo piano) e tutti e quattro i casi con MAXDOP 1 per eliminare l'interferenza del parallelismo. Ecco i risultati:

Conclusione

Per prestazioni definitive, SQL dinamico vince ogni volta (sebbene solo con un piccolo margine su questo set di dati). L'approccio ROW_NUMBER(), sebbene intelligente, è stato il perdente in ogni test (scusa AndriyM).

Diventa ancora più divertente quando vuoi introdurre una clausola WHERE, non importa il paging. Questi tre sono come la tempesta perfetta per introdurre complessità in ciò che inizia come una semplice query di ricerca. Più permutazioni ha la tua query, più è probabile che tu voglia eliminare la leggibilità dalla finestra e utilizzare SQL dinamico in combinazione con l'impostazione "ottimizza per carichi di lavoro ad hoc" per ridurre al minimo l'impatto dei piani monouso nella cache dei piani.