Let's Go Further عملیات CRUD › ایجاد یک فیلم جدید
قبلی · فهرست مطالب · بعدی
فصل 7.2.

ایجاد یک فیلم جدید

بیایید با متد Insert() مدل دیتابیس شروع کنیم و آن را برای ایجاد یک رکورد جدید در جدول movies به‌روزرسانی کنیم. به‌طور خاص، می‌خواهیم این کوئری SQL را اجرا کند:

INSERT INTO movies (title, year, runtime, genres) 
VALUES ($1, $2, $3, $4)
RETURNING id, created_at, version

چند نکته درباره این کوئری وجود دارد که توضیح آن‌ها ضروری است.

اجرای کوئری SQL

در طول این پروژه از پکیج database/sql Go برای اجرای کوئری‌های دیتابیس استفاده می‌کنیم، نه از یک ORM یا ابزار دیگر شخص ثالث. ما درباره نحوه استفاده از database/sql و ویژگی‌ها، رفتارها و نکات آن در Let's Go صحبت کردیم، بنابراین امیدواریم برایتان آشنا باشد. آن را به‌عنوان یک مرور کوتاه در نظر بگیرید!

معمولاً از متد Exec() Go برای اجرای یک دستور INSERT روی جدول دیتابیس استفاده می‌کنید. اما از آنجایی که کوئری SQL ما یک ردیف داده برمی‌گرداند (به لطف عبارت RETURNING)، باید در اینجا به‌جای آن از متد QueryRow() استفاده کنیم.

به فایل internal/data/movies.go برگردید و آن را به این صورت به‌روزرسانی کنید:

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

import (
    "database/sql"
    "time"

    "greenlight.alexedwards.net/internal/validator"

    "github.com/lib/pq" // New import
)

...

// The Insert() method accepts a pointer to a movie struct, which should contain the 
// data for the new record.
func (m MovieModel) Insert(movie *Movie) error {
    // Define a SQL query which inserts a new record in the movies table, and returns
    // the system-generated data.
    query := `
        INSERT INTO movies (title, year, runtime, genres) 
        VALUES ($1, $2, $3, $4)
        RETURNING id, created_at, version`

    // Create an args slice containing the values for the placeholder parameters.
    // Declaring this slice immediately next to our SQL query helps to make it nice
    // and clear *what values are being used where* in the query.
    args := []any{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)}

    // Use the QueryRow() method to execute the SQL query on our connection pool,
    // passing in the elements of the args slice as variadic arguments and scanning 
    // the system-generated id, created_at and version values into the movie struct.
    return m.DB.QueryRow(query, args...).Scan(&movie.ID, &movie.CreatedAt, &movie.Version)
}

...

این کد خلاصه و مختصر است، اما چند نکته مهم وجود دارد که باید ذکر شود.

از آنجایی که امضای متد Insert() یک اشاره‌گر *Movie را به‌عنوان پارامتر می‌گیرد، وقتی Scan() را برای خواندن داده‌های تولیدشده توسط سیستم فراخوانی می‌کنیم، مقادیر را در مکانی که پارامتر به آن اشاره می‌کند به‌روزرسانی می‌کنیم. در اصل، متد Insert() ما استراکت Movie را که به آن پاس می‌دهیم تغییر می‌دهد و مقادیر تولیدشده توسط سیستم را به آن اضافه می‌کند.

نکته بعدی که باید درباره آن صحبت کنیم، ورودی‌های پارامتر جایگزین است که در یک اسلایس args به این صورت تعریف می‌کنیم:

args := []any{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)}

ذخیره ورودی‌ها در یک اسلایس دقیقاً ضروری نیست، اما همان‌طور که در نظرات کد بالا ذکر شد، این یک الگوی خوب است که می‌تواند به شفافیت کد شما کمک کند. شخصاً معمولاً این کار را برای کوئری‌های SQL با بیش از سه پارامتر جایگزین انجام می‌دهم.

همچنین، مقدار نهایی در اسلایس را توجه کنید؟ برای ذخیره مقدار movie.Genres (که یک اسلایس []string است) در دیتابیس، باید آن را قبل از اجرای کوئری SQL از تابع آداپتوری pq.Array() عبور دهیم.

در پشت صحنه، آداپتور pq.Array() اسلایس []string ما را می‌گیرد و آن را به نوع pq.StringArray تبدیل می‌کند. به نوبه خود، نوع pq.StringArray اینترفیس‌های driver.Valuer و sql.Scanner را پیاده‌سازی می‌کند که برای ترجمه اسلایس بومی []string ما به مقادیری که دیتابیس PostgreSQL می‌تواند درک کند و در یک ستون آرایه text[] ذخیره کند، ضروری هستند.

اتصال آن به هندلر API ما

حالا برای بخش هیجان‌انگیز. بیایید متد Insert() را به createMovieHandler متصل کنیم تا اندپوینت POST /v1/movies ما به‌طور کامل کار کند. به این صورت:

فایل: cmd/api/movies.go
package main

...

func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title   string       `json:"title"`
        Year    int32        `json:"year"`
        Runtime data.Runtime `json:"runtime"`
        Genres  []string     `json:"genres"`
    }

    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    // Note that the movie variable contains a *pointer* to a Movie struct.
    movie := &data.Movie{
        Title:   input.Title,
        Year:    input.Year,
        Runtime: input.Runtime,
        Genres:  input.Genres,
    }

    v := validator.New()

    if data.ValidateMovie(v, movie); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    // Call the Insert() method on our movies model, passing in a pointer to the 
    // validated movie struct. This will create a record in the database and update the 
    // movie struct with the system-generated information.
    err = app.models.Movies.Insert(movie)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // When sending an HTTP response, we want to include a Location header to let the 
    // client know which URL they can find the newly-created resource at. We make an  
    // empty http.Header map and then use the Set() method to add a new Location header,
    // interpolating the system-generated ID for our new movie in the URL.
    headers := make(http.Header)
    headers.Set("Location", fmt.Sprintf("/v1/movies/%d", movie.ID))

    // Write a JSON response with a 201 Created status code, the movie data in the 
    // response body, and the Location header.
    err = app.writeJSON(w, http.StatusCreated, envelope{"movie": movie}, headers)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

...

خب، بیایید این را امتحان کنیم!

API را ریستارت کنید، سپس یک پنجره ترمینال دوم باز کنید و درخواست زیر را به اندپوینت POST /v1/movies ارسال کنید:

$ BODY='{"title":"Moana","year":2016,"runtime":"107 mins", "genres":["animation","adventure"]}' 
$ curl -i -d "$BODY" localhost:4000/v1/movies
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/movies/1
Date: Wed, 07 Apr 2021 19:21:41 GMT
Content-Length: 156

{
    "movie": {
        "id": 1,
        "title": "Moana",
        "year": 2016,
        "runtime": "107 mins",
        "genres": [
            "animation",
            "adventure"
        ],
        "version": 1
    }
}

این عالی به نظر می‌رسد. می‌توانیم ببینیم که پاسخ JSON شامل تمام اطلاعات فیلم جدید است، از جمله شناسه و نسخه‌های تولیدشده توسط سیستم. و پاسخ همچنین شامل هدر Location: /v1/movies/1 است که به URL اشاره می‌کند که بعداً فیلم را در سیستم ما نمایش می‌دهد.

ایجاد رکوردهای اضافی

در همین حین، بیایید چند رکورد دیگر در سیستم ایجاد کنیم تا به ما در نمایش عملکردهای مختلف در طول پیشرفت پروژه کمک کند.

اگر همراهی کدنویسی می‌کنید، لطفاً دستورات زیر را برای ایجاد سه رکورد فیلم دیگر در دیتابیس اجرا کنید:

$ BODY='{"title":"Black Panther","year":2018,"runtime":"134 mins","genres":["action","adventure"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{
    "movie": {
        "id": 2,
        "title": "Black Panther",
        "year": 2018,
        "runtime": "134 mins",
        "genres": [
            "action",
            "adventure"
        ],
        "version": 1
    }
}

$ BODY='{"title":"Deadpool","year":2016, "runtime":"108 mins","genres":["action","comedy"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{
    "movie": {
        "id": 3,
        "title": "Deadpool",
        "year": 2016,
        "runtime": "108 mins",
        "genres": [
            "action",
            "comedy"
        ],
        "version": 1
    }
}

$ BODY='{"title":"The Breakfast Club","year":1986, "runtime":"96 mins","genres":["drama"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{
    "movie": {
        "id": 4,
        "title": "The Breakfast Club",
        "year": 1986,
        "runtime": "96 mins",
        "genres": [
            "drama"
        ],
        "version": 1
    }
}

در این مرحله ممکن است بخواهید نگاهی به PostgreSQL بیندازید تا مطمئن شوید رکوردها به‌درستی ایجاد شده‌اند. باید ببینید که محتوای جدول movies اکنون شبیه به این است (شامل فیلم‌های مناسب genres در یک آرایه).

$ psql $GREENLIGHT_DB_DSN
Password for user greenlight: 
psql (15.4 (Ubuntu 15.4-1.pgdg22.04+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.

greenlight=> SELECT * FROM movies;
 id |       created_at       |       title        | year | runtime |        genres         | version 
----+------------------------+--------------------+------+---------+-----------------------+---------
  1 | 2021-04-07 21:21:41+02 | Moana              | 2016 |     107 | {animation,adventure} |       1
  2 | 2021-04-07 21:28:28+02 | Black Panther      | 2018 |     134 | {action,adventure}    |       1
  3 | 2021-04-07 21:28:36+02 | Deadpool           | 2016 |     108 | {action,comedy}       |       1
  4 | 2021-04-07 21:28:44+02 | The Breakfast Club | 1986 |      96 | {drama}               |       1
(4 rows)

اطلاعات اضافی

نماد $N

یک ویژگی خوب نماد $N پارامتر جایگزین PostgreSQL این است که می‌توانید از همان مقدار پارامتر در چندین مکان در دستور SQL خود استفاده کنید. به‌عنوان مثال، نوشتن کدی شبیه به این کاملاً قابل قبول است:

// This SQL statement uses the $1 parameter twice, and the value `123` will be used in 
// both locations where $1 appears.
stmt := "UPDATE foo SET bar = $1 + $2 WHERE bar = $1"
err := db.Exec(stmt, 123, 456)
if err != nil {
    ...
}

اجرای چندین دستور

گاهی اوقات ممکن است در موقعیتی قرار بگیرید که بخواهید بیش از یک دستور SQL را در یک فراخوانی دیتابیس اجرا کنید، مانند این:

stmt := `
    UPDATE foo SET bar = true; 
    UPDATE foo SET baz = false;`

err := db.Exec(stmt)
if err != nil {
    ...
}

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

pq: cannot insert multiple commands into a prepared statement

برای دور زدن این مشکل، باید دستورات را در فراخوانی‌های جداگانه دیتابیس تقسیم کنید، یا اگر این امکان‌پذیر نیست، می‌توانید یک تابع سفارشی در PostgreSQL ایجاد کنید که به‌عنوان یک پوشش دور دستورات SQL متعددی که می‌خواهید اجرا کنید، عمل می‌کند.