feat: First version with working triggers to track tables
This commit is contained in:
parent
5d5b2432af
commit
49b622fc18
27 changed files with 2082 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# https://dart.dev/guides/libraries/private-files
|
||||||
|
# Created by `dart pub`
|
||||||
|
.dart_tool/
|
||||||
|
|
||||||
|
# Avoid committing pubspec.lock for library packages; see
|
||||||
|
# https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||||
|
pubspec.lock
|
||||||
|
|
||||||
|
.vscode/
|
||||||
|
*.g.dart
|
||||||
3
CHANGELOG.md
Normal file
3
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
## 0.0.1
|
||||||
|
|
||||||
|
- Initial version.
|
||||||
48
README.md
Normal file
48
README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<!--
|
||||||
|
This README describes the package. If you publish this package to pub.dev,
|
||||||
|
this README's contents appear on the landing page for your package.
|
||||||
|
|
||||||
|
For information about how to write a good package README, see the guide for
|
||||||
|
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
|
||||||
|
|
||||||
|
For general information about developing packages, see the Dart guide for
|
||||||
|
[creating packages](https://dart.dev/guides/libraries/create-packages)
|
||||||
|
and the Flutter guide for
|
||||||
|
[developing packages and plugins](https://flutter.dev/to/develop-packages).
|
||||||
|
-->
|
||||||
|
|
||||||
|
A plugin to build offline-first applications with Drift. Develop your app as a
|
||||||
|
normal offline app and use this non-invasive plugin to automatically handle the
|
||||||
|
sync process using CRDT (Conflict-free Replicated Data Type). Works
|
||||||
|
out-of-the-box with Drift's existing APIs.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
TODO: List what your package can do. Maybe include GIFs, or videos.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
TODO: List prerequisites and provide or point to information on how to start
|
||||||
|
using the package.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
TODO: Include short and useful examples for package users. Add longer examples
|
||||||
|
to `/example` folder.
|
||||||
|
|
||||||
|
See the `/example` directory for a...
|
||||||
|
|
||||||
|
```dart
|
||||||
|
const like = 'sample';
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- ## Additional information
|
||||||
|
|
||||||
|
TODO: Tell users more about the package: where to find more information, how to
|
||||||
|
contribute to the package, how to file issues, what response they can expect
|
||||||
|
from the package authors, and more. -->
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This package is distributed under the MIT License. See the LICENSE file for
|
||||||
|
more information.
|
||||||
41
SPEC.md
Normal file
41
SPEC.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Specification for the implementation
|
||||||
|
|
||||||
|
## Starting the database
|
||||||
|
|
||||||
|
- After the `createAll()`, call a custom function to inspect the database and
|
||||||
|
list:
|
||||||
|
- Each table unique constraints
|
||||||
|
- All foreign keys
|
||||||
|
|
||||||
|
Check
|
||||||
|
[this research](https://www.perplexity.ai/search/on-drift-for-flutter-after-all-aaj9LV1QTeSST3brpolEMA)
|
||||||
|
for reference.
|
||||||
|
|
||||||
|
## How to specify which constraint to apply to each case?
|
||||||
|
|
||||||
|
Unique can be handled as:
|
||||||
|
|
||||||
|
- on_conflict_merge (default)
|
||||||
|
- on_conflict_overwrite
|
||||||
|
|
||||||
|
Foreign keys can be handled as:
|
||||||
|
|
||||||
|
- on_fk_deleted_delete_this (default, if cascade delete)
|
||||||
|
- on_fk_deleted_set_null (default, if optional)
|
||||||
|
- on_fk_deleted_restore_related
|
||||||
|
|
||||||
|
## After merge on sync, a callback must be available to check custom rules
|
||||||
|
|
||||||
|
There must have an easy way to issue fixes and re-run the merge.
|
||||||
|
|
||||||
|
## Next:
|
||||||
|
|
||||||
|
- [ ] Design the registration of constraints in the database.
|
||||||
|
- [ ] Integrate the CRDT for retrieval of changes.
|
||||||
|
- [ ] Integrate the CRDT merge logic.
|
||||||
|
- [ ] Integrate the compensation logic with the merge logic.
|
||||||
|
- [ ] Implement a regenerate process to run after migrations.
|
||||||
|
- [ ] Add pending tests marked as TODO.
|
||||||
|
- [ ] Transform this repo in a monorepo with this package and a Flutter version
|
||||||
|
that provides a custom `driftDatabase` function that already passes the
|
||||||
|
closure to add the `next_hlc_timestamp` function to the database.
|
||||||
133
analysis_options.yaml
Normal file
133
analysis_options.yaml
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
include: package:lints/recommended.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
- always_declare_return_types
|
||||||
|
- always_put_required_named_parameters_first
|
||||||
|
- annotate_redeclares
|
||||||
|
- avoid_bool_literals_in_conditional_expressions
|
||||||
|
- avoid_catches_without_on_clauses
|
||||||
|
- avoid_catching_errors
|
||||||
|
- avoid_empty_else
|
||||||
|
- avoid_escaping_inner_quotes
|
||||||
|
- avoid_field_initializers_in_const_classes
|
||||||
|
- avoid_final_parameters
|
||||||
|
- avoid_js_rounded_ints
|
||||||
|
- avoid_positional_boolean_parameters
|
||||||
|
- avoid_private_typedef_functions
|
||||||
|
- avoid_relative_lib_imports
|
||||||
|
- avoid_returning_this
|
||||||
|
- avoid_setters_without_getters
|
||||||
|
- avoid_shadowing_type_parameters
|
||||||
|
- avoid_slow_async_io
|
||||||
|
- avoid_types_as_parameter_names
|
||||||
|
- avoid_unused_constructor_parameters
|
||||||
|
- avoid_void_async
|
||||||
|
- await_only_futures
|
||||||
|
- camel_case_extensions
|
||||||
|
- cancel_subscriptions
|
||||||
|
- cascade_invocations
|
||||||
|
- cast_nullable_to_non_nullable
|
||||||
|
- close_sinks
|
||||||
|
- collection_methods_unrelated_type
|
||||||
|
- combinators_ordering
|
||||||
|
- comment_references
|
||||||
|
- conditional_uri_does_not_exist
|
||||||
|
- curly_braces_in_flow_control_structures
|
||||||
|
- dangling_library_doc_comments
|
||||||
|
- depend_on_referenced_packages
|
||||||
|
- deprecated_consistency
|
||||||
|
- deprecated_member_use_from_same_package
|
||||||
|
- directives_ordering
|
||||||
|
- discarded_futures
|
||||||
|
- document_ignores
|
||||||
|
- empty_catches
|
||||||
|
- eol_at_end_of_file
|
||||||
|
- file_names
|
||||||
|
- hash_and_equals
|
||||||
|
- implicit_call_tearoffs
|
||||||
|
- invalid_case_patterns
|
||||||
|
- library_names
|
||||||
|
- literal_only_boolean_expressions
|
||||||
|
- matching_super_parameters
|
||||||
|
- missing_code_block_language_in_doc_comment
|
||||||
|
- missing_whitespace_between_adjacent_strings
|
||||||
|
- no_adjacent_strings_in_list
|
||||||
|
- no_duplicate_case_values
|
||||||
|
- no_literal_bool_comparisons
|
||||||
|
- no_runtimeType_toString
|
||||||
|
- no_self_assignments
|
||||||
|
- no_wildcard_variable_uses
|
||||||
|
- non_constant_identifier_names
|
||||||
|
- noop_primitive_operations
|
||||||
|
- null_check_on_nullable_type_parameter
|
||||||
|
- omit_local_variable_types
|
||||||
|
- one_member_abstracts
|
||||||
|
- only_throw_errors
|
||||||
|
- package_prefixed_library_names
|
||||||
|
- parameter_assignments
|
||||||
|
- prefer_const_constructors
|
||||||
|
- prefer_const_declarations
|
||||||
|
- prefer_final_in_for_each
|
||||||
|
- prefer_final_locals
|
||||||
|
- prefer_foreach
|
||||||
|
- prefer_if_elements_to_conditional_expressions
|
||||||
|
- prefer_is_empty
|
||||||
|
- prefer_is_not_empty
|
||||||
|
- prefer_iterable_whereType
|
||||||
|
- prefer_mixin
|
||||||
|
- prefer_null_aware_method_calls
|
||||||
|
- prefer_relative_imports
|
||||||
|
- prefer_single_quotes
|
||||||
|
- prefer_typing_uninitialized_variables
|
||||||
|
- prefer_void_to_null
|
||||||
|
- provide_deprecation_message
|
||||||
|
- public_member_api_docs
|
||||||
|
- require_trailing_commas
|
||||||
|
- sized_box_shrink_expand
|
||||||
|
- sort_constructors_first
|
||||||
|
- sort_unnamed_constructors_first
|
||||||
|
- test_types_in_equals
|
||||||
|
- throw_in_finally
|
||||||
|
- tighten_type_of_initializing_formals
|
||||||
|
- type_annotate_public_apis
|
||||||
|
- type_literal_in_constant_pattern
|
||||||
|
- unawaited_futures
|
||||||
|
- unintended_html_in_doc_comment
|
||||||
|
- unnecessary_await_in_return
|
||||||
|
- unnecessary_breaks
|
||||||
|
- unnecessary_lambdas
|
||||||
|
- unnecessary_library_directive
|
||||||
|
- unnecessary_null_aware_operator_on_extension_on_nullable
|
||||||
|
- unnecessary_null_checks
|
||||||
|
- unnecessary_overrides
|
||||||
|
- unnecessary_parenthesis
|
||||||
|
- unnecessary_raw_strings
|
||||||
|
- unnecessary_statements
|
||||||
|
- unreachable_from_main
|
||||||
|
- unrelated_type_equality_checks
|
||||||
|
- use_colored_box
|
||||||
|
- use_decorated_box
|
||||||
|
- use_enums
|
||||||
|
- use_if_null_to_convert_nulls_to_bools
|
||||||
|
- use_is_even_rather_than_modulo
|
||||||
|
- use_named_constants
|
||||||
|
- use_raw_strings
|
||||||
|
- use_setters_to_change_properties
|
||||||
|
- use_string_buffers
|
||||||
|
- use_string_in_part_of_directives
|
||||||
|
- use_test_throws_matchers
|
||||||
|
- use_to_and_as_if_applicable
|
||||||
|
- valid_regexps
|
||||||
|
- void_checks
|
||||||
|
|
||||||
|
formatter:
|
||||||
|
page_width: 88
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- "**/*.g.dart"
|
||||||
|
- "**/*.drift.dart"
|
||||||
|
language:
|
||||||
|
strict-casts: true
|
||||||
|
strict-inference: true
|
||||||
10
build.yaml
Normal file
10
build.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
targets:
|
||||||
|
$default:
|
||||||
|
builders:
|
||||||
|
drift_dev:
|
||||||
|
options:
|
||||||
|
use_sql_column_name_as_json_key: true
|
||||||
|
sql:
|
||||||
|
dialects:
|
||||||
|
- sqlite
|
||||||
|
- postgres
|
||||||
5
example/drift_offline_first_example.dart
Normal file
5
example/drift_offline_first_example.dart
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import 'package:drift_offline_first/drift_offline_first.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
print('awesome: $OfflineSyncMigrator');
|
||||||
|
}
|
||||||
9
lib/drift_offline_first.dart
Normal file
9
lib/drift_offline_first.dart
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/// Support for doing something awesome.
|
||||||
|
///
|
||||||
|
/// More docs go here.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/database.dart';
|
||||||
|
export 'src/hlc/converter.dart';
|
||||||
|
export 'src/migrator.dart';
|
||||||
|
export 'src/setup.dart';
|
||||||
29
lib/src/database.dart
Normal file
29
lib/src/database.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import 'tables/compensation.dart';
|
||||||
|
import 'tables/control.dart';
|
||||||
|
import 'tables/data.dart';
|
||||||
|
|
||||||
|
part 'database.g.dart';
|
||||||
|
|
||||||
|
/// The main database class for CRDT synchronization.
|
||||||
|
@DriftDatabase(
|
||||||
|
tables: [
|
||||||
|
CrdtDataTable,
|
||||||
|
CrdtControlTable,
|
||||||
|
CrdtCompensationTable,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class CrdtDatabase extends _$CrdtDatabase {
|
||||||
|
/// Creates a new instance of [CrdtDatabase] with the provided [executor].
|
||||||
|
CrdtDatabase(super.e);
|
||||||
|
|
||||||
|
/// Whether the database is using the SQLite or Postgres dialect.
|
||||||
|
bool get isPostgres => executor.dialect == SqlDialect.postgres;
|
||||||
|
|
||||||
|
/// Not actually used, but required by the generated code. Will be ignored because
|
||||||
|
/// the [CrdtDatabase] always receives an already opened executor. No migrations
|
||||||
|
/// should ever be run on this database to avoid messing with the user schema.
|
||||||
|
@override
|
||||||
|
int schemaVersion = 1;
|
||||||
|
}
|
||||||
20
lib/src/hlc/converter.dart
Normal file
20
lib/src/hlc/converter.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import 'package:crdt/crdt.dart';
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
export 'package:crdt/crdt.dart' show Hlc;
|
||||||
|
|
||||||
|
class _HlcConverter extends TypeConverter<Hlc, String> {
|
||||||
|
const _HlcConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Hlc fromSql(String fromDb) => Hlc.parse(fromDb);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(Hlc value) => value.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converter for [Hlc] to store it in the database as string.
|
||||||
|
const hlcConverter = _HlcConverter();
|
||||||
|
|
||||||
|
/// Null-aware converter for [Hlc] to handle nullable fields.
|
||||||
|
const nullableHlcConverter = NullAwareTypeConverter.wrap(hlcConverter);
|
||||||
31
lib/src/hlc/stateful.dart
Normal file
31
lib/src/hlc/stateful.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:crdt/crdt.dart';
|
||||||
|
|
||||||
|
/// A [Hlc] instance that is used to generate unique timestamps for CRDT operations.
|
||||||
|
/// Will keep track of the HLC during the lifetime of the application.
|
||||||
|
class StatefulHlc {
|
||||||
|
StatefulHlc._(this.nodeId) : lastHlc = Hlc.now(nodeId);
|
||||||
|
|
||||||
|
static final Map<String, StatefulHlc> _instances = {};
|
||||||
|
|
||||||
|
/// Returns a cached instance of [StatefulHlc] for the provided [nodeId].
|
||||||
|
static StatefulHlc cached(String nodeId) {
|
||||||
|
return _instances.putIfAbsent(nodeId, () => StatefulHlc._(nodeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The node ID for the CRDT system.
|
||||||
|
final String nodeId;
|
||||||
|
|
||||||
|
/// The current HLC timestamp for the user. Will be incremented on each operation.
|
||||||
|
late Hlc lastHlc;
|
||||||
|
|
||||||
|
/// Returns the next HLC timestamp for the current node.
|
||||||
|
Hlc nextHlc() {
|
||||||
|
lastHlc = lastHlc.increment();
|
||||||
|
return lastHlc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges another [Hlc] instance into the current one.
|
||||||
|
void merge(Hlc other) {
|
||||||
|
lastHlc = lastHlc.merge(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
244
lib/src/migrator.dart
Normal file
244
lib/src/migrator.dart
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '/src/database.dart';
|
||||||
|
import 'triggers.dart';
|
||||||
|
|
||||||
|
/// Runs migrations declared by a [MigrationStrategy].
|
||||||
|
class OfflineSyncMigrator extends Migrator {
|
||||||
|
/// Used internally by drift when opening the database.
|
||||||
|
OfflineSyncMigrator(
|
||||||
|
super.database, {
|
||||||
|
required this.userId,
|
||||||
|
required this.nodeId,
|
||||||
|
|
||||||
|
/// Restrict tables to synchronize. If not provided, all tables will be considered.
|
||||||
|
List<TableInfo>? synchronizedTables,
|
||||||
|
|
||||||
|
/// Optionally specify tables to exclude from synchronization.
|
||||||
|
List<TableInfo>? excludeTables,
|
||||||
|
}) : synchronizedTables = synchronizedTables ?? database.allTables.toList() {
|
||||||
|
if (excludeTables != null) {
|
||||||
|
this.synchronizedTables.removeWhere(excludeTables.contains);
|
||||||
|
}
|
||||||
|
_validateSynchronizedTables();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user ID for the CRDT system.
|
||||||
|
///
|
||||||
|
/// Note that this is the user ID, not the node ID. A user can have multiple nodes,
|
||||||
|
/// all sharing the same user ID so that they can synchronize with each other.
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// The node ID for the CRDT system.
|
||||||
|
///
|
||||||
|
/// A node is a unique identifier for a device or application instance.
|
||||||
|
final String nodeId;
|
||||||
|
|
||||||
|
/// Tables to be synchronized with CRDT.
|
||||||
|
final List<TableInfo> synchronizedTables;
|
||||||
|
|
||||||
|
/// The database instance to apply the CRDT schema to.
|
||||||
|
late final crdtDb = CrdtDatabase(database.executor);
|
||||||
|
|
||||||
|
/// Migrations for tables that are part of the CRDT system.
|
||||||
|
final pendingMigrations = <String, CRDTTableMigration>{};
|
||||||
|
|
||||||
|
/// Eagerly validates that all synchronized tables are supported.
|
||||||
|
///
|
||||||
|
/// This prevents errors at runtime later if tables do not meet the requirements.
|
||||||
|
void _validateSynchronizedTables() {
|
||||||
|
for (final table in synchronizedTables) {
|
||||||
|
if (table.$primaryKey.isEmpty) {
|
||||||
|
throw ArgumentError(
|
||||||
|
'Table "${table.actualTableName}" has no primary key and is therefore not '
|
||||||
|
'supported to track changes to it. Either add a primary key to the table '
|
||||||
|
'or remove it from the synchronized tables list.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the migration for a table that is part of the CRDT system.
|
||||||
|
///
|
||||||
|
/// If the table is not in the list of synchronized tables, a new migration
|
||||||
|
/// will be created.
|
||||||
|
CRDTTableMigration _register(String tableName) {
|
||||||
|
return pendingMigrations.putIfAbsent(
|
||||||
|
tableName,
|
||||||
|
() => CRDTTableMigration(tableName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the CRDT-related entities in the database.
|
||||||
|
Future<void> createCrdtControl() async {
|
||||||
|
for (final table in crdtDb.allTables) {
|
||||||
|
await createTable(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (crdtDb.isPostgres) {
|
||||||
|
final crdtTable = crdtDb.crdtDataTable;
|
||||||
|
await database.customStatement(
|
||||||
|
'ALTER TABLE ${crdtTable.actualTableName} '
|
||||||
|
'PARTITION BY RANGE (${crdtTable.tblName.name});',
|
||||||
|
// 'PARTITION BY RANGE (${crdtTable.userId.name}, ${crdtTable.tblName.name});',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final table in synchronizedTables) {
|
||||||
|
await _createTriggers(table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createTriggers(TableInfo table) async {
|
||||||
|
final triggerCreator = switch (database.executor.dialect) {
|
||||||
|
SqlDialect.sqlite => Sqlite3OfflineSyncTriggers(this),
|
||||||
|
_ => throw UnsupportedError('Unsupported dialect: ${database.executor.dialect}'),
|
||||||
|
};
|
||||||
|
final triggers = triggerCreator.generateCreateTriggerStatements(table);
|
||||||
|
for (final trigger in triggers) {
|
||||||
|
await database.customStatement(trigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> createAll() async {
|
||||||
|
await super.createAll();
|
||||||
|
await createCrdtControl();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> createTable(TableInfo table) async {
|
||||||
|
// Tables that start with 'tmp_for_copy_' are used in alter table operations.
|
||||||
|
// We do not want to register them as created tables.
|
||||||
|
if (!table.actualTableName.startsWith('tmp_for_copy_')) {
|
||||||
|
_register(table.actualTableName).created = true;
|
||||||
|
}
|
||||||
|
await super.createTable(table);
|
||||||
|
if (synchronizedTables.contains(table)) {
|
||||||
|
await _createTriggers(table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> alterTable(TableMigration migration) async {
|
||||||
|
final table = migration.affectedTable;
|
||||||
|
pendingMigrations.putIfAbsent(
|
||||||
|
table.actualTableName,
|
||||||
|
() => CRDTTableMigration.from(migration),
|
||||||
|
);
|
||||||
|
|
||||||
|
final existingTriggers = await database.getCrdtTriggers();
|
||||||
|
for (final trigger in existingTriggers) {
|
||||||
|
await database.customStatement('DROP TRIGGER IF EXISTS $trigger;');
|
||||||
|
}
|
||||||
|
|
||||||
|
await super.alterTable(migration);
|
||||||
|
await _createTriggers(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> drop(DatabaseSchemaEntity entity) async {
|
||||||
|
if (entity is TableInfo) {
|
||||||
|
_register(entity.actualTableName).dropped = true;
|
||||||
|
}
|
||||||
|
return super.drop(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteTable(String name) async {
|
||||||
|
_register(name).dropped = true;
|
||||||
|
return super.deleteTable(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addColumn(TableInfo table, GeneratedColumn column) async {
|
||||||
|
_register(table.actualTableName).onColumn(column.$name).added = true;
|
||||||
|
return super.addColumn(table, column);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dropColumn(TableInfo table, String column) async {
|
||||||
|
_register(table.actualTableName).onColumn(column).dropped = true;
|
||||||
|
return super.dropColumn(table, column);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> renameColumn(
|
||||||
|
TableInfo table,
|
||||||
|
String oldName,
|
||||||
|
GeneratedColumn column,
|
||||||
|
) async {
|
||||||
|
_register(table.actualTableName).onColumn(column.$name).oldName = oldName;
|
||||||
|
return super.renameColumn(table, oldName, column);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> renameTable(TableInfo table, String oldName) async {
|
||||||
|
_register(table.actualTableName).oldName = oldName;
|
||||||
|
return super.renameTable(table, oldName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A migration for a table that is part of the CRDT system.
|
||||||
|
class CRDTTableMigration {
|
||||||
|
/// Creates a new instance of [CRDTTableMigration].
|
||||||
|
CRDTTableMigration(this.tableName);
|
||||||
|
|
||||||
|
/// The table that is being migrated.
|
||||||
|
final String tableName;
|
||||||
|
|
||||||
|
/// Whether the table was created.
|
||||||
|
bool created = false;
|
||||||
|
|
||||||
|
/// Whether the table was dropped.
|
||||||
|
bool dropped = false;
|
||||||
|
|
||||||
|
/// The old name of the table, if the table was renamed.
|
||||||
|
String? oldName;
|
||||||
|
|
||||||
|
/// The columns that were added.
|
||||||
|
final columns = <String, CRDTColumnMigration>{};
|
||||||
|
|
||||||
|
/// Whether the table was modified.
|
||||||
|
bool get modified => oldName != null || columns.isNotEmpty;
|
||||||
|
|
||||||
|
/// Gets the migration for a column that is part of the CRDT system.
|
||||||
|
///
|
||||||
|
/// If the column is not in the list of synchronized columns, a new migration
|
||||||
|
/// will be created.
|
||||||
|
CRDTColumnMigration onColumn(String columnName) {
|
||||||
|
return columns.putIfAbsent(columnName, () => CRDTColumnMigration(columnName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new instance of [CRDTTableMigration] from a [TableMigration].
|
||||||
|
static CRDTTableMigration from(TableMigration migration) {
|
||||||
|
final table = migration.affectedTable;
|
||||||
|
final tableMigration = CRDTTableMigration(table.actualTableName);
|
||||||
|
for (final column in migration.newColumns) {
|
||||||
|
// TODO: Handle the column transformer.
|
||||||
|
tableMigration.onColumn(column.name);
|
||||||
|
}
|
||||||
|
return tableMigration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A migration for a table that is part of the CRDT system.
|
||||||
|
class CRDTColumnMigration {
|
||||||
|
/// Creates a new instance of [CRDTColumnMigration].
|
||||||
|
CRDTColumnMigration(this.columnName);
|
||||||
|
|
||||||
|
/// The column that is being migrated.
|
||||||
|
final String columnName;
|
||||||
|
|
||||||
|
/// Whether the column was added.
|
||||||
|
bool added = false;
|
||||||
|
|
||||||
|
/// Whether the column was dropped.
|
||||||
|
bool dropped = false;
|
||||||
|
|
||||||
|
/// The old name of the column, if the column was renamed.
|
||||||
|
String? oldName;
|
||||||
|
|
||||||
|
/// Whether the column type was modified.
|
||||||
|
bool changedType = false;
|
||||||
|
}
|
||||||
32
lib/src/setup.dart
Normal file
32
lib/src/setup.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
|
||||||
|
import '/src/hlc/stateful.dart';
|
||||||
|
import '/src/triggers.dart';
|
||||||
|
|
||||||
|
/// The database setup function to register the HLC function.
|
||||||
|
///
|
||||||
|
/// Will register the `next_hlc_timestamp` function in the SQLite3 database for usage
|
||||||
|
/// in the triggers that keep track of changes in the database.
|
||||||
|
///
|
||||||
|
/// If using Dart only, must be passed to the [NativeDatabase] constructor as `setup`
|
||||||
|
/// parameter. If using Flutter, this function must be passed to the `driftDatabase`
|
||||||
|
/// function as `setup` parameter.
|
||||||
|
///
|
||||||
|
DatabaseSetup get registerHlcFunction {
|
||||||
|
// if (_initialized) return (database) {};
|
||||||
|
// _initialized = true;
|
||||||
|
|
||||||
|
return (database) {
|
||||||
|
database.createFunction(
|
||||||
|
functionName: nextHlcFunction,
|
||||||
|
function: (args) {
|
||||||
|
final nodeId = args.firstOrNull as String?;
|
||||||
|
if (nodeId == null) throw ArgumentError('nodeId is required');
|
||||||
|
return StatefulHlc.cached(nodeId).nextHlc().toString();
|
||||||
|
},
|
||||||
|
// argumentCount: const AllowedArgumentCount(1),
|
||||||
|
deterministic: false,
|
||||||
|
directOnly: false,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
61
lib/src/tables/compensation.dart
Normal file
61
lib/src/tables/compensation.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
/// CRDT Compensation rules.
|
||||||
|
///
|
||||||
|
/// This table stores the compensation rules for each table and column.
|
||||||
|
///
|
||||||
|
@DataClassName('CrdtCompensationEntry')
|
||||||
|
class CrdtCompensationTable extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => '__crdt_compensation';
|
||||||
|
|
||||||
|
/// Name of the table that data belongs to.
|
||||||
|
late final tblName = text().named('table_name')();
|
||||||
|
|
||||||
|
/// Name of the column this data belongs to.
|
||||||
|
late final columnName = text()();
|
||||||
|
|
||||||
|
/// Type of constraint this compensation rule applies to.
|
||||||
|
late final constraintType = textEnum<ConstraintType>()();
|
||||||
|
|
||||||
|
/// Compensation rule to preserve invariants.
|
||||||
|
late final compensationRule = textEnum<CompensationRule>()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {tblName, columnName, constraintType};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type of constraint this compensation rule applies to.
|
||||||
|
enum ConstraintType {
|
||||||
|
/// Unique constraint.
|
||||||
|
unique,
|
||||||
|
|
||||||
|
/// Foreign key constraint.
|
||||||
|
foreignKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Available compensation rules for CRDT operations.
|
||||||
|
enum CompensationRule {
|
||||||
|
/// No compensation rule.
|
||||||
|
none,
|
||||||
|
|
||||||
|
/// Handle unique key conflicts by merging values.
|
||||||
|
// ignore: constant_identifier_names
|
||||||
|
on_unique_conflict_merge,
|
||||||
|
|
||||||
|
/// Handle unique key conflicts by overwriting the existing value.
|
||||||
|
// ignore: constant_identifier_names
|
||||||
|
on_unique_conflict_overwrite,
|
||||||
|
|
||||||
|
/// Handle foreign key deletion by deleting this related row.
|
||||||
|
// ignore: constant_identifier_names
|
||||||
|
on_fk_deleted_delete_this,
|
||||||
|
|
||||||
|
/// Handle foreign key deletion by setting the relation on this row to null.
|
||||||
|
// ignore: constant_identifier_names
|
||||||
|
on_fk_deleted_set_null,
|
||||||
|
|
||||||
|
/// Handle foreign key deletion by restoring the related row.
|
||||||
|
// ignore: constant_identifier_names
|
||||||
|
on_fk_deleted_restore_related,
|
||||||
|
}
|
||||||
37
lib/src/tables/control.dart
Normal file
37
lib/src/tables/control.dart
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../hlc/converter.dart';
|
||||||
|
|
||||||
|
// Required for the generated code to know the HLC and converters.
|
||||||
|
export '../hlc/converter.dart';
|
||||||
|
|
||||||
|
/// CRDT control table.
|
||||||
|
///
|
||||||
|
/// This table stores metadata about the CRDT synchronization state for each user.
|
||||||
|
///
|
||||||
|
@DataClassName('CrdtControlEntry')
|
||||||
|
class CrdtControlTable extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => '__crdt_control';
|
||||||
|
|
||||||
|
/// Identifier for the user or client.
|
||||||
|
late final userId = text()();
|
||||||
|
|
||||||
|
/// Node identifier for the client.
|
||||||
|
late final nodeId = text()();
|
||||||
|
|
||||||
|
/// Whether CRDT triggers are currently enabled for this user.
|
||||||
|
late final crdtTriggersOn = boolean().withDefault(const Constant(true))();
|
||||||
|
|
||||||
|
/// The schema version of the CRDT data.
|
||||||
|
late final schemaVersion = integer()();
|
||||||
|
|
||||||
|
/// The last HLC timestamp when the user synchronized.
|
||||||
|
late final lastSyncHlc = text().nullable().map(nullableHlcConverter)();
|
||||||
|
|
||||||
|
/// The last HLC timestamp when merged changes were applied.
|
||||||
|
late final lastApplyHlc = text().nullable().map(nullableHlcConverter)();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {userId, nodeId};
|
||||||
|
}
|
||||||
58
lib/src/tables/data.dart
Normal file
58
lib/src/tables/data.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../hlc/converter.dart';
|
||||||
|
|
||||||
|
// Required for the generated code to know the HLC and converters.
|
||||||
|
export '../hlc/converter.dart';
|
||||||
|
|
||||||
|
/// CRDT data table.
|
||||||
|
///
|
||||||
|
/// This table stores the actual data encoded as JSON strings and all CRDT required
|
||||||
|
/// metadata to perform conflict resolution and synchronization. Schema is designed
|
||||||
|
/// to be generic enough to support various data types and structures. Stores the
|
||||||
|
/// data for all tables that are registered for CRDT synchronization, simplifying
|
||||||
|
/// the process of conflict resolution and data merging.
|
||||||
|
///
|
||||||
|
/// This same schema will be used on a client and server, with the difference that
|
||||||
|
/// the client will have the `user_id` column with a single value, while the server
|
||||||
|
/// will have all users' data in the same table. To avoid performance issues, on a
|
||||||
|
/// Postgres server, the `user_id` and `table_name` columns are partition keys.
|
||||||
|
///
|
||||||
|
@DataClassName('CrdtDataEntry')
|
||||||
|
class CrdtDataTable extends Table {
|
||||||
|
@override
|
||||||
|
String get tableName => '__crdt_data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isStrict => true;
|
||||||
|
|
||||||
|
// MAYBE: It seems it is not possible to have the userId column here without
|
||||||
|
// creating hard requirements on how each table should relate to the userId.
|
||||||
|
// Either all tables would have to have this column, or users would have to
|
||||||
|
// supply a query to join the userId with the changed table. Either way, it
|
||||||
|
// makes the module a lot less plug-and-play than it should, so it might be
|
||||||
|
// better to not have it here and accept the query performance penalty.
|
||||||
|
// /// Identifier for the user or client that owns the data.
|
||||||
|
// late final userId = text()();
|
||||||
|
|
||||||
|
/// Name of the table that data belongs to.
|
||||||
|
late final tblName = text().named('table_name')();
|
||||||
|
|
||||||
|
/// Name of the column this data belongs to.
|
||||||
|
late final columnName = text()();
|
||||||
|
|
||||||
|
/// Unique identifier for the row in the table.
|
||||||
|
late final rowId = text()();
|
||||||
|
|
||||||
|
/// Hybrid Logical Clock timestamp of the data update.
|
||||||
|
late final hlcTimestamp = text().map(hlcConverter)();
|
||||||
|
|
||||||
|
/// Raw value of the data.
|
||||||
|
late final rawValue = sqliteAny().nullable()();
|
||||||
|
|
||||||
|
/// Boolean indicating if the value has changed since last sync.
|
||||||
|
late final valueChanged = boolean().withDefault(const Constant(true))();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {/*userId,*/ tblName, columnName, rowId};
|
||||||
|
}
|
||||||
189
lib/src/triggers.dart
Normal file
189
lib/src/triggers.dart
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '/src/migrator.dart';
|
||||||
|
|
||||||
|
/// The name of the HLC function used in the database.
|
||||||
|
const nextHlcFunction = 'next_hlc_timestamp';
|
||||||
|
|
||||||
|
/// The operation that was performed on a row.
|
||||||
|
// ignore: public_member_api_docs
|
||||||
|
enum Operation { insert, update, delete }
|
||||||
|
|
||||||
|
// TODO: The [OfflineSyncTriggers] class should be reworked since the SQL statements
|
||||||
|
// now are no longer universal as the one developed originally.
|
||||||
|
|
||||||
|
/// Provides the CRDT control trigger statements for the database.
|
||||||
|
abstract class OfflineSyncTriggers {
|
||||||
|
/// Creates a new instance of [OfflineSyncTriggers] with the provided [migrator].
|
||||||
|
const OfflineSyncTriggers(this.migrator);
|
||||||
|
|
||||||
|
/// The migrator instance to apply the CRDT schema to.
|
||||||
|
final OfflineSyncMigrator migrator;
|
||||||
|
|
||||||
|
/// Prefix for all CRDT-related entities.
|
||||||
|
String get _prefix => '__crdt';
|
||||||
|
|
||||||
|
/// Name of the column that stores the information about whether the row is deleted.
|
||||||
|
String get isDeletedColumnName => '${_prefix}_is_deleted';
|
||||||
|
|
||||||
|
/// Name of the CRDT data table.
|
||||||
|
String get _crdtDataTableName => migrator.crdtDb.crdtDataTable.actualTableName;
|
||||||
|
|
||||||
|
/// The list of columns in the CRDT data table.
|
||||||
|
Iterable<String> get _crdtDataAllColumns =>
|
||||||
|
migrator.crdtDb.crdtDataTable.$columns.quotedNames;
|
||||||
|
|
||||||
|
/// Wraps the provided SQL statement as a CREATE TRIGGER statement.
|
||||||
|
///
|
||||||
|
/// The received [innerSql] will be expected to go inside a BEGIN...END block.
|
||||||
|
String wrapAsCreateTriggerStatement({
|
||||||
|
required Operation operation,
|
||||||
|
required String tableName,
|
||||||
|
required String triggerName,
|
||||||
|
required String innerSql,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Gets the name of the trigger for a specific operation on a table.
|
||||||
|
String _getTriggerName(TableInfo<Table, Object?> table, Operation operation) =>
|
||||||
|
'${_prefix}__${table.actualTableName}__${operation.name}';
|
||||||
|
|
||||||
|
/// Returns the SQL statement to insert a new row into the CRDT data table.
|
||||||
|
///
|
||||||
|
/// This statement is compatible with all major SQL dialects like SQLite,
|
||||||
|
/// Postgres, MySQL, SQL Server, etc. So the dialect-specific subclass only
|
||||||
|
/// needs to wrap it in the appropriate CREATE TRIGGER statement.
|
||||||
|
///
|
||||||
|
/// For some dialects, it will only be needed to enable support for quoted
|
||||||
|
/// identifiers that are used in the statement for maximum compatibility. On
|
||||||
|
/// MySQL this is done by setting the `SQL_MODE` to `ANSI_QUOTES` and on SQL
|
||||||
|
/// Server this is done by setting the `QUOTED_IDENTIFIER` option to `ON`.
|
||||||
|
String _getInsertStatement({
|
||||||
|
required TableInfo<Table, Object?> table,
|
||||||
|
required Operation operation,
|
||||||
|
}) {
|
||||||
|
final tableName = table.actualTableName;
|
||||||
|
|
||||||
|
final uniqueRowId =
|
||||||
|
table.getUniqueRowId(tableAlias: operation == Operation.insert ? 'NEW' : 'OLD');
|
||||||
|
|
||||||
|
final columnNamesWithoutPrimaryKey = operation == Operation.delete
|
||||||
|
? [isDeletedColumnName]
|
||||||
|
: table.$columns
|
||||||
|
.map((c) => c.$name)
|
||||||
|
// MAYBE: If we remove the primary key columns from the list, some tables
|
||||||
|
// might be completely ignored (like many-to-many relationship tables).
|
||||||
|
// Do we really need them? Probably yes if we want to avoid deserializing
|
||||||
|
// the split rowId into the original primary key columns, which can have
|
||||||
|
// different types.
|
||||||
|
// .where((c) => !table.$primaryKey.map((p) => p.$name).contains(c))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final columnInserts = columnNamesWithoutPrimaryKey.map((c) {
|
||||||
|
final rawValue = c == isDeletedColumnName ? 'TRUE' : 'NEW."$c"';
|
||||||
|
final valueChanged =
|
||||||
|
operation != Operation.update ? 'TRUE' : 'NEW."$c" IS NOT OLD."$c"';
|
||||||
|
|
||||||
|
return '''
|
||||||
|
SELECT
|
||||||
|
'$c' AS "column_name",
|
||||||
|
$rawValue AS "raw_value",
|
||||||
|
$valueChanged AS "value_changed"''';
|
||||||
|
});
|
||||||
|
|
||||||
|
final whereClause =
|
||||||
|
operation == Operation.update ? '\n WHERE (value_changed = TRUE)' : '';
|
||||||
|
|
||||||
|
return '''
|
||||||
|
INSERT OR REPLACE INTO $_crdtDataTableName (
|
||||||
|
${_crdtDataAllColumns.join(', ')}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'$tableName' AS "table_name",
|
||||||
|
column_name,
|
||||||
|
$uniqueRowId AS "row_id",
|
||||||
|
"hlc_timestamp",
|
||||||
|
raw_value,
|
||||||
|
value_changed
|
||||||
|
FROM (
|
||||||
|
SELECT $nextHlcFunction('${migrator.nodeId}') AS "hlc_timestamp"
|
||||||
|
), (
|
||||||
|
${columnInserts.join('\n UNION ALL\n')}
|
||||||
|
)$whereClause;''';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the SQL statements to create the triggers for the changelog table.
|
||||||
|
List<String> generateCreateTriggerStatements(TableInfo<Table, Object?> table) {
|
||||||
|
return [
|
||||||
|
for (final op in Operation.values)
|
||||||
|
wrapAsCreateTriggerStatement(
|
||||||
|
operation: op,
|
||||||
|
tableName: table.actualTableName,
|
||||||
|
triggerName: _getTriggerName(table, op),
|
||||||
|
innerSql: _getInsertStatement(
|
||||||
|
table: table,
|
||||||
|
operation: op,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provides the CRDT control trigger statements for the SQLite3 database.
|
||||||
|
class Sqlite3OfflineSyncTriggers extends OfflineSyncTriggers {
|
||||||
|
/// Creates a new instance of [Sqlite3OfflineSyncTriggers].
|
||||||
|
Sqlite3OfflineSyncTriggers(super.migrator);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String wrapAsCreateTriggerStatement({
|
||||||
|
required Operation operation,
|
||||||
|
required String tableName,
|
||||||
|
required String triggerName,
|
||||||
|
required String innerSql,
|
||||||
|
}) =>
|
||||||
|
'''
|
||||||
|
CREATE TRIGGER IF NOT EXISTS $triggerName
|
||||||
|
AFTER ${operation.name.toUpperCase()} ON "$tableName"
|
||||||
|
BEGIN
|
||||||
|
$innerSql
|
||||||
|
END;''';
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on TableInfo {
|
||||||
|
/// Returns an expression that uniquely identifies a row in the table.
|
||||||
|
///
|
||||||
|
/// If the table has a single primary key column, the expression will be the
|
||||||
|
/// column value. If it has multiple primary key columns, the values for all
|
||||||
|
/// will be concatenated using the `_||_` operator.
|
||||||
|
String getUniqueRowId({required String tableAlias}) {
|
||||||
|
if ($primaryKey.isEmpty) {
|
||||||
|
throw StateError('Table $actualTableName has no primary key and is therefore '
|
||||||
|
'not supported to track changes to it.');
|
||||||
|
}
|
||||||
|
if ($primaryKey.length == 1) {
|
||||||
|
return '$tableAlias."${$primaryKey.first.$name}"';
|
||||||
|
}
|
||||||
|
final columnNames = $primaryKey.map((c) => '$tableAlias."${c.$name}"').join(', ');
|
||||||
|
return "CONCAT_WS('_||_', $columnNames)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on Iterable<GeneratedColumn> {
|
||||||
|
/// Returns the column names with quotes for safer SQL queries.
|
||||||
|
Iterable<String> get quotedNames => map((c) => '"${c.$name}"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension methods for the [GeneratedDatabase] class to get the CRDT trigger names.
|
||||||
|
extension GetDatabaseCrdtTriggers on GeneratedDatabase {
|
||||||
|
/// Returns the names of the CRDT triggers in the database.
|
||||||
|
Future<List<String>> getCrdtTriggers() async {
|
||||||
|
final result = await customSelect(
|
||||||
|
'''\
|
||||||
|
SELECT type, name, sql
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE (type = "trigger")
|
||||||
|
AND (name LIKE "%%__crdt__%%");''',
|
||||||
|
).get();
|
||||||
|
|
||||||
|
return result.map((row) => row.read<String>('name')).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
19
pubspec.yaml
Normal file
19
pubspec.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
name: drift_offline_first
|
||||||
|
description: A Drift plugin to build offline-first applications with CRDT support.
|
||||||
|
version: 0.0.1
|
||||||
|
repository: https://github.com/marcelomendoncasoares/drift_offline_first
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.3.0
|
||||||
|
|
||||||
|
# TODO: Relax the package constraints to expand compatibility.
|
||||||
|
dependencies:
|
||||||
|
crdt: ^5.1.3
|
||||||
|
drift: ^2.28.0
|
||||||
|
uuid: ^4.5.1
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
build_runner: ^2.6.0
|
||||||
|
drift_dev: ^2.28.0
|
||||||
|
lints: ^6.0.0
|
||||||
|
test: ^1.26.2
|
||||||
98
query_examples.sql
Normal file
98
query_examples.sql
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
UPDATE crdt_metadata
|
||||||
|
SET `value` = d.`value`
|
||||||
|
FROM crdt_metadata AS s
|
||||||
|
JOIN (
|
||||||
|
-- Set multiple fields of the entities that match the where clause
|
||||||
|
SELECT '__is_deleted__' AS `column`, 'TRUE' AS `value`
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'reference_id', NULL;
|
||||||
|
) AS d ON s.`column` = d.`column`
|
||||||
|
WHERE (
|
||||||
|
`table` = 'category' AND
|
||||||
|
`column` = 'parent_id' AND
|
||||||
|
`value` = 'THIS_ENTITY_ROW_ID'
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO crdt_metadata ("table", "column", "value")
|
||||||
|
SELECT
|
||||||
|
'category' AS "table",
|
||||||
|
d."column" AS "column",
|
||||||
|
d."value" AS "value"
|
||||||
|
FROM (
|
||||||
|
SELECT '__is_deleted__' AS "column", 'TRUE' AS "value"
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'reference_id', NULL
|
||||||
|
) AS d
|
||||||
|
ON CONFLICT("table", "column") DO UPDATE
|
||||||
|
SET "value" = EXCLUDED."value";
|
||||||
|
|
||||||
|
INSERT INTO crdt_metadata ("table", "column", "value")
|
||||||
|
VALUES
|
||||||
|
('category', '__is_deleted__', 'TRUE'),
|
||||||
|
('category', 'reference_id', NULL)
|
||||||
|
ON CONFLICT("table", "column") DO UPDATE
|
||||||
|
SET "value" = excluded."value";
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO __crdt_data (
|
||||||
|
'user_id',
|
||||||
|
'table_name',
|
||||||
|
'column_name',
|
||||||
|
'row_id',
|
||||||
|
'hlc_timestamp',
|
||||||
|
'raw_value',
|
||||||
|
'value_changed'
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(?, 'category', '__is_deleted__', ?, ?, ?, 1),
|
||||||
|
(?, 'category', 'reference_id', ?, ?, ?, 1)
|
||||||
|
ON CONFLICT ('user_id', 'table_name', 'column_name', 'row_id') DO UPDATE
|
||||||
|
SET
|
||||||
|
'hlc_timestamp' = excluded.'hlc_timestamp',
|
||||||
|
'raw_value' = excluded.'raw_value',
|
||||||
|
'value_changed' = excluded.'value_changed';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
WITH hlc AS (SELECT $nextHlcFunction() AS ts)
|
||||||
|
INSERT INTO __crdt_data (
|
||||||
|
'table_name', 'column_name', 'row_id', 'hlc_timestamp', 'raw_value', 'value_changed'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'$table_name', d.'column_name', OLD.'$id_field', hlc.ts, d.'raw_value', d.'value_changed'
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
'$column_name' AS 'column_name',
|
||||||
|
'?1' AS 'row_id',
|
||||||
|
'?2' AS 'raw_value',
|
||||||
|
'TRUE' AS 'value_changed'
|
||||||
|
) as d, hlc
|
||||||
|
ON CONFLICT ('table_name', 'column_name', 'row_id') DO UPDATE
|
||||||
|
SET
|
||||||
|
'hlc_timestamp' = excluded.'hlc_timestamp',
|
||||||
|
'raw_value' = excluded.'raw_value',
|
||||||
|
'value_changed' = excluded.'value_changed';
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TRIGGER my_trigger
|
||||||
|
AFTER INSERT ON '$table_name'
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO __crdt_data (
|
||||||
|
'table_name', 'column_name', 'row_id', 'hlc_timestamp', 'raw_value', 'value_changed'
|
||||||
|
)
|
||||||
|
SELECT * FROM (
|
||||||
|
WITH hlc AS (SELECT $nextHlcFunction() AS ts)
|
||||||
|
SELECT
|
||||||
|
'$table_name' AS 'table_name',
|
||||||
|
'$column_name' AS 'column_name',
|
||||||
|
OLD.'$id_field' AS 'row_id',
|
||||||
|
hlc.ts AS 'hlc_timestamp',
|
||||||
|
NEW.'$column_name' AS 'raw_value',
|
||||||
|
NEW.'$column_name' IS NOT OLD.'$column_name' AS 'value_changed'
|
||||||
|
FROM hlc
|
||||||
|
UNION ALL
|
||||||
|
[repeat for each column]
|
||||||
|
)
|
||||||
|
WHERE NEW.'$column_name' IS NOT OLD.'$column_name';
|
||||||
|
END;
|
||||||
35
test/triggers/trigger_creation_test.dart
Normal file
35
test/triggers/trigger_creation_test.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import 'package:drift_offline_first/src/triggers.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../utils/database.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Given a database with an OfflineSyncMigrator', () {
|
||||||
|
group('when running the migration', () {
|
||||||
|
setUp(() async {
|
||||||
|
// Insert a new row to trigger the migration.
|
||||||
|
await database.managers.todosTable.create(
|
||||||
|
(t) => t(content: 'test'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('then the triggers are created for every synchronized table.', () async {
|
||||||
|
final allTriggerNames = await database.getCrdtTriggers();
|
||||||
|
|
||||||
|
for (final table in database.createMigrator().synchronizedTables) {
|
||||||
|
final triggerNames = allTriggerNames
|
||||||
|
.where((name) => name.startsWith('__crdt__${table.actualTableName}__'))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
expect(triggerNames, contains('__crdt__${table.actualTableName}__insert'));
|
||||||
|
expect(triggerNames, contains('__crdt__${table.actualTableName}__update'));
|
||||||
|
expect(triggerNames, contains('__crdt__${table.actualTableName}__delete'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Add a test for the case where a table has no primary key.
|
||||||
|
// TODO: Add a test for the case where a table has multiple primary key columns.
|
||||||
|
// TODO: Add a test for the case where a table has only primary key columns.
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue