Chosen Local Database Library

Hive is a No-SQL lightweight and blazing fast key-value database written in pure Dart.

Refer Local storage comparison for reason behind choosing Hive.
Refer Hive Library for more details.


Introduction to Hive

Questions

Definition

What’s a Box ?

All data stored in Hive is organised in boxes.
Equivalent of Tables/Collections in a database.

Where is data stored ?

File is generated for each box. Eg: boxName.hive

How to initiate Hive ?

Hive.init(path);

All box files will be created in this location.

How to delete all data ?

We have to delete all the box files and close the Hive

await Hive.deleteFromDisk();
await Hive.close();

Hive Operations

  1. Get by Key : box.get(key)

  2. Get all key value pairs : box.toMap()

  3. Insert value: box.add(value)

  4. Insert all values box.addAll(list)

  5. Insert or update a value using key box.put(keyValuePair)

  6. Insert or update all values using keys box.putAll(keyValuePairs)

  7. Delete a value by key box.delete(key)

  8. Delete all values box.deleteAll()

Steps to insert non-primitive data in Hive

  1. Create Hive Type for the non primitive data.

  2. Generate Adapters.

  3. Register the Adapters.

  4. Open a box to get instance.

  5. call add / addAll function.

What’s HiveType ?

&

How are each parameter’s stored ?

A non primitive data type is created using Hive annotations.
Each Hive type should have a different identifier typeId starting from 0

We have to register HiveType to insert an instance of HiveType in Hive.

@HiveType(typeId:0)
class SampleTable {
  @HiveField(0)
  final String name;

  @HiveField(1)
  final int age;

  SampleTable(this.name, this.age);
}

What's HiveField ?
&
Why is HiveField required ?

As Hive stores everything in File, it needs each parameter to be registered for writing and reading.

We have to set HiveField to write and read the parameters in the file.

Why & How to generate Adapter for a HiveType ?

Hive curd operations work on top of Hive adapters.

Command to generate adapters
flutter packages pub run build_runner build --delete-conflicting-outputs

If SampleTable file is sample_table.dart
then generator file is sample_table.g.dart

SampleTable will generate its adapter SampleTableAdapter

Eg: It automatically generated read and write functionality.

class SampleTableAdapter extends TypeAdapter<SampleTable> {
  @override
  int get typeId => 0;

  @override
  void write(BinaryWriter writer, SampleTable obj) {...}

  @override
  SampleTable read(BinaryReader reader) {...}
}

How to register an Adapter ?

Hive.registerAdapter(SampleTableAdapter);

How to open a box ?

Hive expects box name to open a box.

Box box = await Hive.openBox(
      boxName,
      compactionStrategy: (int total, int deleted) {
        return deleted > 20;
      },
    ); 

What’s Compaction ?

When you updated and deleted data is written at the end of the box file which leads to a growing box file.

Compaction is a strategy to compact a box.

What’s compactionStrategy in openBox ?

we can invoke compaction automatically after every n number of updates/ deletes through compactionStrategy function.


Functionalities

We are storing list of items in Local database.
Eg: List of contacts, list of language reference, list of biller, etc.

Required in our application

Functions

Code

Use-cases

Get item by key

box.get(key)

Get one biller by biller code.

biller = box.get(billerCode);

Get list of items

box.toMap().values.toList()

Get list of billers

billers = box.toMap().values.toList();

Insert/Update all

box.putAll(items)

  1. When a new biller is add - add one item

    await box.putAll([
    { newbillerCode: newBillerDetails }
    ]);
  2. When 2 new billers are added - add multiple

    await box.putAll([
    { newbillerCode1: newBillerDetails1 },
    { newbillerCode2: newBillerDetails2 }
    ]);
  3. When a existing biller is updated

    await box.putAll([
    { exsitingBillerCode: entireBillerDetails }
    ]);
  4. When 2 existing billers are added - update multiple

    await box.putAll([
    { exsitingBillerCode1: entireBillerDetails1 },
    { exsitingBillerCode2: entireBillerDetails2 }
    ]);

Delete by key

box.delete(key)

Delete a biller

 await box.delete(billerCode);

Wrapper for CRUD operations - BaseLocalDataSource

Abstract class takes 2 types.
First datatype is used for storing value. [The key is stricted to be String]
Second datatype is the model from which the table is extended from.

This is used for a overriden function that takes inout as list of model datatype and converts to list of table datatype to insert into database. This class contains basic functions such as get, getAll, insertOrUpdateAll, delete, deleteAll.

Initiate the class with box name. Every extended class should register the adapters in the constructor.

ContactsLocalDataSource() : super(boxName: 'contacts') {
  DatabaseUtil.registerAdapter<ContactTable>(ContactTableAdapter());
  DatabaseUtil.registerAdapter<AccountContactTable>(
    AccountContactTableAdapter());
}

It has 5 functions implemented

  Future<TableType> get(String key) async {
    final Box<TableType> box = await boxInstance;
    return box.get(key);
  }

  Future<List<TableType>> getAll() async {
    final Box<TableType> box = await boxInstance;
    return box.toMap().values.toList();
  }

  Future<void> putAll(Map<String, TableType> items) async {
    final Box<TableType> box = await boxInstance;
    await box.putAll(items);
  }

  Future<void> delete(String key) async {
    final Box<TableType> box = await boxInstance;
    await box.delete(key);
  }

  Future<void> deleteAll() async {
    final Box<TableType> box = await boxInstance;
    await box.deleteAll(box.toMap().keys.toList());
  }


It has 3 Overridden members

  1. Future<ModelType> getFormattedItem(String key);

Usecase for converting table item to a model

Future<ContactsItemModel> getFormattedItem(String key) async {
  final ContactTable contact = await get(key);
  if (contact == null) {
    return null;
  }
  return ContactsItemModel.fromContactTable(contact);
}

Future<List<ModelType>> getformattedData();

Usecase for converting list of table item to a list of models

Future<List<ContactsItemModel>> getformattedData() async {
  final List<ContactTable> contacts = await getAll();
  return contacts
      .map((contact) => ContactsItemModel.fromContactTable(contact))
      .toList();
}

Future<void> insertOrUpdateItems(List<ModelType> contacts);

Usecase for converting list of models to a map which contains key which contains the primary key and value which contains the data belonging to table class.

Future<void> insertOrUpdateItems(List<ContactsItemModel> contacts) async {
  final Map<String, ContactTable> contactMap = {
    for (var contact in contacts)
      contact.contactId: ContactTable.fromModel(contact)
  };
  await putAll(contactMap);
}

Example of Implementation of Contacts

Steps:

  1. Create HiveType for Contact and AccountInContact.
    Include a part `contact_account_table.g.dart` if your

    lib/data/datasources/local/databases/tables/contact_table.dart
    lib/data/datasources/local/databases/tables/contact_account_table.dart

    part 'contact.g.dart'; // is for generating a graph
    
    @HiveType(typeId: 0)
    class ContactTable extends ContactsItemModel {
    
      @HiveField(10)
      List<AccountContactTable> accountTableList;
    
    }

     

    part 'contact_account.g.dart'; // is for generating a graph
    
    @HiveType(typeId: 1)
    class AccountContactTable extends AccountInContactsModel {
    
    }


    Instead of creating parameter in ContactTable for all fields (contactId, contactName, …).
    Since its extended from ContactsItemModel which is extended from ContactsItemEntity.

    Therefore, we can define the fields in ContactsItemEntity class.

    class ContactsItemEntity {
      @HiveField(1)
      bool header;
      @HiveField(2)
      bool headerResult;
      @HiveField(3)
      String contactId;
      ...
    }
    
    
    class AccountInContactsEntity {
      @HiveField(0)
      String accountId;
      @HiveField(1)
      String identifier;
      @HiveField(2)
      String name;
      @HiveField(3)
      String username;
      ...
    }


  2. Auto generate the dependency graph code for the application

    flutter packages pub run build_runner build --delete-conflicting-outputs

    Note: `--delete-conflicting-outputs` is optional to override the conflict graph.


  3. You will get a dependency graph generated for the tables with adapter class called ContactTableAdapter& AccountContactTableAdapter with the below file names.
    lib/data/datasources/local/databases/tables/contact_table.g.dart
    lib/data/datasources/local/databases/tables/contact_account_table.g.dart

    (You can change that name with the optional adapterName parameter of @HiveType)

  4. Create a local datasource repository with
    Box name - contacts
    register adapters in constructor
    overridden functions

    class ContactsLocalDataSource
        extends BaseLocalDataSource<ContactTable, ContactsItemModel> {
      ContactsLocalDataSource() : super(boxName: 'contacts') {
        DatabaseUtil.registerAdapter<ContactTable>(ContactTableAdapter());
        DatabaseUtil.registerAdapter<AccountContactTable>(
            AccountContactTableAdapter());
      }
    
      @override
      Future<ContactsItemModel> getFormattedItem(String key) async {
        final ContactTable contact = await get(key);
        if (contact == null) {
          return null;
        }
        return ContactsItemModel.fromContactTable(contact);
      }
    
      @override
      Future<List<ContactsItemModel>> getformattedData() async {
        final List<ContactTable> contacts = await getAll();
        return contacts
            .map((contact) => ContactsItemModel.fromContactTable(contact))
            .toList();
      }
    
      @override
      Future<void> insertOrUpdateItems(List<ContactsItemModel> contacts) async {
        final Map<String, ContactTable> contactMap = {
          for (var contact in contacts)
            contact.contactId: ContactTable.fromModel(contact)
        };
        await putAll(contactMap);
      }
    }

     

  5. Use these function in repository implementation level.

    Check if data exist in local storage,
    if empty, then request from network and save data into local database

    Future<ContactsModel> getContactsItems(String cif) async {
        
        // Check local database
        List<ContactsItemModel> contactsItems =
            await contactsLocalDataSource.getformattedData();
        
        if (contactsItems.isEmpty) {
          // If empty, then call the network and store in local database
          contactsItems = await contactsRemoteDataSource.getContactsItems(cif);
          await contactsLocalDataSource.insertOrUpdateItems(contactsItems);
        }
    
        return ContactsModel(contactsItems: contactsItems);
      }