dorm_framework

pub package pub popularity pub likes pub points

An Object Relational Mapper framework for Dart.

Table of contents

Getting started

Run the following commands in your Dart or Flutter project:

dart pub add dorm_framework
dart pub get

Model

Note: This is a section that explains the theoretical concept of dORM: it uses the ideas and abstract principles related to dORM rather than the practical uses of it. You can automatize all of the steps below using code generation, provided by dorm_annotations and dorm_generator packages. If you are interested on how dORM works behind the scenes, keep reading! Otherwise, go to the next section.

Object structure

An object in this framework is split into two views: data and model.

Its data view contains all the information used by the real world to represent it. Consider a database schema containing two tables: student and school. The school's data view is composed by its name, its phone number and its address, while the student's data view is composed by its name, its birth date, and its email. These are fields the system user can fill in forms, for example.

You can represent the data view of an object in Dart using a simple class:

class SchoolData {
  final String name;
  final String phoneNumber;
  final String address;

  const SchoolData({required this.name, required this.phoneNumber, required this.address});
}

class StudentData {
  final String name;
  final DateTime birthDate;
  final String email;

  const StudentData({required this.name, required this.birthDate, required this.email});
}

In the other hand, the model view of an object contains all the information used by the database logic to represent it, such as identification and relationships. Every database object must have a unique identification, therefore this field is included in the model view. A school does not need a student to be created, so its model view has no further attributes. However, a student needs to be associated with a school, so its model view has to be a reference to it.

You can represent the model view of an object in Dart also using a class, that inherits from the data view class:

class School extends SchoolData {
  final String id;

  const School({
    required this.id,
    /* required super.declarations */
  });
}

class Student extends StudentData {
  final String id;
  final String schoolId;

  const Student({
    required this.id,
    required this.schoolId,
    /* required super.declarations */
  });
}

These fields aren't kept in a single class because of separation of concerns. A form should only be concerned about real world information of a schema, not their primary or foreign keys. So when using a form, use the data view. When reading from database, use the model view.

Serialization

It's highly recommended to add serialization methods to each class, commonly implemented using fromJson and toJson:

class SchoolData {
  // ...

  factory SchoolData.fromJson(Map<String, Object?> json) {
    return SchoolData(/* decode from JSON */);
  }

  // ...

  Map<String, Object?> toJson() =>
      {
        /*  encode to JSON */
      };
}

class School extends SchoolData {
  // ...

  // Since this is a schema model, you must pass an `id` parameter
  factory School.fromJson(String id, Map<String, Object?> json) {
    final SchoolData data = SchoolData.fromJson(json);
    return School(id: id, /* decode from data */);
  }

  // ...

  @override
  Map<String, Object?> toJson() =>
      {
        ...super.toJson(),
        /* encode to JSON */
      };
}

Dependency

The dependency of an object O contains all the references to other objects that O depends to be created (a.k.a. foreign keys). A school can exist without any student. Since there are no more models in our schema, we can say that School does not depend on any model to exist, so its entity type is strong. A student cannot exist without a school, since they study there. Since there are no more models in this system, we can say that Student depends on School to exist, so its entity type is weak. This reasoning is important to implement a dependency for a schema data, which is used when you want to create a new model (an INSERT operation, for example) in the database.

You can represent the dependency of an object in Dart using a class that inherits from Dependency, a class that this package exports:

import 'package:dorm_framework/dorm_framework.dart';

class SchoolDependency extends Dependency<SchoolData> {
  const SchoolDependency() : super.strong();
}

class StudentDependency extends Dependency<StudentData> {
  final String schoolId;

  StudentDependency({required this.schoolId}) : super.weak([schoolId]);
}

Instantiation

To create a complete object, you can use two methods: create or update.

The following represents the update method:

void main() {
  // The model view you want to update
  final Student existing = Student(/*...*/);

  // The data view you want to overwrite
  final StudentData data = StudentData(/*...*/);

  // The updated object
  final Student updated = Student(
    id: existing.id,
    schoolId: existing.schoolId,
    name: data.name,
    birthDate: data.birthDate,
    email: data.email,
  );
}

Note that, for an update, you need an existing object to inherit from.

In a create transformation, this existing object is replaced by a Dependency:

void main() {
  // The data view you want to upgrade
  final StudentData data = StudentData(/*...*/);

  // The dependency you want to inject into the model view
  final StudentDependency dependency = StudentDependency(/*...*/);

  // The created model
  final Student current = Student(
    /* id: ..., */
    schoolId: dependency.schoolId,
    name: data.name,
    birthDate: data.birthDate,
    email: data.email,
  );
}

What can we use as primary key here? You can use some techniques depending on how your object should be identified:

  • If your object needs to be uniquely identified across the system, use an unique identifier such as the one provided by the uuid package:

    import 'package:uuid/uuid.dart';
    
    String createId() => const Uuid().v4();
    
  • If your object depends exclusively on another object (an one-to-one relationship), use a foreign primary key. For example, since a Grade belongs to a single Student, we could define its primary key as being the following:

    String createId(GradeDependency dependency) => dependency.studentId;
    
  • If your object depends on other attributes of your object, use a logical primary key:

    String createId(StudentData data) => data.schoolCode == null ? data.ssn : data.schoolCode!;
    

These are only some methods that can be used to identify an object. Note that our fictional function createId defined above can receive any kind of arguments (nothing, a data view, a dependency). Therefore, we need to find a way to abstract it.

Entity

The entity of an object acts as a bridge that can be used to manipulate the database. This is a single and robust class, exported by this package, that joins data view, model view and dependency into a single place.

You can represent the entity of an object in Dart using a class that inherits from Entity, a class that this package exports:

class SchoolEntity implements Entity<SchoolData, School> {
  const SchoolEntity();

  @override
  String identify(School model) => model.id;

  @override
  School fromJson(String id, Map data) => School.fromJson(id, data);

  @override
  Map<String, Object?> toJson(SchoolData data) => data.toJson();

  // The name of this table in the database, equivalent to `CREATE TABLE schools` from SQL
  @override
  String get tableName => 'schools';

  // This represents the UPDATE method, see the previous section
  @override
  School convert(School model, SchoolData data) =>
      School(
        id: model.id,
        name: data.name,
        phoneNumber: data.phoneNumber,
        address: data.address,
      );

  // This represents the CREATE method, see the previous section
  @override
  School fromData(SchoolDependency dependency, String id, SchoolData data) {
    return School(
      // Choose your primary key strategy here
      id: id,
      name: data.name,
      phoneNumber: data.phoneNumber,
      address: data.address,
    );
  }
}

Engine

An engine is a dORM component that enables communication between the model (defined in the previous section) and the controller. It behaves as a pointer to where the serialized models should be located and as a guide to how the controller should use its syntax to execute queries.

You can represent an engine in Dart using a class that inherits from BaseEngine, a class that this package exports:

class Engine implements BaseEngine {
  BaseReference createReference() {}

  BaseRelationship createRelationship() {}
} 

Note that every engine must provide a reference, which allows the controller to execute queries, and a relationship, which allows the controller to associate tables and join records.

At the moment, dORM exports two database engines through Dart packages: dorm_bloc_database and dorm_firebase_database. These two packages exports a class named Engine, which extends from BaseEngine. You can access it by adding one of them to your pubspec.yaml, importing them within your code and accessing the exported class:

import 'package:dorm_*_database/dorm_*_database.dart' show Engine;

void main() {
  final BaseEngine engine = Engine(/* any required arguments */);
}

Controller

In the Model section, we have created four classes for each table object in our database: TableData, Table, TableDependency and TableEntity. In the Engine section, we have chosen a database engine and its respective Engine class.

These classes now can be used to be integrated with dORM using a database entity. It contains all the concrete methods necessary for you to use the framework.

You can represent it in Dart by instantiating DatabaseEntity, a class that this package exports:

import 'package:dorm_*_database/dorm_*_database.dart' show Engine;

void main() {
  final BaseEngine engine /* = ... */;
  const SchoolEntity entity = SchoolEntity();

  final DatabaseEntity<SchoolData, School> schoolController = DatabaseEntity(
    engine: engine,
    entity: entity,
  );
}

Since DatabaseEntity inherits from Entity, you can access all its methods:

void main() {
  School school;
  final DatabaseEntity<SchoolData, School> controller /* = ... */;

  // Access the table name
  print(controller.tableName); // schools

  // Decode a row
  school = controller.fromJson('123456', {'name': 'School'});

  // Encode a row
  final Map<String, Object?> data = controller.toJson(school);

  // Identify a model
  print(controller.identify(school)); // 123456

  // Create a model
  school = controller.fromData(
    SchoolDependency(),
    '123456',
    SchoolData(name: 'School'),
  );

  // Update a model
  school = controller.convert(school, SchoolData(name: 'College'));
}

Operations

This class provides a repository field you can use to access all the CRUD methods (which conveniently all start with the letter p).

Creating

There are two methods available for creating: put and putAll.

The put method receives a dependency of an object and its data. Its primary concept is to create a new row on the table. It returns the created model:

void main(Repository<SchoolData, School> repository) async {
  final School school = await repository.put(
    const SchoolDependency(),
    SchoolData(
      name: 'Harmony Academy',
      phoneNumber: '(555) 123-4567',
      address: '123 Main Street, Anytown, USA',
    ),
  );
}

The putAll method receives a dependency of an object and a collection of data. If you have more than two or more data views that share the same dependency, this method is preferred rather than calling put repeatedly. It returns the created models:

void main(Repository<SchoolData, School> repository) async {
  final List<School> schools = await repository.putAll(
    const SchoolDependency(),
    [
      SchoolData(
        name: 'Oakwood High School',
        phoneNumber: '(555) 987-6543',
        address: '456 Elm Avenue, Springfield, USA',
      ),
      SchoolData(
        name: 'Maplewood Elementary',
        phoneNumber: '(555) 555-5555',
        address: '789 Oak Street, Willowbrook, USA',
      ),
    ],
  );
}

Note that, even though these schools share the same dependency, they will be created with different IDs.

Reading

There are five methods available for reading: peek, peekAll, pull, pullAll and peekAllKeys.

The peek and pull methods receive a model ID and evaluates its respective model in the underlying database table. If the ID does not exist, the method evaluates to null. The difference between them is that peek returns a Future (read once and return) and pull returns a Stream (read once and listen for changes):

void main(Repository<SchoolData, School> repository) async {
  final School? school = await repository.peek('123456');
  final Stream<School?> streamedSchool = repository.pull('123456');
}

The peekAll and pullAll methods evaluate all models in the underlying database table as a List. They optionally receive a Filter argument, but for now just assume they evaluates all models. If there are no models in the table, the method evaluates to an empty list. Similar as before, the difference between then is that peek returns a Future and pull returns a Stream:

void main(Repository<SchoolData, School> repository) async {
  final List<School> schools = await repository.peekAll();
  final Stream<List<School>> streamedSchools = repository.pullAll();
}

The peekAllKeys method makes more sense in non-relational databases: it returns all primary keys on the database. If you use custom IDs and want to filter them based on a condition, this method is preferred rather than calling peekAll and reading the returned IDs:

void main(Repository<SchoolData, School> repository) async {
  final List<String> ids = await repository.peekAllKeys();
}

Updating

There are three methods available for update: push, pushAll and patch.

The push method receives a model M and writes it to the table. If this model ID does not exist yet, it will be created. If it exists, the previous data will be overwritten by M. It returns nothing:

void main(Repository<SchoolData, School> repository) async {
  await repository.push(School(
    id: '123456',
    name: 'Sunflower Preparatory School',
    phoneNumber: '(555) 222-3333',
    address: '321 Sunflower Lane, Sunnyville, USA',
  ));
}

The pushAll method receives a collection of models. If you have more than two or more models you want to update at the same time, this method is preferred rather than calling push repeatedly. It returns nothing:

void main(Repository<SchoolData, School> repository) async {
  await repository.pushAll([
    School(
      id: '123',
      name: 'Crestview Middle School',
      phoneNumber: '(555) 777-8888',
      address: '654 Hillcrest Road, Mountainview, USA',
    ),
    School(
      id: '456',
      name: 'Riverside Academy',
      phoneNumber: '(555) 444-9999',
      address: '987 Riverfront Drive, Riverdale, USA',
    ),
  ]);
}

The patch method receives a model ID and a callback that receives a model and returns a model. If you want to read a model from the database given its ID, apply some operation to it locally and write it back to the database, this method is preferred rather than calling peek and push sequentially. It returns nothing:

void main(Repository<SchoolData, School> repository) async {
  const String id = '789';
  await repository.patch(id, (School? school) {
    return School(
      id: school?.id ?? id,
      name: 'Willowbrook High School',
      phoneNumber: '(555) 333-1111',
      address: '246 Willow Avenue, Greenfield, USA',
    );
  });
}

Deleting

There are four methods available for deleting: pop, popAll, popKeys and purge.

The pop method receives a model ID and removes its respective model from the underlying database table. If the ID does not exist, nothing is done. It returns nothing:

void main(Repository<SchoolData, School> repository) async {
  await repository.pop('123');
}

The popKeys method receives a collection of IDs. If you have more than two or more models you want to delete at the same time, this method is preferred rather than calling pop repeatedly. It returns nothing:

void main(Repository<SchoolData, School> repository) async {
  await repository.popKeys(['123', '456', '789']);
}

The popAll method receives a Filter and remove all models that match this filter. You'll read more about filtering later, but for now keep in mind that Filter.empty() matches all models. Therefore, if you use it in this method, it'll be the equivalent to removing all models from the table. It returns nothing:

void main(Repository<SchoolData, School> repository) async {
  await repository.popAll(const Filter.empty());
}

The purge method drops the underlying database table (removes all models). If you want to remove all models from a table, this method is preferred rather than calling popAll passing Filter.empty(). It returns nothing:

void main(Repository<SchoolData, School> repository) async {
  await repository.purge();
}

Filters

Batch methods of repositories, such as peekAll, pullAll and popAll, can receive a Filter as parameter. In read operations, this parameter defaults to Filter.empty(), which matches all models from that repository. If you want to limit how many models are matched, you can change it to your appropriate use case.

By value

If you want to match models whose field is equal to a certain value, you can use Filter.value:

void main(Repository<SchoolData, School> repository) async {
  // Peek all active schools
  await repository.peekAll(const Filter.value(true, key: 'active'));

  // Peek all schools that belongs to US
  await repository.peekAll(const Filter.value('US', key: 'country-name'));
}

The argument passed to key should match the serialization field name.

By text

If you want to match models whose field starts with a certain string, you can use Filter.text:

void main(Repository<SchoolData, School> repository) async {
  // Peek all active schools
  await repository.peekAll(const Filter.value(true, key: 'active'));

  // Peek all schools that belongs to US
  await repository.peekAll(const Filter.value('US', key: 'country-name'));
}

Note that this is a exact and case-sensitive search, so the following will not work:

void main(Repository<SchoolData, School> repository) async {
  // User wants to find the Lincoln Elementary school,
  // so they type in the search bar "lincoln el"
  final String userInput = 'lincoln el';

  // Since the stored school name is "Lincoln Elementary"
  // (note the uppercase letters and spaces), nothing will be found
  await repository.peekAll(Filter.text(userInput, key: 'name'));
}

If you want a case-insensitive search, you can create a new serialization field, normalize your field value, and applying the same normalization to your query. For this, update the toJson method of your object's model view to include this new field:

class Student {
  // ...

  @override
  Map<String, Object?> toJson() {
    return {
      'name': name,
      // ...
      '.name': name.toUpperCase().replaceAll(' ', ''),
      // It can be any key, such as `_name` or `_query/name`
    };
  }
}

Now, search for this new field and apply the same transformation to user's query:

void main(Repository<SchoolData, School> repository) async {
  // User wants to find the Lincoln Elementary school,
  // so they type in the search bar "lincoln el"
  final String userInput = 'lincoln el';

  // Successfully finds the desired school
  final String query = userInput.toUpperCase().replaceAll(' ', '');
  await repository.peekAll(Filter.text(query, key: '.name'));
}

By dates

To filter on dates, you must transform your date field using DateTime's toIso8601String method when serializing it inside toJson:

class Student {
  // ...

  @override
  Map<String, Object?> toJson() {
    return {
      // ...
      'birth-date': birthDate.toIso8601String(),
    };
  }
}

You can now use another date to belong to your filter, using the unit parameter to control how exact do you want this matching:

void main() {
  final DateTime dt = DateTime(
      2021,
      06,
      13,
      16,
      05,
      12,
      111);
  Filter? filter;

  // Select entries occurred at 13/06/2021, 16:05:12.111
  filter = Filter.date(dt, key: 'birth-date');

  // Select entries occurred at 2021
  filter = Filter.date(dt, key: 'birth-date', unit: DateFilterUnit.year);

  // Select entries occurred at 13/06/2021
  filter = Filter.date(dt, key: 'birth-date', unit: DateFilterUnit.day);

  // Select entries occurred at 13/06/2021, from 16:00 to 16:59
  filter = Filter.date(dt, key: 'birth-date', unit: DateFilterUnit.hour);
}

By amount

For any filter, you can use its limit method to evaluate the only first or last N models:

void main(Repository<SchoolData, School> repository) async {
  // Peek first 10 schools
  await repository.peekAll(const Filter.empty().limit(10));

  // Peek last 20 schools with name prefixed with DEF
  await repository.peekAll(Filter.text('DEF', key: 'name').limit(-20));
}

Relationships

With a database entity ready to be used, we want to ask the database questions related to relationships between schemas, such as "What are the students of a given school?". These questions can be asked through the relationships field of a database entity.

One-to-one

An one-to-one relationship between two models refers to a unique and bidirectional association where each record in one model is linked to at most one corresponding record in the other model. The relationship is established through a shared key or a foreign key in the database tables.

For instance, consider the models School and Principal. In this scenario, each school can have only one principal, and each principal can be assigned to only one school. This creates a one-to-one association between the school and principal models:

void main() async {
  final DatabaseEntity<SchoolData, School> schoolController /* = ... */;
  final DatabaseEntity<PrincipalData, Principal> principalController /* = ... */;

  final OneToOneAssociation<School, Principal> association;
  association = schoolController.relationships.oneToOne(
    principalController.repository,
    on: (School school) => school.id,
  );

  final Join<School, Principal?>? join = await association.peek('123456');
  if (join == null) {
    // There is no school associated with the '123456' primary key 
  } else {
    final School school = join.left;
    final Principal? principal = join.right;
    if (principal == null) {
      // There is no principal associated with the referred school
    }
  }
}

One-to-many

An one-to-many relationship between two models represents a type of association where a record in one model (the "one" side) can be related to multiple records in the other model (the "many" side). However, each record in the second model can only be associated with one record in the first model. This relationship is established through a foreign key in the "many" side table, which references the primary key of the "one" side table.

For instance, consider the models School and Student. In this scenario, each school can have zero or more students, while each student can be assigned to only one school. This creates an one-to-many association between the school and student models:

void main() async {
  final DatabaseEntity<SchoolData, School> schoolController /* = ... */;
  final DatabaseEntity<StudentData, Student> studentController /* = ... */;

  final OneToManyAssociation<School, Student> association;
  association = schoolController.relationships.oneToMany(
    studentController.repository,
    on: (School school) => Filter.value(school.id, key: 'school-id'),
  );

  final Join<School, List<Student>>? join = await association.peek('123456');
  if (join == null) {
    // There is no school associated with the '123456' primary key 
  } else {
    final School school = join.left;
    final List<Student> students = join.right;
    if (students.isEmpty) {
      // There are no students associated with the referred school
    }
  }
}

Many-to-one

In an one-to-many relationship, all records from the "left" table (the "one" side) are included, along with any matching records from the "right" table (the "many" side). If there are no matches in the "right" table, the result still includes the record from the "left" table.

A many-to-one relationship is the same as the one-to-many relationship. However, in an many-to-one relationship, if there are no matches in the "right" table, the result will not include the record from the "left" table. In the example above, this allows you to assert that students will never be empty:

void main() async {
  final DatabaseEntity<SchoolData, School> schoolController /* = ... */;
  final DatabaseEntity<StudentData, Student> studentController /* = ... */;

  final ManyToOneAssociation<Student, School> association;
  association = studentController.relationships.oneToMany(
    schoolController.repository,
    on: (Student student) => student.schoolId,
  );

  final Join<School, List<Student>>? join = await association.peek('123456');
  if (join == null) {
    // There is no school associated with the '123456' primary key 
  } else {
    final School school = join.left;
    final List<Student> students = join.right;
    assert(students.isNotEmpty);
  }
}

Many-to-many

A many-to-many relationship between two models represents an association where multiple records in one model can be related to multiple records in the other model. This type of relationship cannot be directly represented by a single foreign key in either model. Instead, an intermediary table (also known as a junction table or association table) is used to connect the two models. This intermediary table contains foreign keys that reference the primary keys of both models, establishing the link between them.

Now, let's consider the models School and Teacher. In this scenario, a many-to-many association exists them: a school can have multiple teachers, and a teacher can work in multiple schools. To create this association, an additional Teaching model (intermediary table) is introduced, containing foreign keys that reference the primary keys of both the school and teacher models. Each record in the teaching model represents a connection between a specific teacher and a specific school. This way, the many-to-many relationship is effectively managed through the teaching model, allowing multiple teachers to be associated with multiple schools while maintaining data integrity:

void main() async {
  final DatabaseEntity<SchoolData, School> schoolController /* = ... */;
  final DatabaseEntity<TeacherData, Teacher> teacherController /* = ... */;
  final DatabaseEntity<TeachingData, Teaching> teachingController /* = ... */;

  final ManyToManyAssociation<Student, School> association;
  association = teachingController.relationships.manyToMany(
    left: schoolController.repository,
    onLeft: (Teaching teaching) => teaching.schoolId,
    right: teacherController.repository,
    onRight: (Teaching teaching) => teaching.teacherId,
  );

  final Join<Teaching, (School?, Teacher?)>? join = await association.peek('123456');
  if (join == null) {
    // There is no teaching associated with the '123456' primary key 
  } else {
    final Teaching teaching = join.left;
    final (School? school, Teacher? teacher) = join.right;
    if (school == null) {
      // There is no school associated with the referred teaching
    }
    if (teacher == null) {
      // There is no teacher associated with the referred teaching
    }
  }
}

Libraries

dorm_framework