C言語DB入門|GUIから書籍を削除する

C言語DB入門|GUIから書籍を削除する

前の記事では、一覧から選択した書籍のタイトルや著者名を変更できるようになりました。

今回は、選択した書籍をデータベースから削除する機能を追加します。

削除ボタンを押すと確認画面を表示し、そこで削除するを選んだ場合だけDELETEを実行します。削除後には一覧を読み込み直し、画面にも結果を反映します。

この記事で学習する内容は、次のとおりです。

  • 選択中の書籍に対して削除ボタンを有効にする
  • 削除前に確認画面を表示する
  • 選択したIDを使ってDELETEを実行する
  • SQLの実行結果と削除件数を確認する
  • 削除後に一覧を更新する
  • キャンセルやエラーに対応する

クリアは入力と選択を戻す操作、削除はデータベースの書籍を取り除く操作です。

この違いを意識しながら、GUI版のDelete機能を作っていきましょう。

書籍を削除する流れ

今回の削除処理は、次の順番で進めます。

  1. 一覧から書籍を選択する
  2. 削除ボタンを押す
  3. 確認画面でID、タイトル、著者名を確認する
  4. 削除するを押す
  5. IDを条件にDELETEを実行する
  6. SELECTを再実行して一覧を更新する

確認画面でキャンセルを押した場合は、DELETEを実行しません。

操作データベースへの影響
一覧から書籍を選択する変更しない
メイン画面の削除ボタンを押すまだ削除しない
確認画面でキャンセルを押す削除しない
確認画面を閉じる削除しない
確認画面で削除するを押すDELETEを実行する
メイン画面でクリアを押す書籍は削除しない

今回のアプリには、削除した書籍を元に戻す機能はありません。確認画面で対象を確認してから操作します。

図1:書籍の選択から削除までの流れ

この図から分かること

メイン画面の削除ボタンを押した時点では、書籍は削除されません。

確認画面で削除するを押したときに、データベース処理を実行します。

利用者が操作を取り消す経路も、削除処理と合わせて用意します。

削除対象はIDで指定する

DELETEでも、変更処理と同じようにIDを使って対象を指定します。

使用するSQLは、次の形です。

DELETE FROM books WHERE id = ?;

疑問符の部分には、削除する書籍のIDを設定します。

SQLの部分役割
DELETE FROM booksbooksテーブルから行を削除する
WHERE id = ?指定したIDの行を対象にする

WHEREを省略すると、テーブル内のすべての行が削除対象になります。今回の処理では、必ずIDによる条件を付けます。

入力欄のタイトルや著者名は、削除対象の判定には使いません。

例えば、選択後にタイトル入力欄を空にしていても、選択中のIDは変わりません。削除にはタイトルや著者名の空欄チェックも必要ありません。

作業フォルダとデータベースを確認する

作業フォルダは、引き続きC:\c-db\chap07です。

前の記事まで使用していたbooks.dbをそのまま使います。第6章からコピーし直す必要はありません。

ファイル用途
C:\c-db\chap07\books.db登録済みの書籍を保存しているデータベース
C:\c-db\chap07\book_delete.c削除機能を追加したソースファイル
C:\c-db\chap07\book_delete.exeコンパイル後の実行ファイル

前の記事のアプリは閉じてから、今回のアプリを起動してください。

削除の確認に必要な情報を保持する

確認画面を表示してから、利用者がボタンを押すまでには時間が空きます。

その間も削除対象を保持できるように、DeleteRequestという構造体を追加します。

メンバー役割
dataアプリ全体の管理データを参照する
window削除確認用のウィンドウを参照する
id確認画面を開いたときの削除対象IDを保持する

削除を実行するときは、DeleteRequestに保存したIDを使用します。

また、AppDataにはconfirming_deleteを追加します。確認中であることを記録し、登録・変更・削除の処理が重なって開始されないようにします。

プログラム全体を確認する

次のプログラムを、UTF-8で保存してください。

前の記事の登録・選択・変更の機能を残し、削除機能と確認画面を追加しています。

選択した書籍を確認画面から削除するプログラム

ファイル名: book_delete.c

#include <gtk/gtk.h>
#include <sqlite3.h>
#include <stdlib.h>

#define DATABASE_PATH "books.db"

typedef struct {
    sqlite3_int64 id;
    char *title;
    char *author;
} Book;

typedef struct {
    GtkWidget *window;
    GtkWidget *title_entry;
    GtkWidget *author_entry;
    GtkWidget *add_button;
    GtkWidget *update_button;
    GtkWidget *delete_button;
    GtkWidget *selected_label;
    GtkWidget *result_label;
    GtkWidget *book_list;
} AppWidgets;

typedef struct {
    AppWidgets widgets;
    sqlite3 *db;
    GPtrArray *books;
    gboolean has_selection;
    sqlite3_int64 selected_id;
    gboolean confirming_delete;
    gboolean reload_failed;
    gboolean closing;
    int exit_status;
} AppData;

typedef struct {
    AppData *data;
    GtkWindow *window;
    sqlite3_int64 id;
} DeleteRequest;

static int open_database(const char *path, sqlite3 **db)
{
    return sqlite3_open(path, db);
}

static int initialize_database(sqlite3 *db)
{
    const char *sql =
        "CREATE TABLE IF NOT EXISTS books ("
        "id INTEGER PRIMARY KEY AUTOINCREMENT,"
        "title TEXT NOT NULL,"
        "author TEXT NOT NULL"
        ");";

    return sqlite3_exec(db, sql, NULL, NULL, NULL);
}

static int close_database(sqlite3 **db)
{
    if (*db == NULL) {
        return SQLITE_OK;
    }

    int rc = sqlite3_close(*db);

    if (rc == SQLITE_OK) {
        *db = NULL;
    }

    return rc;
}

static int add_book(
    sqlite3 *db,
    const char *title,
    const char *author
)
{
    const char *sql =
        "INSERT INTO books (title, author) VALUES (?, ?);";

    sqlite3_stmt *stmt = NULL;
    int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);

    if (rc != SQLITE_OK) {
        return rc;
    }

    rc = sqlite3_bind_text(
        stmt, 1, title, -1, SQLITE_TRANSIENT
    );

    if (rc == SQLITE_OK) {
        rc = sqlite3_bind_text(
            stmt, 2, author, -1, SQLITE_TRANSIENT
        );
    }

    if (rc == SQLITE_OK) {
        rc = sqlite3_step(stmt);
    }

    int finalize_rc = sqlite3_finalize(stmt);

    if (rc == SQLITE_DONE) {
        return finalize_rc;
    }

    return rc;
}

static int update_book(
    sqlite3 *db,
    sqlite3_int64 id,
    const char *title,
    const char *author,
    int *changed_rows
)
{
    const char *sql =
        "UPDATE books "
        "SET title = ?, author = ? "
        "WHERE id = ?;";

    sqlite3_stmt *stmt = NULL;
    *changed_rows = 0;

    int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);

    if (rc != SQLITE_OK) {
        return rc;
    }

    rc = sqlite3_bind_text(
        stmt, 1, title, -1, SQLITE_TRANSIENT
    );

    if (rc == SQLITE_OK) {
        rc = sqlite3_bind_text(
            stmt, 2, author, -1, SQLITE_TRANSIENT
        );
    }

    if (rc == SQLITE_OK) {
        rc = sqlite3_bind_int64(stmt, 3, id);
    }

    if (rc == SQLITE_OK) {
        rc = sqlite3_step(stmt);
    }

    int affected = 0;

    if (rc == SQLITE_DONE) {
        affected = sqlite3_changes(db);
    }

    int finalize_rc = sqlite3_finalize(stmt);

    if (rc != SQLITE_DONE) {
        return rc;
    }

    if (finalize_rc != SQLITE_OK) {
        return finalize_rc;
    }

    *changed_rows = affected;
    return SQLITE_OK;
}

static int delete_book(
    sqlite3 *db,
    sqlite3_int64 id,
    int *deleted_rows
)
{
    const char *sql =
        "DELETE FROM books WHERE id = ?;";

    sqlite3_stmt *stmt = NULL;
    *deleted_rows = 0;

    int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);

    if (rc != SQLITE_OK) {
        return rc;
    }

    rc = sqlite3_bind_int64(stmt, 1, id);

    if (rc == SQLITE_OK) {
        rc = sqlite3_step(stmt);
    }

    int affected = 0;

    if (rc == SQLITE_DONE) {
        affected = sqlite3_changes(db);
    }

    int finalize_rc = sqlite3_finalize(stmt);

    if (rc != SQLITE_DONE) {
        return rc;
    }

    if (finalize_rc != SQLITE_OK) {
        return finalize_rc;
    }

    *deleted_rows = affected;
    return SQLITE_OK;
}

static void free_book(gpointer data)
{
    Book *book = data;

    g_free(book->title);
    g_free(book->author);
    g_free(book);
}

static int load_books(sqlite3 *db, GPtrArray **out_books)
{
    const char *sql =
        "SELECT id, title, author FROM books ORDER BY id;";

    sqlite3_stmt *stmt = NULL;
    *out_books = NULL;

    int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);

    if (rc != SQLITE_OK) {
        return rc;
    }

    GPtrArray *books =
        g_ptr_array_new_with_free_func(free_book);

    while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
        sqlite3_int64 id = sqlite3_column_int64(stmt, 0);

        const unsigned char *title =
            sqlite3_column_text(stmt, 1);

        if (title == NULL &&
            sqlite3_errcode(db) == SQLITE_NOMEM) {
            rc = SQLITE_NOMEM;
            break;
        }

        const unsigned char *author =
            sqlite3_column_text(stmt, 2);

        if (author == NULL &&
            sqlite3_errcode(db) == SQLITE_NOMEM) {
            rc = SQLITE_NOMEM;
            break;
        }

        Book *book = g_new0(Book, 1);

        book->id = id;
        book->title = g_strdup(
            title != NULL ? (const char *)title : ""
        );
        book->author = g_strdup(
            author != NULL ? (const char *)author : ""
        );

        g_ptr_array_add(books, book);
    }

    int finalize_rc = sqlite3_finalize(stmt);

    if (rc == SQLITE_DONE) {
        rc = finalize_rc;
    }

    if (rc != SQLITE_OK) {
        g_ptr_array_unref(books);
        return rc;
    }

    *out_books = books;
    return SQLITE_OK;
}

static const Book *find_book_by_id(
    const AppData *data,
    sqlite3_int64 id
)
{
    if (data->books == NULL) {
        return NULL;
    }

    for (guint i = 0; i < data->books->len; i++) {
        const Book *book = g_ptr_array_index(data->books, i);

        if (book->id == id) {
            return book;
        }
    }

    return NULL;
}

static void set_margins(GtkWidget *widget, int margin)
{
    gtk_widget_set_margin_top(widget, margin);
    gtk_widget_set_margin_bottom(widget, margin);
    gtk_widget_set_margin_start(widget, margin);
    gtk_widget_set_margin_end(widget, margin);
}

static void update_buttons(AppData *data)
{
    gboolean ready =
        !data->reload_failed && !data->confirming_delete;

    gtk_widget_set_sensitive(
        data->widgets.add_button,
        ready && !data->has_selection
    );
    gtk_widget_set_sensitive(
        data->widgets.update_button,
        ready && data->has_selection
    );
    gtk_widget_set_sensitive(
        data->widgets.delete_button,
        ready && data->has_selection
    );
}

static void reset_input_state(AppData *data)
{
    AppWidgets *widgets = &data->widgets;

    data->has_selection = FALSE;
    data->selected_id = 0;

    gtk_editable_set_text(
        GTK_EDITABLE(widgets->title_entry), ""
    );
    gtk_editable_set_text(
        GTK_EDITABLE(widgets->author_entry), ""
    );

    gtk_label_set_text(
        GTK_LABEL(widgets->selected_label),
        "選択中のID:なし"
    );

    update_buttons(data);
}

static void on_book_selected(
    GtkListBox *list,
    GtkListBoxRow *row,
    gpointer user_data
)
{
    AppData *data = user_data;
    AppWidgets *widgets = &data->widgets;

    if (data->closing ||
        data->reload_failed ||
        data->confirming_delete) {
        return;
    }

    if (row == NULL) {
        reset_input_state(data);
        gtk_label_set_text(
            GTK_LABEL(widgets->result_label),
            "選択を解除しました。"
        );
        return;
    }

    const sqlite3_int64 *row_id = g_object_get_data(
        G_OBJECT(row), "book-id"
    );

    const Book *book = NULL;

    if (row_id != NULL) {
        book = find_book_by_id(data, *row_id);
    }

    if (book == NULL) {
        gtk_list_box_unselect_all(list);
        reset_input_state(data);
        gtk_label_set_text(
            GTK_LABEL(widgets->result_label),
            "選択した行に対応する書籍データが見つかりません。"
        );
        return;
    }

    data->has_selection = TRUE;
    data->selected_id = book->id;

    gtk_editable_set_text(
        GTK_EDITABLE(widgets->title_entry), book->title
    );
    gtk_editable_set_text(
        GTK_EDITABLE(widgets->author_entry), book->author
    );

    char *text = g_strdup_printf(
        "選択中のID:%lld",
        (long long)data->selected_id
    );

    gtk_label_set_text(
        GTK_LABEL(widgets->selected_label), text
    );
    g_free(text);

    update_buttons(data);

    gtk_label_set_text(
        GTK_LABEL(widgets->result_label),
        "書籍を選択しました。変更または削除ができます。"
    );
}

static GtkWidget *create_book_row(const Book *book)
{
    GtkWidget *row = gtk_list_box_row_new();
    GtkWidget *grid = gtk_grid_new();

    gtk_grid_set_column_homogeneous(GTK_GRID(grid), TRUE);
    gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
    set_margins(grid, 12);

    char *id_text = g_strdup_printf(
        "ID:%lld", (long long)book->id
    );
    char *author_text = g_strdup_printf(
        "著者:%s", book->author
    );

    GtkWidget *labels[3] = {
        gtk_label_new(id_text),
        gtk_label_new(book->title),
        gtk_label_new(author_text)
    };

    g_free(id_text);
    g_free(author_text);

    for (int i = 0; i < 3; i++) {
        gtk_label_set_xalign(GTK_LABEL(labels[i]), 0.0f);
        gtk_label_set_wrap(GTK_LABEL(labels[i]), TRUE);
        gtk_label_set_wrap_mode(
            GTK_LABEL(labels[i]), PANGO_WRAP_WORD_CHAR
        );
        gtk_widget_set_hexpand(labels[i], TRUE);
        gtk_widget_set_valign(labels[i], GTK_ALIGN_START);
        gtk_grid_attach(
            GTK_GRID(grid), labels[i], i, 0, 1, 1
        );
    }

    gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), grid);
    gtk_list_box_row_set_selectable(
        GTK_LIST_BOX_ROW(row), TRUE
    );
    gtk_list_box_row_set_activatable(
        GTK_LIST_BOX_ROW(row), FALSE
    );

    sqlite3_int64 *row_id = g_new(sqlite3_int64, 1);
    *row_id = book->id;

    g_object_set_data_full(
        G_OBJECT(row), "book-id", row_id, g_free
    );

    return row;
}

static void display_books(AppData *data)
{
    AppWidgets *widgets = &data->widgets;
    GtkListBox *list = GTK_LIST_BOX(widgets->book_list);

    gtk_list_box_unselect_all(list);
    reset_input_state(data);

    GtkListBoxRow *row;

    while ((row = gtk_list_box_get_row_at_index(list, 0))
           != NULL) {
        gtk_list_box_remove(list, GTK_WIDGET(row));
    }

    for (guint i = 0; i < data->books->len; i++) {
        const Book *book = g_ptr_array_index(data->books, i);
        gtk_list_box_append(list, create_book_row(book));
    }

    char *message;

    if (data->books->len == 0) {
        message = g_strdup(
            "読み込みは正常に完了しました。書籍は0件です。"
        );
    } else {
        message = g_strdup_printf(
            "%u件の書籍を表示しています。"
            "行をクリックすると選択できます。",
            data->books->len
        );
    }

    gtk_label_set_text(
        GTK_LABEL(widgets->result_label), message
    );
    g_free(message);
}

static int refresh_books(AppData *data)
{
    GPtrArray *new_books = NULL;
    int rc = load_books(data->db, &new_books);

    if (rc != SQLITE_OK) {
        return rc;
    }

    GPtrArray *old_books = data->books;
    data->books = new_books;

    display_books(data);

    if (old_books != NULL) {
        g_ptr_array_unref(old_books);
    }

    return SQLITE_OK;
}

static void show_database_error(
    AppData *data,
    const char *message,
    int rc
)
{
    char *text = g_strdup_printf(
        "%s\nSQLiteエラー %d:%s",
        message, rc, sqlite3_errstr(rc)
    );

    gtk_label_set_text(
        GTK_LABEL(data->widgets.result_label), text
    );
    g_free(text);
}

static gboolean validate_inputs(
    AppData *data,
    const char *title,
    const char *author
)
{
    AppWidgets *widgets = &data->widgets;

    if (title[0] == '\0') {
        gtk_label_set_text(
            GTK_LABEL(widgets->result_label),
            "タイトルを入力してください。"
        );
        gtk_widget_grab_focus(widgets->title_entry);
        return FALSE;
    }

    if (author[0] == '\0') {
        gtk_label_set_text(
            GTK_LABEL(widgets->result_label),
            "著者名を入力してください。"
        );
        gtk_widget_grab_focus(widgets->author_entry);
        return FALSE;
    }

    return TRUE;
}

static void finish_save(
    AppData *data,
    const char *success_message
)
{
    AppWidgets *widgets = &data->widgets;

    gtk_list_box_unselect_all(
        GTK_LIST_BOX(widgets->book_list)
    );
    reset_input_state(data);
    gtk_widget_grab_focus(widgets->title_entry);

    int rc = refresh_books(data);

    if (rc != SQLITE_OK) {
        data->reload_failed = TRUE;
        update_buttons(data);
        gtk_widget_set_sensitive(widgets->book_list, FALSE);

        show_database_error(
            data,
            "データベースの操作は完了しましたが、"
            "一覧を更新できませんでした。\n"
            "同じ操作を繰り返さず、"
            "アプリを閉じて起動し直してください。",
            rc
        );
        return;
    }

    char *message = g_strdup_printf(
        "%s 現在%u件です。",
        success_message, data->books->len
    );

    gtk_label_set_text(
        GTK_LABEL(widgets->result_label), message
    );
    g_free(message);
}

static void on_add_clicked(
    GtkButton *button,
    gpointer user_data
)
{
    (void)button;

    AppData *data = user_data;
    AppWidgets *widgets = &data->widgets;

    if (data->reload_failed || data->confirming_delete) {
        return;
    }

    if (data->has_selection) {
        gtk_label_set_text(
            GTK_LABEL(widgets->result_label),
            "新しく登録するときは、"
            "クリアで選択を解除してください。"
        );
        return;
    }

    const char *title = gtk_editable_get_text(
        GTK_EDITABLE(widgets->title_entry)
    );
    const char *author = gtk_editable_get_text(
        GTK_EDITABLE(widgets->author_entry)
    );

    if (!validate_inputs(data, title, author)) {
        return;
    }

    int rc = add_book(data->db, title, author);

    if (rc != SQLITE_OK) {
        show_database_error(
            data, "書籍を登録できませんでした。", rc
        );
        return;
    }

    finish_save(data, "書籍を登録しました。");
}

static void on_update_clicked(
    GtkButton *button,
    gpointer user_data
)
{
    (void)button;

    AppData *data = user_data;
    AppWidgets *widgets = &data->widgets;

    if (data->reload_failed || data->confirming_delete) {
        return;
    }

    if (!data->has_selection) {
        gtk_label_set_text(
            GTK_LABEL(widgets->result_label),
            "変更する書籍を選択してください。"
        );
        return;
    }

    const char *title = gtk_editable_get_text(
        GTK_EDITABLE(widgets->title_entry)
    );
    const char *author = gtk_editable_get_text(
        GTK_EDITABLE(widgets->author_entry)
    );

    if (!validate_inputs(data, title, author)) {
        return;
    }

    sqlite3_int64 target_id = data->selected_id;
    int changed_rows = 0;

    int rc = update_book(
        data->db, target_id, title, author, &changed_rows
    );

    if (rc != SQLITE_OK) {
        show_database_error(
            data, "書籍を変更できませんでした。", rc
        );
        return;
    }

    if (changed_rows == 0) {
        gtk_label_set_text(
            GTK_LABEL(widgets->result_label),
            "変更対象の書籍がデータベースに見つかりません。\n"
            "入力内容は残しています。"
            "必要な内容を控え、アプリを起動し直して"
            "一覧を確認してください。"
        );
        return;
    }

    char *message = g_strdup_printf(
        "ID:%lldの書籍を変更しました。",
        (long long)target_id
    );

    finish_save(data, message);
    g_free(message);
}

static gboolean on_delete_dialog_close_request(
    GtkWindow *window,
    gpointer user_data
)
{
    (void)window;

    DeleteRequest *request = user_data;
    AppData *data = request->data;

    data->confirming_delete = FALSE;

    if (!data->closing) {
        update_buttons(data);
        gtk_label_set_text(
            GTK_LABEL(data->widgets.result_label),
            "削除をキャンセルしました。"
        );
    }

    return FALSE;
}

static void on_delete_cancel_clicked(
    GtkButton *button,
    gpointer user_data
)
{
    (void)button;

    DeleteRequest *request = user_data;
    gtk_window_close(request->window);
}

static void on_delete_confirmed(
    GtkButton *button,
    gpointer user_data
)
{
    (void)button;

    DeleteRequest *request = user_data;

    AppData *data = request->data;
    sqlite3_int64 target_id = request->id;
    GtkWindow *dialog = request->window;

    data->confirming_delete = FALSE;
    gtk_window_destroy(dialog);

    /* 確認画面の破棄後はrequestを参照しない。 */
    if (data->closing || data->reload_failed) {
        return;
    }

    update_buttons(data);

    int deleted_rows = 0;
    int rc = delete_book(
        data->db, target_id, &deleted_rows
    );

    if (rc != SQLITE_OK) {
        show_database_error(
            data, "書籍を削除できませんでした。", rc
        );
        return;
    }

    if (deleted_rows == 0) {
        gtk_label_set_text(
            GTK_LABEL(data->widgets.result_label),
            "削除対象の書籍がデータベースに見つかりません。\n"
            "今回は削除していません。"
            "アプリを起動し直して一覧を確認してください。"
        );
        return;
    }

    char *message = g_strdup_printf(
        "ID:%lldの書籍を削除しました。",
        (long long)target_id
    );

    finish_save(data, message);
    g_free(message);
}

static void on_delete_clicked(
    GtkButton *button,
    gpointer user_data
)
{
    (void)button;

    AppData *data = user_data;

    if (data->reload_failed || data->confirming_delete) {
        return;
    }

    if (!data->has_selection) {
        gtk_label_set_text(
            GTK_LABEL(data->widgets.result_label),
            "削除する書籍を選択してください。"
        );
        return;
    }

    const Book *book = find_book_by_id(
        data, data->selected_id
    );

    if (book == NULL) {
        gtk_label_set_text(
            GTK_LABEL(data->widgets.result_label),
            "選択した書籍データが見つかりません。"
        );
        return;
    }

    GtkWidget *dialog = gtk_window_new();

    gtk_window_set_title(GTK_WINDOW(dialog), "削除の確認");
    gtk_window_set_default_size(GTK_WINDOW(dialog), 520, 280);
    gtk_window_set_transient_for(
        GTK_WINDOW(dialog),
        GTK_WINDOW(data->widgets.window)
    );
    gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
    gtk_window_set_destroy_with_parent(
        GTK_WINDOW(dialog), TRUE
    );

    DeleteRequest *request = g_new0(DeleteRequest, 1);
    request->data = data;
    request->window = GTK_WINDOW(dialog);
    request->id = book->id;

    g_object_set_data_full(
        G_OBJECT(dialog),
        "delete-request",
        request,
        g_free
    );

    GtkWidget *box = gtk_box_new(
        GTK_ORIENTATION_VERTICAL, 16
    );
    set_margins(box, 24);
    gtk_window_set_child(GTK_WINDOW(dialog), box);

    char *text = g_strdup_printf(
        "この書籍を削除しますか?\n\n"
        "ID:%lld\n"
        "タイトル:%s\n"
        "著者名:%s\n\n"
        "このアプリには元に戻す機能はありません。\n"
        "入力欄の未保存の変更は反映されません。",
        (long long)book->id,
        book->title,
        book->author
    );

    GtkWidget *label = gtk_label_new(text);
    g_free(text);

    gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
    gtk_label_set_wrap(GTK_LABEL(label), TRUE);
    gtk_label_set_wrap_mode(
        GTK_LABEL(label), PANGO_WRAP_WORD_CHAR
    );

    GtkWidget *buttons = gtk_box_new(
        GTK_ORIENTATION_HORIZONTAL, 8
    );
    gtk_widget_set_halign(buttons, GTK_ALIGN_END);

    GtkWidget *cancel_button =
        gtk_button_new_with_label("キャンセル");
    GtkWidget *confirm_button =
        gtk_button_new_with_label("削除する");

    gtk_box_append(GTK_BOX(buttons), cancel_button);
    gtk_box_append(GTK_BOX(buttons), confirm_button);

    gtk_box_append(GTK_BOX(box), label);
    gtk_box_append(GTK_BOX(box), buttons);

    g_signal_connect(
        cancel_button,
        "clicked",
        G_CALLBACK(on_delete_cancel_clicked),
        request
    );
    g_signal_connect(
        confirm_button,
        "clicked",
        G_CALLBACK(on_delete_confirmed),
        request
    );
    g_signal_connect(
        dialog,
        "close-request",
        G_CALLBACK(on_delete_dialog_close_request),
        request
    );

    data->confirming_delete = TRUE;
    update_buttons(data);

    gtk_window_set_default_widget(
        GTK_WINDOW(dialog), cancel_button
    );
    gtk_window_present(GTK_WINDOW(dialog));
    gtk_widget_grab_focus(cancel_button);
}

static void on_clear_clicked(
    GtkButton *button,
    gpointer user_data
)
{
    (void)button;

    AppData *data = user_data;

    if (data->confirming_delete) {
        return;
    }

    gtk_list_box_unselect_all(
        GTK_LIST_BOX(data->widgets.book_list)
    );
    reset_input_state(data);
    gtk_widget_grab_focus(data->widgets.title_entry);

    if (!data->reload_failed) {
        gtk_label_set_text(
            GTK_LABEL(data->widgets.result_label),
            "選択と入力欄をクリアしました。"
        );
    }
}

static gboolean on_close_request(
    GtkWindow *window,
    gpointer user_data
)
{
    (void)window;

    AppData *data = user_data;
    data->closing = TRUE;

    return FALSE;
}

static void on_error_close_clicked(
    GtkButton *button,
    gpointer user_data
)
{
    (void)button;
    gtk_window_close(GTK_WINDOW(user_data));
}

static void show_startup_error(
    GtkApplication *app,
    const char *message,
    int rc
)
{
    GtkWidget *window = gtk_application_window_new(app);
    gtk_window_set_title(GTK_WINDOW(window), "起動エラー");
    gtk_window_set_default_size(GTK_WINDOW(window), 600, 240);

    GtkWidget *box = gtk_box_new(
        GTK_ORIENTATION_VERTICAL, 16
    );
    set_margins(box, 24);
    gtk_window_set_child(GTK_WINDOW(window), box);

    char *text = g_strdup_printf(
        "%s\n\nSQLiteエラー %d:%s\n\n接続先:%s",
        message, rc, sqlite3_errstr(rc), DATABASE_PATH
    );

    GtkWidget *label = gtk_label_new(text);
    g_free(text);

    gtk_label_set_wrap(GTK_LABEL(label), TRUE);
    gtk_label_set_xalign(GTK_LABEL(label), 0.0f);

    GtkWidget *close_button =
        gtk_button_new_with_label("閉じる");
    gtk_widget_set_halign(close_button, GTK_ALIGN_END);

    gtk_box_append(GTK_BOX(box), label);
    gtk_box_append(GTK_BOX(box), close_button);

    g_signal_connect(
        close_button,
        "clicked",
        G_CALLBACK(on_error_close_clicked),
        window
    );

    gtk_window_present(GTK_WINDOW(window));
}

static void create_main_window(
    GtkApplication *app,
    AppData *data
)
{
    AppWidgets *widgets = &data->widgets;

    widgets->window = gtk_application_window_new(app);
    GtkWidget *window = widgets->window;

    gtk_window_set_title(
        GTK_WINDOW(window), "書籍管理アプリ"
    );
    gtk_window_set_default_size(GTK_WINDOW(window), 760, 560);

    GtkWidget *main_box = gtk_box_new(
        GTK_ORIENTATION_VERTICAL, 16
    );
    set_margins(main_box, 24);
    gtk_window_set_child(GTK_WINDOW(window), main_box);

    GtkWidget *input_grid = gtk_grid_new();
    gtk_grid_set_row_spacing(GTK_GRID(input_grid), 12);
    gtk_grid_set_column_spacing(GTK_GRID(input_grid), 12);

    GtkWidget *title_label = gtk_label_new("タイトル");
    GtkWidget *author_label = gtk_label_new("著者名");
    gtk_label_set_xalign(GTK_LABEL(title_label), 0.0f);
    gtk_label_set_xalign(GTK_LABEL(author_label), 0.0f);

    widgets->title_entry = gtk_entry_new();
    widgets->author_entry = gtk_entry_new();

    gtk_entry_set_placeholder_text(
        GTK_ENTRY(widgets->title_entry), "例:C言語実践入門"
    );
    gtk_entry_set_placeholder_text(
        GTK_ENTRY(widgets->author_entry), "例:佐藤一郎"
    );

    gtk_widget_set_hexpand(widgets->title_entry, TRUE);
    gtk_widget_set_hexpand(widgets->author_entry, TRUE);

    gtk_grid_attach(
        GTK_GRID(input_grid), title_label, 0, 0, 1, 1
    );
    gtk_grid_attach(
        GTK_GRID(input_grid), widgets->title_entry, 1, 0, 1, 1
    );
    gtk_grid_attach(
        GTK_GRID(input_grid), author_label, 0, 1, 1, 1
    );
    gtk_grid_attach(
        GTK_GRID(input_grid), widgets->author_entry, 1, 1, 1, 1
    );

    GtkWidget *button_box = gtk_box_new(
        GTK_ORIENTATION_HORIZONTAL, 8
    );
    gtk_widget_set_halign(button_box, GTK_ALIGN_START);

    widgets->add_button = gtk_button_new_with_label("登録");
    widgets->update_button = gtk_button_new_with_label("変更");
    widgets->delete_button = gtk_button_new_with_label("削除");

    GtkWidget *clear_button =
        gtk_button_new_with_label("クリア");

    gtk_box_append(GTK_BOX(button_box), widgets->add_button);
    gtk_box_append(GTK_BOX(button_box), widgets->update_button);
    gtk_box_append(GTK_BOX(button_box), widgets->delete_button);
    gtk_box_append(GTK_BOX(button_box), clear_button);

    widgets->selected_label =
        gtk_label_new("選択中のID:なし");
    gtk_label_set_xalign(
        GTK_LABEL(widgets->selected_label), 0.0f
    );

    widgets->result_label = gtk_label_new("");
    gtk_label_set_xalign(
        GTK_LABEL(widgets->result_label), 0.0f
    );
    gtk_label_set_wrap(
        GTK_LABEL(widgets->result_label), TRUE
    );

    GtkWidget *list_heading =
        gtk_label_new("登録されている書籍");
    gtk_label_set_xalign(GTK_LABEL(list_heading), 0.0f);

    widgets->book_list = gtk_list_box_new();
    gtk_list_box_set_selection_mode(
        GTK_LIST_BOX(widgets->book_list),
        GTK_SELECTION_SINGLE
    );
    gtk_list_box_set_show_separators(
        GTK_LIST_BOX(widgets->book_list), TRUE
    );

    GtkWidget *empty_label =
        gtk_label_new("登録されている書籍はありません。");
    gtk_label_set_wrap(GTK_LABEL(empty_label), TRUE);
    set_margins(empty_label, 24);

    gtk_list_box_set_placeholder(
        GTK_LIST_BOX(widgets->book_list), empty_label
    );

    GtkWidget *scrolled = gtk_scrolled_window_new();
    gtk_scrolled_window_set_policy(
        GTK_SCROLLED_WINDOW(scrolled),
        GTK_POLICY_NEVER,
        GTK_POLICY_AUTOMATIC
    );
    gtk_scrolled_window_set_min_content_height(
        GTK_SCROLLED_WINDOW(scrolled), 180
    );
    gtk_widget_set_vexpand(scrolled, TRUE);
    gtk_scrolled_window_set_child(
        GTK_SCROLLED_WINDOW(scrolled), widgets->book_list
    );

    gtk_box_append(GTK_BOX(main_box), input_grid);
    gtk_box_append(GTK_BOX(main_box), button_box);
    gtk_box_append(GTK_BOX(main_box), widgets->selected_label);
    gtk_box_append(GTK_BOX(main_box), widgets->result_label);
    gtk_box_append(GTK_BOX(main_box), list_heading);
    gtk_box_append(GTK_BOX(main_box), scrolled);

    g_signal_connect(
        widgets->add_button,
        "clicked",
        G_CALLBACK(on_add_clicked),
        data
    );
    g_signal_connect(
        widgets->update_button,
        "clicked",
        G_CALLBACK(on_update_clicked),
        data
    );
    g_signal_connect(
        widgets->delete_button,
        "clicked",
        G_CALLBACK(on_delete_clicked),
        data
    );
    g_signal_connect(
        clear_button,
        "clicked",
        G_CALLBACK(on_clear_clicked),
        data
    );
    g_signal_connect(
        widgets->book_list,
        "row-selected",
        G_CALLBACK(on_book_selected),
        data
    );
    g_signal_connect(
        window,
        "close-request",
        G_CALLBACK(on_close_request),
        data
    );

    display_books(data);
    gtk_window_present(GTK_WINDOW(window));
    gtk_widget_grab_focus(widgets->title_entry);
}

static void on_activate(
    GtkApplication *app,
    gpointer user_data
)
{
    AppData *data = user_data;

    GtkWindow *existing =
        gtk_application_get_active_window(app);

    if (existing != NULL) {
        gtk_window_present(existing);
        return;
    }

    int rc = open_database(DATABASE_PATH, &data->db);

    if (rc != SQLITE_OK) {
        data->exit_status = EXIT_FAILURE;
        show_startup_error(
            app, "データベースを開けませんでした。", rc
        );
        return;
    }

    rc = initialize_database(data->db);

    if (rc != SQLITE_OK) {
        data->exit_status = EXIT_FAILURE;
        show_startup_error(
            app, "booksテーブルを準備できませんでした。", rc
        );
        return;
    }

    rc = load_books(data->db, &data->books);

    if (rc != SQLITE_OK) {
        data->exit_status = EXIT_FAILURE;
        show_startup_error(
            app, "書籍を読み込めませんでした。", rc
        );
        return;
    }

    create_main_window(app, data);
}

int main(int argc, char *argv[])
{
    AppData data = {0};

    GtkApplication *app = gtk_application_new(
        "com.example.cdb.BookDelete",
        G_APPLICATION_DEFAULT_FLAGS
    );

    g_signal_connect(
        app, "activate", G_CALLBACK(on_activate), &data
    );

    int status = g_application_run(
        G_APPLICATION(app), argc, argv
    );

    data.closing = TRUE;
    g_object_unref(app);

    if (data.books != NULL) {
        g_ptr_array_unref(data.books);
        data.books = NULL;
    }

    int close_rc = close_database(&data.db);

    if (close_rc != SQLITE_OK) {
        g_printerr(
            "データベースを閉じられませんでした:%s\n",
            sqlite3_errstr(close_rc)
        );
        data.exit_status = EXIT_FAILURE;
    }

    if (data.exit_status != EXIT_SUCCESS) {
        return data.exit_status;
    }

    return status;
}

選択中だけ削除ボタンを有効にする

AppWidgetsへdelete_buttonを追加し、update_buttonsで状態を変更します。

gboolean ready =
    !data->reload_failed && !data->confirming_delete;

gtk_widget_set_sensitive(
    data->widgets.delete_button,
    ready && data->has_selection
);

削除ボタンが有効になるのは、次の条件をすべて満たす場合です。

  • 保存後の一覧更新に失敗していない
  • 削除の確認中ではない
  • 書籍を選択している

変更と削除は、どちらも選択した書籍を対象にするため、同じ選択状態を使います。

ただし、コールバック関数内でもhas_selectionを確認しています。ボタンの見た目だけに頼らず、処理の開始前にも条件を確認する設計です。

確認画面に表示する書籍情報を取得する

削除ボタンを押したときは、選択中のIDからBookを探します。

const Book *book = find_book_by_id(
    data, data->selected_id
);

確認画面には、このBookが持つID、タイトル、著者名を表示します。

入力欄の文字列を使用しない理由は、入力欄には未保存の編集内容が入っている場合があるためです。

例えば、SQLite実践入門を選択した後、入力欄を別のタイトルへ書き換えても、変更ボタンを押していなければ保存内容は変わっていません。

情報確認画面での扱い
選択中のID削除対象として保持する
読み込み済みのタイトル・著者名対象の説明として表示する
入力欄の未保存の文字列削除対象の判定や表示に使用しない

表示するBookは、直前の読み込み時点のデータです。別のプログラムによる変更を自動的に反映する仕組みではありません。

確認用のウィンドウを表示する

今回は、GtkWindow、GtkLabel、GtkButtonを組み合わせて確認画面を作っています。

GtkWidget *dialog = gtk_window_new();

gtk_window_set_transient_for(
    GTK_WINDOW(dialog),
    GTK_WINDOW(data->widgets.window)
);
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
gtk_window_set_destroy_with_parent(
    GTK_WINDOW(dialog), TRUE
);
関数役割
gtk_window_new確認用のウィンドウを作る
gtk_window_set_transient_forメイン画面との親子関係を設定する
gtk_window_set_modal確認中にほかのアプリ内ウィンドウを操作できないようにする
gtk_window_set_destroy_with_parent親ウィンドウの破棄時に確認画面も破棄する

モーダルウィンドウにすると、利用者は確認画面への応答を終えてからメイン画面の操作へ戻ります。親ウィンドウも指定することで、確認画面とメイン画面の関係をGTKへ伝えられます。

確認画面を表示した後、on_delete_clickedは処理を終えます。

その場で利用者の返答を待つループは書きません。次のボタン操作は、別のコールバック関数で受け取ります。

図2:確認画面では操作に応じて処理が分かれる

この図から分かること

確認画面の表示と、実際の削除は別のタイミングで実行されます。

キャンセルと削除するには、それぞれ異なるコールバック関数を接続します。

確認画面を表示した関数が終了した後も使えるように、削除対象のIDを保持しておく必要があります。

DeleteRequestの寿命を管理する

確認用のデータは、次のように動的に確保します。

DeleteRequest *request = g_new0(DeleteRequest, 1);

request->data = data;
request->window = GTK_WINDOW(dialog);
request->id = book->id;

on_delete_clickedのローカル変数として構造体そのものを作り、そのアドレスを渡してしまうと、関数から戻った後には使用できません。

今回はg_new0で確保することで、確認画面を表示した関数が終了した後も、データを保持します。

確保したメモリは、確認画面へ関連付けます。

g_object_set_data_full(
    G_OBJECT(dialog),
    "delete-request",
    request,
    g_free
);

g_object_set_data_fullには、関連付けが破棄されたときに呼び出す関数も指定できます。ここではg_freeを指定し、確認画面のオブジェクトとともにDeleteRequestを解放できるようにしています。

DeleteRequestの中にあるdataは、mainで管理しているAppDataへのポインターです。DeleteRequestを解放しても、AppDataそのものを解放するわけではありません。

キャンセルと閉じる操作を同じ動作にする

キャンセルボタンでは、gtk_window_closeを呼び出します。

static void on_delete_cancel_clicked(
    GtkButton *button,
    gpointer user_data
)
{
    (void)button;

    DeleteRequest *request = user_data;
    gtk_window_close(request->window);
}

確認画面にはclose-requestのコールバック関数も接続しています。

その中でconfirming_deleteをFALSEへ戻し、メイン画面のボタン状態を更新します。

data->confirming_delete = FALSE;

if (!data->closing) {
    update_buttons(data);
    gtk_label_set_text(
        GTK_LABEL(data->widgets.result_label),
        "削除をキャンセルしました。"
    );
}

この構成により、次の操作を同じキャンセル処理として扱えます。

  • キャンセルボタンを押す
  • 確認画面の閉じるボタンを押す

入力欄と選択状態は残します。キャンセル後に、そのまま内容の変更などを続けられます。

また、確認画面の初期フォーカスと既定のボタンにはキャンセルを設定しています。

gtk_window_set_default_widget(
    GTK_WINDOW(dialog), cancel_button
);
gtk_widget_grab_focus(cancel_button);

既定のウィジェットは、ダイアログ内でEnterキーによる既定動作を扱うための指定です。削除するを既定にしない構成にしています。

確認したIDを使って削除する

削除するを押したときは、DeleteRequestから必要な情報を取り出します。

AppData *data = request->data;
sqlite3_int64 target_id = request->id;
GtkWindow *dialog = request->window;

data->confirming_delete = FALSE;
gtk_window_destroy(dialog);

確認画面の破棄によって、関連付けたDeleteRequestも解放される可能性があります。

そのため、gtk_window_destroyを呼び出した後は、requestを参照しません。

事前に取り出したdataとtarget_idを使って、後続の処理を行います。

ここではgtk_window_closeではなくgtk_window_destroyを使い、キャンセル用のclose-requestを経由せずに確認画面を終了します。GtkWindowを明示的に破棄するときは、gtk_window_destroyを使用します。

delete_bookでDELETEを実行する

delete_bookは、データベース接続、削除対象ID、削除件数の受け取り先を引数に取ります。

static int delete_book(
    sqlite3 *db,
    sqlite3_int64 id,
    int *deleted_rows
)

削除用のSQLを準備した後、IDを設定します。

rc = sqlite3_bind_int64(stmt, 1, id);

if (rc == SQLITE_OK) {
    rc = sqlite3_step(stmt);
}
指定意味
stmt準備済みのDELETE
1最初のプレースホルダー
id削除対象の書籍ID

IDはsqlite3_int64なので、sqlite3_bind_int64で設定します。

削除処理ではタイトルと著者名を使用しないため、sqlite3_bind_textは必要ありません。

SQLの成功と削除件数を確認する

変更処理と同様に、DELETEも対象が存在しなくてもSQL自体は成功します。

そこで、sqlite3_stepがSQLITE_DONEを返した後に、sqlite3_changesで削除件数を取得します。

int affected = 0;

if (rc == SQLITE_DONE) {
    affected = sqlite3_changes(db);
}

sqlite3_changesは、同じデータベース接続で直前に完了したINSERT、UPDATE、DELETEによる件数を返します。今回のDELETEでは、直接削除された行の数を確認できます。

今回のテーブルではidが主キーなので、結果は次のようになります。

状態delete_bookの戻り値deleted_rows
SQLiteの処理でエラーエラーコード判定に使わない
対象のIDが存在しないSQLITE_OK0
対象の書籍を削除したSQLITE_OK1

呼び出し元では、まず戻り値を確認し、その後で件数を確認します。

int deleted_rows = 0;

int rc = delete_book(
    data->db, target_id, &deleted_rows
);

if (rc != SQLITE_OK) {
    show_database_error(
        data, "書籍を削除できませんでした。", rc
    );
    return;
}

deleted_rowsが0の場合には、削除成功とは表示しません。

別のプログラムによってすでに削除されていた場合などは、対象が見つからなかったことを伝え、再起動して一覧を確認するよう案内します。

削除後に一覧を読み込み直す

削除が成功したら、前の記事で作成したfinish_saveを呼び出します。

char *message = g_strdup_printf(
    "ID:%lldの書籍を削除しました。",
    (long long)target_id
);

finish_save(data, message);
g_free(message);

finish_saveでは、次の処理を行います。

  1. 一覧の選択を解除する
  2. 入力欄と選択中のIDを初期状態へ戻す
  3. SELECTを再実行する
  4. 読み込んだ内容で一覧を作り直す
  5. 削除結果と現在の件数を表示する

画面から行を消すだけでは、データベースの書籍は削除されません。

反対に、DELETEを実行しただけでも、表示中の一覧は自動では変わりません。

DELETEで保存内容を変更し、SELECTで読み込み直し、GTK 4で表示を更新するという役割分担になっています。

削除に失敗した場合の状態を整理する

削除処理では、失敗した場所に応じて画面の対応を変えます。

状態データベース入力・選択画面での対応
キャンセル変更しない残すキャンセルを通知する
DELETEでエラー削除に失敗残すSQLiteエラーを表示する
削除件数が0今回は削除していない残す対象が見つからないことを伝える
DELETEと再読み込みが成功削除済み初期状態へ戻す新しい一覧を表示する
DELETE成功後の再読み込みに失敗削除済み初期状態へ戻す操作済みであることと一覧更新の失敗を伝える

一覧更新だけに失敗した場合は、次のメッセージを表示します。

データベースの操作は完了しましたが、一覧を更新できませんでした。
同じ操作を繰り返さず、アプリを閉じて起動し直してください。

この場合、画面に古い行が残っていても、データベースでは削除が完了しています。

登録・変更・削除ボタンと一覧の操作を無効にし、再起動後に保存内容を確認できるようにしています。

コンパイルして実行する

MSYS2 UCRT64で、作業フォルダへ移動します。

cd /c/c-db/chap07

次のコマンドでコンパイルします。

gcc book_delete.c -o book_delete.exe $(pkg-config --cflags --libs gtk4 sqlite3)

C17を指定し、警告も有効にする場合は、次のコマンドを使用します。

gcc -std=c17 -Wall -Wextra book_delete.c -o book_delete.exe $(pkg-config --cflags --libs gtk4 sqlite3)

どちらか一方でコンパイルすれば大丈夫です。

コンパイルが成功したら、実行します。

./book_delete.exe

実行結果の例

ここでは、前の記事でタイトルを変更し、次の3件が登録されている場合を例にします。

IDタイトル著者名
1C言語入門山田太郎
2SQLite実践入門鈴木花子
3C言語実践入門佐藤一郎

実際のIDや件数は、これまでの操作によって異なります。

最初にキャンセルを試す

SQLite実践入門を選択し、削除ボタンを押してください。

確認画面には、次の内容が表示されます。

この書籍を削除しますか?

ID:2
タイトル:SQLite実践入門
著者名:鈴木花子

このアプリには元に戻す機能はありません。
入力欄の未保存の変更は反映されません。

キャンセルを押すと、確認画面が閉じます。

メイン画面には、次のメッセージが表示されます。

削除をキャンセルしました。

書籍は3件のままで、SQLite実践入門の選択と入力欄の内容も残ります。

削除するを押して実行する

もう一度削除ボタンを押し、対象を確認してから削除するを押します。

削除と一覧更新が成功すると、一覧は次のようになります。

IDタイトル著者名
1C言語入門山田太郎
3C言語実践入門佐藤一郎

結果メッセージは、次の表示になります。

ID:2の書籍を削除しました。 現在2件です。

画面の状態も変わります。

項目削除後の状態
タイトル入力欄空
著者名入力欄空
選択中のIDなし
登録ボタン有効
変更ボタン無効
削除ボタン無効
書籍の件数2件

図3:削除後は一覧を更新し、残ったIDをそのまま表示する

この図から分かること

書籍を削除すると件数は減りますが、残った書籍のIDは変わりません。

IDが1と3になっていても正常です。IDは画面上の行番号ではなく、それぞれの書籍を識別する値だからです。

削除後も、行へ関連付けた実際のIDを使って書籍を選択します。

最後の1件を削除した場合

最後の書籍を削除すると、一覧は0件になります。

この状態はエラーではありません。

一覧部分には、次の案内が表示されます。

登録されている書籍はありません。

結果メッセージには、削除したIDと現在0件であることが表示されます。

登録ボタンは有効なので、新しい書籍を入力して登録できます。

booksテーブルやbooks.dbを削除したわけではなく、テーブル内の書籍データが0件になった状態です。

再起動して削除結果を確認する

アプリを閉じ、同じ作業フォルダからもう一度起動します。

cd /c/c-db/chap07
./book_delete.exe

削除した書籍が一覧に表示されないことを確認しましょう。

確認の際も、第6章のデータベースをコピーし直す必要はありません。第7章で操作したbooks.dbを、そのまま開きます。

今回の削除処理でも、データベースを操作するdelete_bookと、利用者の操作を受け取るコールバック関数を分けています。確認画面を変更しても、IDを指定して削除するデータベース処理は共通して利用できます。

次の記事では、クリアボタンや操作終了後の処理を整理し、入力欄・選択中のID・ボタンの状態を初期状態へ戻す仕組みを詳しく確認します。

Follow me!