Build a Game Engine

Serialization with MySQL

Prerequisites

Article

While SQLite is great for a local database, especially one on the client, soon we're going to be looking at a dedicated game server. That will mean multiple instances or servers talking to the same database - and as you scale, you'll probably end up running a dedicated database server.

Enter MySQL - or Postgres, or SQL Server. In this article, we'll focus on MySQL, but you can easily port the code over to whatever backend you want to run.

We'll leverage the DataLoader base class we created in our SQLite tutorial. Let's review quickly:

class UDataLoader : public USystem {
public:
    UDataLoader() = default;
    virtual ~UDataLoader() = default;

    /**
     * Open / close this data source
     */
    virtual bool Open(std::string location) = 0;
    virtual void Close() { }

    /**
     * Get all objects of a specific type
     */
    virtual std::vector<UDataRecord*> GetRecords(std::string type, std::map<std::string, UVariant> search = std::map<std::string, UVariant>()) {
        UASSERT(false, "Method not implemented.");
        return(std::vector<UDataRecord*>());
    }

    /**
     * Get one object of a specific type and ID
     */
    UDataRecord* GetRecord(std::string type, uint32_t id);

    /**
     * Save records
     */
    virtual void SaveRecords(std::string type, std::vector<UDataRecord*> records) = 0;

protected:
    // Location that is opened
    std::string m_location;
};

This class provides us with 4 functions to overload (Open, Close, GetRecords, and SaveRecords) and gives us a uniform way to access data regardless of the backend we choose for it.

Here's our MySQL-specific version:

class UENGINE_API UMySQLDataLoader : public UDataLoader {
public:
    UMySQLDataLoader() : m_handle(0) { }
    ~UMySQLDataLoader() = default;

    /**
     * Open / close this data source
     * Open param is a connection string format "user:password@host:port/schema"
     */
    bool Open(std::string location);
    void Close();

    /**
     * Get all objects of a specific type
     */
    std::vector<UDataRecord*> GetRecords(
        std::string type,
        std::map<std::string, UVariant> search = std::map<std::string, UVariant>()
    );

    /**
     * Save records
     */
    void SaveRecords(std::string type, std::vector<UDataRecord*> records);

protected:
    // Map UVariant type to MySQL column name
    std::string GetColumnTypeName(int type);

protected:
    MYSQL* m_handle;
};

Most of this is the same as the base class, but we've added a helper function called GetColumnTypeName. This function will convert our Variant data types into their MySQL equivalents. For example, we'll store all of our integer types as "INT" and our objects, vectors, quaternions (and actual strings) as VARCHARs, or strings.

We'll be using the MySQL C API to connect to MySQL. Unlike our SQLite or JSON equivalents, MySQL needs a little more information to connect - since most connections are to a remote machine, it takes a host, port, username, and password as well as a database. Other SQL databases support a connection string that incorporates all of this - unfortunately the MySQL C API doesn't provide support for a similar format, so we'll have to create our own.

Let's take a look at the Open function:

bool UMySQLDataLoader::Open(std::string location) {
    // Open param is a connection string format "user:password@host:port/schema", parse that
    std::string username, password, host, port, database;
    try {
        username = location.substr(0, location.find_first_of(":"));
        password = location.substr(username.length() + 1, location.find_first_of("@") - (username.length() + 1));
        host = location.substr(location.find_first_of("@") + 1, location.find_last_of(":") - (location.find_first_of("@") + 1));
        port = location.substr(location.find_last_of(":") + 1, location.find_last_of("/") - (location.find_last_of(":") + 1));
        database = location.substr(location.find_last_of("/") + 1);
    }
    catch (std::exception ex) {
        UASSERT(false, "Unable to parse connection string.");
        return(false);
    }

    // Initialize and connect
    m_handle = mysql_init(0);
    if (mysql_real_connect(m_handle, host.c_str(), username.c_str(), password.c_str(), database.c_str(), atoi(port.c_str()), 0, 0) == 0) {
        UASSERT(false, "Unable to connect to MySQL.");
        return(false);
    }

    return(true);
}

The first part of Open is dedicated to parsing the connection string into parts. We don't use a lot of try catch blocks, but this is a lot of string parsing that will throw exceptions, and we just want to return an error if it fails. Otherwise, it calls the mysql_init function to initialize our MySQL handle and mysql_real_connect to establish a connection.

The Close function is very simple:

void UMySQLDataLoader::Close() {
    mysql_close(m_handle);
}

Let's move on to the meat... the GetRecords function will load rows from a table and return UDataRecords:

std::vector<UDataRecord*>  UMySQLDataLoader::GetRecords(std::string type, std::map<std::string, UVariant> search) {
    std::vector<UDataRecord*> records;

    // Start by constructing a query
    std::string query = "SELECT * FROM " + type;

    // Add where clauses
    std::string primaryKey = type + "_id";
    if (search.size()) {
        query += " WHERE 1=1 ";
        std::map<std::string, UVariant>::iterator it = search.begin();
        for (; it != search.end(); it++) {
            query += " AND " + it->first + " = '" + it->second.ToString() + "'";
        }
    }

    // Execute
    if (mysql_query(m_handle, query.c_str()) != 0) {
        UASSERT(false, "Error executing MySQL query: " + query);
        return(records);
    }

    // Fetch rows
    MYSQL_RES* result = mysql_store_result(m_handle);
    while (MYSQL_ROW row = mysql_fetch_row(result)) {
        // Create a new data record
        UDataRecord* record = new UDataRecord();
        records.push_back(record);

        // Retrieve fields
        unsigned int numFields = mysql_num_fields(result);
        MYSQL_FIELD* fields = mysql_fetch_fields(result);
        for (int i = 0; i < numFields; i++) {
            std::string value = row[i];

            // Determine field type
            int type = 0;
            switch (fields[i].type) {
            case MYSQL_TYPE_TINY:
            case MYSQL_TYPE_SHORT:
            case MYSQL_TYPE_LONG:
            case MYSQL_TYPE_INT24:
                type = UVariant::VAR_INT32;
                break;
            case MYSQL_TYPE_LONGLONG:
                type = UVariant::VAR_INT64;
                break;
            case MYSQL_TYPE_DECIMAL:
            case MYSQL_TYPE_NEWDECIMAL:
            case MYSQL_TYPE_FLOAT:
            case MYSQL_TYPE_DOUBLE:
                type = UVariant::VAR_FLOAT;
                break;
            case MYSQL_TYPE_BIT:
                type = UVariant::VAR_BOOL;
                break;
            case MYSQL_TYPE_STRING:
            case MYSQL_TYPE_VAR_STRING:
                type = UVariant::VAR_STRING;
                break;
            default:
                break;
            }

            UASSERT(type != 0, "Unrecognized data type.");

            // Check for objects
            if (type == UVariant::VAR_STRING) {
                // Use our predefined object format to check for objects
                if (value.substr(0, 1) == "{" && value.substr(value.length() - 1) == "}") {
                    // Get the type and overwrite
                    std::string typestr = value.substr(1, value.find_first_of(":") - 1).c_str();
                    type = atoi(typestr.c_str());
                    value = value.substr(typestr.length() + 2, value.length() - typestr.length() - 3);
                }

                // Handle object case (store DataRecord)
                if (type == UVariant::Type::VAR_OBJECT) {
                    // Split the type and unique ID
                    std::string objectType = value.substr(0, value.find(":"));
                    uint32_t uid = atoi(value.substr(value.find(":") + 1).c_str());

                    // Case where it points to an empty object
                    if (uid == 0) {
                        record->Set(fields[i].name, (UDataRecord*)0);
                    }
                    else {
                        // If this is a nested object, go fetch the actual object as a DataRecord
                        UDataRecord* subrecord = GetRecord(objectType, uid);
                        record->Set(fields[i].name, subrecord);
                    }

                    continue;
                }
            }

            // Read in data
            UVariant v;
            v.FromString(value, type);
            record->Set(fields[i].name, v);
        }
    }

    mysql_free_result(result);

    return(records);
}

We start by constructing a SELECT query and adding any necessary WHERE clauses. Then we execute it by called mysql_query. This runs the query on the server, but it doesn't actually return the result to our application yet. That happens when we call mysql_store_result. We can iterate through the rows using mysql_fetch_row. This returns a MYSQL_ROW, which is actually a collection of strings. To get the field information, we can use mysql_fetch_fields to get MYSQL_FIELD structures, giving us the column name, type, size, etc.

Armed with all of this, we can now use the MYSQL_ROW and the MYSQL_FIELDs to get our data. First, we convert the MySQL types to UVariant types. For vectors and quaternions, we'll store them as strings. Similar to our SQLite format, we'll store them as (for example), "(0, 1, 0)" for a 3-component vector, wrapped in some type information. We'll also store objects as pseudo-foreign-key references to other tables in a string. Finally, we store strings as strings.

Therefore, if it's a string, we'll see if it matches our predefined format, wrapping in curly braces. If it is, we parse it as "type:value", where type is a UVariant type and value is a string representation of our object. Finally, it could be an object type, which we'll need to reduce one step further to get a "tableName:foreignKey" pair that we can use to load a record from another table.

We store all of this information into a Variant using the FromString function and then store all of those Variants into a DataRecord. Finally, because we stored a local copy of our result set, we get rid of it by calling mysql_free_result.

With a way to get records, we now need a way to save them back to the database. Before that, like we converted MySQL types into Variant types, we need a way to convert Variant types into MySQL types. We have a helper function GetColumnTypeName to do just that:

std::string UMySQLDataLoader::GetColumnTypeName(int type) {
    // Map column type
    std::string typestr = "";
    switch (type) {
    case UVariant::VAR_INT32:
    case UVariant::VAR_UINT32:
        typestr = "INT";
        break;
    case UVariant::VAR_INT64:
    case UVariant::VAR_UINT64:
        typestr = "BIGINT";
        break;
    case UVariant::VAR_BOOL:
        typestr = "BIT(1)";
        break;
    case UVariant::VAR_FLOAT:
        typestr = "FLOAT";
        break;
    case UVariant::VAR_VECTOR2:
    case UVariant::VAR_VECTOR3:
    case UVariant::VAR_VECTOR4:
    case UVariant::VAR_QUATERNION:
    case UVariant::VAR_STRING:
    case UVariant::VAR_OBJECT:
        typestr = "VARCHAR(200)";
        break;
    default: break;
    };

    return(typestr);
}

This function will return a string that represents a column type in MySQL from a Variant type in our code. We'll use this in our CREATE TABLE and ALTER TABLE queries to add and remove tables and columns. As you can see, we map the 32-bit integer types to "INT", the 64-bit integer types to "BIGINT", boolean values to a single "BIT", and floats to "FLOAT". As we saw in our GetRecords method, we'll store vectors, quaternions, object references, and strings as strings.

Here is the SaveRecords function:

void UMySQLDataLoader::SaveRecords(std::string type, std::vector<UDataRecord*> records) {
    // Make sure we have records in the list
    if (records.size() == 0) return;

    // Make sure the table exists and spec matches
    std::string query = "CREATE TABLE IF NOT EXISTS ";
    query += type + " (";

    // Add a primary key
    query += type + "_id INT";

    // Use the first record as a model
    UDataRecord* record = records[0];
    std::vector<std::string> keys = record->GetKeys();
    for (auto fi = keys.begin(); fi != keys.end(); fi++) {
        // Map column type
        std::string typestr = GetColumnTypeName(record->Get(*fi).Type());

        // Add all columns here as TEXT type
        query += "," + (*fi) + " " + typestr;
    }

    // Specify primary key
    query += ", PRIMARY KEY(" + type + "_id ASC))";

    if (mysql_query(m_handle, query.c_str()) != 0) {
        UASSERT(false, "Unable to create MySQL table.");
        return;
    }

    // Look for columns that may not exist (but the table does)
    query = "SHOW COLUMNS FROM " + type;
    mysql_query(m_handle, query.c_str());

    // Put the column names into a vector
    std::vector<std::string> existingCols;
    MYSQL_RES* result = mysql_store_result(m_handle);
    while (MYSQL_ROW row = mysql_fetch_row(result)) {
        // Fetch a single row and get the column names
        int fieldCount = mysql_num_fields(result);
        MYSQL_FIELD* fields = mysql_fetch_fields(result);
        for (int i = 0; i < fieldCount; i++) {
            existingCols.push_back(fields[i].name);
        }

        break;
    }
    mysql_free_result(result);

    // Add any we can't find
    auto fi = keys.begin();
    for (; fi != keys.end(); fi++) {
        auto it = std::find(existingCols.begin(), existingCols.end(), *fi);
        if (it == existingCols.end()) {
            std::string typestr = GetColumnTypeName(record->Get(*fi).Type());
            query = "ALTER TABLE " + type + " ADD COLUMN " + (*fi) + " " + typestr;

            if (mysql_query(m_handle, query.c_str()) != 0) {
                UASSERT(false, "Unable to alter MySQL DB.");
                return;
            }
        }
    }

    // Iterate over each record
    auto it = records.begin();
    for (; it != records.end(); it++) {
        // First, check for new records (ID == 0)
        if ((*it)->ID() == 0) {
            std::string query = "INSERT INTO " + type + " (";
            std::string values = ") VALUES (";

            // Use the "data model" from the first record
            auto fi = keys.begin();
            for (; fi != keys.end(); fi++) {
                UVariant v = (*it)->Get(*fi);
                std::string value = v.ToString();

                // Special case for vectors and quaternions
                if (v.IsVector2() || v.IsVector3() || v.IsVector4() || v.IsQuaternion()) {
                    value = "{" + std::to_string(v.Type()) + ":" + value + "}";
                }

                if (v.IsObject()) {
                    // Serialize this object
                    UDataRecord record;
                    USerializable* serializable = v.AsObject<USerializable*>();
                    UObject* obj = v.AsObject();

                    // If this is both a serializable object and a UObject...
                    if (obj && serializable) {
                        // Serialize object
                        serializable->Serialize(&record);

                        // Get class name
                        std::string subtype = obj->UType().className;

                        // Save record
                        SaveRecords(subtype, { &record });
                        value = "{" + std::to_string(v.Type()) + ":" + subtype + ":" + std::to_string(record.ID()) + "}";
                    }
                }

                query += "`" + *fi + "`,";
                values += "'" + value + "',";
            }

            // Replace the last character with a closing bracket
            values = values.substr(0, values.length() - 1);

            // Complete query
            query = query.substr(0, query.length() - 1) + values;
            query += ")";

            if (mysql_query(m_handle, query.c_str()) != 0) {
                UASSERT(false, "Unable to insert object in MySQL DB.");
            }

            unsigned int newID = (unsigned int)mysql_insert_id(m_handle);
            if (newID == 0) {
                UASSERT(false, "Unable to insert object in MySQL DB.");
            }

            (*it)->ID(newID);
            continue;
        }

        // We'll need the ID for the rest
        std::string primaryKey = std::to_string((*it)->ID());

        // The process deletes
        if ((*it)->Deleted()) {
            // Construct query
            std::string query = "DELETE FROM " + type + " WHERE " + type + "_id = " + primaryKey;

            if (mysql_query(m_handle, query.c_str()) != 0) {
                UASSERT(false, "Unable to delete object from MySQL.");
            }

            continue;
        }

        // Finally process updates
        std::string query = "UPDATE " + type + " SET ";
        auto f = keys.begin();
        for (; f != keys.end(); f++) {
            UVariant v = (*it)->Get(*f);
            std::string value = v.ToString();

            // Special case for vectors and quaternions
            if (v.IsVector2() || v.IsVector3() || v.IsVector4() || v.IsQuaternion()) {
                value = "{" + std::to_string(v.Type()) + ":" + value + "}";
            }

            // Handle object case
            if (v.IsObject()) {
                // Serialize this object
                UDataRecord record;
                USerializable* serializable = v.AsObject<USerializable*>();
                UObject* obj = v.AsObject();

                // If this is both a serializable object and a UObject...
                if (obj && serializable) {
                    // Serialize object
                    serializable->Serialize(&record);

                    // Get class name
                    std::string subtype = obj->UType().className;

                    // Save record
                    SaveRecords(subtype, { &record });
                    value = "{" + std::to_string(v.Type()) + ":" + subtype + ":" + std::to_string(record.ID()) + "}";
                }
            }

            if (f != keys.begin()) query += ",";
            query += "`" + *f + "` = '" + value + "'";
        }

        query += " WHERE " + type + "_id = " + primaryKey;

        if (mysql_query(m_handle, query.c_str()) != 0) {
            UASSERT(false, "Unable to update object in MySQL DB.");
        }
    }
}

This function is broken down into three parts:

Our CREATE and ALTER TABLE queries leverage the GetColumnTypeName helper function to help determine what the MySQL column type should be. We create an auto-incrementing primary key column called tableName_id, where tableName is the same as class or object name.

We then iterate through our DataRecords, crafting INSERT statements where the ID is set to zero (we set the ID by calling the mysql_insert_id function after the INSERT), DELETE statements where the Deleted flag is set to true, and UPDATE for everything else. You'll see the same formatting of data for vectors and quaternions as well as objects.

Conclusion

We can now store collection data locally and in a remote SQL database. The remote database serves one purpose: a single server that multiple computers can connect to. You may use this to create a multiplayer dedicated server (we'll cover that in a future article) or enable collaboration between programmers in your editor.

Both databases work great for storing collections of information. Next we'll look at serialization with JSON for cases where we want to store data into text files - engines such as Unity use small JSON files extensively since they work well with source control systems and are human editable and understandable.