Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hot reload #15

Draft
wants to merge 2 commits into
base: method-names
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## 2.0.0

* 🔥 Hot-reload: The connector interface has been adapted to allow for hot reload (in addition to hot restart)
* This means that instead of a configuration object instead, one now need to implement a class, such that updates to the implementation are accessible on hot reload (which was not possible with the configuration object, which was treated as "state" and thus not refreshed)
* While some patterns on top of the configuration objects (e.g. passing factories) could've allowed for hot-reload, requiring an implementation of the connector class ensures that it works all the time. This requires a one-time migration of all existing code though.
* Update method names
* Most notably `insert` is now `write` to transmit the ambivalence between "insert" and "update"
* `get` is now `read` to be symmetric to write (but instead of entities it takes keys, of course)
* `query` now takes an optional `where` parameter, so instead of `getAll()` you can now just use `query()` to get the same result
* `delete` is now switched to take the entity by default, and offers `deleteByKey` to delete by primary key if you know it

## 1.4.3

* Add `insertMany` method to handle batch inserts/updates
Expand Down
4 changes: 2 additions & 2 deletions example/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ final db = IndexedEntityDabase.open('/tmp/appdata.sqlite3'); // in practice put
final todos = db.entityStore(todoConnector);
// While using the String columns here is not super nice, this works without code gen and will throw if using a non-indexed column
final openTodos = todos.query((cols) => cols['done'].equals(false));
final openTodos = todos.query(where: (cols) => cols['done'].equals(false));
print(openTodos.value); // prints an empty list on first run as no TODOs are yet added to the database
todos.insert(
todos.write(
Todo(id: 1, text: 'Publish new version', done: false),
);
Expand Down
2 changes: 2 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:io';

import 'package:flutter/cupertino.dart';
import 'package:indexed_entity_store_example/src/examples/async_value_group_and_detail.dart';
import 'package:indexed_entity_store_example/src/examples/hot_reload.dart';
import 'package:indexed_entity_store_example/src/examples/simple_synchronous.dart';
import 'package:path_provider/path_provider.dart';

Expand Down Expand Up @@ -39,6 +40,7 @@ class _ExampleSelectorState extends State<ExampleSelector> {
Widget? _example;

static Map<String, Widget> examples = {
'Hot-reload example': const HotReloadExample(),
'Simple synchronous data repository': const SimpleSynchronousExample(),
'AsyncValue-based product list & detail view':
const AsyncValueGroupDetailExample(),
Expand Down
4 changes: 2 additions & 2 deletions example/lib/src/examples/async_value_group_and_detail.dart
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ class AsyncProductRepository {
DisposableValueListenable<AsyncValue<ProductDetail>> getProductDetail(
int productId,
) {
final productQuery = _detailStore.get(productId);
final productQuery = _detailStore.read(productId);

if (productQuery.value != null &&
// If data is older than 30s, run the fetch below (but show the most recent value while it's loading)
Expand All @@ -194,7 +194,7 @@ class AsyncProductRepository {
_productApi.getProductDetail(productId).then((product) {
// When we receive the product successfully, we put it into the database,
// from which point on the product query will deliver it to the outside
_detailStore.insert(product);
_detailStore.write(product);

return product;
}).asAsyncValue(),
Expand Down
274 changes: 274 additions & 0 deletions example/lib/src/examples/hot_reload.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import 'dart:convert';

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:indexed_entity_store/indexed_entity_store.dart';
import 'package:indexed_entity_store_example/src/stores/database_helper.dart';

enum HotReloadCase {
intial,

/// Switching to this case will cause an error, as there is a persistent query on the age
///
/// Since this error is not something that happens at normal runtime, but just during development when
/// using index names which are no longer value, it's exposed through the `QueryResult.value` (which
/// will `throw` when access), such that we don't have to wrap the `value` into a success/error wrapper
/// all the time (because under ordinary circumstances we'll always have a successful value).
withoutAgeIndex,

/// When this is active, a new name index is created, and only if active is a query for this index used
///
/// Thus there will be no error, but instead the new query starts working right away, showcasing how the
/// code gracefully handles updates while coding.
withNameIndex,
}

const _currentCase = HotReloadCase.withNameIndex;

/// Showcases how the store can be updated during development, supporting hot-reload
class HotReloadExample extends StatefulWidget {
const HotReloadExample({
super.key,
});

@override
State<HotReloadExample> createState() => _HotReloadExampleState();
}

class _HotReloadExampleState extends State<HotReloadExample> {
final database = getNewDatabase(); // todo merge again

late final PersonStore store = database.entityStore(PersonConnector());

late final QueryResult<List<Person>> everyone = store.query();

late final QueryResult<List<Person>> adults =
store.query(where: (cols) => cols['age'].greaterThanOrEqual(18));

QueryResult<List<Person>>? roberts;

@override
initState() {
super.initState();

// ignore: invalid_use_of_protected_member
// WidgetsBinding.instance.registerSignalServiceExtension(
// name: FoundationServiceExtensions.reassemble.name,
// callback: () async {
// print('reassemble');
// },
// );

store.writeMany([
Person(id: 1, name: 'Sam', age: 5),
Person(id: 2, name: 'Bob', age: 3),
Person(id: 3, name: 'Robert', age: 20),
Person(id: 4, name: 'Max', age: 21),
]);
}

@override
void reassemble() {
super.reassemble();

debugPrint('Reassemble');
database.handleHotReload();
// WidgetsBinding.instance.addPostFrameCallback((_) {
// });

if (_currentCase == HotReloadCase.withNameIndex) {
roberts = store.query(
where: (cols) =>
cols['name'].equals('Robert') | cols['name'].equals('Bob'));
} else {
roberts?.dispose();
roberts = null;
}

// static final _hotReload = ChangeNotifier();
// static Listenable get hotReload => _hotReload;

// WidgetsFlutterBinding.ensureInitialized().addObserver(observer);
// WidgetsFlutterBinding.ensureInitialized().buildOwner.;
}

@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Change the `_currentCase` value in the code and see how the app adapts to the new configuration after hot-reload',
),
Expanded(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_GroupWidget(name: 'Everyone', persons: everyone),
_GroupWidget(name: 'Adults', persons: adults),
if (roberts != null)
_GroupWidget(name: 'Roberts', persons: roberts!),
],
),
),
),
],
);
}

@override
void dispose() {
// In practice, whoever create the database, store, and repository would have to dispose it

everyone.dispose();
adults.dispose();
roberts?.dispose();

super.dispose();
}
}

class _GroupWidget extends StatelessWidget {
const _GroupWidget({
// ignore: unused_element
super.key,
required this.name,
required this.persons,
});

final String name;

final QueryResult<List<Person>> persons;

@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: persons,
builder: (context, persons, _) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 20),
Text(
' $name',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
for (final person in persons)
CupertinoListTile(
title: Text('${person.name} (${person.age})'),
),
],
);
},
);
}
}

// class SimpleSynchronousRepository {
// final TodoStore _store;

// SimpleSynchronousRepository({
// required TodoStore store,
// }) : _store = store;

// void init() {
// _store.write(Todo(id: 1, text: 'Brew Coffe', done: false));
// _store.write(Todo(id: 2, text: 'Get milk', done: false));
// _store.write(Todo(id: 3, text: 'Read newspaper', done: false));
// }

// QueryResult<List<Todo>> getOpenTodos() {
// return _store.query(where: (cols) => cols['done'].equals(false));
// }

// void updateTodo(Todo todo) {
// _store.write(todo);
// }
// }

typedef PersonStore = IndexedEntityStore<Person, int>;

// Instantiate the store on top of the DB?
// with (super.database)
// would that make query writing better by e.g. using `store.ageCol.equals` ?

class PersonConnector
implements
IndexedEntityConnector<
Person,
// key type
int,
// DB value type
String> {
@override
final entityKey = 'person';

@override
void getIndices(IndexCollector<Person> index) {
if (_currentCase != HotReloadCase.withoutAgeIndex) {
index((p) => p.age, as: 'age');
}

if (_currentCase == HotReloadCase.withNameIndex) {
index((p) => p.name, as: 'name');
}
}

@override
int getPrimaryKey(Person e) => e.id;

@override
String serialize(Person e) => jsonEncode(e.toJSON());

@override
Person deserialize(String s) => Person.fromJSON(
jsonDecode(s) as Map<String, dynamic>,
);
}

class Person {
Person({
required this.id,
required this.name,
required this.age,
});

final int id;

final String name;

final int age;

// These would very likely be created by [json_serializable](https://pub.dev/packages/json_serializable)
// or [freezed](https://pub.dev/packages/freezed) already for your models
Map<String, dynamic> toJSON() {
return {
'id': id,
'name': name,
'age': age,
};
}

static Person fromJSON(Map<String, dynamic> json) {
return Person(
id: json['id'],
name: json['name'],
age: json['age'],
);
}
}

class Foo extends WidgetsBindingObserver {}

class HotReloadTracker {
static final _instance = HotReloadTracker._internal();
factory HotReloadTracker() => _instance;

HotReloadTracker._internal() {
if (kDebugMode) {
print("HotReloadTracker initialized - possible hot reload");
}
}
}
10 changes: 5 additions & 5 deletions example/lib/src/examples/simple_synchronous.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,16 @@ class SimpleSynchronousRepository {
}) : _store = store;

void init() {
_store.insert(Todo(id: 1, text: 'Brew Coffe', done: false));
_store.insert(Todo(id: 2, text: 'Get milk', done: false));
_store.insert(Todo(id: 3, text: 'Read newspaper', done: false));
_store.write(Todo(id: 1, text: 'Brew Coffe', done: false));
_store.write(Todo(id: 2, text: 'Get milk', done: false));
_store.write(Todo(id: 3, text: 'Read newspaper', done: false));
}

QueryResult<List<Todo>> getOpenTodos() {
return _store.query((cols) => cols['done'].equals(false));
return _store.query(where: (cols) => cols['done'].equals(false));
}

void updateTodo(Todo todo) {
_store.insert(todo);
_store.write(todo);
}
}
Loading
Loading