Stormberry is a strongly-typed ORM-like code-generation package to provide easy bindings between your dart classes and postgres database. It supports all kinds of relations without any complex configuration.
This package is still in active development. If you have any feedback or feature requests, write me and issue on github.
- Documentation
- Improve Readme
- Improve example
- Testing & Maintenance
- Improve code structure
- Write tests
- Long Term
- Be database agnostic (sub-packages)
To get started, add stormberry
as a dependency and build_runner
as a dev dependency:
dart pub add stormberry
dart pub add build_runner --dev
In your code, specify an abstract class that should act as a table like this:
@Model()
abstract class User {
@PrimaryKey()
String get id;
String get name;
}
Next, create a build.yaml
in the root directory of your package and add this snippet:
targets:
$default:
builders:
stormberry:
generate_for:
# library that exposes all your table classes
# modify this if to match your library file
- lib/models.dart
In order to generate the serialization code, run the following command:
dart pub run build_runner build
You'll need to re-run code generation each time you are making changes to your code. So for development time, use watch
like this
dart pub run build_runner watch
This will generate a .schema.g.dart
file.
Last step is to import
the generated dart file wherever you want / need them.
Models are the key entities for your database mapping. All tables in your database will be deducted from your model classes, and the columns of a table will be deducted from the fields of a model.
You define a model by using the @Model()
annotation on an abstract class. This class should only contain getters and have no constructor.
@Model()
abstract class Book {
@PrimaryKey()
String get id;
String get title;
}
This model uses an additional @PrimaryKey()
annotation on its id
field. It will be translated into the following sql table:
TABLE "books" (
"id" text NOT NULL,
"title" text NOT NULL,
PRIMARY KEY ("id")
);
You can also make any field auto-increment by using the @AutoIncrement()
annotation. Auto-increment fields
must be of type int.
@Model()
abstract class Book {
@PrimaryKey()
@AutoIncrement()
int get id;
@AutoIncrement()
int get someOtherValue;
}
When using relational database systems, you model your data using relations, namely one-to-one, one-to-many, or many-to-many relations.
When you want to specify a relation to another model, you simply use that model as the type of any field.
stormberry
analyzes your models and automatically determines the correct relation types.
@Model()
abstract class Author {
@PrimaryKey()
String get id;
String get name;
}
@Model()
abstract class Book {
@PrimaryKey()
String get id;
String get title;
Author get author;
}
The above code specifies a many-to-one relation between Book
and Author
. It is ok to specify
a relation only in one of the two models, but you could also specify the List<Book> get books;
in the Author
model.
When only one side is specified, stormberry
will default to a many-to-one or one-to-many relation.
To instead specify a one-to-one relation, simply specify Book get book;
on the author side.
Generally, the correct relation type is determined by whether you use List<...>
on one or both sides of the relation.
Depending on the relation type, it is also mandatory to specify a primary key field.
Notice how when you specify both sides of a relation, querying one of the models would lead to a
cyclic dependency. You can solve this by using Views
.
For each table you can define a series of View
s, which you can query for. A view is a modified
subset of fields of the table and resolved relations.
A View
is helpful, when you have different points in your application where you want to query the
same model, but with different access demands, like privacy.
As an easy example take a typical User
model. In the database, you might want to store some public
information for a user, like the username, together with some private information, like the address.
When a user requests its own data, you want to return all available data, public and private. But when
a user requests the information for another user, you want to only return the public information.
With stormberry
you can handle this automatically by defining separate View
s on the User
model.
You define View
s like this:
@Model(views: [
View('Complete', [
Field.view('posts', as: 'Info')
]),
View('Reduced', [
Field.hidden('address'),
Field.hidden('posts'),
]),
])
abstract class User {
@PrimaryKey()
String get id;
String get name;
String get address;
List<Post> get posts;
}
@Model(views: [
View('Base', [
Field.view('author', as: 'Reduced')
]),
View('Info', [
Field.hidden('author'),
]),
])
abstract class Post {
@PrimaryKey()
String get id;
String get content;
User get author;
}
Each View
expects a name and an optional list of field modifiers:
Field.hidden()
hides a field from this view.Field.view()
specifies a view to use for this field (which has to be a relation to another table)
The above model would result in the following view classes:
class CompleteUserView {
String id;
String name;
String address;
List<InfoPostView> posts;
}
class ReducedUserView {
String id;
String name;
}
class BasePostView {
String id;
String content;
ReducedUserView author;
}
class InfoPostView {
String id;
String content;
}
As mentioned before, when you have two-way relations in your models you must use View
s to resolve
any cyclic dependencies. stormberry
can't resolve them for you, however it will warn you if it
detects any when trying to migrate your database schema.
As an advanced configuration you can specify indexes on your table using the TableIndex
class.
You can add indexes to your @Model()
annotation like this:
@Model(
views: [...],
indexes: [
TableIndex(name: 'my_index', columns: ['my_column'], unique: true)
],
)
abstract class MyModel {
...
}
Checkout the api documentation here for a description of the available parameters you can specify on an index.
When using a custom type for a model field, you need to create a custom TypeConverter
for this
type. Implement a custom type converter like this:
@TypeConverter('point')
class LatLngConverter extends TypeConverter<LatLng> {
@override
dynamic encode(LatLng value) => PgPoint(value.latitude, value.longitude);
@override
LatLng decode(dynamic value) {
if (value is PgPoint) {
return LatLng(value.latitude, value.longitude);
} else {
var m = RegExp(r'\((.+),(.+)\)').firstMatch(value.toString());
var lat = double.parse(m!.group(1)!.trim());
var lng = double.parse(m.group(2)!.trim());
return LatLng(lat, lng);
}
}
}
This transforms a value of type LatLng
to the postgres data type point
.
Note the usage of PgPoint
as the encoded object, which comes from the postgres
package.
For decoding, we also cover the case that the value is returned as a point literal instead of an
point object.
When running the build using dart pub run build_runner build
, stormberry
will
generate a Repository
for each model which you can use to query, insert, update or delete data
related to this model.
Repositories
are extensions to the Database
object, which you can create like this:
final db = Database(
host: '127.0.0.1',
port: 5432,
database: 'postgres',
user: 'postgres',
password: 'root',
);
All parameters are optional. When a parameter is not provided, it is taken from the related environment variable, or the shown default value.
A Repository
exists for each Model
on which you can
- query the model table and each of its views
- insert an entry to the model table
- update an entry of the model table
- delete an entry of the model table
You can get a models repository through its property accessor on the Database
instance:
var userRepo = db.users;
.
For the above example with two views Complete
and Reduced
, this would have the following
methods:
Future<CompleteUserView?> queryCompleteView(String id)
Future<List<CompleteUserView>> queryCompleteViews()
Future<ReducedUserView?> queryReducedView(String id)
Future<List<ReducedUserView>> queryReducedViews()
Future<void> insertOne(UserInsertRequest request)
Future<void> insertMany(List<UserInsertRequest> requests)
Future<void> updateOne(UserUpdateRequest request)
Future<void> updateMany(List<UserUpdateRequest> requests)
Future<void> deleteOne(String id)
Future<void> updateMany(List<String> ids)
Each method has a single and multi variant. UserInsertRequest
and UserUpdateRequest
are
special generated classes that enable type-safe inserts and updates while respecting data relations
and key constraints.
With this, stormberry
also supports partial updates of a model. You could for example just update
the name of a user while keeping the other fields untouched like this:
await db.users.updateOne(UserUpdateRequest(id: 'abc', name: 'Tom'));
You can specify a custom query with custom sql by extending the Query<T, U>
class.
You will then need to implement the Future<T> apply(Database db, U params)
method.
Additionally to the model tabels, you can query the model views to automatically get all resolved
relations without needing to do manual joins. Table names are always plural, e.g. users
and view
names are in the format as complete_user_view
.
You can also specify custom Action
s to perform on your table.
Similar to the queries, you extend the Action<T>
class and implement the
Future<void> apply(Database db, T request)
method.
Stormberry comes with a database migration tool, to create or update the schema of your database.
To use this run the following command from the root folder of your project.
dart pub run stormberry
In order to connect to your database, provide the following environment variables:
DB_HOST
, DB_PORT
, DB_NAME
, DB_USERNAME
, DB_PASSWORD
and DB_SSL
.
The tool will analyze the database schema and log any needed changes. It then asks for confirmation before applying the changes or aborting.
The tool supported the following options:
-db=<db_name>
: Specify the database name. Tool will ask if not specified.--dry-run
: Logs any changes to the schema without writing to the database, and exists with code 1 if there are any.--apply-changes
: Apply any changes without asking for confirmation.