diff --git a/README.md b/README.md index 4d81df10..271332ee 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,20 @@ A utility to generate database structs and querying code from diesel schema files. Primarily built for [create-rust-app](https://github.com/Wulf/create-rust-app). +**Why?** + Currently, it's more advantageous to generate code over deriving code with macros because intellisense and autocompletion isn't quite there when it comes to macro expansion. +**Migrating from v0 to v1? See migration notes below.** + +## Features + +- Supports `diesel-async` (enable `async` feature flag) +- Generates structs based on your `schema.rs` file (see the `test/simple_table` folder for sample output) +- Optionally generates CRUD functions and helper methods +- Use as a binary or library (read more below) +- Many options (see `GenerationConfig` in `lib.rs`) -- you have the ability to customize generated code for specific tables; or entirely ignore a specific table! + ## Demo Given the following schema: @@ -30,20 +42,47 @@ cargo dsync -i schema.rs -o models Now we have everything we need! ```rust -use models::todos; - async fn demo(db: Connection) { - let created_todo = todos::create(&mut db, todos::CreateTodo { + /* + CRUD examples + */ + let created_todo = Todo::create(db, CreateTodo { text: "Create a demo", completed: false, - }).await?; - - let todos_list = todos::paginate(&mut db, 1, 10).await?; - - let updated_todo = todos::update(&mut db, created_todo.id, UpdateTodo { + })?; + + let todos_list = Todo::paginate(db, 1, 10)?; + + let read_todo = Todo::read(db, created_todo.id)?; + + let updated_todo = Todo::update(db, created_todo.id, UpdateTodo { text: created_todo.text, completed: true, - }).await?; + })?; + + let deleted_todo = Todo::delete(db, created_todo.id)?; + + /* + Use the "Filter" helper + --> Works well with intellisense! <-- + --> Easily create reusable/portable queries! <-- + + (Support for date/time and other types coming soon) + */ + + // example: fetch incomplete todos + let query = Todo::filter(TodoFilter { + completed: Some(false), + ..Default::default() + }); + let items: Vec = query.load(db)?; + + // example: delete completed todos + let query = Todo::filter(TodoFilter { + completed: Some(true), + ..Default::default() + }) + diesel::delete(query).execute(db)?; } ``` @@ -59,41 +98,41 @@ For a complete example, see [`test/simple_table/schema.rs`](test/simple_table/sc 2. Create a new binary in your project which uses the crate (for example, `bin/dsync.rs`) - ```rust - use std::{collections::HashMap, path::PathBuf}; - use dsync::{GenerationConfig, TableOptions}; - - pub fn main() { - let dir = env!("CARGO_MANIFEST_DIR"); - - dsync::generate_files( - PathBuf::from_iter([dir, "src/schema.rs"]), - PathBuf::from_iter([dir, "src/models"]), - GenerationConfig { /* ... your generation options ... */ } - ); - } - ``` + ```rust + use std::{collections::HashMap, path::PathBuf}; + use dsync::{GenerationConfig, TableOptions}; + + pub fn main() { + let dir = env!("CARGO_MANIFEST_DIR"); + + dsync::generate_files( + PathBuf::from_iter([dir, "src/schema.rs"]), + PathBuf::from_iter([dir, "src/models"]), + GenerationConfig { /* ... your generation options ... */ } + ); + } + ``` 3. Create a `Cargo.toml` binary entry: - ```toml - [[bin]] - name = "dsync" - path = "bin/dsync.rs" - ``` + ```toml + [[bin]] + name = "dsync" + path = "bin/dsync.rs" + ``` 4. Execute! - ```sh - cargo run --bin dsync - ``` +```sh +cargo run --bin dsync +``` - **Protip**: to use `cargo dsync`, create an alias in `.cargo/config`: - - ```toml - [alias] - dsync="run --bin dsync" - ``` +**Protip**: to use `cargo dsync`, create an alias in `.cargo/config`: + +```toml +[alias] +dsync="run --bin dsync" +``` ### Pre-built binary @@ -101,24 +140,34 @@ Setting up a custom binary allows you to completely customize the generation; ho (you'll have to make sure you keep it up-to-date by running this periodically): ```sh -cargo install dsync +cargo install dsync ``` **CLI Usage** -* `-i`: input argument: path to schema file -* `-o`: output argument: path to directory where generated code should be written -* `-c`: connection type (for example: `diesel::r2d2::PooledConnection>`) -* `-g`: (optional) list of columns that are automatically generated by create/update triggers (for example, `created_at`, `updated_at`) -* `--tsync`: (optional) adds `#[tsync]` attribute to generated structs (see ) -* `--model-path`: (optional) set a custom model import path, default `crate::models::` -* `--schema-path`: (optional) set a custom schema import path, default `crate::schema::` -* `--no-serde`: (optional) if set, does not output any serde related code -* `--no-crud`: (optional) Do not generate the CRUD functions for generated models -* `--create-str`: (optional) Set which string type to use for `Create*` structs (possible are `string`, `str`, `cow`) -* `--update-str`: (optional) Set which string type to use for `Update*` structs (possible are `string`, `str`, `cow`) -* `--single-model-file`: (optional) Generate only a single model file, instead of a directory with `mod.rs` and `generated.rs` -* note: the CLI has fail-safes to prevent accidental file overwriting +- `-i`: input argument: path to schema file +- `-o`: output argument: path to directory where generated code should be written +- `-c`: connection type that implements `diesel::connection::Connection`. For example: + - `diesel::r2d2::PooledConnection>`, + - `diesel::pg::PgConnection` + - `diesel::sqlite::SqliteConnection` + - `diesel::mysql::MysqlConnection` + - or, your custom diesel connection type! +- `-g`: (optional) list of columns that are automatically generated by create/update triggers (for example, `created_at`, `updated_at`) +- `--tsync`: (optional) adds `#[tsync]` attribute to generated structs (see ) +- `--model-path`: (optional) set a custom model import path, default `crate::models::` +- `--schema-path`: (optional) set a custom schema import path, default `crate::schema::` +- `--no-serde`: (optional) if set, does not output any serde related code +- `--no-crud`: (optional) Do not generate the CRUD functions for generated models +- `--create-str`: (optional) Set which string type to use for `Create*` structs (possible are `string`, `str`, `cow`) +- `--update-str`: (optional) Set which string type to use for `Update*` structs (possible are `string`, `str`, `cow`) +- `--single-model-file`: (optional) Generate only a single model file, instead of a directory with `mod.rs` and `generated.rs` +- `--diesel-backend`: (optional) Specifies the diesel backend type (something which implements `diesel::backend::Backend`). For example: + - `diesel::pg::Pg`, + - `diesel::sqlite::Sqlite`, + - `diesel::mysql::Mysql`, + - or your own type! +- note: the CLI has fail-safes to prevent accidental file overwriting ```sh dsync -i src/schema.rs -o src/models @@ -130,6 +179,16 @@ See `dsync --help` for more information. Feel free to open tickets for support or feature requests. +## Migrating from v0 to v1 + +- For those that use plural table names: English-based pluralization is no longer handled. If you had a table named `todos`, dsync will not generate a struct named `Todo` anymore, but instead, `struct Todos` will be generated. It might be easier to rename your tables entirely to singular case. +- Replace all instances of `/* This file is generated and managed by dsync */` with `/* @generated and managed by dsync */` in your generated model files. +- If using `dsync` as a library, add `..Default::default()` at the end of your args for the `GenerationConfig` struct. It sets the following new params to their default values: + - `once_common_structs: true`, + - `once_connection_type: true`, + - `diesel_backend: "diesel::pg::Pg"`, +- #[tsync] attributes are no longer included by default. To enable them, change the default table config options or supply `--tsync` if using the CLI. + ## Development/Testing Use `./test/test_all.sh` to run tests. diff --git a/src/bin/main.rs b/src/bin/main.rs index 08f04469..4e8493bf 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -102,6 +102,18 @@ pub struct MainOptions { /// Generate the "ConnectionType" type only once in a "common.rs" file #[arg(long = "once-connection-type")] pub once_connection_type: bool, + + /// Set which diesel backend to use (something which implements `diesel::backend::Backend`) + /// Deisel provides the following backends: + /// - `diesel::pg::Pg` + /// - `diesel::sqlite::Sqlite` + /// - `diesel::mysql::Mysql` + /// + /// See `crate::GenerationConfig::diesel_backend` for more details. + /// + /// Default is "diesel::pg::Pg" + #[arg(long = "diesel-backend", default_value = "diesel::pg::Pg")] + pub diesel_backend: String, } #[derive(Debug, ValueEnum, Clone, PartialEq, Default)] @@ -203,6 +215,7 @@ fn actual_main() -> dsync::Result<()> { model_path: args.model_path, once_common_structs: args.once_common_structs, once_connection_type: args.once_connection_type, + diesel_backend: args.diesel_backend, }, )?; diff --git a/src/code.rs b/src/code.rs index 89ef9a1f..fe2b0864 100644 --- a/src/code.rs +++ b/src/code.rs @@ -215,7 +215,7 @@ impl<'a> Struct<'a> { /// Render the full struct fn render(&mut self) { let ty = self.ty; - let table = &self.table; + let table = self.table; let primary_keys: Vec = table.primary_key_column_names(); @@ -422,25 +422,62 @@ impl {struct_name} {{ )); 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) -> QueryResult> {{ + /// Paginates through the table where page is a 1-based index (i.e. page 1 is the first page) + pub{async_keyword} fn paginate(db: &mut ConnectionType, param_page_starting_with_1: i64, param_page_size: i64, filter: {struct_name}Filter) -> QueryResult> {{ 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::(db){await_keyword}?; + let param_page = param_page_starting_with_1.max(0); + let param_page_size = param_page_size.max(1); + let total_items = Self::filter(filter.clone()).count().get_result(db)?; + let items = Self::filter(filter).limit(param_page_size).offset(param_page * param_page_size).load::(db){await_keyword}?; Ok(PaginationResult {{ items, total_items, - page, - page_size, + page: param_page, + page_size: param_page_size, /* ceiling division of integers */ - num_pages: total_items / page_size + i64::from(total_items % page_size != 0) + num_pages: total_items / param_page_size + i64::from(total_items % param_page_size != 0) }}) }} "##)); + // Table::filter() helper fn + { + let diesel_backend = config.diesel_backend.clone(); + let filters = table + .columns + .iter() + .map(|column| { + let column_name = column.name.to_string(); + 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})); + }}"## + ) + }) + .collect::>() + .join(""); + buffer.push_str(&format!( + r##" + /// A utility function to help build custom search queries + /// + /// Example: + /// + 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) @@ -470,10 +507,56 @@ impl {struct_name} {{ )); buffer.push_str( - r##" -}"##, + r#" +}"#, ); + // generate filter struct for filter() helper function + { + let filter_fields = table + .columns + .iter() + .map(|column| { + let column_name = column.name.to_string(); + format!( + "pub {column_name}: Option<{column_type}>,", + column_name = column_name, + column_type = if column.is_nullable { + format!("Option<{}>", column.ty) + } else { + column.ty.clone() + } + ) + }) + .collect::>() + .join("\n "); + let filter_fields_default = table + .columns + .iter() + .map(|column| { + let column_name = column.name.to_string(); + format!("{column_name}: None,") + }) + .collect::>() + .join("\n "); + buffer.push_str(&format!( + r##" +#[derive(Clone)] +pub struct {struct_name}Filter {{ + {filter_fields} +}} + +impl Default for {struct_name}Filter {{ + fn default() -> {struct_name}Filter {{ + {struct_name}Filter {{ + {filter_fields_default} + }} + }} +}} +"## + )); + } + buffer } @@ -500,7 +583,7 @@ pub struct PaginationResult {{ }} "##, serde_derive = if table_options.get_serde() { - "Serialize" + "serde::Serialize" } else { "" } @@ -510,7 +593,7 @@ pub struct PaginationResult {{ /// Generate connection-type type pub fn generate_connection_type(config: &GenerationConfig) -> String { format!( - "\ntype ConnectionType = {connection_type};", + "\npub type ConnectionType = {connection_type};", connection_type = config.connection_type, ) } @@ -569,7 +652,7 @@ fn build_imports(table: &ParsedTableMacro, config: &GenerationConfig) -> String format!( indoc! {" - use crate::diesel::*; + use diesel::*; use {schema_path};{fns_imports}{common_structs_imports} {serde_imports}{async_imports} {belongs_imports} diff --git a/src/lib.rs b/src/lib.rs index 305db755..9672eb97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -120,7 +120,7 @@ impl<'a> TableOptions<'a> { #[cfg(feature = "tsync")] pub fn tsync(self) -> Self { Self { - tsync: Some(true), + tsync: Some(false), ..self } } @@ -214,14 +214,26 @@ impl<'a> Default for TableOptions<'a> { #[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 /// - /// Example: `diesel::SqliteConnection` + /// For example: + /// - `diesel::pg::PgConnection` (default) + /// - `diesel::sqlite::SqliteConnection` + /// - `diesel::mysql::MysqlConnection` + /// - or, your custom diesel connection type (struct which implements `diesel::connection::Connection`) pub connection_type: String, + /// 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, /// Diesel schema import path /// /// by default `crate::schema::` @@ -231,11 +243,28 @@ pub struct GenerationConfig<'a> { /// by default `crate::models::` pub model_path: String, /// Generate common structs only once in a "common.rs" file + /// (true by default) pub once_common_structs: bool, /// Generate the "ConnectionType" type only once in a "common.rs" file + /// (true by default) pub once_connection_type: bool, } +impl<'a> Default for GenerationConfig<'a> { + fn default() -> Self { + Self { + table_options: Default::default(), + default_table_options: Default::default(), + connection_type: "diesel::pg::PgConnection".into(), + diesel_backend: "diesel::pg::Pg".into(), + schema_path: "crate::schema::".into(), + model_path: "crate::models::".into(), + once_common_structs: true, + once_connection_type: true, + } + } +} + impl GenerationConfig<'_> { pub fn table(&self, name: &str) -> TableOptions<'_> { let t = self @@ -343,6 +372,7 @@ pub fn generate_files( common_file.ensure_file_signature()?; common_file.change_file_contents({ let mut tmp = String::from(FILE_SIGNATURE); + tmp.push('\n'); if config.once_common_structs { tmp.push_str(&code::generate_common_structs( &config.default_table_options,