C言語DB入門|GUIから書籍を変更する

C言語DB入門|GUIから書籍を変更する

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

今回は、その選択機能を使って、書籍の内容を変更できるようにしましょう。

一覧から書籍を選び、入力欄を書き換えて変更ボタンを押すと、SQLiteへUPDATEを実行します。保存後には一覧を読み込み直し、変更した内容を画面へ表示します。

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

  • 書籍の選択状態に応じて変更ボタンを有効にする
  • 選択中のIDを使って変更対象を指定する
  • プリペアドステートメントでUPDATEを実行する
  • SQLの実行結果と更新件数を確認する
  • 変更後に一覧を更新し、入力欄と選択状態を戻す
  • 変更の失敗と、保存後の一覧更新の失敗を区別する

書籍を特定するのは、選択時に保存したIDです。

入力欄のタイトルを書き換えても、変更対象の書籍は同じIDで指定できます。

書籍を変更する流れ

書籍の変更は、次の順番で進めます。

  1. 一覧から書籍を選択する
  2. 選択した書籍の情報を入力欄へ表示する
  3. タイトルや著者名を書き換える
  4. 変更ボタンを押す
  5. 選択中のIDを条件にしてUPDATEを実行する
  6. SELECTを再実行し、一覧を更新する

例えば、IDが2の書籍を次のように変更するとします。

項目変更前変更後
ID22
タイトルSQLite入門SQLite実践入門
著者名鈴木花子鈴木花子

変更するのはタイトルと著者名です。IDは書籍を特定するために使い、変更しません。

図1:選択した書籍を変更して一覧へ反映する流れ

この図から分かること

入力欄を書き換えるだけでは、データベースは変更されません。

変更ボタンを押したときに、入力内容と選択中のIDを使ってUPDATEを実行します。

保存後はデータベースを読み込み直し、画面の一覧にも変更を反映します。

登録と変更の違いを整理する

登録と変更では、入力する情報は同じでも、実行するSQLが異なります。

項目登録変更
対象新しい書籍選択済みの書籍
使用するSQLINSERTUPDATE
IDの扱いSQLiteが新しく割り当てる選択中のIDを使う
書籍の件数1件増える通常は変わらない
成功後一覧更新、入力欄のクリア一覧更新、入力欄と選択の解除

今回は、選択状態に応じてボタンを次のように切り替えます。

状態登録変更削除クリア
選択していない有効無効無効有効
書籍を選択している無効有効無効有効
保存後の一覧更新に失敗した無効無効無効有効

削除機能は次の記事で追加するため、削除ボタンは無効のままです。

作業フォルダと使用するファイル

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

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

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

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

今回追加・変更する関数

データベース処理にはupdate_bookを追加します。

画面側では、変更ボタンのコールバック関数を追加し、登録と変更で共通に使う処理を整理します。

関数役割
update_bookIDを指定してUPDATEを実行する
update_buttons登録・変更ボタンの有効状態を切り替える
validate_inputsタイトルと著者名が空でないか確認する
finish_save保存後の入力初期化と一覧更新を行う
on_update_clicked変更ボタンを押したときの処理をまとめる

finish_saveは、登録と変更の両方から呼び出します。保存後の処理を共通にしておくと、どちらの操作でも同じ流れで画面を更新できます。

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

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

前の記事の登録と選択の機能を残し、変更機能を追加しています。

選択した書籍のタイトルと著者名を変更するプログラム

ファイル名: book_update.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 *title_entry;
    GtkWidget *author_entry;
    GtkWidget *add_button;
    GtkWidget *update_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 reload_failed;
    gboolean closing;
    int exit_status;
} AppData;

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 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;

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

    gtk_widget_set_sensitive(
        data->widgets.update_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) {
        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) {
        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) {
        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 void on_clear_clicked(
    GtkButton *button,
    gpointer user_data
)
{
    (void)button;

    AppData *data = user_data;

    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;

    GtkWidget *window = gtk_application_window_new(app);

    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("変更");

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

    gtk_widget_set_sensitive(delete_button, FALSE);

    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), 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(
        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.BookUpdate",
        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にupdate_buttonを追加しています。

前の記事では変更ボタンを常に無効にしていましたが、今回は選択状態に応じて有効・無効を切り替えます。

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

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

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

通常の状態では、has_selectionによって次のように切り替わります。

  • FALSEなら、登録ボタンを有効にする
  • TRUEなら、変更ボタンを有効にする

reload_failedがTRUEの場合は、表示している一覧が保存後の状態と一致していない可能性があります。そのため、登録と変更の両方を無効にします。

変更ボタンとコールバック関数は、次のように接続しています。

g_signal_connect(
    widgets->update_button,
    "clicked",
    G_CALLBACK(on_update_clicked),
    data
);

変更ボタンを押すと、on_update_clickedが呼び出されます。

変更する書籍が選択されているか確認する

on_update_clickedでは、最初に選択状態を確認します。

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

通常は、未選択のときに変更ボタンを押すことはできません。

それでも関数側で確認しておくと、後から呼び出し方や画面構成を変更した場合にも、未選択の状態でUPDATEを実行することを防げます。

画面上のボタン制御と、処理を実行する前の条件確認を、両方用意しています。

入力内容を確認する

タイトルと著者名は、登録処理と同じように取得します。

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;
}

validate_inputsでは、空欄を確認します。

入力内容に問題があればFALSEを返し、UPDATEを実行せずにコールバック関数から戻ります。

このとき、選択した書籍や入力内容はそのまま残します。利用者が内容を修正して、もう一度変更できるようにするためです。

今回は前の記事と同様に、長さが0の文字列を空欄として扱います。空白文字だけの入力への対応は、入力エラーを整理する記事で扱います。

選択中のIDを変更対象として保存する

入力内容を確認した後、選択中のIDをローカル変数へ保存します。

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

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

target_idは、今回変更する書籍のIDです。

成功後には選択を解除するため、data->selected_idは初期値へ戻ります。その後も成功メッセージにIDを表示できるように、target_idへ値を保持しています。

タイトルがSQLite入門からSQLite実践入門へ変わっても、target_idは変わりません。

図2:IDを条件にして変更する書籍を限定する

この図から分かること

変更する内容と、変更する対象は別の情報です。

タイトルと著者名は新しい内容として渡し、IDは対象を絞り込む条件として渡します。

主キーのIDを使うことで、同じタイトルの書籍が複数あっても、選択した1冊を指定できます。

update_bookでUPDATEを実行する

update_bookで使用するSQLは、次のとおりです。

const char *sql =
    "UPDATE books "
    "SET title = ?, author = ? "
    "WHERE id = ?;";
SQLの部分役割
UPDATE booksbooksテーブルを更新する
SET title = ?新しいタイトルを設定する
author = ?新しい著者名を設定する
WHERE id = ?変更対象のIDを指定する

WHEREは、UPDATEの対象となる行を絞り込む条件です。WHEREを省略すると、テーブル内のすべての行が更新対象になります。

今回のbooksテーブルではidが主キーなので、指定したIDに一致する行は最大1件です。

3つのプレースホルダーへ値を設定する

プレースホルダーには、次の順番で値を設定します。

番号設定する値使用する関数
1新しいタイトルsqlite3_bind_text
2新しい著者名sqlite3_bind_text
3変更対象のIDsqlite3_bind_int64

IDを設定する部分は、次のとおりです。

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

IDにはsqlite3_int64を使っているため、設定する関数もsqlite3_bind_int64を使用します。

文字列はsqlite3_bind_textで渡し、SQLITE_TRANSIENTを指定してSQLite側にコピーさせます。プレースホルダーの番号は1から始まります。

登録と同じように、入力値をSQL文字列へ直接連結しない方法です。

SQLの成功と更新件数を分けて確認する

UPDATEでは、SQLが正常に実行されたことと、対象の書籍が存在したことを分けて確認します。

存在しないIDをWHEREに指定した場合でも、SQLの実行自体は正常に終了します。

そのため、sqlite3_stepの戻り値だけで「書籍を変更できた」と判断しないようにします。

int affected = 0;

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

sqlite3_changesは、指定した接続で直前に完了したINSERT、UPDATE、DELETEによる対象件数を返します。今回のような通常のテーブルに対するUPDATEでは、この値で更新対象となった行の件数を確認します。

今回のSQLとテーブル定義では、次のように判断できます。

状態update_bookの戻り値changed_rows
SQLの実行に失敗SQLiteのエラーコード判断に使用しない
SQLは成功したが対象のIDが存在しないSQLITE_OK0
対象のIDが存在し、UPDATEが完了SQLITE_OK1

件数は、別の書き込みSQLを実行する前に取得しておきます。

同じ値を保存した場合の扱い

タイトルも著者名も変更せず、そのまま変更ボタンを押すこともできます。

今回のテーブルとUPDATEでは、同じ値を設定しても、対象行があれば更新件数は1になります。

したがって、changed_rowsは「入力内容に違いがあったか」を判定するためには使いません。

今回は、対象行へのUPDATEが完了すれば、同じ値であっても保存成功として扱います。

changed_rowsをポインターで受け取る理由

update_bookでは、2種類の結果を呼び出し元へ返したいと考えています。

  • SQLiteの処理結果
  • 更新対象となった行の件数

関数の戻り値にはSQLiteの処理結果を使い、件数はポインター経由で受け渡します。

呼び出し元では、変数のアドレスを渡します。

int changed_rows = 0;

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

update_book側では、intへのポインターとして受け取ります。

int *changed_rows

結果を書き込むときは、アスタリスクを使います。

*changed_rows = affected;
記述意味
呼び出し元のchanged_rows件数を保存する変数
&changed_rowsその変数のアドレス
関数側のint *changed_rowsint型の変数を指すポインター
*changed_rows = affected呼び出し元の変数へ件数を書き込む

今回のupdate_bookでは、SQLの実行とsqlite3_finalizeの成功を確認してから、件数を呼び出し元へ返しています。

sqlite3_finalizeにもエラーを返す場合があるため、その戻り値も確認します。

変更対象が見つからない場合

コールバック関数では、まずrcを確認し、その後で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;
}

一覧に表示されていた書籍でも、アプリが読み込んだ後に別のプログラムから削除されれば、UPDATEの時点では存在しないことがあります。

その場合は、変更成功と表示せず、対象が見つからなかったことを伝えます。

入力内容は残しますが、自動的に新しい書籍として登録することはしません。

今回のアプリは、ほかのプログラムによる変更を自動検出する仕組みは持っていません。対象が見つからない場合は、必要な入力内容を控えてから再起動し、一覧を読み込み直します。

保存後の処理をfinish_saveにまとめる

登録と変更では、保存後に行う画面処理が共通しています。

そこで、次の処理をfinish_saveへまとめました。

  1. 一覧の選択を解除する
  2. 入力欄と選択中のIDを初期状態へ戻す
  3. SELECTを再実行する
  4. 新しい内容で一覧を更新する
  5. 成功メッセージを表示する

登録後は、次のように呼び出します。

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

変更後は、IDを含むメッセージを用意して呼び出します。

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

finish_save(data, message);

g_free(message);

finish_saveが入力欄を空にすると、gtk_editable_get_textで取得した文字列をその後も参照できるとは限りません。

今回の処理では、UPDATEが終わるまで入力欄を変更せず、finish_saveを呼んだ後にはtitleやauthorを使用していません。

成功メッセージにも、入力欄の文字列ではなく、値として保存したtarget_idを使っています。

保存の失敗と一覧更新の失敗を区別する

変更処理でも、失敗した場所によって画面の対応を変えます。

状態保存結果入力欄・選択表示する内容
入力が空UPDATEを実行しない残す入力を促す
UPDATEでエラー保存に失敗残すSQLiteエラー
更新件数が0対象が見つからない残す対象が存在しない案内
UPDATEと再読み込みが成功保存済み初期状態へ戻す変更成功
UPDATE成功後の再読み込みに失敗保存済み初期状態へ戻す保存済みだが一覧更新に失敗した案内

保存後の一覧更新に失敗した場合は、次のように表示します。

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

この状態では、登録・変更ボタンと一覧の操作を無効にします。

画面に古いタイトルが残っていても、データベースでは変更が完了している可能性があります。画面の表示だけで、保存に失敗したと判断しないことが大切です。

コンパイルして実行する

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

cd /c/c-db/chap07

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

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

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

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

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

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

./book_update.exe

実行結果の例

ここでは、次の3件が登録されている場合を例にします。

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

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

変更する書籍を選択する

一覧のSQLite入門をクリックしてください。

入力欄には、次の内容が表示されます。

項目表示内容
タイトルSQLite入門
著者名鈴木花子
選択中のID2

登録ボタンが無効になり、変更ボタンが有効になります。

タイトルを書き換えて保存する

タイトル入力欄を、「SQLite実践入門」へ書き換えます。

著者名は鈴木花子のままにして、変更ボタンを押してください。

保存と再読み込みが成功すると、一覧は次のようになります。

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

結果メッセージには、次のように表示されます。

ID:2の書籍を変更しました。 現在3件です。

変更後の画面は、次の状態になります。

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

図3:変更してもIDと書籍の件数は変わらない

この図から分かること

UPDATEは、選択した書籍の内容を変更する処理です。

今回の例ではタイトルだけが変わり、IDと書籍の件数は変わりません。

保存後にSELECTを実行し直すことで、変更された内容を一覧へ反映できます。

空欄のまま変更できないことを確認する

書籍を選択し、タイトル入力欄の文字をすべて消してから変更ボタンを押してみましょう。

次のメッセージが表示されます。

タイトルを入力してください。

この場合、UPDATEは実行されません。

著者名を空にした場合も、次のメッセージが表示されます。

著者名を入力してください。

入力を修正すれば、そのまま変更処理をやり直せます。

変更せずに操作を終えたい場合は、クリアを押してください。入力欄と選択状態が戻り、データベースの内容は変わりません。

再起動して変更内容を確認する

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

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

一覧にSQLite実践入門が表示されれば、変更内容がbooks.dbへ保存されています。

さらに、その行を選択して、入力欄にもSQLite実践入門と表示されることを確認しましょう。

今回追加したupdate_bookは、画面の部品を直接操作しません。コールバック関数が入力内容とIDを渡し、データベース処理の結果を受け取って画面を更新します。

次の記事では、同じselected_idを使ってDELETEを実行し、選択した書籍をGUIから削除できるようにします。

Follow me!