Let's Go پاسخ‌های مبتنی بر پایگاه داده (Database-Driven Responses) › پرس و جوهای SQL تک رکوردی (Single Record SQL Queries)
قبلی · فهرست · بعدی
فصل 4.7.

پرس و جوهای SQL تک رکوردی (Single Record SQL Queries)

در این بخش، ما متد SnippetModel.Get() را پیاده‌سازی خواهیم کرد تا یک رکورد واحد (Single Record) را بر اساس کلید اصلی (Primary Key) آن بازیابی کند.

برای این کار، از متد QueryRow() استفاده می‌کنیم که برای اجرای پرس و جوهای تک رکوردی (Single Record Queries) طراحی شده است.

برای انجام این کار، باید پرس‌وجوی SQL (SQL Query) زیر را در پایگاه داده اجرا کنیم:

SELECT id, title, content, created, expires FROM snippets
WHERE expires > UTC_TIMESTAMP() AND id = ?

از آنجا که جدول snippets ما از ستون id به عنوان کلید اصلی (Primary Key) خود استفاده می‌کند، این پرس‌وجو همیشه دقیقاً یک سطر پایگاه داده (یا هیچ) را برمی‌گرداند. پرس‌وجو همچنین شامل یک بررسی بر روی زمان انقضا (Expiry Time) است تا هیچ قطعه‌ای که منقضی شده است را برنگرداند.

همچنین توجه کنید که آیا دوباره از یک پارامتر جایگزین (Placeholder Parameter) برای مقدار id استفاده می‌کنیم؟

فایل internal/models/snippets.go را باز کنید و کد زیر را اضافه کنید:

File: internal/models/snippets.go
package models

import (
    "database/sql"
    "errors" // New import
    "time" 
)

...

func (m *SnippetModel) Get(id int) (Snippet, error) {
    // Write the SQL statement we want to execute. Again, I've split it over two
    // lines for readability.
    stmt := `SELECT id, title, content, created, expires FROM snippets
    WHERE expires > UTC_TIMESTAMP() AND id = ?`

    // Use the QueryRow() method on the connection pool to execute our
    // SQL statement, passing in the untrusted id variable as the value for the
    // placeholder parameter. This returns a pointer to a sql.Row object which
    // holds the result from the database.
    row := m.DB.QueryRow(stmt, id)

    // Initialize a new zeroed Snippet struct.
    var s Snippet

    // Use row.Scan() to copy the values from each field in sql.Row to the
    // corresponding field in the Snippet struct. Notice that the arguments
    // to row.Scan are *pointers* to the place you want to copy the data into,
    // and the number of arguments must be exactly the same as the number of
    // columns returned by your statement.
    err := row.Scan(&s.ID, &s.Title, &s.Content, &s.Created, &s.Expires)
    if err != nil {
        // If the query returns no rows, then row.Scan() will return a
        // sql.ErrNoRows error. We use the errors.Is() function check for that
        // error specifically, and return our own ErrNoRecord error
        if errors.Is(err, sql.ErrNoRows) {
            return Snippet{}, ErrNoRecord
        } else {
            return Snippet{}, err
        }
    }

    // If everything went OK, then return the filled Snippet struct.
    return s, nil
}

...

در پشت صحنه rows.Scan() درایور شما به‌طور خودکار خروجی خام از پایگاه داده SQL را به انواع بومی Go مورد نیاز تبدیل می‌کند. تا زمانی که با انواعی که بین SQL و Go نگاشت می‌کنید معقول باشید، این تبدیل‌ها باید به‌طور کلی به‌خوبی کار کنند. معمولاً:

اگر در این نقطه سعی کنید برنامه را اجرا کنید، باید یک خطای زمان کامپایل دریافت کنید که می‌گوید مقدار ErrNoRecord تعریف نشده است:

$ go run ./cmd/web/
# snippetbox.letsgofa.net/internal/models
internal/models/snippets.go:82:25: undefined: ErrNoRecord

بیایید اکنون آن را در یک فایل جدید internal/models/errors.go ایجاد کنیم. به این صورت:

$ touch internal/models/errors.go
File: internal/models/errors.go
package models

import (
    "errors"
)

var ErrNoRecord = errors.New("models: no matching record found")

به عنوان یک نکته جانبی، ممکن است بپرسید چرا ما خطای ErrNoRecord را از متد SnippetModel.Get() خود برمی‌گردانیم، به جای sql.ErrNoRows به‌طور مستقیم. دلیل این کار کمک به کپسوله‌سازی کامل مدل است، به‌طوری که هندلرهای ما نگران پایگاه داده زیرین نباشند یا به خطاهای خاص پایگاه داده (مانند sql.ErrNoRows) برای رفتار خود وابسته نباشند.

استفاده از مدل در هندلرهای ما (Using the Model in our Handlers)

بسیار خوب، بیایید متد SnippetModel.Get() را به کار بگیریم.

فایل cmd/web/handlers.go خود را باز کنید و هندلر snippetView را به‌روزرسانی کنید تا داده‌های یک رکورد خاص را به عنوان یک پاسخ HTTP برگرداند:

File: cmd/web/handlers.go
package main

import (
    "errors" // New import
    "fmt"
    "html/template"
    "net/http"
    "strconv"

    "snippetbox.letsgofa.net/internal/models" // New import
)

...

func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil || id < 1 {
        http.NotFound(w, r)
        return
    }

    // Use the SnippetModel's Get() method to retrieve the data for a
    // specific record based on its ID. If no matching record is found,
    // return a 404 Not Found response.
    snippet, err := app.snippets.Get(id)
    if err != nil {
        if errors.Is(err, models.ErrNoRecord) {
            http.NotFound(w, r)
        } else {
            app.serverError(w, r, err)
        }
        return
    }

    // Write the snippet data as a plain-text HTTP response body.
    fmt.Fprintf(w, "%+v", snippet)
}

...

بیایید این را امتحان کنیم. برنامه را مجدداً راه‌اندازی کنید، سپس مرورگر وب خود را باز کنید و به http://localhost:4000/snippet/view/1 بروید. باید یک پاسخ HTTP ببینید که شبیه به این است:

04.07-01.png

همچنین ممکن است بخواهید برخی درخواست‌ها برای قطعات دیگر که منقضی شده‌اند یا هنوز وجود ندارند (مانند یک مقدار id برابر با 99) را امتحان کنید تا تأیید کنید که آنها یک پاسخ 404 page not found برمی‌گردانند:

04.07-02.png

اطلاعات تکمیلی (Additional Information)

بررسی خطاهای خاص (Checking for Specific Errors)

چندین بار در این فصل از تابع errors.Is() برای بررسی اینکه آیا یک خطا با یک مقدار خاص مطابقت دارد استفاده کرده‌ایم. مانند این:

if errors.Is(err, models.ErrNoRecord) {
    http.NotFound(w, r)
} else {
    app.serverError(w, r, err)
}

در نسخه‌های بسیار قدیمی Go (قبل از 1.13)، روش ایدئال برای مقایسه خطاها استفاده از عملگر برابری == بود، به این صورت:

if err == models.ErrNoRecord {
    http.NotFound(w, r)
} else {
    app.serverError(w, r, err)
}

اما، در حالی که این کد هنوز کامپایل می‌شود، استفاده از تابع errors.Is() ایمن‌تر و بهترین روش است.

این به این دلیل است که Go 1.13 قابلیت افزودن اطلاعات اضافی به خطاها را با بسته‌بندی آنها معرفی کرد. اگر یک خطا به‌طور اتفاقی بسته‌بندی شود، یک مقدار خطای کاملاً جدید ایجاد می‌شود — که به نوبه خود به این معنی است که بررسی مقدار خطای اصلی زیرین با استفاده از عملگر برابری معمولی == ممکن نیست.

تابع errors.Is() با باز کردن خطاها در صورت لزوم قبل از بررسی برای مطابقت کار می‌کند.

همچنین یک تابع دیگر به نام errors.As() وجود دارد که می‌توانید از آن برای بررسی اینکه آیا یک خطا (احتمالاً بسته‌بندی شده) دارای یک نوع خاص است استفاده کنید. ما بعداً در این کتاب از این استفاده خواهیم کرد.

پرس و جوهای تک رکوردی کوتاه (Shorthand Single Record Queries)

من عمداً کد در SnippetModel.Get() را کمی طولانی کرده‌ام تا به وضوح و تأکید بر آنچه در پشت صحنه کد اتفاق می‌افتد کمک کنم.

در عمل، می‌توانید کد را کمی کوتاه کنید با استفاده از این واقعیت که خطاها از DB.QueryRow() تا زمانی که Scan() فراخوانی شود به تعویق می‌افتند. این هیچ تفاوت عملکردی ندارد، اما اگر بخواهید می‌توانید کد را به این صورت بازنویسی کنید:

func (m *SnippetModel) Get(id int) (Snippet, error) {
    var s Snippet
    
    err := m.DB.QueryRow("SELECT ...", id).Scan(&s.ID, &s.Title, &s.Content, &s.Created, &s.Expires)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return Snippet{}, ErrNoRecord
        } else {
             return Snippet{}, err
        }
    }

    return s, nil
}

واژه‌نامه اصطلاحات فنی

اصطلاح فارسی معادل انگلیسی توضیح
رکورد واحد Single Record یک ردیف منفرد از داده‌ها در پایگاه داده
کلید اصلی Primary Key شناسه منحصر به فرد برای هر رکورد در جدول پایگاه داده
پرس و جوهای تک رکوردی Single Record Queries کوئری‌های SQL که یک رکورد واحد را برمی‌گردانند
زمان انقضا Expiry Time زمانی که یک رکورد منقضی می‌شود و دیگر معتبر نیست
پارامتر جایگزین Placeholder Parameter نشانگری در کوئری SQL که با مقادیر واقعی جایگزین می‌شود
نگاشت نوع داده Data Type Mapping تبدیل خودکار انواع داده بین SQL و زبان برنامه‌نویسی
خطای رکورد یافت نشد No Record Error خطایی که زمانی رخ می‌دهد که رکورد مورد نظر در پایگاه داده پیدا نشود
بسته‌بندی خطا Error Wrapping فرآیند اضافه کردن اطلاعات اضافی به یک خطا