diff --git a/Cargo.toml b/Cargo.toml index 8047ac0..ec29b4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,9 +3,13 @@ name = "diesel-dynamic-schema" version = "1.0.0" authors = ["Sean Griffin "] license = "MIT OR Apache-2.0" +autotests = false [dependencies] -diesel = "1.0.0" +diesel = { version = "1.4.2", default-features = false } + +[dev-dependencies] +dotenv = "0.14" [[example]] name = "querying_basic_schemas" @@ -26,4 +30,14 @@ required-features = ["diesel/sqlite"] name = "integration_tests" path = "tests/lib.rs" harness = true -required-features = ["diesel/sqlite"] +#required-features = ["diesel/sqlite"] + +[features] +postgres = ["diesel/postgres"] +sqlite = ["diesel/sqlite"] +mysql = ["diesel/mysql"] + + +[patch.crates-io] +diesel = {git = "https://github.com/GiGainfosystems/diesel", rev = "0744b7e6e05582bf8fca21c0a5fbba08555abd94"} +diesel_derives = {git = "https://github.com/GiGainfosystems/diesel", rev = "0744b7e6e05582bf8fca21c0a5fbba08555abd94"} diff --git a/src/column.rs b/src/column.rs index 8d453c3..79977fe 100644 --- a/src/column.rs +++ b/src/column.rs @@ -22,6 +22,16 @@ impl Column { _sql_type: PhantomData, } } + + /// Gets a reference to the table of the column. + pub fn table(&self) -> &T { + &self.table + } + + /// Gets the name of the column, as provided on creation. + pub fn name(&self) -> &U { + &self.name + } } impl QueryId for Column { diff --git a/src/dynamic_select.rs b/src/dynamic_select.rs new file mode 100644 index 0000000..6ad1fa1 --- /dev/null +++ b/src/dynamic_select.rs @@ -0,0 +1,94 @@ +use diesel::backend::Backend; +use diesel::query_builder::{ + AstPass, IntoBoxedSelectClause, QueryFragment, QueryId, SelectClauseExpression, + SelectClauseQueryFragment, +}; +use diesel::{ + sql_types::HasSqlType, AppearsOnTable, Expression, QueryResult, SelectableExpression, +}; +use std::marker::PhantomData; + +#[allow(missing_debug_implementations)] +pub struct DynamicSelectClause<'a, DB, QS> { + selects: Vec + 'a>>, + p: PhantomData, +} + +impl<'a, DB, QS> QueryId for DynamicSelectClause<'a, DB, QS> { + const HAS_STATIC_QUERY_ID: bool = false; + type QueryId = (); +} + +impl<'a, DB, QS> DynamicSelectClause<'a, DB, QS> { + pub fn new() -> Self { + Self { + selects: Vec::new(), + p: PhantomData, + } + } + pub fn add_field(&mut self, field: F) + where + F: QueryFragment + SelectableExpression + 'a, + DB: Backend, + { + self.selects.push(Box::new(field)) + } +} + +impl<'a, QS, DB> Expression for DynamicSelectClause<'a, DB, QS> +where + DB: Backend + HasSqlType, +{ + type SqlType = crate::dynamic_value::Any; +} + +impl<'a, DB, QS> AppearsOnTable for DynamicSelectClause<'a, DB, QS> where Self: Expression {} + +impl<'a, DB, QS> SelectableExpression for DynamicSelectClause<'a, DB, QS> where + Self: AppearsOnTable +{ +} + +impl<'a, QS, DB> SelectClauseExpression for DynamicSelectClause<'a, DB, QS> { + type SelectClauseSqlType = crate::dynamic_value::Any; +} + +impl<'a, QS, DB> SelectClauseQueryFragment for DynamicSelectClause<'a, QS, DB> +where + DB: Backend, + Self: QueryFragment, +{ + fn walk_ast(&self, _source: &QS, pass: AstPass) -> QueryResult<()> { + >::walk_ast(self, pass) + } +} + +impl<'a, DB, QS> QueryFragment for DynamicSelectClause<'a, DB, QS> +where + DB: Backend, +{ + fn walk_ast(&self, mut pass: AstPass) -> QueryResult<()> { + let mut first = true; + for s in &self.selects { + if first { + first = false; + } else { + pass.push_sql(", "); + } + s.walk_ast(pass.reborrow())?; + } + Ok(()) + } +} + +impl<'a, DB, QS> IntoBoxedSelectClause<'a, DB, QS> for DynamicSelectClause<'a, DB, QS> +where + Self: 'a + QueryFragment + SelectClauseExpression, + DB: Backend, +{ + type SqlType = >::SelectClauseSqlType; + + fn into_boxed(self, _source: &QS) -> Box + 'a> { + Box::new(self) + } +} diff --git a/src/dynamic_value.rs b/src/dynamic_value.rs new file mode 100644 index 0000000..8c4be5b --- /dev/null +++ b/src/dynamic_value.rs @@ -0,0 +1,228 @@ +use diesel::deserialize::{self, FromSql, FromSqlRow, Queryable, QueryableByName}; +use diesel::row::{NamedRow, Row}; +use diesel::{backend::Backend, QueryId, SqlType}; +use std::iter::FromIterator; +use std::ops::Index; + +#[derive(Debug, Clone, Copy, Default, QueryId, SqlType)] +#[postgres(oid = "0", array_oid = "0")] +#[sqlite_type = "Integer"] +pub struct Any; + +#[cfg(feature = "mysql")] +impl diesel::sql_types::HasSqlType for diesel::mysql::Mysql { + fn metadata(_lookup: &Self::MetadataLookup) -> Self::TypeMetadata { + None + } +} + +#[derive(Debug, Clone)] +pub struct DynamicRow { + values: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct NamedField { + pub name: String, + pub value: I, +} + +impl FromIterator for DynamicRow { + fn from_iter(iter: T) -> Self + where + T: IntoIterator, + { + DynamicRow { + values: iter.into_iter().collect(), + } + } +} + +impl DynamicRow { + pub fn get(&self, index: usize) -> Option<&I> { + self.values.get(index) + } +} + +impl DynamicRow> { + pub fn get_by_name>(&self, name: S) -> Option<&I> { + self.values + .iter() + .find(|f| f.name == name.as_ref()) + .map(|f| &f.value) + } +} + +#[cfg(feature = "postgres")] +impl FromSqlRow for DynamicRow +where + I: FromSql, +{ + const FIELDS_NEEDED: usize = 1; + + fn build_from_row>(row: &mut T) -> deserialize::Result { + (0..row.column_count()) + .map(|_| I::from_sql(row.take())) + .collect::>() + } +} + +#[cfg(feature = "sqlite")] +impl FromSqlRow for DynamicRow +where + I: FromSql, +{ + const FIELDS_NEEDED: usize = 1; + + fn build_from_row>(row: &mut T) -> deserialize::Result { + (0..row.column_count()) + .map(|_| I::from_sql(row.take())) + .collect::>() + } +} + +#[cfg(feature = "mysql")] +impl FromSqlRow for DynamicRow +where + I: FromSql, +{ + const FIELDS_NEEDED: usize = 1; + + fn build_from_row>(row: &mut T) -> deserialize::Result { + (0..row.column_count()) + .map(|_| I::from_sql(row.take())) + .collect::>() + } +} + +impl Queryable for DynamicRow +where + DB: Backend, + Self: FromSqlRow, +{ + type Row = DynamicRow; + + fn build(row: Self::Row) -> Self { + row + } +} + +impl QueryableByName for DynamicRow> +where + DB: Backend, + I: FromSql, +{ + fn build>(row: &R) -> deserialize::Result { + row.field_names() + .into_iter() + .map(|name| { + Ok(NamedField { + name: name.to_owned(), + value: row.get::(name)?, + }) + }) + .collect() + } +} + +#[cfg(feature = "postgres")] +impl QueryableByName for DynamicRow +where + I: FromSql, +{ + fn build>(row: &R) -> deserialize::Result { + row.field_names() + .into_iter() + .map(|name| row.get::(name)) + .collect() + } +} + +#[cfg(feature = "sqlite")] +impl QueryableByName for DynamicRow +where + I: FromSql, +{ + fn build>(row: &R) -> deserialize::Result { + row.field_names() + .into_iter() + .map(|name| row.get::(name)) + .collect() + } +} + +#[cfg(feature = "mysql")] +impl QueryableByName for DynamicRow +where + I: FromSql, +{ + fn build>(row: &R) -> deserialize::Result { + row.field_names() + .into_iter() + .map(|name| row.get::(name)) + .collect() + } +} + +impl FromSqlRow for DynamicRow> +where + DB: Backend, + I: FromSql, +{ + const FIELDS_NEEDED: usize = 1; + + fn build_from_row>(row: &mut T) -> deserialize::Result { + Ok(DynamicRow { + values: (0..row.column_count()) + .map(|_| { + let name = row + .column_name() + .ok_or_else(|| "Request name for an unnamed column")? + .into(); + Ok(NamedField { + name, + value: I::from_sql(row.take())?, + }) + }) + .collect::>()?, + }) + } +} + +impl Index for DynamicRow { + type Output = I; + + fn index(&self, index: usize) -> &Self::Output { + &self.values[index] + } +} + +impl<'a, I> Index<&'a str> for DynamicRow> { + type Output = I; + + fn index(&self, field_name: &'a str) -> &Self::Output { + self.values + .iter() + .find(|f| f.name == field_name) + .map(|f| &f.value) + .expect("Field not found") + } +} + +impl IntoIterator for DynamicRow { + type Item = V; + type IntoIter = as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.values.into_iter() + } +} + +impl<'a, V> IntoIterator for &'a DynamicRow { + type Item = &'a V; + type IntoIter = <&'a Vec as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + (&self.values).into_iter() + } +} diff --git a/src/lib.rs b/src/lib.rs index a001819..b7a54d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,6 @@ //! # Diesel dynamic schema //! -//! Diesel is an ORM and query builder designed to reduce +//! Diesel is an ORM and query builder designed to reduce //! the boilerplate for database interactions. //! //! If this is your first time reading about Diesel, then @@ -11,21 +11,21 @@ //! [many other long form guides]: https://diesel.rs/guides //! //! Diesel is built to provide strong compile time guarantees that your -//! queries are valid. To do this, it needs to represent your schema -//! at compile time. However, there are some times where you don't +//! queries are valid. To do this, it needs to represent your schema +//! at compile time. However, there are some times where you don't //! actually know the schema you're interacting with until runtime. -//! +//! //! This crate provides tools to work with those cases, while still being -//! able to use Diesel's query builder. Keep in mind that many compile time -//! guarantees are lost. We cannot verify that the tables/columns you ask +//! able to use Diesel's query builder. Keep in mind that many compile time +//! guarantees are lost. We cannot verify that the tables/columns you ask //! for actually exist, or that the types you state are correct. -//! +//! //! # Getting Started -//! +//! //! The `table` function is used to create a new Diesel table. -//! Note that you must always provide an explicit select clause +//! Note that you must always provide an explicit select clause //! when using this crate. -//! +//! //! ```rust //! # extern crate diesel; //! # extern crate diesel_dynamic_schema; @@ -44,7 +44,7 @@ //! let users = table("users"); //! let id = users.column::("id"); //! let name = users.column::("name"); -//! +//! //! // Now you can use typical Diesel syntax; see the Diesel docs for more. //! let results = users //! .select((id, name)) @@ -59,7 +59,7 @@ //! println!("id:{} name:{}", x, y); //! } //! ``` -//! +//! //! See the `/examples` directory for runnable code examples. //! //! ## Getting help @@ -69,14 +69,14 @@ //! [gitter.im/diesel-rs/diesel](https://gitter.im/diesel-rs/diesel) // Built-in Lints -#![deny( - missing_docs -)] +#![warn(missing_docs)] extern crate diesel; mod column; mod dummy_expression; +pub mod dynamic_select; +pub mod dynamic_value; mod schema; mod table; diff --git a/src/schema.rs b/src/schema.rs index a8ec81c..32b9ed2 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -12,6 +12,11 @@ impl Schema { Self { name } } + /// Gets the name of the schema, as specified on creation. + pub fn name(&self) -> &T { + &self.name + } + /// Create a table with this schema. pub fn table(&self, name: U) -> Table where diff --git a/src/table.rs b/src/table.rs index 8da9e38..edc20db 100644 --- a/src/table.rs +++ b/src/table.rs @@ -21,6 +21,11 @@ impl Table { Self { name, schema: None } } + /// Gets the name of the column, as especified on creation. + pub fn name(&self) -> &T { + &self.name + } + pub(crate) fn with_schema(schema: U, name: T) -> Self { Self { name, @@ -28,7 +33,7 @@ impl Table { } } - /// Create a column with this table. + /// Creates a column with this table. pub fn column(&self, name: V) -> Column where Self: Clone, diff --git a/tests/dynamic_values.rs b/tests/dynamic_values.rs new file mode 100644 index 0000000..995ed72 --- /dev/null +++ b/tests/dynamic_values.rs @@ -0,0 +1,232 @@ +use diesel::deserialize::*; +use diesel::prelude::*; +use diesel::sql_query; +use diesel::sql_types::*; +use diesel_dynamic_schema::dynamic_select::DynamicSelectClause; +use diesel_dynamic_schema::dynamic_value::*; + +#[derive(PartialEq, Debug)] +enum MyDynamicValue { + String(String), + Integer(i32), + Null, +} + +#[cfg(feature = "postgres")] +impl FromSql for MyDynamicValue { + fn from_sql(value: Option) -> Result { + use diesel::pg::Pg; + use std::num::NonZeroU32; + + const VARCHAR_OID: NonZeroU32 = unsafe { NonZeroU32::new_unchecked(1043) }; + const TEXT_OID: NonZeroU32 = unsafe { NonZeroU32::new_unchecked(25) }; + const INTEGER_OID: NonZeroU32 = unsafe { NonZeroU32::new_unchecked(23) }; + + if let Some(value) = value { + match value.get_oid() { + VARCHAR_OID | TEXT_OID => { + >::from_sql(Some(value)) + .map(MyDynamicValue::String) + } + INTEGER_OID => { + >::from_sql(Some(value)) + .map(MyDynamicValue::Integer) + } + e => Err(format!("Unknown type: {}", e).into()), + } + } else { + Ok(MyDynamicValue::Null) + } + } +} + +#[cfg(feature = "sqlite")] +impl FromSql for MyDynamicValue { + fn from_sql(value: Option<&diesel::sqlite::SqliteValue>) -> Result { + use diesel::sqlite::{Sqlite, SqliteType}; + if let Some(value) = value { + match value.value_type() { + Some(SqliteType::Text) => { + >::from_sql(Some(value)) + .map(MyDynamicValue::String) + } + Some(SqliteType::Long) => { + >::from_sql(Some(value)) + .map(MyDynamicValue::Integer) + } + _ => Err("Unknown data type".into()), + } + } else { + Ok(MyDynamicValue::Null) + } + } +} + +#[cfg(feature = "mysql")] +impl FromSql for MyDynamicValue { + fn from_sql(value: Option) -> Result { + use diesel::mysql::{Mysql, MysqlType, MysqlTypeMetadata}; + if let Some(value) = value { + match value.value_type() { + MysqlTypeMetadata { + data_type: MysqlType::String, + .. + } + | MysqlTypeMetadata { + data_type: MysqlType::Blob, + .. + } => >::from_sql(Some(value)) + .map(MyDynamicValue::String), + MysqlTypeMetadata { + data_type: MysqlType::Long, + is_unsigned: false, + } => >::from_sql(Some(value)) + .map(MyDynamicValue::Integer), + e => Err(format!("Unknown data type: {:?}", e).into()), + } + } else { + Ok(MyDynamicValue::Null) + } + } +} + +#[test] +fn dynamic_query() { + let connection = super::establish_connection(); + super::create_user_table(&connection); + sql_query("INSERT INTO users (name) VALUES ('Sean'), ('Tess')") + .execute(&connection) + .unwrap(); + + let users = diesel_dynamic_schema::table("users"); + let id = users.column::("id"); + let name = users.column::("name"); + let hair_color = users.column::, _>("hair_color"); + + let mut select = DynamicSelectClause::new(); + + select.add_field(id); + select.add_field(name); + select.add_field(hair_color); + + let actual_data: Vec>> = + users.select(select).load(&connection).unwrap(); + + assert_eq!( + actual_data[0]["name"], + MyDynamicValue::String("Sean".into()) + ); + assert_eq!( + actual_data[0][1], + NamedField { + name: "name".into(), + value: MyDynamicValue::String("Sean".into()) + } + ); + assert_eq!( + actual_data[1]["name"], + MyDynamicValue::String("Tess".into()) + ); + assert_eq!( + actual_data[1][1], + NamedField { + name: "name".into(), + value: MyDynamicValue::String("Tess".into()) + } + ); + assert_eq!(actual_data[0]["hair_color"], MyDynamicValue::Null); + assert_eq!( + actual_data[0][2], + NamedField { + name: "hair_color".into(), + value: MyDynamicValue::Null + } + ); + assert_eq!(actual_data[1]["hair_color"], MyDynamicValue::Null); + assert_eq!( + actual_data[1][2], + NamedField { + name: "hair_color".into(), + value: MyDynamicValue::Null + } + ); + + let mut select = DynamicSelectClause::new(); + + select.add_field(id); + select.add_field(name); + select.add_field(hair_color); + + let actual_data: Vec> = + users.select(select).load(&connection).unwrap(); + + assert_eq!(actual_data[0][1], MyDynamicValue::String("Sean".into())); + assert_eq!(actual_data[1][1], MyDynamicValue::String("Tess".into())); + assert_eq!(actual_data[0][2], MyDynamicValue::Null); + assert_eq!(actual_data[1][2], MyDynamicValue::Null); +} + +#[test] +fn dynamic_query_2() { + let connection = super::establish_connection(); + super::create_user_table(&connection); + sql_query("INSERT INTO users (name) VALUES ('Sean'), ('Tess')") + .execute(&connection) + .unwrap(); + + let actual_data: Vec>> = + sql_query("SELECT id, name, hair_color FROM users") + .load(&connection) + .unwrap(); + + dbg!(&actual_data); + + assert_eq!( + actual_data[0]["name"], + MyDynamicValue::String("Sean".into()) + ); + assert_eq!( + actual_data[0][1], + NamedField { + name: "name".into(), + value: MyDynamicValue::String("Sean".into()) + } + ); + assert_eq!( + actual_data[1]["name"], + MyDynamicValue::String("Tess".into()) + ); + assert_eq!( + actual_data[1][1], + NamedField { + name: "name".into(), + value: MyDynamicValue::String("Tess".into()) + } + ); + assert_eq!(actual_data[0]["hair_color"], MyDynamicValue::Null); + assert_eq!( + actual_data[0][2], + NamedField { + name: "hair_color".into(), + value: MyDynamicValue::Null + } + ); + assert_eq!(actual_data[1]["hair_color"], MyDynamicValue::Null); + assert_eq!( + actual_data[1][2], + NamedField { + name: "hair_color".into(), + value: MyDynamicValue::Null + } + ); + + let actual_data: Vec> = + sql_query("SELECT id, name, hair_color FROM users") + .load(&connection) + .unwrap(); + + assert_eq!(actual_data[0][1], MyDynamicValue::String("Sean".into())); + assert_eq!(actual_data[1][1], MyDynamicValue::String("Tess".into())); + assert_eq!(actual_data[0][2], MyDynamicValue::Null); + assert_eq!(actual_data[1][2], MyDynamicValue::Null); +} diff --git a/tests/lib.rs b/tests/lib.rs index c6b2340..d00a93e 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -2,16 +2,69 @@ extern crate diesel; extern crate diesel_dynamic_schema; use diesel::sql_types::*; -use diesel::sqlite::Sqlite; use diesel::*; -use diesel_dynamic_schema::{schema, table}; +use diesel_dynamic_schema::table; + +mod dynamic_values; + +#[cfg(feature = "postgres")] +fn create_user_table(conn: &PgConnection) { + sql_query("CREATE TABLE users (id Serial PRIMARY KEY, name TEXT NOT NULL DEFAULT '', hair_color TEXT)") + .execute(conn) + .unwrap(); +} + +#[cfg(feature = "sqlite")] +fn create_user_table(conn: &SqliteConnection) { + sql_query("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL DEFAULT '', hair_color TEXT)") + .execute(conn) + .unwrap(); +} + +#[cfg(feature = "mysql")] +fn create_user_table(conn: &MysqlConnection) { + sql_query("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTO_INCREMENT, name TEXT NOT NULL DEFAULT '', hair_color TEXT)") + .execute(conn) + .unwrap(); + sql_query("DELETE FROM users").execute(conn).unwrap(); +} + +#[cfg(feature = "sqlite")] +fn establish_connection() -> SqliteConnection { + SqliteConnection::establish(":memory:").unwrap() +} + +#[cfg(feature = "postgres")] +fn establish_connection() -> PgConnection { + let conn = PgConnection::establish( + &dotenv::var("DATABASE_URL") + .or_else(|_| dotenv::var("PG_DATABASE_URL")) + .expect("Set either `DATABASE_URL` or `PG_DATABASE_URL`"), + ) + .unwrap(); + + conn.begin_test_transaction().unwrap(); + conn +} + +#[cfg(feature = "mysql")] +fn establish_connection() -> MysqlConnection { + let conn = MysqlConnection::establish( + &dotenv::var("DATABASE_URL") + .or_else(|_| dotenv::var("MYSQL_DATABASE_URL")) + .expect("Set either `DATABASE_URL` or `MYSQL_DATABASE_URL`"), + ) + .unwrap(); + + conn.begin_test_transaction().unwrap(); + + conn +} #[test] fn querying_basic_schemas() { let conn = establish_connection(); - sql_query("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT)") - .execute(&conn) - .unwrap(); + create_user_table(&conn); sql_query("INSERT INTO users DEFAULT VALUES") .execute(&conn) .unwrap(); @@ -25,9 +78,7 @@ fn querying_basic_schemas() { #[test] fn querying_multiple_types() { let conn = establish_connection(); - sql_query("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)") - .execute(&conn) - .unwrap(); + create_user_table(&conn); sql_query("INSERT INTO users (name) VALUES ('Sean'), ('Tess')") .execute(&conn) .unwrap(); @@ -42,9 +93,7 @@ fn querying_multiple_types() { #[test] fn columns_used_in_where_clause() { let conn = establish_connection(); - sql_query("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)") - .execute(&conn) - .unwrap(); + create_user_table(&conn); sql_query("INSERT INTO users (name) VALUES ('Sean'), ('Tess')") .execute(&conn) .unwrap(); @@ -60,12 +109,10 @@ fn columns_used_in_where_clause() { } #[test] +#[cfg(feature = "sqlite")] fn providing_custom_schema_name() { + use diesel_dynamic_schema::schema; let table = schema("information_schema").table("users"); - let sql = debug_query::(&table); + let sql = debug_query::(&table); assert_eq!("`information_schema`.`users` -- binds: []", sql.to_string()); } - -fn establish_connection() -> SqliteConnection { - SqliteConnection::establish(":memory:").unwrap() -}