native_db

repository·main·Indexed 20 days ago

https://github.com/vincent-herlemont/native_db

A fast, ACID-compliant embedded database for multi-platform Rust applications (server, desktop, and mobile). It features transparent serialization via native_model, automatic model migrations, and support for primary, secondary, unique, and optional keys. The library provides type-safe queries, real-time subscriptions for data operations, and hot snapshots for concurrent access.

Tokens
18.8K
Snippets
50
Records
62
Agent score
71%

What's inside native_db

  1. Core features of Native DB

    main

    Native DB is a fast, embedded, multi-platform database (server, desktop, mobile) with the following key features:

    • Multiple Indexes: Supports primary, secondary, unique, non-unique, and optional keys.
      • Note: Optional secondary keys with None values cannot be queried using range syntax.
    • Type Safety: Ensures query type safety to prevent errors from selecting with incorrect types.
    • Automatic Model Migration: Handles schema changes automatically.
    • ACID Compliance: Provides thread-safe, ACID-compliant transactions via redb.
    • Transparent Serialization: Uses native_model for seamless Rust type syncing. You can swap in other libraries like bincode or postcard.
    • Real-time Subscriptions: Supports filtering for insert, update, and delete operations.
    • Hot Snapshots: Supports concurrent access via snapshots.
  2. Understand Native DB benchmark methodology

    main

    Native DB benchmarks compare its performance against Redb (its underlying backend) and SQLite (a lightweight relational alternative).

    Key Comparison Metrics

    • N:SK: Represents the number of secondary keys (or columns with secondary indexes in SQLite) for the same data. Examples include 1:SK, 10:SK, etc.
    • n:T: Represents the number of operations per transaction.
      • 1:T means exactly one operation per transaction.
      • n:T means n operations are bundled within a single transaction (as defined by criterion).

    Benchmark Limitations

    The following overheads are ignored in these results:

    • native_model overhead.
    • Serialization overhead (e.g., bincode, postcard) used by native_model.
    • The fact that redb can perform zero-copy writes.

    Note that Redb results may show N/A for secondary key comparisons because it is a key-value database and does not natively support secondary indexes.

  3. Manage native_model version numbers during upgrades

    main

    When defining models for an upgrade, you have two choices for the version field in the #[native_model(version = X)] attribute:

    1. Reset to 1: If the model is used strictly for internal database storage, you can reset the version number to 1 for the new model.
    2. Increment: If the model is used by external systems (e.g., network communication or shared file formats), you should continue incrementing the version number to maintain continuity and ensure compatibility with those external systems.
  4. How to perform a major version upgrade in Native DB

    main

    When upgrading Native DB to a new major version with breaking changes, existing databases cannot be opened directly. You must perform a migration using the Builder::upgrade method.

    The process involves:

    1. Defining dual models: Define your data structures for both the old version and the current version.
    2. Implementing conversion: Implement the From trait to convert the old model into the new model.
    3. Executing the upgrade: Use Builder::upgrade to open the old database, scan its contents, and insert the converted items into a new database instance.

    To manage multiple versions of Native DB in a single project, use Cargo's package renaming feature in your Cargo.toml to avoid name collisions.

    // Example of the upgrade flow logic
    let upgraded_db = CurrentBuilder::new().upgrade(&current_models, &db_path, |new_txn| {
        // 1. Open old database using old models/builder
        let mut old_models = V08xModels::new();
        old_models.define::<V08xModel>().upgrade_context("defining old model")?;
        let old_db = V08xBuilder::new().open(&old_models, &db_path).upgrade_context("opening old database")?;
    
        // 2. Scan old data
        let old_txn = old_db.r_transaction().upgrade_context("creating read transaction")?;
        let scan = old_txn.scan().primary().upgrade_context("creating primary scan")?;
    
        // 3. Migrate items
        for item_result in scan.all()? {
            let old_item: V08xModel = item_result.upgrade_context("reading item")?;
            let new_item: CurrentModel = old_item.into(); // Uses From implementation
            new_txn.insert(new_item)?;
        }
        Ok(())
    })?;
  5. Install Native DB

    main

    To use Native DB in your Rust project, add native_db and native_model to your Cargo.toml dependencies.

    Note: The current version in the documentation is 0.8.1 for native_db and 0.4.20 for native_model.

    [dependencies]
    native_db = "0.8.1"
    native_model = "0.4.20"
  6. Configure multiple Native DB versions in Cargo.toml

    main

    To handle migrations, you must import both the current version of native_db/native_model and the previous version. Use Cargo's package key to rename them so they can coexist in the same dependency graph.

    Example configuration for upgrading from v0.8.x to v0.9.x:

    # Current version (requires >=0.9.x for major upgrade functionality)
    native_model_current = { package = "native_model", version = "0.6.2" }
    native_db_current = { package = "native_db", version = "0.9.0" }
    
    # Previous version (from crates.io)
    native_model_v0_4_x = { package = "native_model", version = "0.4.20" }
    native_db_v0_8_x = { package = "native_db", version = "0.8.2" }
  7. Manage multiple models with the `Models` collection

    main

    The Models struct is a collection used to define and manage multiple database models within your application. It is typically used during the database initialization phase.

    Lifetime Considerations

    Because models are often needed by asynchronous libraries (like Axum) that require 'static lifetimes, it is common practice to define your Models collection as a static variable using once_cell::sync::Lazy or the standard library's std::sync::LazyLock.

    Usage Pattern

    1. Create a new Models instance using Models::new().
    2. Register each model using .define::<T>().
    3. Pass the Models collection to your database builder (e.g., Builder::new().create_in_memory(&MODELS)).

    Note: The lifetime of the Models collection must be longer than or equal to the lifetime of the database instance.

    use native_db::*;
    use once_cell::sync::Lazy;
    
    static MODELS: Lazy<Models> = Lazy::new(|| {
        let mut models = Models::new();
        models.define::<data::v1::Person>().unwrap();
        models
    });
    
    fn main() -> Result<(), db_type::Error> {
        let db = Builder::new().create_in_memory(&MODELS)?;
        Ok(())
    }
  8. Use RwTransaction for data modification

    main

    The RwTransaction struct is used to perform read-write operations on the database. It allows you to insert, update, remove, and migrate data.

    Important: All changes made within an RwTransaction are only applied to the database when you explicitly call .commit(). If the transaction is dropped without committing, or if .abort() is called, the changes will not be applied. It is highly recommended to perform migrations within a single transaction to ensure atomicity.

    use native_db::*;
    
    fn main() -> Result<(), db_type::Error> {
        let mut models = Models::new();
        let db = Builder::new().create_in_memory(&models)?;
        
        // Open a read-write transaction
        let rw = db.rw_transaction()?;
        
        // Perform operations...
        // rw.insert(item)?
        
        // Commit the changes
        rw.commit()?;
    
        Ok(())
    }
  9. How watch scans work in Native DB

    main

    Watch scans allow you to observe changes to a subset of data in the database. You can initiate a scan through the watch().scan() interface, which provides two main entry points: primary() for observing changes based on the primary key, and secondary(key_def) for observing changes based on a specific secondary key.

    Each scan operation returns a tuple containing:

    1. An MpscReceiver<watch::Event>: A receiver used to listen for data change events.
    2. A u64 ID: A unique identifier for the watch session.

    Scans can be filtered using three patterns:

    • all(): Watch every item matching the criteria.
    • range(range): Watch items where the key falls within a specific range (e.g., 1..=10).
    • start_with(key): Watch items where the key starts with a specific prefix.
    // Basic pattern for starting a watch scan
    let (_recv, _id) = db.watch().scan().primary().all::<Data>()?;
  10. Define a primary key for a model

    main

    A primary key is mandatory and must be unique. You can define exactly one primary key per model using one of two methods:

    1. On a Field

    Use the #[primary_key] attribute directly on a struct field. The field's type will be used as the primary key type.

    2. With a Custom Method

    Use the #[native_db(primary_key(<method_name> -> <return_type>))] attribute on the type. You must implement a method with the specified name that returns the specified type. The return type must be explicitly stated in the attribute for runtime query checking.

    If no primary key is defined, the compiler will return the error: Primary key is not set.

    // Method 1: On a field
    #[derive(Serialize, Deserialize)]
    #[native_model(id=1, version=1)]
    #[native_db]
    struct Data {
        #[primary_key]
        id: u64,
    }
    
    // Method 2: Custom method
    #[derive(Serialize, Deserialize)]
    #[native_model(id=1, version=1)]
    #[native_db(primary_key(custom_id -> u32))]
    struct Data(u64);
    
    impl Data {
        fn custom_id(&self) -> u32 {
            (self.0 + 1) as u32
        }
    }
  11. Define secondary keys for a model

    main

    Secondary keys are optional and flexible. You can define zero, one, or multiple secondary keys. They can be defined on a field or via a custom method.

    Options

    • unique: (Default: false) Ensures each instance has a unique value for this key. If a duplicate is inserted, insert will return an error.
    • optional: (Default: false) Allows the key to be None. When using optional, the field type must be an Option<T>.

    Important Limitation for optional keys

    Items with None values in optional secondary keys cannot be queried using range syntax (e.g., range(None..=None)) because None values are not indexed in the secondary key table. To find None values, you must query all items and filter in your application code or use a sentinel value.

    Implementation Styles

    On a Field

    #[derive(Serialize, Deserialize)]
    #[native_model(id=1, version=1)]
    #[native_db]
    struct Data {
        #[primary_key]
        id: u64,
        #[secondary_key(unique, optional)]
        name: Option<String>,
    }

    With a Custom Method

    #[derive(Serialize, Deserialize)]
    #[native_model(id=1, version=1)]
    #[native_db(secondary_key(custom_name -> Option<String>, optional))]
    struct Data {
        #[primary_key]
        id: u64,
        name: String,
        flag: bool,
    }
    
    impl Data {
        fn custom_name(&self) -> Option<String> {
            if self.flag { Some(self.name.clone().to_uppercase()) } else { None }
        }
    }