0 purchases
dorm framework
dorm_framework #
An Object Relational Mapper framework for Dart.
Table of contents #
Getting started
Model
Object structure
Serialization
Dependency
Instantiation
Entity
Engine
Controller
Operations
Creating
Reading
Updating
Deleting
Filters
By value
By text
By dates
By amount
Relationships
One-to-one
One-to-many
Many-to-one
Many-to-many
Getting started #
Run the following commands in your Dart or Flutter project:
dart pub add dorm_framework
dart pub get
copied to clipboard
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});
}
copied to clipboard
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 */
});
}
copied to clipboard
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 */
};
}
copied to clipboard
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]);
}
copied to clipboard
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,
);
}
copied to clipboard
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,
);
}
copied to clipboard
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();
copied to clipboard
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;
copied to clipboard
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!;
copied to clipboard
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,
);
}
}
copied to clipboard
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() {}
}
copied to clipboard
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 */);
}
copied to clipboard
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,
);
}
copied to clipboard
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'));
}
copied to clipboard
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',
),
);
}
copied to clipboard
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',
),
],
);
}
copied to clipboard
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');
}
copied to clipboard
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();
}
copied to clipboard
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();
}
copied to clipboard
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',
));
}
copied to clipboard
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',
),
]);
}
copied to clipboard
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',
);
});
}
copied to clipboard
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');
}
copied to clipboard
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']);
}
copied to clipboard
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());
}
copied to clipboard
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();
}
copied to clipboard
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'));
}
copied to clipboard
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'));
}
copied to clipboard
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'));
}
copied to clipboard
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`
};
}
}
copied to clipboard
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'));
}
copied to clipboard
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(),
};
}
}
copied to clipboard
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);
}
copied to clipboard
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));
}
copied to clipboard
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
}
}
}
copied to clipboard
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
}
}
}
copied to clipboard
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);
}
}
copied to clipboard
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
}
}
}
copied to clipboard
For personal and professional use. You cannot resell or redistribute these repositories in their original state.
There are no reviews.