Initial DB schama, migrations and persistence models for achievements.

This commit is contained in:
Shane Handley 2020-06-13 00:11:10 +10:00
parent 0b66a88ae5
commit 707412edc0
15 changed files with 299 additions and 5 deletions

1
Cargo.lock generated
View File

@ -993,6 +993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2de9deab977a153492a1468d1b1c0662c1cf39e5ea87d0c060ecd59ef18d8c"
dependencies = [
"byteorder 1.3.4",
"chrono",
"diesel_derives",
"libsqlite3-sys",
]

View File

@ -0,0 +1,10 @@
[
(
title: "Collect 10 Apples",
achievement_type: Collect("common.items.apple", 10)
),
(
title: "Collect 50 Apples",
achievement_type: Collect("common.items.apple", 50)
)
]

23
common/src/achievement.rs Normal file
View File

@ -0,0 +1,23 @@
use crate::comp::item::{Item, ItemKind};
// TODO: Kill(Race, amount)
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AchievementType {
Collect(String, i32),
ReachLevel(i32),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AchievementItem {
pub title: String,
pub achievement_type: AchievementType,
}
impl Default for AchievementItem {
fn default() -> Self {
Self {
title: String::new(),
achievement_type: AchievementType::ReachLevel(9999),
}
}
}

View File

@ -11,6 +11,7 @@
option_zip
)]
pub mod achievement;
pub mod assets;
pub mod astar;
pub mod character;

View File

@ -36,6 +36,6 @@ tiny_http = "0.7.0"
portpicker = { git = "https://github.com/xMAC94x/portpicker-rs" }
authc = { git = "https://gitlab.com/veloren/auth.git", rev = "223a4097f7ebc8d451936dccb5e6517194bbf086" }
libsqlite3-sys = { version = "0.18", features = ["bundled"] }
diesel = { version = "1.4.3", features = ["sqlite"] }
diesel = { version = "1.4.3", features = ["sqlite", "chrono"] }
diesel_migrations = "1.4.0"
dotenv = "0.15.0"

View File

@ -0,0 +1,10 @@
[
(
title: "Collect 10 Apples",
achievement_type: Collect("common.items.apple", 10)
),
(
title: "Collect 50 Apples",
achievement_type: Collect("common.items.apple", 50)
)
]

View File

@ -263,6 +263,15 @@ impl Server {
info!(?e, "Migration error");
}
// Sync Achievement Data
debug!("Syncing Achievement data...");
if let Some(e) =
persistence::achievement::sync(&this.server_settings.persistence_db_dir).err()
{
info!(?e, "Achievement data migration error");
}
debug!(?settings, "created veloren server with");
let git_hash = *common::util::GIT_HASH;

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS "achievement";

View File

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS "achievement" (
id INTEGER PRIMARY KEY NOT NULL,
checksum VARCHAR(64) NOT NULL UNIQUE,
details TEXT NOT NULL
);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS "data_migration";

View File

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS "data_migration" (
id INTEGER PRIMARY KEY NOT NULL,
title VARCHAR(64) NOT NULL,
checksum VARCHAR(64) NOT NULL,
last_run TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@ -0,0 +1,121 @@
extern crate diesel;
use super::{
error::Error,
establish_connection,
models::{Achievement as AchievementModel, DataMigration, NewAchievement, NewDataMigration},
schema,
};
use common::achievement::*;
use diesel::{
prelude::*,
result::{DatabaseErrorKind, Error as DieselError},
};
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
};
use tracing::{info, warn};
pub fn sync(db_dir: &str) -> Result<(), Error> {
let achievements = load_data();
let connection = establish_connection(db_dir);
// Get a hash of the Vec<Achievement> and compare to the migration table
let result = schema::data_migration::dsl::data_migration
.filter(schema::data_migration::title.eq(String::from("achievements")))
.load::<DataMigration>(&connection)?;
// First check whether the table has an entry for this data type
if result.is_empty() {
let migration = NewDataMigration {
title: "achievements",
checksum: &hash(&achievements).to_string(),
last_run: chrono::Utc::now().naive_utc(),
};
diesel::insert_into(schema::data_migration::table)
.values(&migration)
.execute(&connection)?;
}
// Also check checksum. Bail if same, continue if changed
if result.is_empty() {
info!("Achievements need updating...");
// Use the full dataset for checks
let persisted_achievements =
schema::achievement::dsl::achievement.load::<AchievementModel>(&connection)?;
// Make use of the unique constraint in the DB, attempt to insert, on unique
// failure check if it needs updating and do so if necessary
for item in &achievements {
let new_item = NewAchievement::from(item);
if let Err(error) = diesel::insert_into(schema::achievement::table)
.values(&new_item)
.execute(&connection)
{
match error {
DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _) => {
let entry = persisted_achievements
.iter()
.find(|&a| &a.checksum == &new_item.checksum);
if let Some(existing_item) = entry {
if existing_item.details != new_item.details {
match diesel::update(
schema::achievement::dsl::achievement.filter(
schema::achievement::checksum
.eq(String::from(&existing_item.checksum)),
),
)
.set(schema::achievement::details.eq(new_item.details))
.execute(&connection)
{
Ok(_) => warn!(?existing_item.checksum, "Updated achievement"),
Err(err) => return Err(Error::DatabaseError(err)),
}
}
}
},
_ => return Err(Error::DatabaseError(error)),
}
}
}
// Update the checksum for the migration
diesel::update(schema::data_migration::dsl::data_migration)
.filter(schema::data_migration::title.eq(String::from("achievements")))
.set((
schema::data_migration::checksum.eq(hash(&achievements).to_string()),
schema::data_migration::last_run.eq(chrono::Utc::now().naive_utc()),
))
.execute(&connection)?;
} else {
info!("No achievement updates required");
}
Ok(())
}
fn load_data() -> Vec<AchievementItem> {
if let Ok(path) = std::fs::canonicalize("../data/achievements.ron") {
let path = std::path::PathBuf::from(path);
info!(?path, "Path: ");
match std::fs::File::open(path) {
Ok(file) => ron::de::from_reader(file).expect("Error parsing achievement data"),
Err(error) => panic!(error.to_string()),
}
} else {
Vec::new()
}
}
pub fn hash<T: Hash>(t: &T) -> u64 {
let mut s = DefaultHasher::new();
t.hash(&mut s);
s.finish()
}

View File

@ -5,7 +5,7 @@
//! for managing table migrations
//! - [`diesel-cli`](https://github.com/diesel-rs/diesel/tree/master/diesel_cli/)
//! for generating and testing migrations
pub mod achievement;
pub mod character;
mod error;

View File

@ -1,8 +1,12 @@
extern crate serde_json;
use super::schema::{body, character, inventory, loadout, stats};
use super::{
achievement::hash,
schema::{achievement, body, character, data_migration, inventory, loadout, stats},
};
use crate::comp;
use common::character::Character as CharacterData;
use chrono::NaiveDateTime;
use common::{achievement::AchievementItem, character::Character as CharacterData};
use diesel::sql_types::Text;
use serde::{Deserialize, Serialize};
use tracing::warn;
@ -348,6 +352,91 @@ impl From<(i32, &comp::Loadout)> for LoadoutUpdate {
}
}
/// Generic datamigration entry
#[derive(Queryable, Debug, Identifiable)]
#[table_name = "data_migration"]
pub struct DataMigration {
pub id: i32,
pub title: String,
pub checksum: String,
pub last_run: NaiveDateTime,
}
#[derive(Insertable, PartialEq, Debug)]
#[table_name = "data_migration"]
pub struct NewDataMigration<'a> {
pub title: &'a str,
pub checksum: &'a str,
pub last_run: NaiveDateTime,
}
/// Achievements hold the data related to achievements available in-game. They
/// are the data referenced for characters
/// A wrapper type for Loadout components used to serialise to and from JSON
/// If the column contains malformed JSON, a default loadout is returned, with
/// the starter sword set as the main weapon
#[derive(SqlType, AsExpression, Debug, Deserialize, Serialize, FromSqlRow, PartialEq)]
#[sql_type = "Text"]
pub struct AchievementData(AchievementItem);
impl<DB> diesel::deserialize::FromSql<Text, DB> for AchievementData
where
DB: diesel::backend::Backend,
String: diesel::deserialize::FromSql<Text, DB>,
{
fn from_sql(
bytes: Option<&<DB as diesel::backend::Backend>::RawValue>,
) -> diesel::deserialize::Result<Self> {
let t = String::from_sql(bytes)?;
match serde_json::from_str(&t) {
Ok(data) => Ok(Self(data)),
Err(e) => {
warn!(?e, "Failed to deserialise achevement data");
Ok(Self(AchievementItem::default()))
},
}
}
}
impl<DB> diesel::serialize::ToSql<Text, DB> for AchievementData
where
DB: diesel::backend::Backend,
{
fn to_sql<W: std::io::Write>(
&self,
out: &mut diesel::serialize::Output<W, DB>,
) -> diesel::serialize::Result {
let s = serde_json::to_string(&self.0)?;
<String as diesel::serialize::ToSql<Text, DB>>::to_sql(&s, out)
}
}
#[derive(Queryable, Debug, Identifiable)]
#[table_name = "achievement"]
pub struct Achievement {
pub id: i32,
pub checksum: String,
pub details: AchievementData,
}
#[derive(Insertable, PartialEq, Debug)]
#[table_name = "achievement"]
pub struct NewAchievement {
pub checksum: String,
pub details: AchievementData,
}
impl From<&AchievementItem> for NewAchievement {
fn from(item: &AchievementItem) -> Self {
Self {
checksum: hash(item).to_string(),
details: AchievementData(item.clone()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -1,3 +1,11 @@
table! {
achievement (id) {
id -> Integer,
checksum -> Text,
details -> Text,
}
}
table! {
body (character_id) {
character_id -> Integer,
@ -22,6 +30,15 @@ table! {
}
}
table! {
data_migration (id) {
id -> Integer,
title -> Text,
checksum -> Text,
last_run -> Timestamp,
}
}
table! {
inventory (character_id) {
character_id -> Integer,
@ -54,4 +71,4 @@ joinable!(inventory -> character (character_id));
joinable!(loadout -> character (character_id));
joinable!(stats -> character (character_id));
allow_tables_to_appear_in_same_query!(body, character, inventory, loadout, stats);
allow_tables_to_appear_in_same_query!(achievement, body, character, inventory, loadout, stats);