C言語DB入門|GUIから書籍を登録する

C言語DB入門|GUIから書籍を登録する

第6章では、SQLiteに保存されている書籍を読み込み、GTK 4の画面に一覧表示できるようになりました。

第7章では、この画面へ登録・変更・削除の機能を追加し、GUI版の書籍管理アプリを完成させていきます。

この記事では、タイトルと著者名を入力して登録ボタンを押すと、書籍をデータベースへ保存できるようにします。登録後には書籍を読み込み直し、新しい内容を一覧へ表示します。

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

  • 第6章のデータベースを第7章の作業フォルダへコピーする
  • GtkEntryから入力された文字列を取得する
  • 登録ボタンとコールバック関数を接続する
  • プリペアドステートメントでINSERTを実行する
  • 登録後にSELECTを再実行して一覧を更新する
  • 登録結果やエラーを画面に表示する

作業フォルダには、第2章で作成済みのC:\c-db\chap07を使用します。

データベースは、第6章で使用したC:\c-db\chap06\books.dbを、コマンドでC:\c-db\chap07へコピーして準備しましょう。

GUIから書籍を登録する流れ

コンソール版では、キーボードから読み取った文字列を使って書籍を登録しました。

GUI版では、文字列の取得元がGtkEntryに変わります。データベースへ保存する部分では、これまでと同じSQLiteのC APIを使用します。

処理コンソール版GUI版
タイトルと著者名の入力ターミナルで入力するGtkEntryへ入力する
登録処理の開始メニューなどで登録を選ぶ登録ボタンを押す
入力値の取得標準入力から読み取るgtk_editable_get_textで取得する
データの保存INSERTを実行するINSERTを実行する
結果の通知ターミナルへ文字を出力するGtkLabelの表示を変更する
一覧の表示ターミナルへ出力するGtkListBoxへ表示する

登録ボタンを押した後は、次の順番で処理を進めます。

  1. タイトルと著者名を取得する
  2. 空欄になっていないか確認する
  3. INSERTで書籍を保存する
  4. SELECTで書籍を読み込み直す
  5. 新しい一覧を画面へ表示する

データベースへの保存と、画面の更新は別の処理であることが、今回の大切なポイントです。

図1:入力した書籍がデータベースへ登録される流れ

この図から分かること

登録ボタンを押すと、接続されているコールバック関数が呼び出されます。

コールバック関数は入力欄から文字列を取得し、データベース処理用の関数へ渡します。その関数がINSERTを実行することで、書籍が保存されます。

GTK 4の画面とSQLiteの処理を、C言語の関数でつないでいく仕組みです。

第7章で使用するデータベースをコピーする

作業フォルダのC:\c-db\chap07は、第2章ですでに作成しています。今回は、このフォルダへ移動するところから始めます。

まず、第6章のGUIアプリや、books.dbを開いているSQLiteのコマンドラインツールを終了してください。

SQLiteのコマンドラインツールを対話形式で使用している場合は、次のコマンドで終了できます。

.exit

データベースを使用しているプログラムを正常に終了してから、ファイルをコピーします。

作業フォルダへ移動する

MSYS2 UCRT64のターミナルで、次のコマンドを実行します。

cd /c/c-db/chap07

現在の作業フォルダは、pwdで確認できます。

pwd

表示例は、次のとおりです。

/c/c-db/chap07

第6章のbooks.dbをコピーする

続いて、次のコマンドを実行します。

cp -i /c/c-db/chap06/books.db /c/c-db/chap07/books.db
指定意味
cpファイルをコピーする
-iコピー先に同名ファイルがある場合、上書き前に確認する
/c/c-db/chap06/books.dbコピー元
/c/c-db/chap07/books.dbコピー先

コピー先にbooks.dbがなければ、通常は何も表示されずにコピーが完了します。

同名ファイルが存在する場合は、上書きの確認が表示されます。コピー先のデータを残したい場合はn、コピー元の内容で置き換える場合はyを入力します。

コピー後のファイルを確認しましょう。

ls -l books.db

ファイル情報が表示されれば、現在の作業フォルダにbooks.dbが存在しています。

コピーした書籍データを確認する

次のコマンドで、コピーしたデータベースの内容を確認できます。

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

第6章で登録した書籍が次の2件であれば、このように表示されます。

1|C言語入門|山田太郎
2|SQLite入門|鈴木花子

-readonlyは、この確認用の接続を読み取り専用にする指定です。後で起動するGUIアプリの接続まで読み取り専用になるわけではありません。

第6章ですでに書籍データを準備しているため、6章で作成した prepare_reading_books.sqlを実行し直す必要はありません。

今回のコピーで、テーブルと保存済みの書籍データをまとめて引き継げます。

コピー元とコピー先は別のデータベースになる

コピー後は、次の2つのファイルが存在します。

ファイル用途
C:\c-db\chap06\books.db第6章で使用したデータベース
C:\c-db\chap07\books.db第7章で使用するデータベース

第7章のアプリで書籍を登録しても、第6章のbooks.dbには反映されません。

ファイルをコピーした後は、それぞれ独立したデータベースとして扱われます。

今回のアプリでできること

この記事では、登録機能を中心に実装します。

項目今回の動作
登録ボタン入力内容を確認し、書籍を登録する
タイトルが空の場合メッセージを表示し、登録しない
著者名が空の場合メッセージを表示し、登録しない
登録に成功した場合入力欄を空にし、一覧を読み込み直す
登録に失敗した場合入力内容を残して、エラーを表示する
変更ボタン使用できない状態にする
削除ボタン使用できない状態にする
一覧の行選択まだ使用しない
クリアボタン入力欄を空にする

空欄の確認では、文字列の長さが0かどうかを調べます。空白文字だけの入力への対応などは、入力エラーを整理する記事で詳しく扱います。

また、今回の登録処理では、同じタイトルと著者名の組み合わせを複数回保存できます。重複を禁止する機能は追加していません。

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

第6章の一覧表示プログラムへ、主に次の関数を追加します。

関数役割
add_bookINSERTを実行して書籍を保存する
refresh_books書籍を読み込み直し、一覧を更新する
clear_inputsタイトルと著者名の入力欄を空にする
show_database_errorSQLiteのエラーを画面へ表示する
on_add_clicked登録ボタンを押したときの処理をまとめる

ソースファイルは、C:\c-db\chap07\book_add.cとしてUTF-8で保存してください。

データベースの指定には、相対パスのbooks.dbを使用します。

C:\c-db\chap07へ移動してから実行することで、先ほどコピーしたC:\c-db\chap07\books.dbを開きます。

入力した書籍をSQLiteへ登録して一覧を更新するプログラム

ファイル名: book_add.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 *result_label;
    GtkWidget *book_list;
} AppWidgets;

typedef struct {
    AppWidgets widgets;
    sqlite3 *db;
    GPtrArray *books;
    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 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 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 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), FALSE
    );
    gtk_list_box_row_set_activatable(
        GTK_LIST_BOX_ROW(row), FALSE
    );

    return row;
}

static void display_books(
    AppWidgets *widgets,
    GPtrArray *books
)
{
    GtkListBox *list = GTK_LIST_BOX(widgets->book_list);
    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 < books->len; i++) {
        const Book *book = g_ptr_array_index(books, i);

        gtk_list_box_append(list, create_book_row(book));
    }

    char *message;

    if (books->len == 0) {
        message = g_strdup(
            "読み込みは正常に完了しました。書籍は0件です。"
        );
    } else {
        message = g_strdup_printf(
            "%u件の書籍を表示しています。", 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;
    }

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

    data->books = new_books;

    display_books(&data->widgets, data->books);

    return SQLITE_OK;
}

static void clear_inputs(AppWidgets *widgets)
{
    gtk_editable_set_text(
        GTK_EDITABLE(widgets->title_entry), ""
    );
    gtk_editable_set_text(
        GTK_EDITABLE(widgets->author_entry), ""
    );

    gtk_widget_grab_focus(widgets->title_entry);
}

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 void on_add_clicked(
    GtkButton *button,
    gpointer user_data
)
{
    (void)button;

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

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

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

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

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

    clear_inputs(widgets);

    rc = refresh_books(data);

    if (rc != SQLITE_OK) {
        gtk_widget_set_sensitive(
            widgets->add_button, FALSE
        );

        show_database_error(
            data,
            "書籍の保存は完了しましたが、"
            "一覧を更新できませんでした。\n"
            "同じ書籍を再登録せず、"
            "アプリを閉じて起動し直してください。",
            rc
        );
        return;
    }

    char *message = g_strdup_printf(
        "書籍を登録しました。現在%u件です。",
        data->books->len
    );

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

    g_free(message);
}

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

    AppData *data = user_data;

    clear_inputs(&data->widgets);

    gtk_label_set_text(
        GTK_LABEL(data->widgets.result_label),
        "入力欄をクリアしました。"
    );
}

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

    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("登録");

    GtkWidget *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(update_button, FALSE);
    gtk_widget_set_sensitive(delete_button, FALSE);

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

    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_NONE
    );
    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->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(
        clear_button,
        "clicked",
        G_CALLBACK(on_clear_clicked),
        data
    );

    display_books(widgets, data->books);

    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.BookAdd",
        G_APPLICATION_DEFAULT_FLAGS
    );

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

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

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

#include <gtk/gtk.h>のincludePathエラーが出る場合

以下の記事を参考にしてください。

C言語GUI入門|#include <gtk gtk.h="">のincludePathエラー</gtk>

C言語GUI入門|#include <gtk/gtk.h>のincludePathエラー このエラーは、VS Code の C/C++拡張機能(IntelliSense)が gtk/gtk.h の場所を見つけられていないときに…

コピーしたデータベースを開く仕組み

データベースのパスは、次のように指定しています。

#define DATABASE_PATH "books.db"

books.dbは相対パスなので、実行時の作業フォルダを基準に開きます。

今回の対応関係を確認しましょう。

項目内容
作業フォルダC:\c-db\chap07
プログラムで指定するパスbooks.db
実際に開くファイルC:\c-db\chap07\books.db

実行ファイルが置かれたフォルダを、自動的に基準にするわけではありません。この記事では、必ずC:\c-db\chap07へ移動してから実行します。

initialize_databaseでは、引き続きCREATE TABLE IF NOT EXISTSを使用しています。

コピーしたデータベースにはbooksテーブルが存在するため、ここでテーブルを作り直したり、保存済みの書籍を消したりすることはありません。

なお、sqlite3_openには、指定したファイルが存在しない場合に新しく作成する動作があります。コピーのし忘れや作業フォルダの間違いを避けるため、先ほどの確認コマンドで書籍を確認しておきましょう。

AppDataを使って登録に必要な情報を渡す

登録処理では、画面の部品、データベース接続、書籍データを使用します。

それらをまとめているのがAppDataです。

typedef struct {
    AppWidgets widgets;
    sqlite3 *db;
    GPtrArray *books;
    int exit_status;
} AppData;
メンバー用途
widgets入力値の取得、メッセージ表示、一覧更新
dbSQLiteのデータベース接続
books読み込んだ書籍データの保持
exit_status起動や終了処理で発生した失敗の記録

登録ボタンのコールバック関数には、このAppDataへのポインターを渡します。

また、AppWidgetsにはadd_buttonを追加しています。登録後の一覧更新に失敗した場合に、登録ボタンを無効にするためです。

登録ボタンとコールバック関数を接続する

次の部分で、登録ボタンのclickedシグナルにon_add_clickedを接続しています。

g_signal_connect(
    widgets->add_button,
    "clicked",
    G_CALLBACK(on_add_clicked),
    data
);
引数指定したもの役割
第1引数widgets->add_buttonシグナルを発生させる対象
第2引数clicked接続するシグナルの名前
第3引数G_CALLBACK(on_add_clicked)呼び出す関数
第4引数dataコールバック関数へ渡すデータ

第4引数のdataは、コールバック関数のuser_dataとして受け取ります。AppDataの中身がコピーされるのではなく、同じAppDataを指すポインターが渡されます。

コールバック関数では、次のように利用します。

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

mainで作成したAppDataは、g_application_runが戻るまで存在しています。ボタンの操作はその間に処理されるため、コールバック関数から参照できます。

補足

なぜ、AppData *data = user_data; としているのか。

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

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

gpointer 自体がポインタ型だからです。

図2:コールバック関数が画面とデータベースをつなぐ仕組み

この図から分かること

コールバック関数は、利用者の操作を受け取り、必要な処理を順番に呼び出します。

入力欄の読み取りや画面の変更にはGTK 4を使い、書籍の保存にはSQLiteのC APIを使います。

AppDataを渡しておくことで、コールバック関数から画面とデータベースの両方へアクセスできます。

GtkEntryから入力内容を取得する

タイトルと著者名は、gtk_editable_get_textで取得します。

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

GtkEntryはGtkEditableの機能を持つため、GTK_EDITABLEで型を変換して渡します。

戻り値は、入力されているUTF-8文字列へのポインターです。この文字列はGTK側が管理しており、取得した側で書き換えたり、g_freeで解放したりしてはいけません。

今回のプログラムでは、取得した文字列をadd_bookへ渡し、登録処理が終わってから入力欄を空にします。

入力欄を変更した後は、先ほど取得したtitleやauthorを使い続けないようにしています。

空欄のまま登録されることを防ぐ

タイトルが空の場合は、メッセージを表示して登録処理を終了します。

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

C言語の文字列では、ヌル文字が文字列の終わりを表します。

先頭の文字がヌル文字なら、文字列には何も入っていません。

この場合はINSERTを実行せず、returnでコールバック関数から戻ります。アプリ全体が終了するわけではありません。

gtk_widget_grab_focusは、タイトル入力欄へフォーカスを移す処理です。メッセージを確認した後、そのまま入力し直せるようにしています。

著者名も、同じ方法で確認します。

booksテーブルにはNOT NULLを指定していますが、NULLと空文字列は異なる値です。NOT NULLだけでは空文字列の登録を防げないため、アプリ側でも入力を確認します。

add_bookで書籍を保存する

add_bookは、データベース接続と、保存する2つの文字列を受け取ります。

static int add_book(
    sqlite3 *db,
    const char *title,
    const char *author
)

この関数にはGtkEntryやGtkLabelを渡していません。

画面の部品に依存せず、文字列を受け取ってデータベースへ保存する役割に絞っています。

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

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

idはINSERTの対象に含めていません。今回のテーブル定義では、SQLiteが自動的にIDを割り当てます。

プレースホルダーへ文字列を設定する

SQLに含まれる2つの疑問符は、値を後から設定するプレースホルダーです。

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

if (rc == SQLITE_OK) {
    rc = sqlite3_bind_text(
        stmt, 2, author, -1, SQLITE_TRANSIENT
    );
}
番号設定する値
1タイトル
2著者名

sqlite3_bind_textの引数は、次の意味を持ちます。

引数今回の指定意味
第1引数stmt準備済みのSQL
第2引数1または2値を設定する位置
第3引数titleまたはauthor保存する文字列
第4引数-1ヌル文字までを文字列として扱う
第5引数SQLITE_TRANSIENTSQLite側で文字列をコピーする

プレースホルダーの番号は1から始まります。SQLITE_TRANSIENTを指定すると、SQLiteはsqlite3_bind_textが戻る前に文字列をコピーします。

入力文字列をSQLへ直接連結しないため、アポストロフィを含む書名なども、SQLの構文ではなく文字列の値として渡せます。

INSERTの完了を確認する

値の設定に成功したら、sqlite3_stepを呼び出します。

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

int finalize_rc = sqlite3_finalize(stmt);

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

return rc;

今回のINSERTにはRETURNINGを指定していないため、正常に完了したときの戻り値はSQLITE_DONEです。SELECTで1行取得したときのSQLITE_ROWとは異なります。

最後にsqlite3_finalizeで準備済みのSQLを解放します。

finalize_rcには、sqlite3_finalizeの戻り値が入ります。正常に終了すればSQLITE_OKです。今回のadd_bookは、INSERTが完了した後、この値を呼び出し元へ返しています。

処理成功時に確認する値
sqlite3_prepare_v2SQLITE_OK
sqlite3_bind_textSQLITE_OK
今回のINSERTを実行するsqlite3_stepSQLITE_DONE
sqlite3_finalizeSQLITE_OK
今回作成したadd_bookSQLITE_OK

登録後に一覧を読み込み直す

INSERTを実行するとデータベースの内容は変わりますが、AppDataのbooksや画面の一覧は、そのままでは変わりません。

そこで、登録後にrefresh_booksを呼び出します。

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

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

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

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

    data->books = new_books;

    display_books(&data->widgets, data->books);

    return SQLITE_OK;
}

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

  1. load_booksでSELECTを実行する
  2. 読み込んだデータをnew_booksで受け取る
  3. 読み込みに成功したら古い書籍データを解放する
  4. AppDataのbooksを新しいデータへ入れ替える
  5. display_booksで画面の一覧を更新する

ここで大切なのは、新しいデータの読み込みが成功するまで、古いデータを残しておくことです。

先に古いデータを解放すると、SELECTに失敗したときに、それまで保持していた書籍データも失ってしまいます。

一時的な受け取り先としてnew_booksを用意すると、読み込みに成功した場合だけ入れ替えられます。

読み込んだ文字列を保持する

load_booksでは、SQLiteから取得した文字列をg_strdupでコピーしています。

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

sqlite3_column_textが返すポインターには有効期間があります。次のsqlite3_stepやsqlite3_finalizeの後まで、そのまま保存して使い続けることはできません。

そこで、Bookが保持する文字列を別に用意します。

不要になったBookは、free_bookで文字列と一緒に解放します。

GPtrArrayの作成時にfree_bookを登録しているため、配列の最後の参照をg_ptr_array_unrefで解放すると、配列に格納したBookも解放されます。

登録のたびに一覧を読み込み直すので、新しいデータを用意する処理と、古いデータを解放する処理を対にして考えましょう。

登録後に入力欄を空にする

INSERTが成功した後は、clear_inputsを呼び出します。

static void clear_inputs(AppWidgets *widgets)
{
    gtk_editable_set_text(
        GTK_EDITABLE(widgets->title_entry), ""
    );
    gtk_editable_set_text(
        GTK_EDITABLE(widgets->author_entry), ""
    );

    gtk_widget_grab_focus(widgets->title_entry);
}

この関数は、タイトルと著者名を空にし、タイトル入力欄へフォーカスを戻します。

結果メッセージは変更しないため、呼び出し元に応じたメッセージを表示できます。

呼び出し元表示するメッセージ
登録処理書籍を登録しました。現在○件です。
クリアボタン入力欄をクリアしました。

入力欄を空にしても、データベースの書籍は削除されません。入力欄の操作とデータベースの操作は別です。

登録の失敗と一覧更新の失敗を区別する

on_add_clickedでは、add_bookとrefresh_booksの戻り値を別々に確認しています。

失敗した場所によって、データベースの状態が異なるためです。

状態データベースの状態画面での対応
入力が空INSERTを実行していない入力を促す
INSERTが失敗今回の登録は完了していない入力内容を残してエラーを表示する
INSERTと再読み込みが成功新しい書籍が保存されている一覧と成功メッセージを表示する
INSERTは成功したが再読み込みが失敗新しい書籍は保存済み保存済みであることと一覧更新の失敗を伝える

保存済みなのに「登録に失敗しました」と表示すると、利用者が同じ書籍をもう一度登録してしまう可能性があります。

そこで、一覧更新だけに失敗した場合は、次のように表示します。

書籍の保存は完了しましたが、一覧を更新できませんでした。
同じ書籍を再登録せず、アプリを閉じて起動し直してください。

今回は、この状態になると登録ボタンを無効にします。表示中の一覧は残りますが、最新の内容ではない可能性があります。

再起動時にはSELECTを実行し直すため、正常に読み込めれば保存済みの書籍を確認できます。

コンパイルして実行する

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

cd /c/c-db/chap07

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

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

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

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

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

今回はGTK 4とSQLiteの両方を使うため、pkg-configにはgtk4とsqlite3を指定します。

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

./book_add.exe

起動時に、コピー済みのbooks.dbから書籍を読み込み、一覧へ表示します。

実行結果の例

ここでは、第6章からコピーしたデータベースに、次の2件が登録されている場合を例にします。

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

アプリを起動すると、入力欄と4つのボタン、その下に書籍一覧が表示されます。

登録とクリアは操作できますが、変更と削除は操作できません。

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

2件の書籍を表示しています。

新しい書籍を入力して登録する

入力欄へ、次の内容を入力してください。

入力欄入力内容
タイトルC言語実践入門
著者名佐藤一郎

登録ボタンを押すと、INSERTと一覧の再読み込みが行われます。

これまでに上記2件だけを登録し、その後の登録や削除による採番の違いがない場合、一覧は次のようになります。

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

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

書籍を登録しました。現在3件です。

タイトルと著者名の入力欄は空になり、タイトル入力欄へフォーカスが戻ります。

IDはSQLiteが割り当てる値です。コピー元で別の登録や削除も行っている場合には、上の例と異なるIDになることがあります。

図3:登録後にデータベースを読み込み直して一覧へ反映する流れ

この図から分かること

INSERTの後にSELECTを実行し直すことで、新しい書籍を含む一覧を取得できます。

画面へ表示しているのは、データベースから読み直した内容です。そのため、SQLiteが自動的に割り当てたIDも、タイトルや著者名と一緒に表示できます。

空欄のときの動作を確認する

タイトルも著者名も入力せずに、登録ボタンを押してみましょう。

タイトルを先に確認しているため、次のメッセージが表示されます。

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

次に、タイトルだけを入力して登録ボタンを押すと、次のメッセージが表示されます。

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

どちらの場合もINSERTは実行されず、書籍の件数は増えません。

入力欄に薄く表示されている入力例は、プレースホルダーテキストです。実際の入力値ではないため、入力例が見えていても、何も入力していなければ空欄として扱われます。

再起動して保存されたことを確認する

書籍を登録したら、ウィンドウを閉じてアプリを終了します。

同じ作業フォルダから、もう一度起動してください。

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

先ほど登録したC言語実践入門が、起動直後の一覧に表示されることを確認しましょう。

これにより、書籍が画面上だけでなく、C:\c-db\chap07\books.dbへ保存されていることを確認できます。

データベースのコピーは、第7章の学習を始める際の準備です。起動するたびにコピーする必要はありません。 第6章のデータベースで上書きすると、第7章で追加した書籍が失われるため、以後はこのbooks.dbをそのまま使い続けます。

次の記事では、一覧から書籍を選択し、その書籍のID、タイトル、著者名を取得できるようにします。選択した書籍を特定する仕組みを作り、変更と削除につなげていきましょう。

Follow me!