C言語DB入門|GUI版書籍管理アプリを完成させる

C言語DB入門|GUI版書籍管理アプリを完成させる

ここまでの記事で、書籍の登録・一覧表示・選択・変更・削除をGUI画面から操作できるようになりました。

さらに、入力欄のクリア、データ変更後の一覧更新、エラーの表示も組み込みました。

この記事では、これらの機能を整理し、GUI版書籍管理アプリを完成させます。

完成版のプログラムを確認した後、実際に書籍を登録して変更・削除し、アプリを終了しても保存内容が残ることを確かめましょう。

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

  • 完成した書籍管理アプリの機能
  • GUI・コールバック関数・データベース処理の役割
  • 完成版のプログラム全体
  • コンパイルと実行の手順
  • 登録から削除までの動作確認
  • コンソール版からGUI版へ発展した点

作業フォルダは、引き続き C:\c-db\chap07 を使用します。books.dbも、第7章で使用してきたものをそのまま使います。

完成したアプリでできること

まず、完成版の機能を整理しましょう。

機能画面での操作プログラムの動作
書籍の登録タイトルと著者名を入力して登録を押すINSERTで書籍を保存する
書籍の一覧表示アプリを起動するSELECTで書籍を読み込んで表示する
書籍の選択一覧の行をクリックするIDを保持し、タイトルと著者名を入力欄に表示する
書籍の変更選択した書籍の入力内容を修正して変更を押すIDを指定してUPDATEを実行する
書籍の削除書籍を選択して削除を押す確認後、IDを指定してDELETEを実行する
入力欄のクリアクリアを押す入力内容と選択を解除する
一覧の更新登録・変更・削除を完了するSELECTを再実行して一覧を作り直す
エラーの表示入力やデータベース処理に問題が起きる原因や次の操作を画面に表示する

登録・変更・削除が成功すると、データベースから書籍を読み込み直します。

そのため、画面に表示する一覧を、処理後のデータベースの内容に合わせることができます。

ただし、ほかのアプリによる変更を自動監視する機能はありません。今回のアプリでは、起動時と、自分で登録・変更・削除を行った後に一覧を読み込みます。

図1:GUI版書籍管理アプリの完成画面

この図から分かること

入力欄、操作ボタン、メッセージ欄、一覧が、それぞれ役割を持っています。

一覧から書籍を選ぶと、その書籍の情報が入力欄に表示され、変更と削除ができる状態になります。

プログラムを3つの役割に分けて考える

完成版では多くの関数を使いますが、全体を3つの役割に分けると理解しやすくなります。

役割主な担当内容
画面を作って表示するGTK 4入力欄、ボタン、一覧、メッセージを表示する
利用者の操作を受け取るコールバック関数入力確認や処理の呼び出しを行う
データを保存・取得するC言語のデータベース関数とSQLiteSQLを実行し、結果を返す

たとえば登録ボタンを押した場合は、次のように役割を分担します。

  1. GTK 4がボタンのクリックを受け取る
  2. on_add_clickedが呼び出される
  3. 入力内容を確認する
  4. add_bookがINSERTを実行する
  5. finish_saveが一覧を読み込み直す
  6. GTK 4の画面に結果を表示する

データベースの関数から、直接入力欄やラベルを書き換えているわけではありません。

SQLの実行結果をコールバック側へ返し、その結果に応じて画面を更新しています。

完成版で使用するデータを整理する

GUIアプリでは、画面の部品だけでなく、選択状態や読み込んだ書籍も管理する必要があります。

今回の構造体を整理しておきましょう。

構造体管理するもの
Book1件の書籍のID、タイトル、著者名
DbErrorSQLiteのエラーコードと詳細文
AppWidgets入力欄、ボタン、ラベル、一覧などのウィジェット
AppDataデータベース接続、書籍配列、選択状態、画面の状態
DeleteRequest削除確認画面で使用するデータと削除対象のID

AppDataは、アプリ全体の状態をまとめた構造体です。

ボタンのコールバック関数には、user_dataを通じてAppDataへのポインタを渡しています。

これにより、各コールバックから同じ入力欄、データベース接続、選択中のIDを参照できます。

完成版のプログラムを作成する

完成版は、前の記事のエラー表示を含めた構成です。

新しい操作を追加するのではなく、ここまで作成した機能を1つのプログラムとして確認します。

完成版のファイル名はbook_manager.cとします。これまでのファイルは学習記録として残しておいて構いません。

登録・選択・変更・削除とエラー表示を備えた書籍管理アプリ

ファイル名: book_manager.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 {
    int code;
    char *message;
} DbError;

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 requires_restart;
    gboolean closing;
    int exit_status;
} AppData;

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

/* ---------- SQLiteのエラー情報 ---------- */

static void clear_database_error(DbError *error)
{
    g_clear_pointer(&error->message, g_free);
    error->code = SQLITE_OK;
}

static void capture_database_error(
    sqlite3 *db,
    int rc,
    DbError *error)
{
    clear_database_error(error);

    error->code = rc;
    const char *message = NULL;

    if (db != NULL &&
        rc != SQLITE_MISUSE &&
        (sqlite3_errcode(db) & 0xff) == (rc & 0xff)) {
        error->code = sqlite3_extended_errcode(db);
        message = sqlite3_errmsg(db);
    }

    if (message == NULL) {
        message = sqlite3_errstr(rc);
    }

    error->message = g_strdup(
        message != NULL ? message : "Unknown SQLite error"
    );
}

static int finish_statement(
    sqlite3 *db,
    sqlite3_stmt *stmt,
    int rc,
    DbError *error)
{
    if (rc != SQLITE_DONE) {
        capture_database_error(db, rc, error);
        sqlite3_finalize(stmt);
        return rc;
    }

    int finalize_rc = sqlite3_finalize(stmt);

    if (finalize_rc != SQLITE_OK) {
        capture_database_error(db, finalize_rc, error);
    }

    return finalize_rc;
}

/* ---------- データベースの準備と終了 ---------- */

static int open_database(
    const char *path,
    sqlite3 **db,
    DbError *error)
{
    int rc = sqlite3_open(path, db);

    if (rc != SQLITE_OK) {
        capture_database_error(*db, rc, error);
    }

    return rc;
}

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

    int rc = sqlite3_exec(db, sql, NULL, NULL, NULL);

    if (rc != SQLITE_OK) {
        capture_database_error(db, rc, error);
    }

    return rc;
}

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,
    DbError *error)
{
    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) {
        capture_database_error(db, rc, error);
        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);
    }

    return finish_statement(db, stmt, rc, error);
}

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

    *changed_rows = 0;

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

    if (rc != SQLITE_OK) {
        capture_database_error(db, rc, error);
        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);
    }

    rc = finish_statement(db, stmt, rc, error);

    if (rc == SQLITE_OK) {
        *changed_rows = affected;
    }

    return rc;
}

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

    *deleted_rows = 0;

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

    if (rc != SQLITE_OK) {
        capture_database_error(db, rc, error);
        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);
    }

    rc = finish_statement(db, stmt, rc, error);

    if (rc == SQLITE_OK) {
        *deleted_rows = affected;
    }

    return rc;
}

/* ---------- 書籍の読み込みと解放 ---------- */

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

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

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

    *out_books = NULL;

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

    if (rc != SQLITE_OK) {
        capture_database_error(db, rc, error);
        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);
    }

    rc = finish_statement(db, stmt, rc, error);

    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 show_message(
    AppData *data,
    const char *kind,
    const char *message)
{
    char *text = g_strdup_printf(
        "【%s】\n%s", kind, message
    );

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

    g_free(text);
}

static const char *database_error_reason(int code)
{
    switch (code & 0xff) {
    case SQLITE_BUSY:
        return
            "ほかの接続の処理によって、"
            "データベースを利用できません。";

    case SQLITE_LOCKED:
        return
            "データベース内の処理が競合しています。";

    case SQLITE_READONLY:
        return
            "データベースへ書き込めない状態です。";

    case SQLITE_CANTOPEN:
        return
            "データベースや関連するファイルを開けません。";

    case SQLITE_CONSTRAINT:
        return
            "データベースで定められた条件に違反しています。";

    case SQLITE_FULL:
        return
            "保存先や一時ファイルの保存領域に"
            "空き容量が不足しています。";

    case SQLITE_IOERR:
        return
            "ファイルの読み書きで問題が発生しました。";

    case SQLITE_CORRUPT:
        return
            "データベースの破損が検出されました。";

    case SQLITE_NOTADB:
        return
            "接続先のファイルをSQLiteデータベースとして"
            "読み取れません。";

    case SQLITE_NOMEM:
        return
            "SQLiteが必要なメモリを確保できませんでした。";

    default:
        return
            "データベースの処理中に問題が発生しました。";
    }
}

static char *format_database_error(
    const char *summary,
    const char *action,
    const DbError *error)
{
    return g_strdup_printf(
        "%s\n"
        "%s\n"
        "%s\n\n"
        "詳細:SQLite %d:%s",
        summary,
        database_error_reason(error->code),
        action,
        error->code,
        error->message != NULL
            ? error->message
            : "詳細情報はありません。"
    );
}

static void show_database_error(
    AppData *data,
    const char *kind,
    const char *summary,
    const char *action,
    const DbError *error)
{
    char *message = format_database_error(
        summary, action, error
    );

    show_message(data, kind, message);
    g_free(message);
}

static void show_write_error(
    AppData *data,
    const char *summary,
    const DbError *error)
{
    const char *action;

    if ((error->code & 0xff) == SQLITE_BUSY) {
        action =
            "入力内容は残しています。\n"
            "ほかのアプリでのデータベース操作が完了してから、"
            "今回の操作をやり直してください。";
    } else {
        action =
            "入力内容は残しています。\n"
            "必要な入力内容と詳細を控え、"
            "接続先や保存先の状態を確認してください。";
    }

    show_database_error(
        data,
        "データベースエラー",
        summary,
        action,
        error
    );
}

/* ---------- 入力と選択の管理 ---------- */

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->requires_restart &&
        !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 require_restart(AppData *data)
{
    data->requires_restart = TRUE;
    update_buttons(data);

    gtk_widget_set_sensitive(
        data->widgets.book_list, FALSE
    );
}

static void reset_input_state(AppData *data)
{
    data->has_selection = FALSE;
    data->selected_id = 0;

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

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

    update_buttons(data);
}

static void clear_form(AppData *data, gboolean focus_title)
{
    gtk_list_box_unselect_all(
        GTK_LIST_BOX(data->widgets.book_list)
    );

    reset_input_state(data);

    if (focus_title) {
        gtk_widget_grab_focus(data->widgets.title_entry);
    }
}

static gboolean is_blank_text(const char *text)
{
    for (const char *p = text; *p != '\0';
         p = g_utf8_next_char(p)) {
        gunichar character = g_utf8_get_char(p);

        if (!g_unichar_isspace(character)) {
            return FALSE;
        }
    }

    return TRUE;
}

static gboolean validate_inputs(
    AppData *data,
    const char *title,
    const char *author)
{
    if (is_blank_text(title)) {
        show_message(
            data,
            "入力エラー",
            "タイトルを入力してください。\n"
            "空白だけの入力は登録・変更できません。"
        );

        gtk_widget_grab_focus(data->widgets.title_entry);
        return FALSE;
    }

    if (is_blank_text(author)) {
        show_message(
            data,
            "入力エラー",
            "著者名を入力してください。\n"
            "空白だけの入力は登録・変更できません。"
        );

        gtk_widget_grab_focus(data->widgets.author_entry);
        return FALSE;
    }

    return TRUE;
}

static void on_book_selected(
    GtkListBox *list,
    GtkListBoxRow *row,
    gpointer user_data)
{
    (void)list;

    AppData *data = user_data;

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

    if (row == NULL) {
        reset_input_state(data);
        show_message(data, "案内", "選択を解除しました。");
        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) {
        require_restart(data);

        show_message(
            data,
            "一覧の確認が必要です",
            "選択した行に対応する書籍データが見つかりません。\n"
            "必要な入力内容を控え、アプリを起動し直してください。"
        );

        return;
    }

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

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

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

    gtk_label_set_text(
        GTK_LABEL(data->widgets.selected_label), text
    );

    g_free(text);
    update_buttons(data);

    show_message(
        data,
        "案内",
        "書籍を選択しました。変更または削除ができます。"
    );
}

/* ---------- 一覧の表示 ---------- */

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)
{
    GtkListBox *list =
        GTK_LIST_BOX(data->widgets.book_list);

    clear_form(data, FALSE);

    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 = g_strdup_printf(
        "%u件の書籍を表示しています。",
        data->books->len
    );

    show_message(data, "案内", message);
    g_free(message);
}

static int refresh_books(AppData *data, DbError *error)
{
    GPtrArray *new_books = NULL;

    int rc = load_books(data->db, &new_books, error);

    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 finish_save(
    AppData *data,
    const char *success_message)
{
    clear_form(data, TRUE);

    DbError error = {0};
    int rc = refresh_books(data, &error);

    if (rc != SQLITE_OK) {
        require_restart(data);

        show_database_error(
            data,
            "一覧更新エラー",
            "データベースへの変更は完了しましたが、"
            "一覧を更新できませんでした。",
            "画面の一覧は最新ではありません。\n"
            "同じ登録・変更・削除を繰り返さず、"
            "アプリを終了してください。\n"
            "原因を確認してから起動し直し、"
            "保存内容を確認してください。",
            &error
        );

        clear_database_error(&error);
        return;
    }

    clear_database_error(&error);

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

    show_message(data, "完了", message);
    g_free(message);
}

static void show_missing_book(
    AppData *data,
    sqlite3_int64 id,
    const char *operation)
{
    require_restart(data);

    char *message = g_strdup_printf(
        "ID:%lldの書籍がデータベースに見つかりません。\n"
        "今回の%sでは対象の書籍を操作していません。\n"
        "入力内容は残しています。必要な内容を控え、"
        "アプリを起動し直して一覧を確認してください。",
        (long long)id,
        operation
    );

    show_message(data, "対象が見つかりません", message);
    g_free(message);
}

/* ---------- 登録ボタン ---------- */

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

    AppData *data = user_data;

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

    if (data->has_selection) {
        show_message(
            data,
            "操作の確認",
            "新しく登録するときは、"
            "クリアで選択を解除してください。"
        );
        return;
    }

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

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

    DbError error = {0};
    int rc = add_book(data->db, title, author, &error);

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

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

/* ---------- 変更ボタン ---------- */

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

    AppData *data = user_data;

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

    if (!data->has_selection) {
        show_message(
            data,
            "操作の確認",
            "変更する書籍を一覧から選択してください。"
        );
        return;
    }

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

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

    sqlite3_int64 target_id = data->selected_id;
    int changed_rows = 0;
    DbError error = {0};

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

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

    clear_database_error(&error);

    if (changed_rows == 0) {
        show_missing_book(data, target_id, "変更");
        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);
        show_message(data, "案内", "削除をキャンセルしました。");
    }

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

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

    update_buttons(data);

    int deleted_rows = 0;
    DbError error = {0};

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

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

    clear_database_error(&error);

    if (deleted_rows == 0) {
        show_missing_book(data, target_id, "削除");
        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->closing ||
        data->requires_restart ||
        data->confirming_delete) {
        return;
    }

    if (!data->has_selection) {
        show_message(
            data,
            "操作の確認",
            "削除する書籍を一覧から選択してください。"
        );
        return;
    }

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

    if (book == NULL) {
        require_restart(data);

        show_message(
            data,
            "一覧の確認が必要です",
            "選択した書籍データが見つかりません。\n"
            "必要な入力内容を控え、アプリを起動し直してください。"
        );

        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 *message = 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(message);
    g_free(message);

    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->closing || data->confirming_delete) {
        return;
    }

    clear_form(data, TRUE);

    if (!data->requires_restart) {
        show_message(
            data, "案内", "選択と入力欄をクリアしました。"
        );
    }
}

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 *summary,
    const DbError *error)
{
    GtkWidget *window = gtk_application_window_new(app);

    gtk_window_set_title(GTK_WINDOW(window), "起動エラー");
    gtk_window_set_default_size(GTK_WINDOW(window), 640, 320);

    GtkWidget *box =
        gtk_box_new(GTK_ORIENTATION_VERTICAL, 16);

    set_margins(box, 24);
    gtk_window_set_child(GTK_WINDOW(window), box);

    char *detail = format_database_error(
        summary,
        "接続先、フォルダの権限、"
        "データベースの状態を確認してください。",
        error
    );

    char *path = g_canonicalize_filename(
        DATABASE_PATH, NULL
    );

    char *message = g_strdup_printf(
        "【起動エラー】\n%s\n\n接続先:%s",
        detail,
        path
    );

    GtkWidget *label = gtk_label_new(message);

    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
    );
    gtk_label_set_selectable(GTK_LABEL(label), TRUE);

    g_free(detail);
    g_free(path);
    g_free(message);

    GtkWidget *button =
        gtk_button_new_with_label("閉じる");

    gtk_widget_set_halign(button, GTK_ALIGN_END);

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

    gtk_box_append(GTK_BOX(box), label);
    gtk_box_append(GTK_BOX(box), button);
    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);

    gtk_window_set_title(
        GTK_WINDOW(widgets->window), "書籍管理アプリ"
    );
    gtk_window_set_default_size(
        GTK_WINDOW(widgets->window), 800, 680
    );

    GtkWidget *box =
        gtk_box_new(GTK_ORIENTATION_VERTICAL, 16);

    set_margins(box, 24);
    gtk_window_set_child(
        GTK_WINDOW(widgets->window), box
    );

    GtkWidget *grid = gtk_grid_new();

    gtk_grid_set_row_spacing(GTK_GRID(grid), 12);
    gtk_grid_set_column_spacing(GTK_GRID(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(grid), title_label, 0, 0, 1, 1
    );
    gtk_grid_attach(
        GTK_GRID(grid), widgets->title_entry, 1, 0, 1, 1
    );
    gtk_grid_attach(
        GTK_GRID(grid), author_label, 0, 1, 1, 1
    );
    gtk_grid_attach(
        GTK_GRID(grid), widgets->author_entry, 1, 1, 1, 1
    );

    GtkWidget *buttons =
        gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);

    gtk_widget_set_halign(buttons, 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(buttons), widgets->add_button);
    gtk_box_append(GTK_BOX(buttons), widgets->update_button);
    gtk_box_append(GTK_BOX(buttons), widgets->delete_button);
    gtk_box_append(GTK_BOX(buttons), 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
    );
    gtk_label_set_wrap_mode(
        GTK_LABEL(widgets->result_label),
        PANGO_WRAP_WORD_CHAR
    );
    gtk_label_set_selectable(
        GTK_LABEL(widgets->result_label), TRUE
    );

    GtkWidget *heading =
        gtk_label_new("登録されている書籍");

    gtk_label_set_xalign(GTK_LABEL(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(box), grid);
    gtk_box_append(GTK_BOX(box), buttons);
    gtk_box_append(GTK_BOX(box), widgets->selected_label);
    gtk_box_append(GTK_BOX(box), widgets->result_label);
    gtk_box_append(GTK_BOX(box), heading);
    gtk_box_append(GTK_BOX(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(
        widgets->window,
        "close-request",
        G_CALLBACK(on_close_request),
        data
    );

    display_books(data);
    gtk_window_present(GTK_WINDOW(widgets->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;
    }

    DbError error = {0};

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

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

    rc = initialize_database(data->db, &error);

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

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

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

    clear_database_error(&error);
    create_main_window(app, data);
}

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

    GtkApplication *app = gtk_application_new(
        "com.example.cdb.BookManager",
        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 rc = close_database(&data.db);

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

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

    return status;
}

initialize_databaseは、booksテーブルがない場合に作成します。すでに存在するテーブルを削除したり、登録済みの書籍を初期化したりする処理ではありません。

また、既存のテーブル定義を変更する処理でもありません。第6章から使用しているbooksテーブルに対して、そのままCRUDを実行します。

起動から終了までの流れを確認する

完成版のプログラムは、起動時・操作時・終了時に分けると読みやすくなります。

タイミング主な処理
起動時SQLiteを開く、テーブルを準備する、書籍を読み込む、画面を表示する
操作時コールバック関数で入力を確認し、SQLを実行して画面を更新する
終了時書籍配列を解放し、データベース接続を閉じる

mainの中では、AppDataを初期化しています。

AppData data = {0};

そのアドレスを、activateシグナルのコールバックへ渡します。

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

dataはmainのローカル変数ですが、g_application_runが動作している間も存在します。

そのため、実行中のコールバック関数から、同じAppDataを利用できます。

g_application_runが戻った後は、保持していた書籍配列を解放し、データベース接続を閉じます。

図2:画面操作とデータベース処理をつなぐ完成版の構造

イラスト作成用プロンプト

この図から分かること

GTK 4は利用者とのやり取りを担当し、SQLiteはデータの保存と取得を担当します。

その間を、C言語で作成したコールバック関数とデータベース関数がつないでいます。

登録・変更・削除で共通する部分を確認する

登録・変更・削除には、それぞれ異なるSQLが必要です。

一方、操作を受け取ってから結果を表示するまでの考え方は共通しています。

処理段階登録変更削除
対象の確認未選択であることを確認選択中のIDを確認選択中のIDを確認
入力の確認タイトルと著者名を確認タイトルと著者名を確認削除確認画面を表示
SQLの実行INSERTUPDATEDELETE
結果の確認戻り値を確認戻り値と対象行数を確認戻り値と対象行数を確認
成功後入力と選択をクリアし、一覧を更新入力と選択をクリアし、一覧を更新入力と選択をクリアし、一覧を更新

成功後の共通処理は、finish_saveで行っています。

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

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

変更と削除の場合も、表示する成功メッセージを変えて同じ関数を呼び出します。

このようにすると、一覧の更新方法を変更するときも、共通部分を確認すればよくなります。

SQLには入力値をバインドする

登録処理では、次のSQLを使用しています。

const char *sql =
    "INSERT INTO books (title, author) VALUES (?, ?);";

タイトルと著者名は、SQL文字列に直接連結せず、sqlite3_bind_textで渡します。

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

SQLITE_TRANSIENTを指定すると、SQLiteは渡された文字列をコピーして保持します。入力値をSQLの構文と分けて渡せるため、タイトルに引用符などが含まれていても、文字列データとして扱えます。

第4章で学習したプリペアドステートメントの使い方は、GUI版でも同じです。

変更・削除では対象行数も確認する

変更処理では、SQLの実行が完了したときに対象行数を取得しています。

int affected = 0;

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

sqlite3_changesは、その接続で直前に完了したINSERT・UPDATE・DELETEが直接処理した行数を返します。この記事では主キーのIDで1件を指定するため、変更・削除の対象が見つかったかを確認するために使っています。sqlite.org

この行数は、一覧に表示する書籍の総数とは別です。

値意味
changed_rows今回のUPDATEが処理した行数
deleted_rows今回のDELETEが処理した行数
data->books->len読み込んだ書籍の総数

選択中の書籍は行番号ではなくIDで管理する

一覧の先頭の行が、必ずID:1とは限りません。

書籍を削除すると、一覧の行数や位置は変わりますが、残っている書籍のIDを詰め直すわけではありません。

そのため、選択した行には、その書籍のIDを関連付けています。

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

選択時には、そのIDを取り出します。

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

このIDを使って書籍データを探し、AppDataに選択状態を保存します。

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

has_selectionは選択の有無、selected_idは選択した書籍のIDです。

「IDが0なら未選択」とだけ判断せず、選択の有無を別の値で管理しています。

選択解除もコールバックで受け取る

row-selectedシグナルは、行が選択されたときだけでなく、選択を解除したときにも発生します。その場合、rowはNULLになります。

if (row == NULL) {
    reset_input_state(data);
    show_message(data, "案内", "選択を解除しました。");
    return;
}

そのため、rowがNULLかどうかを確認してから、行に関連付けたデータを取り出しています。

操作できるボタンを状態に合わせる

update_buttonsは、アプリの状態に応じてボタンを有効・無効にする関数です。

static void update_buttons(AppData *data)
{
    gboolean ready =
        !data->requires_restart &&
        !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 int refresh_books(AppData *data, DbError *error)
{
    GPtrArray *new_books = NULL;

    int rc = load_books(data->db, &new_books, error);

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

処理の順番は、次のとおりです。

  1. new_booksに書籍を読み込む
  2. 読み込み結果を確認する
  3. AppDataの書籍配列を新しいものへ差し替える
  4. 一覧の行を作り直す
  5. 古い書籍配列を解放する

読み込みに失敗した場合は、古い書籍配列と一覧を残します。

ただし、その一覧が最新であるとは限りません。保存後の読み込みに失敗した場合は、finish_saveで操作を無効にし、再起動して確認するように案内します。

書籍が0件でも正常な結果として扱う

SELECTが成功しても、書籍が1件もない場合があります。

このときは、要素数が0のGPtrArrayを受け取ります。

display_booksは古い行を取り除き、新しく追加する行がなければ、GtkListBoxのプレースホルダーを表示します。

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

書籍が0件であることと、読み込みに失敗したことは、区別して扱っています。

メモリ上のデータと保存済みのデータを区別する

完成版では、同じ書籍の情報が複数の場所に存在します。

場所役割アプリ終了後
GtkEntry利用者が入力・編集している文字列残らない
BookとGPtrArray読み込んだ書籍をメモリ上で保持する残らない
books.dbSQLで保存した書籍を保持する残る

入力欄を書き換えただけでは、books.dbは変更されません。

登録や変更を実行し、SQLiteの処理が成功して初めて保存内容が変わります。

また、クリアは入力欄と選択を初期状態へ戻す処理です。すでに保存した書籍を削除したり、完了した変更を取り消したりするものではありません。

読み込んだ文字列はコピーして保持する

SQLiteから取得した文字列は、Bookにコピーしています。

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

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

sqlite3_column_textが返す文字列を、そのままずっと保持できるわけではありません。次の行へ進んだり、SQL文を終了したりすると、そのポインタが無効になることがあります。

そこで、アプリ側で必要な文字列をコピーし、使い終わったらfree_bookで解放しています。

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

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

書籍配列の作成時に、この関数を解放用の関数として登録しています。

GPtrArray *books =
    g_ptr_array_new_with_free_func(free_book);

この構成では、配列の最後の参照を解放すると、保持している各Bookもfree_bookで解放されます。

コンパイルして完成版を起動する

book_manager.cをUTF-8で保存し、MSYS2 UCRT64のターミナルを開きます。

第7章の作業フォルダへ移動する

cd /c/c-db/chap07
pwd
ls -l books.db

C:\c-db\chap07は第2章で作成済みです。

books.dbもすでに用意されているため、新しく作り直したり、第6章からコピーし直したりする必要はありません。

このプログラムは、次の相対パスでデータベースを開きます。

#define DATABASE_PATH "books.db"

基準になるのは、実行時の作業フォルダです。実行ファイルが置かれた場所を自動的に基準にするわけではありません。

別の場所で実行すると、その場所のbooks.dbを開いたり、新しく作成したりすることがあるため、作業フォルダを確認してから起動しましょう。

コンパイルする

基本のコマンドは次のとおりです。

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

C17を指定し、警告も確認する場合は、次のコマンドを使います。

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

GTK 4とSQLiteの両方を使用するため、pkg-configにはgtk4とsqlite3を指定しています。

実行する

前の記事のアプリが動いている場合は終了してから、完成版を起動します。

./book_manager.exe

登録から削除までを順番に確認する

ここからは、完成版を使って一連の操作を確認します。

以下の実行結果は、最初に書籍が2件ある場合の例です。IDや件数は、これまでの登録・削除の履歴によって変わります。

起動時の一覧を確認する

アプリを起動すると、保存されている書籍が表示されます。

実行結果の例

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

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

【案内】
2件の書籍を表示しています。

この時点では、入力欄は空で、選択中のIDはありません。

「登録」ボタンは有効、「変更」・「削除」ボタンは無効になっています。

確認用の書籍を登録する

次の内容を入力して、「登録」ボタンを押します。

項目入力内容
タイトルGTK 4書籍管理実践
著者名山本太郎

実行結果の例

【完了】
書籍を登録しました。現在3件です。

一覧に新しい書籍が追加され、入力欄が空になります。

IDタイトル著者名
1C言語入門山田太郎
3C言語実践入門佐藤一郎
5GTK 4書籍管理実践山本太郎

ここでは追加されたIDを5としていますが、実際に表示されたIDで確認してください。

一覧の3行目だからIDも3になる、という関係ではありません。

登録した書籍を選択して変更する

一覧から、今登録した書籍を選びます。

入力欄にタイトルと著者名が表示され、選択中のIDも表示されます。

タイトルを次の内容に変更し、「変更」ボタンを押します。

GTK 4とSQLite実践

実行結果の例

【完了】
ID:5の書籍を変更しました。現在3件です。

一覧のタイトルが変わり、件数とIDはそのままです。

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

変更後は入力欄と選択がクリアされます。

未保存の入力をクリアする

もう一度、確認用の書籍を選択します。

タイトルを「保存しない編集」に書き換え、変更ボタンを押さずにクリアを押します。

実行結果の例

【案内】
選択と入力欄をクリアしました。

入力欄と選択は解除されますが、一覧のタイトルはGTK 4とSQLite実践のままです。

入力欄の編集内容は、変更を実行するまでデータベースに保存されないことが確認できます。

終了してから起動し直す

ウィンドウを閉じ、同じ作業フォルダで再度実行します。

./book_manager.exe

実行結果の例

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

変更後のタイトルが表示されれば、books.dbに保存されていることを確認できます。

入力欄に一時的に表示していただけの状態との違いが、ここで分かります。

削除のキャンセルを確認する

確認用の書籍を選び、「削除」ボタンを押します。

確認画面で「キャンセル」を押してください。

実行結果の例

【案内】
削除をキャンセルしました。

書籍は一覧に残り、入力内容と選択も残ります。

確認用の書籍を削除する

もう一度削除ボタンを押し、確認画面で削除するを押します。

実行結果の例

【完了】
ID:5の書籍を削除しました。現在2件です。

一覧から確認用の書籍がなくなります。

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

残った書籍のIDは変わりません。

もう一度終了して起動し直し、削除した書籍が表示されないことも確認してみましょう。

図3:再起動してもデータベースの保存内容は残る

この図から分かること

入力欄や書籍配列は、アプリの実行中に使うデータです。

一方、books.dbに保存した内容は、アプリを終了しても残ります。再起動すると、その内容を読み込んで一覧を作り直します。

入力エラーと画面の状態を確認する

CRUDの正常な動作に加えて、入力ミスがあった場合も確認しておきましょう。

確認する操作期待する動作
タイトルを空にして登録タイトルの入力を促す
著者名を空にして登録著者名の入力を促す
全角スペースだけで登録空白だけの入力として止める
選択した書籍のタイトルを空にして変更UPDATEを実行せず、入力内容と選択を残す
未選択の状態変更・削除ボタンが無効になる
書籍を選択する登録が無効になり、変更・削除が有効になる
削除確認をキャンセルDELETEを実行せず、書籍を残す
通常のクリア入力欄と選択を解除する

SQLiteの競合エラーは、前の記事で紹介した別接続のBEGIN IMMEDIATEを使う方法で確認できます。

保存後の一覧更新に失敗した場合は、次の点が通常の入力エラーとは異なります。

  • データベースへの変更は完了している
  • 古い一覧が残っている可能性がある
  • 同じ登録・変更・削除を繰り返さないように案内する
  • 登録・変更・削除と一覧選択を無効にする

画面に表示する言葉と、実際にどこまで処理が完了したかを一致させることが大切です。

SQLiteから保存内容を直接確認する

GUIでの確認が終わったら、アプリを終了し、SQLiteのコマンドからも保存内容を確認できます。

cd /c/c-db/chap07
sqlite3 -readonly -list books.db "SELECT id, title, author FROM books ORDER BY id;"

実行結果の例

1|C言語入門|山田太郎
3|C言語実践入門|佐藤一郎

GUIで表示した内容と一致していれば、画面とデータベースが正しくつながっていることを確認できます。

もし内容が違う場合は、まず実行時の作業フォルダを確認しましょう。

同じbooks.dbというファイル名でも、保存されているフォルダが違えば別のデータベースです。

コンソール版からGUI版へ発展したこと

PartⅠのコンソール版と、今回のGUI版を比較してみましょう。

比較する項目コンソール版GUI版
操作の選択メニュー番号を入力するボタンをクリックする
書籍情報の入力コンソールから入力するGtkEntryに入力する
対象の指定IDを入力する一覧の行を選択する
処理の開始メニューの分岐から関数を呼ぶコールバック関数から呼ぶ
一覧の表示printfなどで出力するGtkListBoxに行を表示する
エラーの表示コンソールへ出力するメッセージ欄へ表示する
保存処理SQLite C APIを使うSQLite C APIを使う
データの保存先books.dbbooks.db

利用者とのやり取りは大きく変わりましたが、SQLiteを開く、SQLを準備する、値をバインドする、実行する、SQL文を解放する、という基本は共通しています。

GUI版を作るために、データベースの仕組みを一から学び直したわけではありません。

PartⅠで身につけたCRUDの処理に、GTK 4による入力・表示・イベント処理を組み合わせました。

完成版の範囲と今後の拡張を考える

今回完成したのは、少量の書籍を扱いながら、GUIとSQLiteの連携を学習するためのアプリです。

登録・変更・削除の結果をSELECTで読み込み直す構成なので、処理の流れを追いやすくなっています。

一方、次のような機能は、今回の完成版には含めていません。

拡張する機能目的
タイトルや著者名での検索必要な書籍を見つけやすくする
並べ替えタイトル順などで表示する
ページ単位の読み込み書籍数が増えたときの表示負荷を抑える
バックアップと復元保存したデータを保護する
複数利用者の同時編集への対応外部の変更との衝突を扱う
時間のかかる処理の分離データベース処理中も画面を操作しやすくする

現在のプログラムは、データベース処理をGUIのコールバック内で同期的に実行します。データ量や処理時間が大きくなった場合には、画面の応答性も考えて構成を見直す必要があります。

また、未保存の入力について、終了時や別の行を選択するときに確認する機能はありません。保存する場合は登録・変更を実行し、不要な入力はクリアする、という操作になっています。

こうした現在の動作を把握したうえで、必要な機能を少しずつ追加していくと、変更の目的が明確になります。

C言語の構造体や関数、SQLiteのCRUD、GTK 4のイベント処理を組み合わせることで、入力した情報を保存し、画面から操作できるアプリを作れるようになりました。完成版を手元で動かしながら、ボタンを押した後にどの関数が呼ばれ、どのSQLが実行されるのかをコードで追ってみてください。

Follow me!