Let's Go Further فیلتر کردن، مرتب‌سازی و صفحه‌بندی › مرتب‌سازی لیست‌ها
قبلی · فهرست مطالب · بعدی
فصل ۹.۶.

مرتب‌سازی لیست‌ها

اکنون بیایید اندپوینت GET /v1/movies خود را به‌روزرسانی کنیم تا کلاینت بتواند نحوه مرتب‌سازی فیلم‌ها در پاسخ JSON را مشخص کند.

همان‌طور که پیش‌تر به‌اختصار توضیح دادیم، می‌خواهیم به کلاینت اجازه دهیم ترتیب مرتب‌سازی را از طریق یک پارامتر query string با فرمت sort={-}{field_name} کنترل کند، که در آن پیشوند اختیاری - برای نشان دادن ترتیب مرتب‌سازی نزولی استفاده می‌شود. به عنوان مثال:

// Sort the movies on the title field in ascending alphabetical order.
/v1/movies?sort=title

// Sort the movies on the year field in descending numerical order.
/v1/movies?sort=-year

در پشت صحنه، می‌خواهیم این را به یک کلاوز ORDER BY در کوئری SQL خود تبدیل کنیم، به‌طوری که پارامتر query string مانند sort=-year منجر به کوئری SQL مانند زیر شود:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (STRPOS(LOWER(title), LOWER($1)) > 0 OR $1 = '') 
AND (genres @> $2 OR $2 = '{}')     
ORDER BY year DESC --<-- Order the result by descending year

مشکل اینجا این است که مقادیر کلاوز ORDER BY باید در زمان اجرا بر اساس مقادیر query string از کلاینت تولید شوند. در حالت ایده‌آل از پارامترهای جایگذار برای درج این مقادیر پویا در کوئری خود استفاده می‌کردیم، اما متأسفانه امکان استفاده از پارامترهای جایگذار برای نام ستون‌ها یا کلمات کلیدی SQL وجود ندارد (شامل ASC و DESC).

بنابراین به جای آن، باید این مقادیر پویا را با استفاده از fmt.Sprintf() در کوئری خود درج کنیم — و مطمئن شویم که مقادیر ابتدا در برابر یک لیست ایمن سخت‌گیرانه بررسی می‌شوند تا از حمله SQL injection جلوگیری شود.

هنگام کار با PostgreSQL، همچنین مهم است که بدانیم ترتیب ردیف‌های بازگشتی فقط توسط قوانینی که کلاوز ORDER BY شما اعمال می‌کند تضمین می‌شود. از مستندات رسمی:

اگر مرتب‌سازی انتخاب نشود، ردیف‌ها در ترتیب نامعلومی بازگردانده می‌شوند. ترتیب واقعی در آن حالت به نوع plan اسکن و join و ترتیب روی دیسک بستگی دارد، اما نباید به آن اعتماد کرد. یک ترتیب خروجی خاص فقط در صورتی تضمین می‌شود که مرحله مرتب‌سازی به‌طور صریح انتخاب شده باشد.

این به آن معناست که اگر کلاوز ORDER BY را شامل نشویم، PostgreSQL ممکن است فیلم‌ها را در هر ترتیبی بازگرداند — و ترتیب ممکن است در هر بار اجرای کوئری تغییر کند یا نکند.

به همین ترتیب، در پایگاه داده ما چندین فیلم مقدار year یکسانی خواهند داشت. اگر بر اساس ستون year مرتب کنیم، فیلم‌ها بر اساس سال مرتب شدن تضمین می‌شوند، اما فیلم‌های مربوط به یک سال خاص ممکن است در هر زمانی در هر ترتیبی ظاهر شوند.

این نکته به‌ویژه در زمینه اندپوینتی که صفحه‌بندی ارائه می‌دهد مهم است. باید مطمئن شویم که ترتیب فیلم‌ها بین درخواست‌ها کاملاً یکنواخت باشد تا از «پرش» آیتم‌ها بین صفحات جلوگیری شود.

خوشبختانه، تضمین ترتیب ساده است — فقط باید مطمئن شویم که کلاوز ORDER BY همیشه شامل یک ستون کلید اولیه (یا ستون دیگری با محدودیت یکتا روی آن) باشد. بنابراین، در مورد ما، می‌توانیم یک مرتب‌سازی ثانویه روی ستون id اعمال کنیم تا ترتیب همیشه یکنواخت تضمین شود. به این صورت:

SELECT id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (STRPOS(LOWER(title), LOWER($1)) > 0 OR $1 = '') 
AND (genres @> $2 OR $2 = '{}')     
ORDER BY year DESC, id ASC

پیاده‌سازی مرتب‌سازی

برای راه‌اندازی مرتب‌سازی پویا، بیایید با به‌روزرسانی ساختار Filters خود شروع کنیم تا شامل برخی توابع کمکی sortColumn() و sortDirection() باشد که مقدار query string (مانند -year) را به مقادیری تبدیل می‌کنند که می‌توانیم در کوئری SQL خود استفاده کنیم.

فایل: internal/data/filters.go
package data

import (
    "slices"  // New import
    "strings" // New import


    "greenlight.alexedwards.net/internal/validator"
)

type Filters struct {
    Page         int
    PageSize     int
    Sort         string
    SortSafelist []string
}

// Check that the client-provided Sort field matches one of the entries in our safelist
// and if it does, extract the column name from the Sort field by stripping the leading
// hyphen character (if one exists).
func (f Filters) sortColumn() string {
    if slices.Contains(f.SortSafelist, f.Sort) {
        return strings.TrimPrefix(f.Sort, "-")
    }

    panic("unsafe sort parameter: " + f.Sort)
}

// Return the sort direction ("ASC" or "DESC") depending on the prefix character of the
// Sort field.
func (f Filters) sortDirection() string {
    if strings.HasPrefix(f.Sort, "-") {
        return "DESC"
    }

    return "ASC"
}

...

توجه کنید که تابع sortColumn() به‌گونه‌ای ساخته شده که اگر مقدار Sort ارائه‌شده توسط کلاینت با یکی از مقادیر لیست ایمن ما مطابقت نداشته باشد، panic می‌دهد. در تئوری این نباید اتفاق بیفتد — مقدار Sort باید قبلاً با فراخوانی تابع ValidateFilters() بررسی شده باشد — اما این یک احتیاط منطقی برای جلوگیری از وقوع حمله SQL injection است.

اکنون بیایید فایل internal/data/movies.go خود را به‌روزرسانی کنیم تا این متدها را فراخوانی کند و مقادیر بازگشتی را در کلاوز ORDER BY کوئری SQL خود درج کند. به این صورت:

فایل: internal/data/movies.go
package data

import (
    "context"
    "database/sql"
    "errors"
    "fmt" // New import
    "time"

    "greenlight.alexedwards.net/internal/validator"

    "github.com/lib/pq"
)

...

func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, error) {
    // Add an ORDER BY clause and interpolate the sort column and direction. Importantly
    // notice that we also include a secondary sort on the movie ID to ensure a
    // consistent ordering.
    query := fmt.Sprintf(`
        SELECT id, created_at, title, year, runtime, genres, version
        FROM movies
        WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '') 
        AND (genres @> $2 OR $2 = '{}')     
        ORDER BY %s %s, id ASC`, filters.sortColumn(), filters.sortDirection())

    // Nothing else below needs to change.
    ...
}

و پس از اتمام این کار، باید آماده باشیم تا آن را امتحان کنیم.

برنامه را مجدداً راه‌اندازی کنید و سپس به عنوان مثال، سعی کنید درخواستی برای فیلم‌ها مرتب‌شده بر اساس title نزولی ارسال کنید. باید پاسخی شبیه به این دریافت کنید:

$ curl "localhost:4000/v1/movies?sort=-title"
{
    "movies": [
        {
            "id": 4,
            "title": "The Breakfast Club",
            "year": 1985,
            "runtime": "97 mins",
            "genres": [
                "comedy"
            ],
            "version": 5
        },
        {
            "id": 1,
            "title": "Moana",
            "year": 2016,
            "runtime": "107 mins",
            "genres": [
                "animation",
                "adventure"
            ],
            "version": 1
        },
        {
            "id": 2,
            "title": "Black Panther",
            "year": 2018,
            "runtime": "134 mins",
            "genres": [
                "sci-fi",
                "action",
                "adventure"
            ],
            "version": 2
        }
    ]
}

در مقابل، استفاده از پارامتر مرتب‌سازی runtime نزولی باید پاسخی کاملاً متفاوت ترتیب ارائه دهد. چیزی شبیه به این:

$ curl "localhost:4000/v1/movies?sort=-runtime"
{
    "movies": [
        {
            "id": 2,
            "title": "Black Panther",
            "year": 2018,
            "runtime": "134 mins",
            "genres": [
                "sci-fi",
                "action",
                "adventure"
            ],
            "version": 2
        },
        {
            "id": 1,
            "title": "Moana",
            "year": 2016,
            "runtime": "107 mins",
            "genres": [
                "animation",
                "adventure"
            ],
            "version": 1
        },
        {
            "id": 4,
            "title": "The Breakfast Club",
            "year": 1985,
            "runtime": "97 mins",
            "genres": [
                "comedy"
            ],
            "version": 5
        }
    ]
}