Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Advanced queries flag (BREAKING CHANGE) #126

Merged
merged 5 commits into from
Jan 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ async = []
backtrace = []
# enable derive "QueryableByName"
derive-queryablebyname = []
# enable *experimental* queries
advanced-queries = []

[dependencies]
clap = { version = "4.4", features = ["derive", "wrap_help"] }
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@ async fn demo(db: Connection) {
text: "Create a demo",
completed: false,
})?;

let todos_list = todos::paginate(&mut db, 1, 10)?;


let updated_todo = todos::update(&mut db, created_todo.id, UpdateTodo {
text: created_todo.text,
completed: true,
Expand Down Expand Up @@ -120,6 +118,7 @@ cargo install dsync
* `--single-model-file`: (optional) Generate only a single model file, instead of a directory with `mod.rs` and `generated.rs`
* `--readonly-prefix`: (optional, repeatable) A prefix to treat a table matching this as readonly *2
* `--readonly-suffix`: (optional, repeatable) A suffix to treat a table matching this as readonly *2
* `--diesel-backend`: (when the "advanced-queries" feature is enabled) The diesel backend in use (possible values include `diesel::pg::Pg`, `diesel::sqlite::Sqlite`, `diesel::mysql::Mysql`, or your custom backend type)
* note: the CLI has fail-safes to prevent accidental file overwriting

```sh
Expand All @@ -131,6 +130,14 @@ Notes:
- *2: "readonly" tables dont have `Update*` & `Create*` structs, only `*`(no suffix / prefix) structs.
For example this is useful for Sqlite views, which are read-only (cannot be written to, but can be read)

## Experimental API

We're currently experimenting with advanced query generation. This includes pagination, filtering/searching, and the like. Enable the `advanced-queries` feature flag to see some of it in action.

Alternatively, you can see what gets generated in the advanced queries test here: [`test/advanced_queries/models`](test/advanced_queries/models)

Feel free to open an issue to discuss these API and provide your feeedback.

## Docs

See `dsync --help` for more information.
Expand Down
13 changes: 13 additions & 0 deletions src/bin/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,17 @@ pub struct MainOptions {
/// A Suffix to treat a table matching this as readonly (only generate the Read struct)
#[arg(long = "readonly-suffix")]
pub readonly_suffixes: Vec<String>,

#[cfg(feature = "advanced-queries")]
/// Set which diesel backend to use (something which implements `diesel::backend::Backend`)
/// Diesel provides the following backends:
/// - `diesel::pg::Pg`
/// - `diesel::sqlite::Sqlite`
/// - `diesel::mysql::Mysql`
///
/// See `crate::GenerationConfig::diesel_backend` for more details.
#[arg(short = 'b', long = "diesel-backend")]
pub diesel_backend: String,
}

#[derive(Debug, ValueEnum, Clone, PartialEq, Default)]
Expand Down Expand Up @@ -251,6 +262,8 @@ fn actual_main() -> dsync::Result<()> {
once_connection_type: args.once_connection_type,
readonly_prefixes: args.readonly_prefixes,
readonly_suffixes: args.readonly_suffixes,
#[cfg(feature = "advanced-queries")]
diesel_backend: args.diesel_backend,
},
)?;

Expand Down
98 changes: 94 additions & 4 deletions src/code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,14 +535,16 @@ fn build_table_fns(
"##
));

#[cfg(feature = "advanced-queries")]
buffer.push_str(&format!(r##"
/// Paginates through the table where page is a 0-based index (i.e. page 0 is the first page)
pub{async_keyword} fn paginate(db: &mut ConnectionType, page: i64, page_size: i64) -> diesel::QueryResult<PaginationResult<Self>> {{
pub{async_keyword} fn paginate(db: &mut ConnectionType, page: i64, page_size: i64, filter: {struct_name}Filter) -> diesel::QueryResult<PaginationResult<Self>> {{
use {schema_path}{table_name}::dsl::*;

let page_size = if page_size < 1 {{ 1 }} else {{ page_size }};
let total_items = {table_name}.count().get_result(db){await_keyword}?;
let items = {table_name}.limit(page_size).offset(page * page_size).load::<Self>(db){await_keyword}?;
let page = page.max(0);
let page_size = page_size.max(1);
let total_items = Self::filter(filter.clone()).count().get_result(db)?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe in the future we can eliminate this and only do it once, so that subsequent calls dont need to also execute this (something like returning a generator / iterator)

let items = Self::filter(filter).limit(page_size).offset(page * page_size).load::<Self>(db){await_keyword}?;

Ok(PaginationResult {{
items,
Expand All @@ -555,6 +557,67 @@ fn build_table_fns(
}}
"##));

#[cfg(feature = "advanced-queries")]
// Table::filter() helper fn
{
let diesel_backend = &config.diesel_backend;
let filters = table
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this array / iterator could likely be directly pushed onto the buffer, instead of collecting it in a intermediate vec (though not important to change now)

.columns
.iter()
.map(|column| {
let column_name = column.name.to_string();

if column.is_nullable {
// "Option::None" will never match anything, and "is_null" is required to be used, see https://docs.diesel.rs/master/diesel/expression_methods/trait.ExpressionMethods.html#method.eq
format!(
Wulf marked this conversation as resolved.
Show resolved Hide resolved
r##"
if let Some(filter_{column_name}) = filter.{column_name}.clone() {{
query = if filter_{column_name}.is_some() {{
query.filter({schema_path}{table_name}::{column_name}.eq(filter_{column_name}))
}} else {{
query.filter({schema_path}{table_name}::{column_name}.is_null())
}};
}}"##
)
} else {
format!(
r##"
if let Some(filter_{column_name}) = filter.{column_name}.clone() {{
query = query.filter({schema_path}{table_name}::{column_name}.eq(filter_{column_name}));
}}"##
Comment on lines +574 to +587
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really need to .clone everything? shouldnt it be possible to just use partial-moves because the function takes in full ownership of the passed filter instead of a reference and does not return the filter itself?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out -- I'll have to look into it later

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

)
}
})
.collect::<Vec<_>>()
.join("");
buffer.push_str(&format!(
r##"
/// A utility function to help build custom search queries
///
/// Example:
///
/// ```
/// // create a filter for completed todos
/// let query = Todo::filter(TodoFilter {{
/// completed: Some(true),
/// ..Default::default()
/// }});
///
/// // delete completed todos
/// diesel::delete(query).execute(db)?;
/// ```
pub fn filter<'a>(
filter: {struct_name}Filter,
) -> {schema_path}{table_name}::BoxedQuery<'a, {diesel_backend}> {{
let mut query = {schema_path}{table_name}::table.into_boxed();
{filters}

query
}}
"##
));
}

// TODO: If primary key columns are attached to the form struct (not optionally)
// then don't require item_id_params (otherwise it'll be duplicated)

Expand Down Expand Up @@ -589,6 +652,33 @@ fn build_table_fns(

buffer.push_str("}\n");

#[cfg(feature = "advanced-queries")]
// generate filter struct for filter() helper function
{
let filter_fields = table
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, this could also be done without a intermediate vec (though not necessary now)

.columns
.iter()
.map(|column| {
let struct_field = StructField::from(column);
format!(
"pub {column_name}: Option<{column_type}>,",
column_name = struct_field.name,
column_type = struct_field.to_rust_type()
)
})
.collect::<Vec<_>>()
.join("\n ");

buffer.push_str(&formatdoc!(
r##"
#[derive(Debug, Default, Clone)]
pub struct {struct_name}Filter {{
{filter_fields}
}}
"##
));
}

buffer
}

Expand Down
4 changes: 4 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ pub enum ErrorEnum {
#[error("NoFileSignature: {0}")]
NoFileSignature(String),

/// Invalid generation config
#[error("InvalidGenerationConfig: {0}")]
InvalidGenerationConfig(String),

/// Variant for Other messages
#[error("Other: {0}")]
Other(String),
Expand Down
38 changes: 36 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ use std::collections::HashMap;
use std::fmt::Display;
use std::path::{Path, PathBuf};

use crate::error::ErrorEnum;

/// Available options for string types
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum StringType {
Expand Down Expand Up @@ -306,9 +308,9 @@ impl<'a> Default for TableOptions<'a> {
/// Global config, not table specific
#[derive(Debug, Clone)]
pub struct GenerationConfig<'a> {
/// Specific Table options for a given table
/// Specific code generation options for a particular table
pub table_options: HashMap<&'a str, TableOptions<'a>>,
/// Default table options, used when not in `table_options`
/// Default table options, can be overriden by `table_options`
pub default_table_options: TableOptions<'a>,
/// Connection type to insert
///
Expand All @@ -335,6 +337,16 @@ pub struct GenerationConfig<'a> {
pub readonly_prefixes: Vec<String>,
/// Suffixes to treat tables as readonly
pub readonly_suffixes: Vec<String>,

#[cfg(feature = "advanced-queries")]
/// Diesel backend
///
/// For example:
/// - `diesel::pg::Pg` (default)
/// - `diesel::sqlite::Sqlite`
/// - `diesel::mysql::Mysql`
/// - or, your custom diesel backend type (struct which implements `diesel::backend::Backend`)
pub diesel_backend: String,
}

impl GenerationConfig<'_> {
Expand All @@ -356,6 +368,25 @@ impl GenerationConfig<'_> {
}
}

#[cfg(feature = "advanced-queries")]
pub fn validate_config(config: &GenerationConfig) -> Result<()> {
const VALID_BACKENDS: [&str; 3] = [
"diesel::pg::Pg",
"diesel::sqlite::Sqlite",
"diesel::mysql::Mysql",
];

if config.diesel_backend.is_empty() {
return Err(Error::new(ErrorEnum::InvalidGenerationConfig(format!(
"Invalid diesel_backend '{}', please use one of the following: {:?}; or, a custom diesel backend type (a struct which implements `diesel::backend::Backend`).",
&config.diesel_backend,
VALID_BACKENDS.join(", ")
))));
}

Ok(())
}

/// Generate a model for the given schema contents
///
/// Model is returned and not saved to disk yet
Expand Down Expand Up @@ -436,6 +467,9 @@ pub fn generate_files(
output_models_dir: &Path,
config: GenerationConfig,
) -> Result<Vec<FileChange>> {
#[cfg(feature = "advanced-queries")]
validate_config(&config)?;

let generated = generate_code(
&std::fs::read_to_string(input_diesel_schema_file)
.attach_path_err(input_diesel_schema_file)?,
Expand Down
1 change: 1 addition & 0 deletions test/advanced_queries/models/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod todos;
Loading