├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE.txt ├── README.md ├── src ├── database_seeder.rs ├── lib.rs ├── reader.rs ├── resolver.rs └── struct_loader.rs └── tests ├── database_seeder.rs ├── database_seeder_async.rs ├── fixtures ├── customers.yml ├── items.yml └── orders.yml ├── struct_loader.rs └── test_utils ├── mock_database.rs ├── mod.rs └── types.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: cder 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | env: 9 | RUSTFLAGS: -Dwarnings 10 | CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse 11 | 12 | jobs: 13 | test: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | version: [1.68.0, stable] 19 | name: Test with Rust ${{ matrix.version }} on ${{ matrix.os }} 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: dtolnay/rust-toolchain@stable 24 | with: 25 | toolchain: ${{ matrix.version }} 26 | components: rustfmt, clippy 27 | - run: cargo fmt --all -- --check 28 | - run: cargo clippy --all-targets --all-features 29 | - run: cargo test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | /tmp 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.2.1 4 | * Fix typo in 'set record' function name by @SergPonomar in https://github.com/estie-inc/cder/pull/6 5 | * Added support for uuid by @SergPonomar in https://github.com/estie-inc/cder/pull/5 6 | 7 | ## New Contributors 8 | * @SergPonomar made their first contribution in https://github.com/estie-inc/cder/pull/6 9 | 10 | **Full Changelog**: https://github.com/estie-inc/cder/compare/v0.2.0...v0.2.1 11 | 12 | ## 0.2.0 13 | * fix: typo by @kenkoooo in https://github.com/estie-inc/cder/pull/1 14 | * Make populate_async doesn't require Pin by @hatoo in https://github.com/estie-inc/cder/pull/2 15 | 16 | ## 0.1.1 17 | * add keywords to Cargo.toml for better searchability 18 | * format inline docs 19 | 20 | ## 0.1.0 21 | * publish! 22 | * add license by @hoyo 23 | * implement cder by @fursich 24 | 25 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cder" 3 | version = "0.2.2" 4 | edition = "2021" 5 | description = "database seed generator that helps create and persist struct-typed instances based on serde-compatible yaml files" 6 | keywords = ["seed", "seeding", "fixture", "database", "yaml"] 7 | categories = ["development-tools"] 8 | repository = "https://github.com/estie-inc/cder" 9 | authors = ["Koji "] 10 | readme = "README.md" 11 | license = "MIT" 12 | 13 | [dependencies] 14 | anyhow = "1.0" 15 | serde = { version = "1.0", features = ["derive"] } 16 | serde_yaml = "0.9.16" 17 | regex = "1.7" 18 | once_cell = "1.16" 19 | 20 | [dev-dependencies] 21 | chrono = { version = "0.4", features = ["serde"] } 22 | tokio = { version = "=1.38", features = ["time", "rt-multi-thread", "macros"] } 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023, estie, inc. and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![cder](https://github.com/estie-inc/cder/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/estie-inc/cder/actions/workflows/ci.yml) 2 | [![Latest version](https://img.shields.io/crates/v/cder.svg)](https://crates.io/crates/cder) 3 | [![Documentation](https://docs.rs/cder/badge.svg)](https://docs.rs/cder) 4 | ![licence](https://img.shields.io/github/license/estie-inc/cder) 5 | 6 | # cder 7 | 8 | 9 | ### A lightweight, simple database seeding tool for Rust 10 |
11 | 12 | cder (_see-der_) is a database seeding tool to help you import fixture data in your local environment. 13 | 14 | Generating seeds programmatically is an easy task, but maintaining them is not. 15 | Every time when your schema is changed, your seeds can be broken. 16 | It costs your team extra effort to keep them updated. 17 | 18 | #### with cder you can: 19 | - maintain your data in a readable format, separated from the seeding program 20 | - handle reference integrities on-the-fly, using **embedded tags** 21 | - reuse existing structs and insert functions, with only a little glue code is needed 22 | 23 | cder has no mechanism for database interaction, so it can work with any type of ORM or database wrapper (e.g. sqlx) your application has. 24 | 25 | This embedded-tag mechanism is inspired by [fixtures](https://github.com/rails/rails/blob/c9a0f1ab9616ca8e94f03327259ab61d22f04b51/activerecord/lib/active_record/fixtures.rb) that Ruby on Rails provides for test data generation. 26 | 27 | ## Installation 28 | 29 | ```toml 30 | # Cargo.toml 31 | [dependencies] 32 | cder = "0.2" 33 | ``` 34 | ## Usage 35 | 36 | ### Quick start 37 | 38 | Suppose you have users table as seeding target: 39 | 40 | ```sql 41 | CREATE TABLE 42 | users ( 43 | `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, 44 | `name` VARCHAR(255) NOT NULL, 45 | `email` VARCHAR(255) NOT NULL, 46 | ) 47 | ``` 48 | 49 | In your application you also have: 50 | 51 | - a struct of type `` (usually a model, built upon a underlying table) 52 | - database insertion method that returns id of the new record: `Fn(T) -> Result` 53 | 54 | First, add DeserializeOwned trait on the struct. 55 | (cder brings in *serde* as dependencies, so `derive(Deserialize)` macro can do the job) 56 | 57 | ```rust 58 | use serde::Deserialize; 59 | 60 | #[derive(Deserialize)] // add this derive macro 61 | User { 62 | name: String, 63 | email: String, 64 | } 65 | 66 | impl User { 67 | // can be sync or async functions 68 | async fn insert(&self) -> Result<(i64)> { 69 | // 70 | // inserts a corresponding record into table, and returns its id when succeeded 71 | // 72 | } 73 | } 74 | ``` 75 | 76 | Your User seed is defined by two separate files, data and glue code. 77 | 78 | Now create a seed data file 'fixtures/users.yml' 79 | 80 | ```yaml 81 | # fixtures/users.yml 82 | 83 | User1: 84 | name: Alice 85 | email: 'alice@example.com' 86 | User2: 87 | name: Bob 88 | email: 'bob@example.com' 89 | ``` 90 | 91 | Now you can insert above two users into your database: 92 | 93 | ```rust 94 | use cder::DatabaseSeeder; 95 | 96 | async fn populate_seeds() -> Result<()> { 97 | let mut seeder = DatabaseSeeder::new() 98 | 99 | seeder 100 | .populate_async("fixtures/users.yml", |input| { 101 | async move { User::insert(&input).await } 102 | }) 103 | .await?; 104 | 105 | Ok(()) 106 | } 107 | ``` 108 | 109 | Et voila! You will get the records `Alice` and `Bob` populated in your database. 110 | 111 | #### Working with non-async functions 112 | If your function is non-async (normal) function, use `Seeder::populate` instead of `Seeder::populate_async`. 113 | 114 | ```rust 115 | use cder::DatabaseSeeder; 116 | 117 | fn main() -> Result<()> { 118 | let mut seeder = DatabaseSeeder::new(); 119 | 120 | seeder 121 | .populate("fixtures/users.yml", |input| { 122 | // this block can contain any non-async functions 123 | // but it has to return Result in the end 124 | diesel::insert_into(users) 125 | .values((name.eq(input.name), email.eq(input.email))) 126 | .returning(id) 127 | .get_result(conn) 128 | .map(|value| value.into()) 129 | }) 130 | 131 | Ok(()) 132 | } 133 | ``` 134 | 135 | ### Constructing instances 136 | 137 | If you want to take more granular control over the deserialized structs before inserting, use StructLoader instead. 138 | 139 | ```rust 140 | use cder::{ Dict, StructLoader }; 141 | 142 | fn construct_users() -> Result<()> { 143 | // provide your fixture filename followed by its directory 144 | let mut loader = StructLoader::::new("users.yml", "fixtures"); 145 | 146 | // deserializes User struct from the given fixture 147 | // the argument is related to name resolution (described later) 148 | loader.load(&Dict::::new())?; 149 | 150 | let customer = loader.get("User1")?; 151 | assert_eq!(customer.name, "Alice"); 152 | assert_eq!(customer.email, "alice@example.com"); 153 | 154 | let customer = loader.get("User2")?; 155 | assert_eq!(customer.name, "Bob"); 156 | assert_eq!(customer.email, "bob@example.com"); 157 | 158 | ok(()) 159 | } 160 | ``` 161 | 162 | ### Defining values on-the-go 163 | cder replaces certain tags with values based on a couple of rules. 164 | This 'pre-processing' runs just before deserialization, so that you can define *dynamic* values that can vary depending on your local environments. 165 | 166 | Currently following two cases are covered: 167 | 168 | #### 1. Defining relations (foreign keys) 169 | 170 | Let's say you have two records to be inserted in `companies` table. 171 | `companies.id`s are unknown, as they are given by the local database on insert. 172 | 173 | ```yaml 174 | # fixtures/companies.yml 175 | 176 | Company1: 177 | name: MassiveSoft 178 | Company2: 179 | name: BuggyTech 180 | ``` 181 | 182 | Now you have user records that reference to these companies: 183 | 184 | ```yaml 185 | # fixtures/users.yml 186 | 187 | User1: 188 | name: Alice 189 | company_id: 1 // this might be wrong 190 | ``` 191 | 192 | You might end up with failing building User1, as Company1 is not guaranteed to have id=1 (especially if you already have operated on the companies table). 193 | For this, use `${{ REF(label) }}` tag in place of undecided values. 194 | 195 | ```yaml 196 | User1: 197 | name: Alice 198 | company_id: ${{ REF(Company1) }} 199 | ``` 200 | 201 | Now, how does Seeder know id of Compnay1 record? 202 | As described earlier, the block given to Seeder must return `Result`. Seeder stores the result value mapped against the record label, which will be re-used later to resolve the tag references. 203 | 204 | ```rust 205 | use cder::DatabaseSeeder; 206 | 207 | async fn populate_seeds() -> Result<()> { 208 | let mut seeder = DatabaseSeeder::new(); 209 | // you can specify the base directory, relative to the project root 210 | seeder.set_dir("fixtures"); 211 | 212 | // Seeder stores mapping of companies record label and its id 213 | seeder 214 | .populate_async("companies.yml", |input| { 215 | async move { Company::insert(&input).await } 216 | }) 217 | .await?; 218 | // the mapping is used to resolve the reference tags 219 | seeder 220 | .populate_async("users.yml", |input| { 221 | async move { User::insert(&input).await } 222 | }) 223 | .await?; 224 | 225 | Ok(()) 226 | } 227 | ``` 228 | 229 | A couple of watch-outs: 230 | 1. Insert a file that contains 'referenced' records first (`companies` in above examples) before 'referencing' records (`users`). 231 | 2. Currently Seeder resolve the tag when reading the source file. That means you cannot have references to the record within the same file. 232 | If you want to reference a user record from another one, you could achieve this by splitting the yaml file in two. 233 | 234 | #### 2. Environment vars 235 | You can also refer to environment variables using `${{ ENV(var_name) }}` syntax. 236 | 237 | ```yaml 238 | Dev: 239 | name: Developer 240 | email: ${{ ENV(DEVELOPER_EMAIL) }} 241 | ``` 242 | 243 | The email is replaced with `DEVELOPER_EMAIL` if that environment var is defined. 244 | 245 | If you would prefer to use default value, use (shell-like) syntax: 246 | 247 | ```yaml 248 | Dev: 249 | name: Developer 250 | email: ${{ ENV(DEVELOPER_EMAIL:-"developer@example.com") }} 251 | ``` 252 | 253 | Without specifying the default value, all the tags that point to undefined environment vars are simply replaced by empty string "". 254 | 255 | ### Data representation 256 | cder deserializes yaml data based on [serde-yaml](https://github.com/dtolnay/serde-yaml), that supports powerful [serde serialization framework](https://serde.rs/). With serde, you can deserialize pretty much any struct. You can see a few [sample structs](tests/test_utils/types.rs) with various types of attributes and [the yaml files](tests/fixtures) that can be used as their seeds. 257 | 258 | Below are a few basics of required YAML format. 259 | Check [serde-yaml's github page](https://github.com/dtolnay/serde-yaml) for further details. 260 | 261 | #### Basics 262 | 263 | ```yaml 264 | Label_1: 265 | name: Alice 266 | email: 'alice@example.com' 267 | Label_2: 268 | name: Bob 269 | email: 'bob@example.com' 270 | ``` 271 | 272 | Notice that, cder requires each record to be labeled (*Label_x*). 273 | A label can be anything (as long as it is a valid yaml key) but you might want to keep them unique to avoid accidental mis-references. 274 | 275 | #### Enums and Complex types 276 | 277 | Enums can be deserialized using YAML's `!tag`. 278 | Suppose you have a struct CustomerProfile with enum `Contact`. 279 | 280 | ```rust 281 | struct CustomerProfile { 282 | name: String, 283 | contact: Option, 284 | } 285 | 286 | enum Contact { 287 | Email { email: String } 288 | Employee(usize), 289 | Unknown 290 | } 291 | ``` 292 | 293 | You can generate customers with each type of contact as follows; 294 | 295 | ```yaml 296 | Customer1: 297 | name: "Jane Doe" 298 | contact: !Email { email: "jane@example.com" } 299 | Customer2: 300 | name: "Uncle Doe" 301 | contact: !Employee(10100) 302 | Customer3: 303 | name: "John Doe" 304 | contact: !Unknown 305 | ``` 306 | 307 | ### Not for production use 308 | cder is designed to populate seeds in development (or possibly, test) environment. Production use is NOT recommended. 309 | 310 | ## License 311 | 312 | The project is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 313 | 314 | ## Contribution 315 | 316 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, shall be licensed as MIT, without any additional terms or conditions. 317 | 318 | Bug reports and pull requests are welcome on GitHub at https://github.com/estie-inc/cder 319 | -------------------------------------------------------------------------------- /src/database_seeder.rs: -------------------------------------------------------------------------------- 1 | use crate::{load_named_records, Dict}; 2 | use anyhow::Result; 3 | use serde::de::DeserializeOwned; 4 | use std::future::Future; 5 | /// DatabaseSeeder persists data deserialized from specified file. 6 | /// Internally it keeps record label mapped against its id on insertion. The mapping can be reused 7 | /// later process to resolve embedded tags. 8 | /// 9 | /// NOTE: record names must be unique, otherwise the ealier records will be overwritten by the latter. 10 | /// 11 | /// # Examples 12 | /// ```rust 13 | /// use serde::Deserialize; 14 | /// use anyhow::Result; 15 | /// 16 | /// // a model (struct) 17 | /// #[derive(Deserialize)] // add this derive macro 18 | /// struct User { 19 | /// name: String, 20 | /// email: String, 21 | /// } 22 | /// 23 | /// // a function that persists user record into users table 24 | /// impl User { 25 | /// // can be sync or async functions 26 | /// async fn insert(input: &User) -> Result<(i64)> { 27 | /// // 28 | /// // this function inserts a corresponding User record into table, 29 | /// // and returns its id when succeeded 30 | /// // 31 | /// # Ok(1) 32 | /// } 33 | /// } 34 | /// 35 | /// // glue code you need to add 36 | /// use cder::DatabaseSeeder; 37 | /// 38 | /// # fn main() { 39 | /// # populate_seeds(); 40 | /// # } 41 | /// 42 | /// async fn populate_seeds() -> Result<()> { 43 | /// let mut seeder = DatabaseSeeder::new(); 44 | /// 45 | /// seeder 46 | /// .populate_async("fixtures/users.yml", |input| { 47 | /// async move { User::insert(&input).await } 48 | /// }) 49 | /// .await?; 50 | /// 51 | /// Ok(()) 52 | /// } 53 | /// ``` 54 | 55 | pub struct DatabaseSeeder { 56 | pub filenames: Vec, 57 | pub base_dir: String, 58 | name_resolver: Dict, 59 | } 60 | 61 | impl Default for DatabaseSeeder { 62 | fn default() -> Self { 63 | Self::new() 64 | } 65 | } 66 | 67 | impl DatabaseSeeder { 68 | pub fn new() -> Self { 69 | Self { 70 | filenames: Vec::new(), 71 | base_dir: String::new(), 72 | name_resolver: Dict::::new(), 73 | } 74 | } 75 | 76 | pub fn set_dir(&mut self, base_dir: &str) { 77 | self.base_dir = base_dir.to_string(); 78 | } 79 | 80 | /// ```rust 81 | /// use cder::DatabaseSeeder; 82 | /// # use serde::Deserialize; 83 | /// # use anyhow::Result; 84 | /// # 85 | /// # #[derive(Deserialize)] // add this derive macro 86 | /// # struct User { 87 | /// # name: String, 88 | /// # email: String, 89 | /// # } 90 | /// # 91 | /// # impl User { 92 | /// # fn insert(input: &User) -> Result<(i64)> { 93 | /// # // 94 | /// # // this function inserts a corresponding User record into table, 95 | /// # // and returns its id when succeeded 96 | /// # // 97 | /// # Ok(1) 98 | /// # } 99 | /// # } 100 | /// # 101 | /// # fn main() { 102 | /// # populate_seeds(); 103 | /// # } 104 | /// 105 | /// async fn populate_seeds() -> Result<()> { 106 | /// let mut seeder = DatabaseSeeder::new(); 107 | /// 108 | /// seeder 109 | /// .populate("fixtures/users.yml", |input| { 110 | /// // this block can contain any non-async functions 111 | /// // but it has to return Result in the end 112 | /// User::insert(&input) 113 | /// }); 114 | /// 115 | /// Ok(()) 116 | /// } 117 | /// ``` 118 | pub fn populate(&mut self, filename: &str, mut loader: F) -> Result> 119 | where 120 | F: FnMut(T) -> Result, 121 | T: DeserializeOwned, 122 | U: ToString, 123 | { 124 | let named_records = load_named_records::(filename, &self.base_dir, &self.name_resolver)?; 125 | let mut ids = Vec::new(); 126 | 127 | for (name, record) in named_records { 128 | let id = loader(record)?; 129 | self.name_resolver.insert(name.clone(), id.to_string()); 130 | ids.push(id); 131 | } 132 | Ok(ids) 133 | } 134 | 135 | /// ```rust 136 | /// use cder::DatabaseSeeder; 137 | /// # use serde::Deserialize; 138 | /// # use anyhow::Result; 139 | /// # 140 | /// # #[derive(Deserialize)] // add this derive macro 141 | /// # struct User { 142 | /// # name: String, 143 | /// # email: String, 144 | /// # } 145 | /// # 146 | /// # impl User { 147 | /// # async fn insert(input: &User) -> Result<(i64)> { 148 | /// # // 149 | /// # // this function inserts a corresponding User record into table, 150 | /// # // and returns its id when succeeded 151 | /// # // 152 | /// # Ok(1) 153 | /// # } 154 | /// # } 155 | /// # 156 | /// # fn main() { 157 | /// # populate_seeds(); 158 | /// # } 159 | /// 160 | /// async fn populate_seeds() -> Result<()> { 161 | /// let mut seeder = DatabaseSeeder::new(); 162 | /// 163 | /// seeder 164 | /// .populate_async("fixtures/users.yml", |input| { 165 | /// async move { User::insert(&input).await } 166 | /// }) 167 | /// .await?; 168 | /// 169 | /// Ok(()) 170 | /// } 171 | /// ``` 172 | pub async fn populate_async( 173 | &mut self, 174 | filename: &str, 175 | mut loader: F, 176 | ) -> Result> 177 | where 178 | Fut: Future>, 179 | F: FnMut(T) -> Fut, 180 | T: DeserializeOwned, 181 | U: ToString, 182 | { 183 | let named_records = load_named_records::(filename, &self.base_dir, &self.name_resolver)?; 184 | self.filenames.push(filename.to_string()); 185 | 186 | let mut ids = Vec::new(); 187 | 188 | for (name, record) in named_records { 189 | let id = loader(record).await?; 190 | self.name_resolver.insert(name.clone(), id.to_string()); 191 | ids.push(id); 192 | } 193 | Ok(ids) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod database_seeder; 2 | mod reader; 3 | mod resolver; 4 | mod struct_loader; 5 | pub use database_seeder::DatabaseSeeder; 6 | pub use struct_loader::StructLoader; 7 | 8 | use anyhow::Result; 9 | use reader::read_file; 10 | use resolver::resolve_tags; 11 | use serde::de::DeserializeOwned; 12 | use std::collections::HashMap; 13 | 14 | pub type Dict = HashMap; 15 | 16 | fn load_named_records( 17 | filename: &str, 18 | base_dir: &str, 19 | dependencies: &Dict, 20 | ) -> Result> 21 | where 22 | T: DeserializeOwned, 23 | { 24 | // read contents as string from the seed file 25 | let raw_text = read_file(filename, base_dir)?; 26 | 27 | // replace embedded tags before deserialization gets started 28 | let parsed_text = resolve_tags(&raw_text, dependencies).map_err(|err| { 29 | anyhow::anyhow!( 30 | "failed to pre-process embedded tags: {}\n err: {}", 31 | filename, 32 | err 33 | ) 34 | })?; 35 | 36 | // deserialization 37 | // currently accepts yaml format only, but this could accept any other serde-compatible format, e.g. json 38 | let records = serde_yaml::from_str(&parsed_text).map_err(|err| { 39 | anyhow::anyhow!( 40 | "deserialization failed. check the file: {} 41 | err: {}", 42 | filename, 43 | err 44 | ) 45 | })?; 46 | 47 | Ok(records) 48 | } 49 | -------------------------------------------------------------------------------- /src/reader.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::{env, fs, path::PathBuf}; 3 | 4 | /// Read seeds from specified file 5 | pub fn read_file(filename: &str, base_dir: &str) -> Result { 6 | let path = env::var("CARGO_MANIFEST_DIR") 7 | .map(PathBuf::from) 8 | .unwrap_or_default() 9 | .join(base_dir) 10 | .join(filename); 11 | 12 | fs::read_to_string(&path) 13 | .map_err(|err| anyhow::anyhow!("Can't open the file: {:?}\n err: {}", path, err)) 14 | } 15 | -------------------------------------------------------------------------------- /src/resolver.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::{collections::HashMap, env}; 3 | 4 | macro_rules! regex { 5 | ($re:literal $(,)?) => {{ 6 | static RE: once_cell::sync::OnceCell = once_cell::sync::OnceCell::new(); 7 | RE.get_or_init(|| regex::Regex::new($re).unwrap()) 8 | }}; 9 | } 10 | 11 | /// replaces embedded custom tags before deserialization 12 | /// tags can be used to allocate dynamic values to the seed object 13 | /// format: 14 | /// tags must be surrounded between two consecutive braces: ${{ ... }} 15 | /// inside, there must be a pair of 'directive' followed by a 'key' surrounded in the parenthesis. 16 | /// so the basic form is: ${{ directive(key) }} 17 | /// you can also add a 'default' value as follows, which can be used in case it fails to resolve 18 | /// the specified key: ${{ directive(key:-default) }} 19 | /// 20 | /// currently it accepts following types as directive: 21 | /// ENV(FOO_BAR) ... replace the tag with the environment variable 'FOO' 22 | /// REF(some_name) ... replace the tag with an ID of an object, referred by the key named 'some_name' 23 | /// constraints: 24 | /// all keys must consist of alphabet or numbers. 25 | /// default values must consist of alphanumeric, or string surrounded by double quotes "..." (the 26 | /// string must not contain any other double quotes or control charactors) 27 | pub fn resolve_tags(raw_text: &str, dict: &HashMap) -> Result { 28 | let mut index: usize = 0; 29 | let mut parsed_text: String = "".to_string(); 30 | 31 | while index < raw_text.len() { 32 | let source_text = &raw_text[index..]; 33 | 34 | let result = try_consume(source_text)?; 35 | 36 | index += match result { 37 | ParseResult::Nothing => { 38 | parsed_text.push_str(source_text); 39 | source_text.len() 40 | } 41 | 42 | ParseResult::Found { 43 | directive, 44 | key, 45 | default, 46 | start, 47 | end, 48 | } => { 49 | // finds a value (text) that has to be replaced with the directive/key. 50 | // ENV() ... replace it with the environment var 51 | // REF() ... replace it with the object id referred by the 52 | let replacement = match directive.as_str() { 53 | "ENV" => resolve_env(&key, default), 54 | "REF" => resolve_ref(&key, dict), 55 | _ => Err(anyhow::anyhow!( 56 | "the directive: ` {}` is not supported.", 57 | directive 58 | )), 59 | }?; 60 | if start > 0 { 61 | parsed_text.push_str(&source_text[..start]); 62 | } 63 | parsed_text.push_str(&replacement); 64 | end 65 | } 66 | }; 67 | } 68 | 69 | Ok(parsed_text) 70 | } 71 | 72 | fn resolve_ref(key: &str, dict: &HashMap) -> Result { 73 | dict.get(key) 74 | .map(|value| value.to_owned()) 75 | .ok_or_else(|| anyhow::anyhow!("failed to idintify a record referred by the key: `{key}`")) 76 | } 77 | 78 | /// this enum is used to hold the type of the directive indicated by the tag 79 | #[derive(PartialEq, Debug)] 80 | enum ParseResult { 81 | Found { 82 | // contains the parse result if the string matches with any of the discriptor patterns 83 | directive: String, 84 | key: String, 85 | default: Option, 86 | start: usize, // index the first charactor that matched with ${{...}} 87 | end: usize, // index the last charactor that matched with ${{...}} 88 | }, 89 | Nothing, // no matches 90 | } 91 | 92 | /// retrieve the values from the environment variable that matches the provided key 93 | fn resolve_env(key: &str, defalut: Option) -> Result { 94 | env::var(key).or_else(|_| match defalut { 95 | Some(value) => Ok(value), 96 | None => Err(anyhow::anyhow!( 97 | "environment variable: `{}` is not found", 98 | key 99 | )), 100 | }) 101 | } 102 | 103 | /// captures the directive and the key surrounded by ${{ }}, returns a ParseResult object 104 | fn try_consume(source: &str) -> Result { 105 | // matches with something like: ${{ AnyTag(some_key) }} 106 | let re = regex!( 107 | r#"\$\{\{\s*(?P[[:alnum:]]+)\(\s*(?P[[:alnum:]_-]+)(\s*:-\s*(?P([[:alnum:]]+|"[^"[:cntrl:]]+")))?\s*\)\s*\}\}"# 108 | ); 109 | 110 | let captures = match re.captures(source) { 111 | Some(captures) => captures, 112 | None => return Ok(ParseResult::Nothing), 113 | }; 114 | 115 | let directive = captures 116 | .name("directive") 117 | .map(|matched| matched.as_str().to_string()); 118 | let key = captures 119 | .name("key") 120 | .map(|matched| matched.as_str().to_string()); 121 | let default = captures 122 | .name("default") 123 | .map(|matched| matched.as_str().to_string()); 124 | 125 | let base_capture = captures.get(0); 126 | let start = base_capture.map(|matched| matched.start()); 127 | let end = base_capture.map(|matched| matched.end()); 128 | 129 | match (directive, key, start, end) { 130 | (Some(directive), Some(key), Some(start), Some(end)) => Ok(ParseResult::Found { 131 | directive, 132 | key, 133 | default, 134 | start, 135 | end, 136 | }), 137 | // usually this should not happen 138 | _ => Err(anyhow::anyhow!( 139 | "match failed for unknown reasons: check that the regex has valid form" 140 | )), 141 | } 142 | } 143 | 144 | #[cfg(test)] 145 | mod tests { 146 | use crate::resolver::*; 147 | use std::env; 148 | 149 | #[test] 150 | // test against embedded tags 151 | fn test_resolve_tags() { 152 | let raw_text = 153 | "The quick brown ${{ ENV(FOX) }} jumps over\nthe lazy ${{ REF(dog) }}".to_string(); 154 | 155 | // when correspoinding env var is defined 156 | env::set_var("FOX", "🦊"); 157 | // when the ref is successfully resolved 158 | let dict = HashMap::from([ 159 | ("swan".to_string(), "🦢".to_string()), 160 | ("dog".to_string(), "🐕".to_string()), 161 | ]); 162 | let parsed_text = resolve_tags(&raw_text, &dict).unwrap(); 163 | assert_eq!(parsed_text, "The quick brown 🦊 jumps over\nthe lazy 🐕"); 164 | 165 | // when the ref is undefined 166 | let dict = HashMap::from([ 167 | ("swan".to_string(), "🦢".to_string()), 168 | ("dolphin".to_string(), "🐬".to_string()), 169 | ]); 170 | let parsed_text = resolve_tags(&raw_text, &dict); 171 | assert!(parsed_text.is_err()); 172 | 173 | // when the dict is empty 174 | let dict = HashMap::new(); 175 | let parsed_text = resolve_tags(&raw_text, &dict); 176 | assert!(parsed_text.is_err()); 177 | 178 | // when correspoinding env var is NOT defined 179 | env::remove_var("FOX"); 180 | // when the ref is successfully resolved 181 | let dict = HashMap::from([ 182 | ("swan".to_string(), "🦢".to_string()), 183 | ("dog".to_string(), "🐕".to_string()), 184 | ]); 185 | let parsed_text = resolve_tags(&raw_text, &dict); 186 | assert!(parsed_text.is_err()); 187 | 188 | // when the tag cannot be recognized (due to incorrect format) 189 | let raw_text = "The quick brown ${{ENV(FOX?)}} jumps over\nthe lazy {REF(dog)}".to_string(); 190 | let parsed_text = resolve_tags(&raw_text, &dict).unwrap(); 191 | // it simply outputs the original text as it is 192 | assert_eq!( 193 | parsed_text, 194 | "The quick brown ${{ENV(FOX?)}} jumps over\nthe lazy {REF(dog)}".to_string() 195 | ); 196 | 197 | // when the tag contains unsupported directive name 198 | let raw_text = "The quick brown ${{REFERENCE(fox_id)}} jumps over the lazy dog".to_string(); 199 | let parsed_text = resolve_tags(&raw_text, &dict); 200 | assert!(parsed_text.is_err()); 201 | } 202 | 203 | #[test] 204 | fn test_resolve_ref() { 205 | let dict = HashMap::from([ 206 | ("foo".to_string(), "bar".to_string()), 207 | ("umi".to_string(), "yama".to_string()), 208 | ]); 209 | 210 | let value = resolve_ref("foo", &dict).unwrap(); 211 | assert_eq!(value, "bar"); 212 | 213 | let value = resolve_ref("BAZ", &dict); 214 | assert!(value.is_err()); 215 | 216 | let dict = HashMap::new(); 217 | let value = resolve_ref("foo", &dict); 218 | assert!(value.is_err()); 219 | } 220 | 221 | #[test] 222 | fn test_resolve_env() { 223 | let key = "FOO"; 224 | 225 | // when correspoinding env var is NOT defined 226 | env::remove_var(key); 227 | assert!(resolve_env(key, None).is_err()); 228 | 229 | let value = resolve_env(key, Some("default".to_string())).unwrap(); 230 | assert_eq!(value, "default"); 231 | 232 | // when correspoinding env var is defined 233 | env::set_var(key, "SOME_VALUE"); 234 | assert_eq!(resolve_env(key, None).unwrap(), "SOME_VALUE"); 235 | 236 | let value = resolve_env(key, Some("default".to_string())).unwrap(); 237 | assert_eq!(value, "SOME_VALUE"); 238 | } 239 | 240 | #[test] 241 | fn test_try_consume() { 242 | let source_text = "abc${{ SomeDirective(key-is-here) }}xyz"; 243 | let result = try_consume(source_text).unwrap(); 244 | // extracts the directive and the key surrounded between double braces ${{ }} 245 | assert_eq!( 246 | result, 247 | ParseResult::Found { 248 | directive: "SomeDirective".to_string(), 249 | key: "key-is-here".to_string(), 250 | default: None, 251 | start: 3, 252 | end: 37, 253 | } 254 | ); 255 | 256 | // when default value is provided after the key 257 | let source_text = r#"abc${{ SomeDirective(key-is-here:-DEFAULT1) }}xyz"#; 258 | let result = try_consume(source_text).unwrap(); 259 | // extracts the directive, the key, and the default value surrounded between double braces ${{ }} 260 | assert_eq!( 261 | result, 262 | ParseResult::Found { 263 | directive: "SomeDirective".to_string(), 264 | key: "key-is-here".to_string(), 265 | default: Some("DEFAULT1".to_string()), 266 | start: 3, 267 | end: 47, 268 | } 269 | ); 270 | 271 | // the default value may contain any non-control charactors surrounded by double quotes 272 | // (be it a non-ascii charactor or punctuation) 273 | let source_text = r#"abc${{ SomeDirective(key-is-here:-"See? th|s @lso fa!!s b/\ck to .. `default` value 🏡") }}xyz"#; 274 | let result = try_consume(source_text).unwrap(); 275 | assert_eq!( 276 | result, 277 | ParseResult::Found { 278 | directive: "SomeDirective".to_string(), 279 | key: "key-is-here".to_string(), 280 | default: Some( 281 | r#""See? th|s @lso fa!!s b/\ck to .. `default` value 🏡""#.to_string() 282 | ), 283 | start: 3, 284 | end: 94, 285 | } 286 | ); 287 | 288 | // when there is multiple "directive-key" matches 289 | let source_text = 290 | "abc${{ SomeDirective(key-is-here) }}xyz${{ SomeOtherDirective(key) }}pqrs${{FOO(bar)}}"; 291 | let result = try_consume(source_text).unwrap(); 292 | // it captures the first one 293 | assert_eq!( 294 | result, 295 | ParseResult::Found { 296 | directive: "SomeDirective".to_string(), 297 | key: "key-is-here".to_string(), 298 | default: None, 299 | start: 3, 300 | end: 37, 301 | } 302 | ); 303 | 304 | // spaces inside double braces are ignored 305 | let source_text = "${{    FOOOOO( \t bar ) \t }}"; 306 | let result = try_consume(source_text).unwrap(); 307 | assert_eq!( 308 | result, 309 | ParseResult::Found { 310 | directive: "FOOOOO".to_string(), 311 | key: "bar".to_string(), 312 | default: None, 313 | start: 0, 314 | end: 36, 315 | } 316 | ); 317 | 318 | // when parsing the original text (without offset) 319 | let source_text = "123456789${{Hoge(fuga)}}"; 320 | let result = try_consume(source_text).unwrap(); 321 | assert_eq!( 322 | result, 323 | ParseResult::Found { 324 | directive: "Hoge".to_string(), 325 | key: "fuga".to_string(), 326 | default: None, 327 | start: 9, 328 | end: 24, 329 | } 330 | ); 331 | // when parsing the text from certain offset index 332 | let result = try_consume(&source_text[9..]).unwrap(); 333 | assert_eq!( 334 | result, 335 | ParseResult::Found { 336 | directive: "Hoge".to_string(), 337 | key: "fuga".to_string(), 338 | default: None, 339 | start: 0, 340 | end: 15, 341 | } 342 | ); 343 | 344 | // it detects the closest tag that appears after the offset 345 | let source_text = "${{A1(key1)}} ${{A2(key2)}} ${{A3(key3)}}"; 346 | let result = try_consume(source_text).unwrap(); 347 | assert_eq!( 348 | result, 349 | ParseResult::Found { 350 | directive: "A1".to_string(), 351 | key: "key1".to_string(), 352 | default: None, 353 | start: 0, 354 | end: 13, 355 | } 356 | ); 357 | let result = try_consume(&source_text[1..]).unwrap(); 358 | assert_eq!( 359 | result, 360 | ParseResult::Found { 361 | directive: "A2".to_string(), 362 | key: "key2".to_string(), 363 | default: None, 364 | start: 14, 365 | end: 27, 366 | } 367 | ); 368 | let result = try_consume(&source_text[16..]).unwrap(); 369 | assert_eq!( 370 | result, 371 | ParseResult::Found { 372 | directive: "A3".to_string(), 373 | key: "key3".to_string(), 374 | default: None, 375 | start: 13, 376 | end: 26, 377 | } 378 | ); 379 | let result = try_consume(&source_text[30..]).unwrap(); 380 | assert_eq!(result, ParseResult::Nothing); 381 | 382 | // does NOT capture the tag that is inside a pair of single braces 383 | let source_text = "foo bar baz{ hoge: fuga }"; 384 | let result = try_consume(source_text).unwrap(); 385 | assert_eq!(result, ParseResult::Nothing); 386 | 387 | // does NOT capture the tag surrounded by non-pairing braces, or non-consecutive braces 388 | let source_text = "{not(a-tag)}} ${{not(a-tag-too)} }"; 389 | let result = try_consume(source_text).unwrap(); 390 | assert_eq!(result, ParseResult::Nothing); 391 | 392 | // non-alphanumeric charactors are not recognized as directive 393 | let source_text = "${{F-O-O(Bar)}}"; 394 | let result = try_consume(source_text).unwrap(); 395 | assert_eq!(result, ParseResult::Nothing); 396 | 397 | // does NOT capture a tag that has no keys surrounded by parenthesis 398 | let source_text = "${{no-directive-here}}"; 399 | let result = try_consume(source_text).unwrap(); 400 | assert_eq!(result, ParseResult::Nothing); 401 | 402 | // does NOT capture a tag that has mal-formatted key/parenthesis 403 | let source_text = "${{foo(bar)(baz)}} ${{foo(hoge}}"; 404 | let result = try_consume(source_text).unwrap(); 405 | assert_eq!(result, ParseResult::Nothing); 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/struct_loader.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use serde::de::DeserializeOwned; 3 | 4 | use crate::{load_named_records, Dict}; 5 | 6 | /// StructLoader deserializes struct instances from specified file. 7 | /// To resolve embedded tags, you need to provide HashMap that indicates corresponding records to 8 | /// the labels specified in the yaml file. 9 | /// 10 | /// NOTE: record names must be unique, otherwise the ealier records will be overwritten by the latter. 11 | /// 12 | /// # Examples 13 | /// ```rust 14 | /// use serde::Deserialize; 15 | /// use anyhow::Result; 16 | /// 17 | /// // a model (struct) 18 | /// #[derive(Deserialize, Clone)] // add this derive macro 19 | /// struct User { 20 | /// name: String, 21 | /// email: String, 22 | /// } 23 | /// 24 | /// // a function that persists user record into users table 25 | /// impl User { 26 | /// // can be sync or async functions 27 | /// async fn insert(input: &User) -> Result<(i64)> { 28 | /// // 29 | /// // this function inserts a corresponding User record into table, 30 | /// // and returns its id when succeeded 31 | /// // 32 | /// # Ok(1) 33 | /// } 34 | /// } 35 | /// 36 | /// // glue code you need to add 37 | /// use cder::{ Dict, StructLoader }; 38 | /// 39 | /// # fn main() { 40 | /// # load_user("Peter"); 41 | /// # } 42 | /// 43 | /// fn load_user(label: &str) -> Result { 44 | /// // provide your fixture filename followed by its directory 45 | /// let mut loader = StructLoader::::new("users.yml", "fixtures"); 46 | /// 47 | /// // deserializes User struct from the given fixture 48 | /// // the argument is related to name resolution (described later) 49 | /// let result = loader.load(&Dict::::new())?; 50 | /// result.get(label).map(|user| user.clone()) 51 | /// } 52 | /// ``` 53 | pub struct StructLoader 54 | where 55 | T: DeserializeOwned, 56 | { 57 | pub filename: String, 58 | pub base_dir: String, 59 | named_records: Option>, 60 | } 61 | 62 | impl StructLoader 63 | where 64 | T: DeserializeOwned, 65 | { 66 | pub fn new(filename: &str, base_dir: &str) -> Self { 67 | Self { 68 | filename: filename.to_string(), 69 | base_dir: base_dir.to_string(), 70 | named_records: None, 71 | } 72 | } 73 | 74 | pub fn load(&mut self, dependencies: &Dict) -> Result<&Self> { 75 | if self.named_records.is_some() { 76 | return Err(anyhow::anyhow!( 77 | "filename : {} the records have been loaded already", 78 | self.filename, 79 | )); 80 | } 81 | 82 | let records = load_named_records::(&self.filename, &self.base_dir, dependencies)?; 83 | self.set_records(records)?; 84 | 85 | Ok(self) 86 | } 87 | 88 | pub fn get(&self, key: &str) -> Result<&T> { 89 | let records = self.get_records()?; 90 | records.get(key).ok_or_else(|| { 91 | anyhow::anyhow!( 92 | "{}: no record was found referred by the key: {}", 93 | self.filename, 94 | key, 95 | ) 96 | }) 97 | } 98 | 99 | pub fn get_all_records(&self) -> Result<&Dict> { 100 | self.get_records() 101 | } 102 | 103 | fn set_records(&mut self, named_records: Dict) -> Result<()> { 104 | if self.named_records.is_some() { 105 | return Err(anyhow::anyhow!( 106 | "filename : {} the records have been loaded already", 107 | self.filename, 108 | )); 109 | } 110 | 111 | self.named_records = Some(named_records); 112 | Ok(()) 113 | } 114 | 115 | fn get_records(&self) -> Result<&Dict> { 116 | self.named_records.as_ref().ok_or_else(|| { 117 | anyhow::anyhow!( 118 | "filename : {} no records have been loaded yet", 119 | self.filename, 120 | ) 121 | }) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tests/database_seeder.rs: -------------------------------------------------------------------------------- 1 | mod test_utils; 2 | use test_utils::{ 3 | get_test_base_dir, parse_datetime, sort_records_by_ids, Customer, Item, MockTable, Order, Plan, 4 | }; 5 | extern crate cder; 6 | 7 | use anyhow::Result; 8 | use cder::DatabaseSeeder; 9 | use tokio::runtime::Runtime; 10 | 11 | #[test] 12 | fn test_database_seeder_new() { 13 | let mut seeder = DatabaseSeeder::new(); 14 | seeder.set_dir("fixtures"); 15 | assert!(seeder.filenames.is_empty()); 16 | assert_eq!(seeder.base_dir, "fixtures".to_string()); 17 | } 18 | 19 | #[test] 20 | fn test_database_seeder_populate_items() -> Result<()> { 21 | let base_dir = get_test_base_dir(); 22 | let mock_table = MockTable::::new(vec![ 23 | ("melon".to_string(), 1), 24 | ("orange".to_string(), 2), 25 | ("apple".to_string(), 3), 26 | ("carrot".to_string(), 4), 27 | ]); 28 | let rt = Runtime::new().unwrap(); 29 | 30 | let mut seeder = DatabaseSeeder::new(); 31 | let ids = seeder.populate(&format!("{}/items.yml", base_dir), |input: Item| { 32 | let mut mock_table = mock_table.clone(); 33 | rt.block_on(mock_table.insert(input)) 34 | })?; 35 | 36 | let persisted_records = mock_table.get_records(); 37 | let records = sort_records_by_ids(persisted_records, ids); 38 | 39 | assert_eq!(records[0].name, "melon"); 40 | assert_eq!(records[0].price, 500.0); 41 | 42 | assert_eq!(records[1].name, "orange"); 43 | assert_eq!(records[1].price, 200.0); 44 | 45 | assert_eq!(records[2].name, "apple"); 46 | assert_eq!(records[2].price, 100.0); 47 | 48 | assert_eq!(records[3].name, "carrot"); 49 | assert_eq!(records[3].price, 150.0); 50 | 51 | Ok(()) 52 | } 53 | 54 | #[test] 55 | fn test_database_seeder_populate_customers() -> Result<()> { 56 | let base_dir = get_test_base_dir(); 57 | let mock_table = MockTable::::new(vec![ 58 | ("Alice".to_string(), 1), 59 | ("Bob".to_string(), 2), 60 | ("Developer".to_string(), 3), 61 | ]); 62 | let rt = Runtime::new().unwrap(); 63 | 64 | let mut seeder = DatabaseSeeder::new(); 65 | seeder.set_dir(&base_dir); 66 | let ids = seeder.populate("customers.yml", |input: Customer| { 67 | let mut mock_table = mock_table.clone(); 68 | rt.block_on(mock_table.insert(input)) 69 | })?; 70 | 71 | let persisted_records = mock_table.get_records(); 72 | let records = sort_records_by_ids(persisted_records, ids); 73 | 74 | assert_eq!(records[0].name, "Alice"); 75 | assert_eq!(records[0].emails.len(), 1); 76 | assert_eq!(records[0].emails[0], "alice@example.com"); 77 | assert_eq!(records[0].plan, Plan::Premium); 78 | assert_eq!(records[0].country_code, None); 79 | 80 | assert_eq!(records[1].name, "Bob"); 81 | assert_eq!(records[1].emails.len(), 2); 82 | assert_eq!(records[1].emails[0], "bob@example.com"); 83 | assert_eq!(records[1].emails[1], "bob.doe@example.co.jp"); 84 | assert_eq!( 85 | records[1].plan, 86 | Plan::Family { 87 | shared_membership: 4 88 | } 89 | ); 90 | assert_eq!(records[1].country_code, Some(81)); 91 | 92 | assert_eq!(records[2].name, "Developer"); 93 | assert_eq!(records[2].emails.len(), 1); 94 | // falls back to default 95 | assert_eq!(records[2].emails[0], "developer@example.com"); 96 | assert_eq!(records[2].plan, Plan::Standard); 97 | assert_eq!(records[2].country_code, Some(44)); 98 | 99 | Ok(()) 100 | } 101 | 102 | #[test] 103 | fn test_database_seeder_populate_orders() -> Result<()> { 104 | let base_dir = get_test_base_dir(); 105 | let rt = Runtime::new().unwrap(); 106 | 107 | let mut seeder = DatabaseSeeder::new(); 108 | seeder.set_dir(&base_dir); 109 | 110 | { 111 | // when dependencies are missing 112 | 113 | let mock_orders_table = MockTable::::new(vec![ 114 | ("1200".to_string(), 1), 115 | ("1201".to_string(), 2), 116 | ("1202".to_string(), 3), 117 | ("1203".to_string(), 4), 118 | ]); 119 | let results = seeder.populate("orders.yml", |input: Order| { 120 | let mut mock_orders_table = mock_orders_table.clone(); 121 | rt.block_on(mock_orders_table.insert(input)) 122 | }); 123 | 124 | assert!(results.is_err()); 125 | } 126 | 127 | { 128 | // when dependencies are provided 129 | let mock_items_table = MockTable::::new(vec![ 130 | ("melon".to_string(), 1), 131 | ("orange".to_string(), 2), 132 | ("apple".to_string(), 3), 133 | ("carrot".to_string(), 4), 134 | ]); 135 | seeder.populate("items.yml", |input: Item| { 136 | let mut mock_items_table = mock_items_table.clone(); 137 | rt.block_on(mock_items_table.insert(input)) 138 | })?; 139 | let mock_customers_table = MockTable::::new(vec![ 140 | ("Alice".to_string(), 1), 141 | ("Bob".to_string(), 2), 142 | ("Developer".to_string(), 3), 143 | ]); 144 | seeder.populate("customers.yml", |input: Customer| { 145 | let mut mock_customers_table = mock_customers_table.clone(); 146 | rt.block_on(mock_customers_table.insert(input)) 147 | })?; 148 | 149 | let mock_orders_table = MockTable::::new(vec![ 150 | ("1200".to_string(), 1), 151 | ("1201".to_string(), 2), 152 | ("1202".to_string(), 3), 153 | ("1203".to_string(), 4), 154 | ]); 155 | let ids = seeder.populate("orders.yml", |input: Order| { 156 | let mut mock_orders_table = mock_orders_table.clone(); 157 | rt.block_on(mock_orders_table.insert(input)) 158 | })?; 159 | 160 | let persisted_records = mock_orders_table.get_records(); 161 | let records = sort_records_by_ids(persisted_records, ids); 162 | 163 | assert_eq!(records[0].id, 1200); 164 | assert_eq!(records[0].customer_id, 1); 165 | assert_eq!(records[0].item_id, 3); 166 | assert_eq!(records[0].quantity, 2); 167 | assert_eq!( 168 | records[0].purchased_at, 169 | parse_datetime("2021-03-01 15:15:44")? 170 | ); 171 | 172 | assert_eq!(records[1].id, 1201); 173 | assert_eq!(records[1].customer_id, 2); 174 | assert_eq!(records[1].item_id, 1); 175 | assert_eq!(records[1].quantity, 1); 176 | assert_eq!( 177 | records[1].purchased_at, 178 | parse_datetime("2021-03-02 07:51:20")? 179 | ); 180 | 181 | assert_eq!(records[2].id, 1202); 182 | assert_eq!(records[2].customer_id, 1); 183 | assert_eq!(records[2].item_id, 4); 184 | assert_eq!(records[2].quantity, 4); 185 | assert_eq!( 186 | records[2].purchased_at, 187 | parse_datetime("2021-03-10 10:10:33")? 188 | ); 189 | 190 | assert_eq!(records[3].id, 1203); 191 | assert_eq!(records[3].customer_id, 3); 192 | assert_eq!(records[3].item_id, 1); 193 | assert_eq!(records[3].quantity, 2); 194 | assert_eq!( 195 | records[3].purchased_at, 196 | parse_datetime("2021-03-11 11:55:44")? 197 | ); 198 | } 199 | 200 | Ok(()) 201 | } 202 | -------------------------------------------------------------------------------- /tests/database_seeder_async.rs: -------------------------------------------------------------------------------- 1 | mod test_utils; 2 | use test_utils::{ 3 | get_test_base_dir, parse_datetime, sort_records_by_ids, Customer, Item, MockTable, Order, Plan, 4 | }; 5 | extern crate cder; 6 | 7 | use anyhow::Result; 8 | use cder::DatabaseSeeder; 9 | 10 | #[test] 11 | fn test_database_seeder_new() { 12 | let mut seeder = DatabaseSeeder::new(); 13 | seeder.set_dir("fixtures"); 14 | assert!(seeder.filenames.is_empty()); 15 | assert_eq!(seeder.base_dir, "fixtures".to_string()); 16 | } 17 | 18 | #[tokio::test] 19 | async fn test_database_seeder_populate_async_items() -> Result<()> { 20 | let base_dir = get_test_base_dir(); 21 | let mock_table = MockTable::::new(vec![ 22 | ("melon".to_string(), 1), 23 | ("orange".to_string(), 2), 24 | ("apple".to_string(), 3), 25 | ("carrot".to_string(), 4), 26 | ]); 27 | 28 | let mut seeder = DatabaseSeeder::new(); 29 | seeder.set_dir(&base_dir); 30 | let ids = seeder 31 | .populate_async("items.yml", |input: Item| { 32 | let mut mock_table = mock_table.clone(); 33 | async move { mock_table.insert(input).await } 34 | }) 35 | .await?; 36 | 37 | let persisted_records = mock_table.get_records(); 38 | let records = sort_records_by_ids(persisted_records, ids); 39 | 40 | assert_eq!(records[0].name, "melon"); 41 | assert_eq!(records[0].price, 500.0); 42 | 43 | assert_eq!(records[1].name, "orange"); 44 | assert_eq!(records[1].price, 200.0); 45 | 46 | assert_eq!(records[2].name, "apple"); 47 | assert_eq!(records[2].price, 100.0); 48 | 49 | assert_eq!(records[3].name, "carrot"); 50 | assert_eq!(records[3].price, 150.0); 51 | 52 | Ok(()) 53 | } 54 | 55 | #[tokio::test] 56 | async fn test_database_seeder_populate_async_customers() -> Result<()> { 57 | let base_dir = get_test_base_dir(); 58 | let mock_table = MockTable::::new(vec![ 59 | ("Alice".to_string(), 1), 60 | ("Bob".to_string(), 2), 61 | ("Developer".to_string(), 3), 62 | ]); 63 | 64 | let mut seeder = DatabaseSeeder::new(); 65 | seeder.set_dir(&base_dir); 66 | let ids = seeder 67 | .populate_async("customers.yml", |input: Customer| { 68 | let mut mock_table = mock_table.clone(); 69 | async move { mock_table.insert(input).await } 70 | }) 71 | .await?; 72 | 73 | let persisted_records = mock_table.get_records(); 74 | let records = sort_records_by_ids(persisted_records, ids); 75 | 76 | assert_eq!(records[0].name, "Alice"); 77 | assert_eq!(records[0].emails.len(), 1); 78 | assert_eq!(records[0].emails[0], "alice@example.com"); 79 | assert_eq!(records[0].plan, Plan::Premium); 80 | assert_eq!(records[0].country_code, None); 81 | 82 | assert_eq!(records[1].name, "Bob"); 83 | assert_eq!(records[1].emails.len(), 2); 84 | assert_eq!(records[1].emails[0], "bob@example.com"); 85 | assert_eq!(records[1].emails[1], "bob.doe@example.co.jp"); 86 | assert_eq!( 87 | records[1].plan, 88 | Plan::Family { 89 | shared_membership: 4 90 | } 91 | ); 92 | assert_eq!(records[1].country_code, Some(81)); 93 | 94 | assert_eq!(records[2].name, "Developer"); 95 | assert_eq!(records[2].emails.len(), 1); 96 | // falls back to default 97 | assert_eq!(records[2].emails[0], "developer@example.com"); 98 | assert_eq!(records[2].plan, Plan::Standard); 99 | assert_eq!(records[2].country_code, Some(44)); 100 | 101 | Ok(()) 102 | } 103 | 104 | #[tokio::test] 105 | async fn test_database_seeder_populate_async_orders() -> Result<()> { 106 | let base_dir = get_test_base_dir(); 107 | let mut seeder = DatabaseSeeder::new(); 108 | seeder.set_dir(&base_dir); 109 | 110 | { 111 | // when dependencies are missing 112 | 113 | let mock_orders_table = MockTable::::new(vec![ 114 | ("1200".to_string(), 1), 115 | ("1201".to_string(), 2), 116 | ("1202".to_string(), 3), 117 | ("1203".to_string(), 4), 118 | ]); 119 | let results = seeder 120 | .populate_async("orders.yml", |input: Order| { 121 | let mut mock_orders_table = mock_orders_table.clone(); 122 | async move { mock_orders_table.insert(input).await } 123 | }) 124 | .await; 125 | 126 | assert!(results.is_err()); 127 | } 128 | 129 | { 130 | // when dependencies are provided 131 | let mock_items_table = MockTable::::new(vec![ 132 | ("melon".to_string(), 1), 133 | ("orange".to_string(), 2), 134 | ("apple".to_string(), 3), 135 | ("carrot".to_string(), 4), 136 | ]); 137 | seeder 138 | .populate_async("items.yml", |input: Item| { 139 | let mut mock_items_table = mock_items_table.clone(); 140 | async move { mock_items_table.insert(input).await } 141 | }) 142 | .await?; 143 | let mock_customers_table = MockTable::::new(vec![ 144 | ("Alice".to_string(), 1), 145 | ("Bob".to_string(), 2), 146 | ("Developer".to_string(), 3), 147 | ]); 148 | seeder 149 | .populate_async("customers.yml", |input: Customer| { 150 | let mut mock_customers_table = mock_customers_table.clone(); 151 | async move { mock_customers_table.insert(input).await } 152 | }) 153 | .await?; 154 | 155 | let mock_orders_table = MockTable::::new(vec![ 156 | ("1200".to_string(), 1), 157 | ("1201".to_string(), 2), 158 | ("1202".to_string(), 3), 159 | ("1203".to_string(), 4), 160 | ]); 161 | let ids = seeder 162 | .populate_async("orders.yml", |input: Order| { 163 | let mut mock_orders_table = mock_orders_table.clone(); 164 | async move { mock_orders_table.insert(input).await } 165 | }) 166 | .await?; 167 | 168 | let persisted_records = mock_orders_table.get_records(); 169 | let records = sort_records_by_ids(persisted_records, ids); 170 | 171 | assert_eq!(records[0].id, 1200); 172 | assert_eq!(records[0].customer_id, 1); 173 | assert_eq!(records[0].item_id, 3); 174 | assert_eq!(records[0].quantity, 2); 175 | assert_eq!( 176 | records[0].purchased_at, 177 | parse_datetime("2021-03-01 15:15:44")? 178 | ); 179 | 180 | assert_eq!(records[1].id, 1201); 181 | assert_eq!(records[1].customer_id, 2); 182 | assert_eq!(records[1].item_id, 1); 183 | assert_eq!(records[1].quantity, 1); 184 | assert_eq!( 185 | records[1].purchased_at, 186 | parse_datetime("2021-03-02 07:51:20")? 187 | ); 188 | 189 | assert_eq!(records[2].id, 1202); 190 | assert_eq!(records[2].customer_id, 1); 191 | assert_eq!(records[2].item_id, 4); 192 | assert_eq!(records[2].quantity, 4); 193 | assert_eq!( 194 | records[2].purchased_at, 195 | parse_datetime("2021-03-10 10:10:33")? 196 | ); 197 | 198 | assert_eq!(records[3].id, 1203); 199 | assert_eq!(records[3].customer_id, 3); 200 | assert_eq!(records[3].item_id, 1); 201 | assert_eq!(records[3].quantity, 2); 202 | assert_eq!( 203 | records[3].purchased_at, 204 | parse_datetime("2021-03-11 11:55:44")? 205 | ); 206 | } 207 | 208 | Ok(()) 209 | } 210 | -------------------------------------------------------------------------------- /tests/fixtures/customers.yml: -------------------------------------------------------------------------------- 1 | Alice: 2 | name: Alice 3 | emails: ["alice@example.com"] 4 | plan: !Premium 5 | Bob: 6 | name: Bob 7 | emails: ["bob@example.com", "bob.doe@example.co.jp"] 8 | plan: !Family { shared_membership: 4 } 9 | country_code: 81 10 | Dev: 11 | name: Developer 12 | emails: [${{ ENV(DEV_EMAIL:-"developer@example.com") }}] 13 | plan: !Standard 14 | country_code: 44 15 | -------------------------------------------------------------------------------- /tests/fixtures/items.yml: -------------------------------------------------------------------------------- 1 | Melon: 2 | name: melon 3 | price: 500 4 | Orange: 5 | name: orange 6 | price: 200 7 | Apple: 8 | name: apple 9 | price: 100 10 | Carrot: 11 | name: carrot 12 | price: 150 13 | -------------------------------------------------------------------------------- /tests/fixtures/orders.yml: -------------------------------------------------------------------------------- 1 | Order1: 2 | id: 1200 3 | customer_id: ${{ REF(Alice) }} 4 | item_id: ${{ REF(Apple) }} 5 | quantity: 2 6 | purchased_at: "2021-03-01T15:15:44" 7 | Order2: 8 | id: 1201 9 | customer_id: ${{ REF(Bob) }} 10 | item_id: ${{ REF(Melon) }} 11 | quantity: 1 12 | purchased_at: "2021-03-02T07:51:20" 13 | Order3: 14 | id: 1202 15 | customer_id: ${{ REF(Alice) }} 16 | item_id: ${{ REF(Carrot) }} 17 | quantity: 4 18 | purchased_at: "2021-03-10T10:10:33" 19 | Order4: 20 | id: 1203 21 | customer_id: ${{ REF(Dev) }} 22 | item_id: ${{ REF(Melon) }} 23 | quantity: 2 24 | purchased_at: "2021-03-11T11:55:44" 25 | -------------------------------------------------------------------------------- /tests/struct_loader.rs: -------------------------------------------------------------------------------- 1 | mod test_utils; 2 | use test_utils::{get_test_base_dir, parse_datetime, Customer, Item, Order, Plan}; 3 | extern crate cder; 4 | 5 | use anyhow::Result; 6 | use cder::{Dict, StructLoader}; 7 | use std::env; 8 | 9 | #[test] 10 | fn test_struct_loader_new() { 11 | let loader = StructLoader::::new("items.yml", "fixtures"); 12 | assert_eq!(loader.filename, "items.yml"); 13 | assert_eq!(loader.base_dir, "fixtures".to_string()); 14 | } 15 | 16 | #[test] 17 | fn test_struct_loader_load_items() -> Result<()> { 18 | let empty_dict = Dict::::new(); 19 | let base_dir = get_test_base_dir(); 20 | 21 | let mut loader = StructLoader::::new("items.yml", &base_dir); 22 | loader.load(&empty_dict)?; 23 | 24 | let item = loader.get("Melon")?; 25 | assert_eq!(item.name, "melon"); 26 | assert_eq!(item.price, 500.0); 27 | 28 | let item = loader.get("Orange")?; 29 | assert_eq!(item.name, "orange"); 30 | assert_eq!(item.price, 200.0); 31 | 32 | let item = loader.get("Apple")?; 33 | assert_eq!(item.name, "apple"); 34 | assert_eq!(item.price, 100.0); 35 | 36 | let item = loader.get("Carrot")?; 37 | assert_eq!(item.name, "carrot"); 38 | assert_eq!(item.price, 150.0); 39 | 40 | Ok(()) 41 | } 42 | 43 | #[test] 44 | fn test_struct_loader_get_all_items() -> Result<()> { 45 | let empty_dict = Dict::::new(); 46 | let base_dir = get_test_base_dir(); 47 | 48 | let mut loader = StructLoader::::new("items.yml", &base_dir); 49 | loader.load(&empty_dict)?; 50 | 51 | let named_records = loader.get_all_records()?; 52 | 53 | let item = named_records.get("Melon").unwrap(); 54 | assert_eq!(item.name, "melon"); 55 | assert_eq!(item.price, 500.0); 56 | 57 | let item = named_records.get("Orange").unwrap(); 58 | assert_eq!(item.name, "orange"); 59 | assert_eq!(item.price, 200.0); 60 | 61 | let item = named_records.get("Apple").unwrap(); 62 | assert_eq!(item.name, "apple"); 63 | assert_eq!(item.price, 100.0); 64 | 65 | let item = named_records.get("Carrot").unwrap(); 66 | assert_eq!(item.name, "carrot"); 67 | assert_eq!(item.price, 150.0); 68 | 69 | Ok(()) 70 | } 71 | 72 | #[test] 73 | fn test_struct_loader_load_customers() -> Result<()> { 74 | let empty_dict = Dict::::new(); 75 | let base_dir = get_test_base_dir(); 76 | 77 | { 78 | // when ENV var is specified 79 | 80 | env::set_var("DEV_EMAIL", "johndoo@dev.example.com"); 81 | let mut loader = StructLoader::::new("customers.yml", &base_dir); 82 | loader.load(&empty_dict)?; 83 | 84 | let customer = loader.get("Alice")?; 85 | assert_eq!(customer.name, "Alice"); 86 | assert_eq!(customer.emails.len(), 1); 87 | assert_eq!(customer.emails[0], "alice@example.com"); 88 | assert_eq!(customer.plan, Plan::Premium); 89 | assert_eq!(customer.country_code, None); 90 | 91 | let customer = loader.get("Bob")?; 92 | assert_eq!(customer.name, "Bob"); 93 | assert_eq!(customer.emails.len(), 2); 94 | assert_eq!(customer.emails[0], "bob@example.com"); 95 | assert_eq!(customer.emails[1], "bob.doe@example.co.jp"); 96 | assert_eq!( 97 | customer.plan, 98 | Plan::Family { 99 | shared_membership: 4 100 | } 101 | ); 102 | assert_eq!(customer.country_code, Some(81)); 103 | 104 | let customer = loader.get("Dev")?; 105 | assert_eq!(customer.name, "Developer"); 106 | assert_eq!(customer.emails.len(), 1); 107 | // replaced by the env var 108 | assert_eq!(customer.emails[0], "johndoo@dev.example.com"); 109 | assert_eq!(customer.plan, Plan::Standard); 110 | assert_eq!(customer.country_code, Some(44)); 111 | 112 | // teardown 113 | env::remove_var("DEV_EMAIL"); 114 | } 115 | 116 | { 117 | // when ENV var is not specified 118 | 119 | let mut loader = StructLoader::::new("customers.yml", &base_dir); 120 | loader.load(&empty_dict)?; 121 | 122 | let customer = loader.get("Alice")?; 123 | assert_eq!(customer.name, "Alice"); 124 | assert_eq!(customer.emails.len(), 1); 125 | assert_eq!(customer.emails[0], "alice@example.com"); 126 | assert_eq!(customer.plan, Plan::Premium); 127 | assert_eq!(customer.country_code, None); 128 | 129 | let customer = loader.get("Bob")?; 130 | assert_eq!(customer.name, "Bob"); 131 | assert_eq!(customer.emails.len(), 2); 132 | assert_eq!(customer.emails[0], "bob@example.com"); 133 | assert_eq!(customer.emails[1], "bob.doe@example.co.jp"); 134 | assert_eq!( 135 | customer.plan, 136 | Plan::Family { 137 | shared_membership: 4 138 | } 139 | ); 140 | assert_eq!(customer.country_code, Some(81)); 141 | 142 | let customer = loader.get("Dev")?; 143 | assert_eq!(customer.name, "Developer"); 144 | assert_eq!(customer.emails.len(), 1); 145 | // falls back to default 146 | assert_eq!(customer.emails[0], "developer@example.com"); 147 | assert_eq!(customer.plan, Plan::Standard); 148 | assert_eq!(customer.country_code, Some(44)); 149 | } 150 | 151 | Ok(()) 152 | } 153 | 154 | #[test] 155 | fn test_struct_loader_load_orders() -> Result<()> { 156 | let base_dir = get_test_base_dir(); 157 | let empty_dict = Dict::::new(); 158 | 159 | { 160 | // when dependencies are missing 161 | 162 | let mut loader = StructLoader::::new("orders.yml", &base_dir); 163 | let result = loader.load(&empty_dict); 164 | 165 | assert!(result.is_err()); 166 | } 167 | 168 | { 169 | // when dependencies are provided 170 | let foreign_keys = vec![ 171 | ("Alice", 1), 172 | ("Bob", 2), 173 | ("Dev", 3), 174 | ("Melon", 100), 175 | ("Orange", 101), 176 | ("Apple", 102), 177 | ("Carrot", 103), 178 | ]; 179 | let mapping = foreign_keys 180 | .into_iter() 181 | .map(|(name, id)| (name.to_string(), id.to_string())) 182 | .collect::>(); 183 | 184 | let mut loader = StructLoader::::new("orders.yml", &base_dir); 185 | loader.load(&mapping)?; 186 | 187 | let order = loader.get("Order1")?; 188 | assert_eq!(order.id, 1200); 189 | assert_eq!(order.customer_id, 1); 190 | assert_eq!(order.item_id, 102); 191 | assert_eq!(order.quantity, 2); 192 | assert_eq!(order.purchased_at, parse_datetime("2021-03-01 15:15:44")?); 193 | 194 | let order = loader.get("Order2")?; 195 | assert_eq!(order.id, 1201); 196 | assert_eq!(order.customer_id, 2); 197 | assert_eq!(order.item_id, 100); 198 | assert_eq!(order.quantity, 1); 199 | assert_eq!(order.purchased_at, parse_datetime("2021-03-02 07:51:20")?); 200 | 201 | let order = loader.get("Order3")?; 202 | assert_eq!(order.id, 1202); 203 | assert_eq!(order.customer_id, 1); 204 | assert_eq!(order.item_id, 103); 205 | assert_eq!(order.quantity, 4); 206 | assert_eq!(order.purchased_at, parse_datetime("2021-03-10 10:10:33")?); 207 | 208 | let order = loader.get("Order4")?; 209 | assert_eq!(order.id, 1203); 210 | assert_eq!(order.customer_id, 3); 211 | assert_eq!(order.item_id, 100); 212 | assert_eq!(order.quantity, 2); 213 | assert_eq!(order.purchased_at, parse_datetime("2021-03-11 11:55:44")?); 214 | } 215 | 216 | Ok(()) 217 | } 218 | -------------------------------------------------------------------------------- /tests/test_utils/mock_database.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use crate::{Customer, Item, Order}; 4 | use anyhow::Result; 5 | use std::collections::HashMap; 6 | use std::sync::{Arc, Mutex}; 7 | 8 | // async insertion is done in random order, so records has to be sorted before testing 9 | pub fn sort_records_by_ids(records: Vec, ids: Vec) -> Vec { 10 | let mut indexed_records = ids.iter().zip(records).collect::>(); 11 | indexed_records.sort_unstable_by_key(|(i, _)| *i); 12 | indexed_records 13 | .into_iter() 14 | .map(|(_, r)| r) 15 | .collect::>() 16 | } 17 | 18 | #[derive(Clone)] 19 | pub struct MockTable 20 | where 21 | T: Clone, 22 | { 23 | ids_by_name: Arc>>, 24 | records: Arc>>, 25 | } 26 | 27 | // tentative mock 'database' that can store records to get tested later on. 28 | // TODO: use database to make it work with async 29 | impl MockTable 30 | where 31 | T: Clone, 32 | { 33 | pub fn new(ids_by_name: Vec<(String, i64)>) -> Self { 34 | let ids_by_name = HashMap::from_iter(ids_by_name); 35 | 36 | MockTable { 37 | ids_by_name: Arc::new(Mutex::new(ids_by_name)), 38 | records: Arc::new(Mutex::new(Vec::new())), 39 | } 40 | } 41 | 42 | pub fn get_records(&self) -> Vec { 43 | self.records.lock().unwrap().clone() 44 | } 45 | } 46 | 47 | impl MockTable { 48 | // simply registers the record and returns pre-reistered `id` for testing purpose 49 | pub async fn insert(&mut self, record: Item) -> Result { 50 | tokio::time::sleep(std::time::Duration::from_millis(10)).await; 51 | 52 | let ids_by_name = self.ids_by_name.lock().unwrap(); 53 | let id = ids_by_name 54 | .get(&record.name) 55 | .map(|i| i.to_owned()) 56 | .ok_or_else(|| anyhow::anyhow!("insert failed")); 57 | let mut records = self.records.lock().unwrap(); 58 | records.push(record); 59 | 60 | id 61 | } 62 | } 63 | 64 | impl MockTable { 65 | // simply registers the record and returns pre-reistered `id` for testing purpose 66 | pub async fn insert(&mut self, record: Customer) -> Result { 67 | tokio::time::sleep(std::time::Duration::from_millis(10)).await; 68 | 69 | let ids_by_name = self.ids_by_name.lock().unwrap(); 70 | let id = ids_by_name 71 | .get(&record.name) 72 | .map(|i| i.to_owned()) 73 | .ok_or_else(|| anyhow::anyhow!("insert failed")); 74 | let mut records = self.records.lock().unwrap(); 75 | records.push(record); 76 | 77 | id 78 | } 79 | } 80 | 81 | impl MockTable { 82 | // simply registers the record and returns pre-reistered `id` for testing purpose 83 | pub async fn insert(&mut self, record: Order) -> Result { 84 | tokio::time::sleep(std::time::Duration::from_millis(10)).await; 85 | 86 | let ids_by_name = self.ids_by_name.lock().unwrap(); 87 | let id = ids_by_name 88 | .get(&record.id.to_string()) 89 | .map(|i| i.to_owned()) 90 | .ok_or_else(|| anyhow::anyhow!("insert failed")); 91 | let mut records = self.records.lock().unwrap(); 92 | records.push(record); 93 | 94 | id 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/test_utils/mod.rs: -------------------------------------------------------------------------------- 1 | mod mock_database; 2 | mod types; 3 | 4 | // FIXME: workaround for false positive detection of unused_imports, which might be related to: 5 | // https://github.com/rust-lang/rust/issues/121708 6 | #[allow(unused_imports)] 7 | pub use mock_database::{sort_records_by_ids, MockTable}; 8 | 9 | pub use types::{Customer, Item, Order, Plan}; 10 | 11 | use anyhow::Result; 12 | use chrono::NaiveDateTime; 13 | use std::env; 14 | 15 | pub fn get_test_base_dir() -> String { 16 | let mut path = env::current_dir().unwrap(); 17 | path.push("tests/fixtures"); 18 | path.to_str().unwrap().to_string() 19 | } 20 | 21 | pub fn parse_datetime(s: &str) -> Result { 22 | let datetime = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")?; 23 | Ok(datetime) 24 | } 25 | -------------------------------------------------------------------------------- /tests/test_utils/types.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | use serde::Deserialize; 3 | 4 | #[derive(Deserialize, Clone)] 5 | pub struct Item { 6 | pub name: String, 7 | pub price: f64, 8 | } 9 | #[derive(Deserialize, Clone)] 10 | pub struct Customer { 11 | pub name: String, 12 | pub emails: Vec, 13 | pub plan: Plan, 14 | pub country_code: Option, 15 | } 16 | 17 | #[derive(Deserialize, Debug, Clone, PartialEq)] 18 | pub enum Plan { 19 | Premium, 20 | Family { shared_membership: u8 }, 21 | Standard, 22 | } 23 | #[derive(Deserialize, Clone)] 24 | pub struct Order { 25 | pub id: i64, 26 | pub customer_id: i64, 27 | pub item_id: i64, 28 | pub quantity: i64, 29 | pub purchased_at: NaiveDateTime, 30 | } 31 | --------------------------------------------------------------------------------