Initial support for generated columns

This commit is contained in:
Simon Binder 2021-11-07 21:20:12 +01:00
parent 5f8b74c8df
commit 3469bade62
No known key found for this signature in database
GPG Key ID: 7891917E4147B8C0
22 changed files with 332 additions and 106 deletions

View File

@ -7,6 +7,7 @@
Thanks to [@westito](https://github.com/westito). Thanks to [@westito](https://github.com/westito).
- Allow the generator to emit correct SQL code when using arrays with the - Allow the generator to emit correct SQL code when using arrays with the
`new_sql_code_generation` option in specific scenarios. `new_sql_code_generation` option in specific scenarios.
- Add the `generatedAs` method to declare generated columns for Dart tables.
- Improved support for pausing query stream subscriptions. Instead of buffering events, - Improved support for pausing query stream subscriptions. Instead of buffering events,
query streams will suspend fetching data if all listeners are paused. query streams will suspend fetching data if all listeners are paused.

View File

@ -62,32 +62,22 @@ typedef BlobColumn = Column<Uint8List?>;
/// A column that stores floating point numeric values. /// A column that stores floating point numeric values.
typedef RealColumn = Column<double?>; typedef RealColumn = Column<double?>;
class _BaseColumnBuilder<T> {}
/// A column builder is used to specify which columns should appear in a table. /// A column builder is used to specify which columns should appear in a table.
/// All of the methods defined in this class and its subclasses are not meant to /// All of the methods defined in this class and its subclasses are not meant to
/// be called at runtime. Instead, the generator will take a look at your /// be called at runtime. Instead, the generator will take a look at your
/// source code (specifically, it will analyze which of the methods you use) to /// source code (specifically, it will analyze which of the methods you use) to
/// figure out the column structure of a table. /// figure out the column structure of a table.
class ColumnBuilder<T> {} class ColumnBuilder<T> extends _BaseColumnBuilder<T> {}
/// A column builder for virtual, generated columns.
///
/// This is a different class so that some methods are not available
class VirtualColumnBuilder<T> extends _BaseColumnBuilder<T> {}
/// DSL extension to define a column inside a drift table. /// DSL extension to define a column inside a drift table.
extension BuildColumn<T> on ColumnBuilder<T> { extension BuildColumn<T> on ColumnBuilder<T> {
/// By default, the field name will be used as the column name, e.g.
/// `IntColumn get id = integer()` will have "id" as its associated name.
/// Columns made up of multiple words are expected to be in camelCase and will
/// be converted to snake_case (e.g. a getter called accountCreationDate will
/// result in an SQL column called account_creation_date).
/// To change this default behavior, use something like
/// `IntColumn get id = integer((c) => c.named('user_id'))`.
///
/// Note that using [named] __does not__ have an effect on the json key of an
/// object. To change the json key, annotate this column getter with
/// [JsonKey].
ColumnBuilder<T> named(String name) => _isGenerated();
/// Marks this column as nullable. Nullable columns should not appear in a
/// primary key. Columns are non-null by default.
ColumnBuilder<T?> nullable() => _isGenerated();
/// Tells drift to write a custom constraint after this column definition when /// Tells drift to write a custom constraint after this column definition when
/// writing this column, for instance in a CREATE TABLE statement. /// writing this column, for instance in a CREATE TABLE statement.
/// ///
@ -164,45 +154,6 @@ extension BuildColumn<T> on ColumnBuilder<T> {
/// apply the default value. /// apply the default value.
ColumnBuilder<T> clientDefault(T Function() onInsert) => _isGenerated(); ColumnBuilder<T> clientDefault(T Function() onInsert) => _isGenerated();
/// Uses a custom [converter] to store custom Dart objects in a single column
/// and automatically mapping them from and to sql.
///
/// An example might look like this:
/// ```dart
/// // this is the custom object with we want to store in a column. It
/// // can be as complex as you want it to be
/// class MyCustomObject {
/// final String data;
/// MyCustomObject(this.data);
/// }
///
/// class CustomConverter extends TypeConverter<MyCustomObject, String> {
/// // this class is responsible for turning a custom object into a string.
/// // this is easy here, but more complex objects could be serialized using
/// // json or any other method of your choice.
/// const CustomConverter();
/// @override
/// MyCustomObject mapToDart(String fromDb) {
/// return fromDb == null ? null : MyCustomObject(fromDb);
/// }
///
/// @override
/// String mapToSql(MyCustomObject value) {
/// return value?.data;
/// }
/// }
///
/// ```
///
/// In that case, you could have a table with this column
/// ```dart
/// TextColumn get custom => text().map(const CustomConverter())();
/// ```
/// The generated row class will then use a `MyFancyClass` instead of a
/// `String`, which would usually be used for [Table.text] columns.
ColumnBuilder<T> map<Dart>(TypeConverter<Dart, T> converter) =>
_isGenerated();
/// Adds a foreign-key reference from this column. /// Adds a foreign-key reference from this column.
/// ///
/// The [table] type must be a Dart class name defining a drift table. /// The [table] type must be a Dart class name defining a drift table.
@ -242,6 +193,104 @@ extension BuildColumn<T> on ColumnBuilder<T> {
_isGenerated(); _isGenerated();
} }
/// Declare a generated column.
///
/// Generated columns are backed by an expression, declared with
/// [generatedAs]:
///
/// ```dart
/// class Products extends Table {
/// TextColumn get name => text()();
///
/// RealColumn get price => real()();
/// RealColumn get discount => real()();
/// RealColumn get tax => real()();
/// RealColumn get netPrice => real().generatedAs(
/// price * (Constant(1) - discount) * (Constant(1) + tax))();
/// }
/// ```
///
/// Generated columns can either be `VIRTUAL` (the default) or `STORED`
/// (enabled with the [stored] parameter). Stored generated columns are
/// computed on each update and are stored in the database. Virtual columns
/// consume less space, but are re-computed on each read.
///
/// Generated columns can't be updated or inserted (neither with the Dart API
/// or though SQL queries), so they are not represented in companions.
///
/// __Important__: When a generated column can be nullable, don't forget to
/// call [BuildGeneralColumn.nullable] on it to reflect this in the generated
/// code.
/// Also, note that generated columns are part of your databases schema and
/// cannot be updated easily. When changing the [generatedAs] expression for a
/// column, you need to re-generate the table with a [TableMigration].
///
/// Note that generated columns are only available in sqlite3 version
/// `3.31.0`. When using `sqlite3_flutter_libs` or a web database, this is not
/// a problem.
VirtualColumnBuilder<T> generatedAs(Expression<T?> generatedAs,
{bool stored = false}) =>
_isGenerated();
}
/// Column builders available for both virtual and non-virtual columns.
extension BuildGeneralColumn<T> on _BaseColumnBuilder<T> {
/// By default, the field name will be used as the column name, e.g.
/// `IntColumn get id = integer()` will have "id" as its associated name.
/// Columns made up of multiple words are expected to be in camelCase and will
/// be converted to snake_case (e.g. a getter called accountCreationDate will
/// result in an SQL column called account_creation_date).
/// To change this default behavior, use something like
/// `IntColumn get id = integer((c) => c.named('user_id'))`.
///
/// Note that using [named] __does not__ have an effect on the json key of an
/// object. To change the json key, annotate this column getter with
/// [JsonKey].
ColumnBuilder<T> named(String name) => _isGenerated();
/// Marks this column as nullable. Nullable columns should not appear in a
/// primary key. Columns are non-null by default.
ColumnBuilder<T?> nullable() => _isGenerated();
/// Uses a custom [converter] to store custom Dart objects in a single column
/// and automatically mapping them from and to sql.
///
/// An example might look like this:
/// ```dart
/// // this is the custom object with we want to store in a column. It
/// // can be as complex as you want it to be
/// class MyCustomObject {
/// final String data;
/// MyCustomObject(this.data);
/// }
///
/// class CustomConverter extends TypeConverter<MyCustomObject, String> {
/// // this class is responsible for turning a custom object into a string.
/// // this is easy here, but more complex objects could be serialized using
/// // json or any other method of your choice.
/// const CustomConverter();
/// @override
/// MyCustomObject mapToDart(String fromDb) {
/// return fromDb == null ? null : MyCustomObject(fromDb);
/// }
///
/// @override
/// String mapToSql(MyCustomObject value) {
/// return value?.data;
/// }
/// }
///
/// ```
///
/// In that case, you could have a table with this column
/// ```dart
/// TextColumn get custom => text().map(const CustomConverter())();
/// ```
/// The generated row class will then use a `MyFancyClass` instead of a
/// `String`, which would usually be used for [Table.text] columns.
ColumnBuilder<T> map<Dart>(TypeConverter<Dart, T> converter) =>
_isGenerated();
/// Turns this column builder into a column. This method won't actually be /// Turns this column builder into a column. This method won't actually be
/// called in your code. Instead, the generator will take a look at your /// called in your code. Instead, the generator will take a look at your
/// source code to figure out your table structure. /// source code to figure out your table structure.

View File

@ -44,6 +44,10 @@ class GeneratedColumn<T> extends Column<T> {
/// The sql type name, such as TEXT for texts. /// The sql type name, such as TEXT for texts.
final String typeName; final String typeName;
/// If this column is generated (that is, it is a SQL expression of other)
/// columns, contains information about how to generate this column.
final GeneratedAs? generatedAs;
/// Whether a value is required for this column when inserting a new row. /// Whether a value is required for this column when inserting a new row.
final bool requiredDuringInsert; final bool requiredDuringInsert;
@ -67,6 +71,7 @@ class GeneratedColumn<T> extends Column<T> {
this.defaultValue, this.defaultValue,
this.additionalChecks, this.additionalChecks,
this.requiredDuringInsert = false, this.requiredDuringInsert = false,
this.generatedAs,
}) : _defaultConstraints = defaultConstraints; }) : _defaultConstraints = defaultConstraints;
/// Applies a type converter to this column. /// Applies a type converter to this column.
@ -86,6 +91,7 @@ class GeneratedColumn<T> extends Column<T> {
defaultValue, defaultValue,
additionalChecks, additionalChecks,
requiredDuringInsert, requiredDuringInsert,
generatedAs,
); );
} }
@ -111,6 +117,15 @@ class GeneratedColumn<T> extends Column<T> {
if (writeBrackets) into.buffer.write(')'); if (writeBrackets) into.buffer.write(')');
} }
final generated = generatedAs;
if (generated != null) {
into.buffer.write(' GENERATED ALWAYS AS (');
generated.generatedAs.writeInto(into);
into.buffer
..write(') ')
..write(generated.stored ? 'STORED' : 'VIRTUAL');
}
// these custom constraints refer to builtin constraints from drift // these custom constraints refer to builtin constraints from drift
if (_defaultConstraints != null) { if (_defaultConstraints != null) {
into.buffer into.buffer
@ -221,6 +236,7 @@ class GeneratedColumnWithTypeConverter<D, S> extends GeneratedColumn<S> {
Expression<S>? defaultValue, Expression<S>? defaultValue,
VerificationResult Function(S, VerificationMeta)? additionalChecks, VerificationResult Function(S, VerificationMeta)? additionalChecks,
bool requiredDuringInsert, bool requiredDuringInsert,
GeneratedAs? generatedAs,
) : super( ) : super(
name, name,
tableName, tableName,
@ -232,6 +248,7 @@ class GeneratedColumnWithTypeConverter<D, S> extends GeneratedColumn<S> {
defaultValue: defaultValue, defaultValue: defaultValue,
additionalChecks: additionalChecks, additionalChecks: additionalChecks,
requiredDuringInsert: requiredDuringInsert, requiredDuringInsert: requiredDuringInsert,
generatedAs: generatedAs,
); );
/// Compares this column against the mapped [dartValue]. /// Compares this column against the mapped [dartValue].
@ -241,3 +258,17 @@ class GeneratedColumnWithTypeConverter<D, S> extends GeneratedColumn<S> {
return equals(converter.mapToSql(dartValue) as S); return equals(converter.mapToSql(dartValue) as S);
} }
} }
/// Information filled out by the generator to support generated or virtual
/// columns.
class GeneratedAs {
/// The expression that this column evaluates to.
final Expression generatedAs;
/// Wehter this column is stored in the database, as opposed to being
/// `VIRTUAL` and evaluated on each read.
final bool stored;
/// Creates a [GeneratedAs] clause.
GeneratedAs(this.generatedAs, this.stored);
}

View File

@ -1,6 +1,6 @@
name: drift name: drift
description: Drift is a reactive library to store relational data in Dart and Flutter applications. description: Drift is a reactive library to store relational data in Dart and Flutter applications.
version: 1.0.0 version: 1.1.0-dev
repository: https://github.com/simolus3/moor repository: https://github.com/simolus3/moor
homepage: https://drift.simonbinder.eu/ homepage: https://drift.simonbinder.eu/
issue_tracker: https://github.com/simolus3/moor/issues issue_tracker: https://github.com/simolus3/moor/issues

View File

@ -38,6 +38,9 @@ class Categories extends Table with AutoIncrement {
text().named('desc').customConstraint('NOT NULL UNIQUE')(); text().named('desc').customConstraint('NOT NULL UNIQUE')();
IntColumn get priority => IntColumn get priority =>
intEnum<CategoryPriority>().withDefault(const Constant(0))(); intEnum<CategoryPriority>().withDefault(const Constant(0))();
TextColumn get descriptionInUpperCase =>
text().generatedAs(description.upper())();
} }
enum CategoryPriority { low, medium, high } enum CategoryPriority { low, medium, high }

View File

@ -11,8 +11,12 @@ class Category extends DataClass implements Insertable<Category> {
final int id; final int id;
final String description; final String description;
final CategoryPriority priority; final CategoryPriority priority;
final String descriptionInUpperCase;
Category( Category(
{required this.id, required this.description, required this.priority}); {required this.id,
required this.description,
required this.priority,
required this.descriptionInUpperCase});
factory Category.fromData(Map<String, dynamic> data, {String? prefix}) { factory Category.fromData(Map<String, dynamic> data, {String? prefix}) {
final effectivePrefix = prefix ?? ''; final effectivePrefix = prefix ?? '';
return Category( return Category(
@ -22,6 +26,8 @@ class Category extends DataClass implements Insertable<Category> {
.mapFromDatabaseResponse(data['${effectivePrefix}desc'])!, .mapFromDatabaseResponse(data['${effectivePrefix}desc'])!,
priority: $CategoriesTable.$converter0.mapToDart(const IntType() priority: $CategoriesTable.$converter0.mapToDart(const IntType()
.mapFromDatabaseResponse(data['${effectivePrefix}priority']))!, .mapFromDatabaseResponse(data['${effectivePrefix}priority']))!,
descriptionInUpperCase: const StringType().mapFromDatabaseResponse(
data['${effectivePrefix}description_in_upper_case'])!,
); );
} }
@override @override
@ -51,6 +57,8 @@ class Category extends DataClass implements Insertable<Category> {
id: serializer.fromJson<int>(json['id']), id: serializer.fromJson<int>(json['id']),
description: serializer.fromJson<String>(json['description']), description: serializer.fromJson<String>(json['description']),
priority: serializer.fromJson<CategoryPriority>(json['priority']), priority: serializer.fromJson<CategoryPriority>(json['priority']),
descriptionInUpperCase:
serializer.fromJson<String>(json['descriptionInUpperCase']),
); );
} }
factory Category.fromJsonString(String encodedJson, factory Category.fromJsonString(String encodedJson,
@ -65,35 +73,45 @@ class Category extends DataClass implements Insertable<Category> {
'id': serializer.toJson<int>(id), 'id': serializer.toJson<int>(id),
'description': serializer.toJson<String>(description), 'description': serializer.toJson<String>(description),
'priority': serializer.toJson<CategoryPriority>(priority), 'priority': serializer.toJson<CategoryPriority>(priority),
'descriptionInUpperCase':
serializer.toJson<String>(descriptionInUpperCase),
}; };
} }
Category copyWith( Category copyWith(
{int? id, String? description, CategoryPriority? priority}) => {int? id,
String? description,
CategoryPriority? priority,
String? descriptionInUpperCase}) =>
Category( Category(
id: id ?? this.id, id: id ?? this.id,
description: description ?? this.description, description: description ?? this.description,
priority: priority ?? this.priority, priority: priority ?? this.priority,
descriptionInUpperCase:
descriptionInUpperCase ?? this.descriptionInUpperCase,
); );
@override @override
String toString() { String toString() {
return (StringBuffer('Category(') return (StringBuffer('Category(')
..write('id: $id, ') ..write('id: $id, ')
..write('description: $description, ') ..write('description: $description, ')
..write('priority: $priority') ..write('priority: $priority, ')
..write('descriptionInUpperCase: $descriptionInUpperCase')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@override @override
int get hashCode => Object.hash(id, description, priority); int get hashCode =>
Object.hash(id, description, priority, descriptionInUpperCase);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
(other is Category && (other is Category &&
other.id == this.id && other.id == this.id &&
other.description == this.description && other.description == this.description &&
other.priority == this.priority); other.priority == this.priority &&
other.descriptionInUpperCase == this.descriptionInUpperCase);
} }
class CategoriesCompanion extends UpdateCompanion<Category> { class CategoriesCompanion extends UpdateCompanion<Category> {
@ -185,8 +203,16 @@ class $CategoriesTable extends Categories
requiredDuringInsert: false, requiredDuringInsert: false,
defaultValue: const Constant(0)) defaultValue: const Constant(0))
.withConverter<CategoryPriority>($CategoriesTable.$converter0); .withConverter<CategoryPriority>($CategoriesTable.$converter0);
final VerificationMeta _descriptionInUpperCaseMeta =
const VerificationMeta('descriptionInUpperCase');
late final GeneratedColumn<String?> descriptionInUpperCase =
GeneratedColumn<String?>('description_in_upper_case', aliasedName, false,
typeName: 'TEXT',
requiredDuringInsert: false,
generatedAs: GeneratedAs(description.upper(), false));
@override @override
List<GeneratedColumn> get $columns => [id, description, priority]; List<GeneratedColumn> get $columns =>
[id, description, priority, descriptionInUpperCase];
@override @override
String get aliasedName => _alias ?? 'categories'; String get aliasedName => _alias ?? 'categories';
@override @override
@ -206,6 +232,12 @@ class $CategoriesTable extends Categories
context.missing(_descriptionMeta); context.missing(_descriptionMeta);
} }
context.handle(_priorityMeta, const VerificationResult.success()); context.handle(_priorityMeta, const VerificationResult.success());
if (data.containsKey('description_in_upper_case')) {
context.handle(
_descriptionInUpperCaseMeta,
descriptionInUpperCase.isAcceptableOrUnknown(
data['description_in_upper_case']!, _descriptionInUpperCaseMeta));
}
return context; return context;
} }

View File

@ -81,6 +81,7 @@ void main() {
id: 3, id: 3,
description: 'description', description: 'description',
priority: CategoryPriority.low, priority: CategoryPriority.low,
descriptionInUpperCase: 'ignored',
); );
final companion = entry.toCompanion(false); final companion = entry.toCompanion(false);

View File

@ -386,6 +386,7 @@ void main() {
{ {
'id': 1, 'id': 1,
'desc': 'description', 'desc': 'description',
'description_in_upper_case': 'DESCRIPTION',
'priority': 1, 'priority': 1,
}, },
]), ]),

View File

@ -107,6 +107,7 @@ void main() {
id: 1, id: 1,
description: 'Description', description: 'Description',
priority: CategoryPriority.low, priority: CategoryPriority.low,
descriptionInUpperCase: 'DESCRIPTION',
), ),
); );
}, skip: ifOlderThanSqlite335()); }, skip: ifOlderThanSqlite335());

View File

@ -26,7 +26,8 @@ void main() {
'SELECT t.id AS "t.id", t.title AS "t.title", ' 'SELECT t.id AS "t.id", t.title AS "t.title", '
't.content AS "t.content", t.target_date AS "t.target_date", ' 't.content AS "t.content", t.target_date AS "t.target_date", '
't.category AS "t.category", c.id AS "c.id", c."desc" AS "c.desc", ' 't.category AS "t.category", c.id AS "c.id", c."desc" AS "c.desc", '
'c.priority AS "c.priority" ' 'c.priority AS "c.priority", '
'c.description_in_upper_case AS "c.description_in_upper_case" '
'FROM todos t LEFT OUTER JOIN categories c ON c.id = t.category;', 'FROM todos t LEFT OUTER JOIN categories c ON c.id = t.category;',
argThat(isEmpty))); argThat(isEmpty)));
}); });
@ -46,6 +47,7 @@ void main() {
't.category': 3, 't.category': 3,
'c.id': 3, 'c.id': 3,
'c.desc': 'description', 'c.desc': 'description',
'c.description_in_upper_case': 'DESCRIPTION',
'c.priority': 2, 'c.priority': 2,
} }
]); ]);
@ -74,6 +76,7 @@ void main() {
id: 3, id: 3,
description: 'description', description: 'description',
priority: CategoryPriority.high, priority: CategoryPriority.high,
descriptionInUpperCase: 'DESCRIPTION',
), ),
); );
@ -198,15 +201,22 @@ void main() {
when(executor.runSelect(any, any)).thenAnswer((_) async { when(executor.runSelect(any, any)).thenAnswer((_) async {
return [ return [
{'c.id': 3, 'c.desc': 'Description', 'c.priority': 1, 'c3': 11} {
'c.id': 3,
'c.desc': 'Description',
'c.description_in_upper_case': 'DESCRIPTION',
'c.priority': 1,
'c4': 11
}
]; ];
}); });
final result = await query.getSingle(); final result = await query.getSingle();
verify(executor.runSelect( verify(executor.runSelect(
'SELECT c.id AS "c.id", c."desc" AS "c.desc", c.priority AS "c.priority"' 'SELECT c.id AS "c.id", c."desc" AS "c.desc", '
', LENGTH(c."desc") AS "c3" ' 'c.priority AS "c.priority", c.description_in_upper_case AS '
'"c.description_in_upper_case", LENGTH(c."desc") AS "c4" '
'FROM categories c;', 'FROM categories c;',
[], [],
)); ));
@ -217,6 +227,7 @@ void main() {
Category( Category(
id: 3, id: 3,
description: 'Description', description: 'Description',
descriptionInUpperCase: 'DESCRIPTION',
priority: CategoryPriority.medium, priority: CategoryPriority.medium,
), ),
), ),
@ -239,7 +250,13 @@ void main() {
when(executor.runSelect(any, any)).thenAnswer((_) async { when(executor.runSelect(any, any)).thenAnswer((_) async {
return [ return [
{'c.id': 3, 'c.desc': 'Description', 'c.priority': 1, 'c3': 11} {
'c.id': 3,
'c.desc': 'Description',
'c.description_in_upper_case': 'DESCRIPTION',
'c.priority': 1,
'c4': 11,
},
]; ];
}); });
@ -247,7 +264,8 @@ void main() {
verify(executor.runSelect( verify(executor.runSelect(
'SELECT c.id AS "c.id", c."desc" AS "c.desc", c.priority AS "c.priority"' 'SELECT c.id AS "c.id", c."desc" AS "c.desc", c.priority AS "c.priority"'
', LENGTH(c."desc") AS "c3" ' ', c.description_in_upper_case AS "c.description_in_upper_case", '
'LENGTH(c."desc") AS "c4" '
'FROM categories c ' 'FROM categories c '
'INNER JOIN todos t ON c.id = t.category;', 'INNER JOIN todos t ON c.id = t.category;',
[], [],
@ -259,6 +277,7 @@ void main() {
Category( Category(
id: 3, id: 3,
description: 'Description', description: 'Description',
descriptionInUpperCase: 'DESCRIPTION',
priority: CategoryPriority.medium, priority: CategoryPriority.medium,
), ),
), ),
@ -287,7 +306,13 @@ void main() {
when(executor.runSelect(any, any)).thenAnswer((_) async { when(executor.runSelect(any, any)).thenAnswer((_) async {
return [ return [
{'c.id': 3, 'c.desc': 'desc', 'c.priority': 0, 'c3': 10} {
'c.id': 3,
'c.desc': 'desc',
'c.priority': 0,
'c4': 10,
'c.description_in_upper_case': 'DESC',
}
]; ];
}); });
@ -295,7 +320,9 @@ void main() {
verify(executor.runSelect( verify(executor.runSelect(
'SELECT c.id AS "c.id", c."desc" AS "c.desc", ' 'SELECT c.id AS "c.id", c."desc" AS "c.desc", '
'c.priority AS "c.priority", COUNT(t.id) AS "c3" ' 'c.priority AS "c.priority", '
'c.description_in_upper_case AS "c.description_in_upper_case", '
'COUNT(t.id) AS "c4" '
'FROM categories c INNER JOIN todos t ON t.category = c.id ' 'FROM categories c INNER JOIN todos t ON t.category = c.id '
'GROUP BY c.id HAVING COUNT(t.id) >= ?;', 'GROUP BY c.id HAVING COUNT(t.id) >= ?;',
[10])); [10]));
@ -306,6 +333,7 @@ void main() {
Category( Category(
id: 3, id: 3,
description: 'desc', description: 'desc',
descriptionInUpperCase: 'DESC',
priority: CategoryPriority.low, priority: CategoryPriority.low,
), ),
); );

View File

@ -30,7 +30,10 @@ void main() {
'CREATE TABLE IF NOT EXISTS categories ' 'CREATE TABLE IF NOT EXISTS categories '
'(id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, ' '(id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, '
'"desc" TEXT NOT NULL UNIQUE, ' '"desc" TEXT NOT NULL UNIQUE, '
'priority INTEGER NOT NULL DEFAULT 0);', 'priority INTEGER NOT NULL DEFAULT 0, '
'description_in_upper_case TEXT NOT NULL GENERATED ALWAYS AS '
'(UPPER("desc")) VIRTUAL'
');',
[])); []));
verify(mockExecutor.runCustom( verify(mockExecutor.runCustom(

View File

@ -175,6 +175,7 @@ void main() {
{ {
'id': 1, 'id': 1,
'desc': 'description', 'desc': 'description',
'description_in_upper_case': 'DESCRIPTION',
'priority': 2, 'priority': 2,
} }
]); ]);
@ -187,6 +188,7 @@ void main() {
Category( Category(
id: 1, id: 1,
description: 'description', description: 'description',
descriptionInUpperCase: 'DESCRIPTION',
priority: CategoryPriority.high, priority: CategoryPriority.high,
), ),
); );

View File

@ -88,7 +88,12 @@ void main() {
await first.first; // subscribe to first stream, then drop subscription await first.first; // subscribe to first stream, then drop subscription
when(executor.runSelect(any, any)).thenAnswer((_) => Future.value([ when(executor.runSelect(any, any)).thenAnswer((_) => Future.value([
{'id': 1, 'desc': 'd', 'priority': 0} {
'id': 1,
'desc': 'd',
'description_in_upper_case': 'D',
'priority': 0,
}
])); ]));
await db await db
.into(db.categories) .into(db.categories)
@ -104,7 +109,12 @@ void main() {
final subscription = first.listen((_) {}); final subscription = first.listen((_) {});
when(executor.runSelect(any, any)).thenAnswer((_) => Future.value([ when(executor.runSelect(any, any)).thenAnswer((_) => Future.value([
{'id': 1, 'desc': 'd', 'priority': 0} {
'id': 1,
'desc': 'd',
'description_in_upper_case': 'D',
'priority': 0,
}
])); ]));
await db await db
.into(db.categories) .into(db.categories)
@ -246,7 +256,12 @@ void main() {
// Return a new row on the next select // Return a new row on the next select
when(executor.runSelect(any, any)).thenAnswer((_) => Future.value([ when(executor.runSelect(any, any)).thenAnswer((_) => Future.value([
{'id': 1, 'desc': 'd', 'priority': 0} {
'id': 1,
'desc': 'd',
'description_in_upper_case': 'D',
'priority': 0,
}
])); ]));
db.markTablesUpdated([db.categories]); db.markTablesUpdated([db.categories]);
await pumpEventQueue(); await pumpEventQueue();

View File

@ -35,20 +35,15 @@ void main() {
}); });
test('can convert a companion to a row class', () { test('can convert a companion to a row class', () {
const companion = CategoriesCompanion( const companion = SharedTodosCompanion(
id: Value(3), todo: Value(3),
description: Value('description'), user: Value(4),
priority: Value(CategoryPriority.low),
); );
final user = db.categories.mapFromCompanion(companion); final user = db.sharedTodos.mapFromCompanion(companion);
expect( expect(
user, user,
Category( SharedTodo(todo: 3, user: 4),
id: 3,
description: 'description',
priority: CategoryPriority.low,
),
); );
}); });

View File

@ -27,6 +27,7 @@ const String _methodCustomConstraint = 'customConstraint';
const String _methodDefault = 'withDefault'; const String _methodDefault = 'withDefault';
const String _methodClientDefault = 'clientDefault'; const String _methodClientDefault = 'clientDefault';
const String _methodMap = 'map'; const String _methodMap = 'map';
const String _methodGenerated = 'generatedAs';
const String _errorMessage = 'This getter does not create a valid column that ' const String _errorMessage = 'This getter does not create a valid column that '
'can be parsed by moor. Please refer to the readme from moor to see how ' 'can be parsed by moor. Please refer to the readme from moor to see how '
@ -70,6 +71,7 @@ class ColumnParser {
Expression? clientDefaultExpression; Expression? clientDefaultExpression;
Expression? createdTypeConverter; Expression? createdTypeConverter;
DartType? typeConverterRuntime; DartType? typeConverterRuntime;
ColumnGeneratedAs? generatedAs;
var nullable = false; var nullable = false;
final foundFeatures = <ColumnFeature>[]; final foundFeatures = <ColumnFeature>[];
@ -249,6 +251,32 @@ class ColumnParser {
createdTypeConverter = expression; createdTypeConverter = expression;
typeConverterRuntime = type; typeConverterRuntime = type;
break; break;
case _methodGenerated:
Expression? generatedExpression;
var stored = false;
for (final expr in remainingExpr.argumentList.arguments) {
if (expr is NamedExpression && expr.name.label.name == 'stored') {
final storedValue = expr.expression;
if (storedValue is BooleanLiteral) {
stored = storedValue.value;
} else {
base.step.reportError(ErrorInDartCode(
message: 'Must be a boolean literal',
affectedNode: expr,
affectedElement: element,
));
}
} else {
generatedExpression = expr;
}
}
if (generatedExpression != null) {
final code = element.source!.contents.data
.substring(generatedExpression.offset, generatedExpression.end);
generatedAs = ColumnGeneratedAs(code, stored);
}
} }
// We're not at a starting method yet, so we need to go deeper! // We're not at a starting method yet, so we need to go deeper!
@ -322,10 +350,11 @@ class ColumnParser {
typeConverter: converter, typeConverter: converter,
declaration: DartColumnDeclaration(element, base.step.file), declaration: DartColumnDeclaration(element, base.step.file),
documentationComment: docString, documentationComment: docString,
generatedAs: generatedAs,
); );
} }
ColumnType _startMethodToColumnType(String startMethod) { ColumnType _startMethodToColumnType(String name) {
return const { return const {
startBool: ColumnType.boolean, startBool: ColumnType.boolean,
startString: ColumnType.text, startString: ColumnType.text,
@ -334,7 +363,7 @@ class ColumnParser {
startDateTime: ColumnType.datetime, startDateTime: ColumnType.datetime,
startBlob: ColumnType.blob, startBlob: ColumnType.blob,
startReal: ColumnType.real, startReal: ColumnType.real,
}[startMethod]!; }[name]!;
} }
String? _readJsonKey(Element getter) { String? _readJsonKey(Element getter) {

View File

@ -55,6 +55,7 @@ class CreateTableReader {
UsedTypeConverter? converter; UsedTypeConverter? converter;
String? defaultValue; String? defaultValue;
String? overriddenJsonKey; String? overriddenJsonKey;
ColumnGeneratedAs? generatedAs;
final typeName = column.definition?.typeName; final typeName = column.definition?.typeName;
@ -158,6 +159,7 @@ class CreateTableReader {
typeConverter: converter, typeConverter: converter,
overriddenJsonName: overriddenJsonKey, overriddenJsonName: overriddenJsonKey,
declaration: declaration, declaration: declaration,
generatedAs: generatedAs,
); );
foundColumns[column.name] = parsed; foundColumns[column.name] = parsed;

View File

@ -106,6 +106,10 @@ class MoorColumn implements HasDeclaration, HasType {
/// Stored as a multi line string with leading triple-slashes `///` for every line /// Stored as a multi line string with leading triple-slashes `///` for every line
final String? documentationComment; final String? documentationComment;
final ColumnGeneratedAs? generatedAs;
bool get isGenerated => generatedAs != null;
/// The column type from the dsl library. For instance, if a table has /// The column type from the dsl library. For instance, if a table has
/// declared an `IntColumn`, the matching dsl column name would also be an /// declared an `IntColumn`, the matching dsl column name would also be an
/// `IntColumn`. /// `IntColumn`.
@ -180,6 +184,7 @@ class MoorColumn implements HasDeclaration, HasType {
this.typeConverter, this.typeConverter,
this.declaration, this.declaration,
this.documentationComment, this.documentationComment,
this.generatedAs,
}); });
} }
@ -256,3 +261,10 @@ class ResolvedDartForeignKeyReference extends ColumnFeature {
ResolvedDartForeignKeyReference( ResolvedDartForeignKeyReference(
this.otherTable, this.otherColumn, this.onUpdate, this.onDelete); this.otherTable, this.otherColumn, this.onUpdate, this.onDelete);
} }
class ColumnGeneratedAs {
final String? dartExpression;
final bool stored;
ColumnGeneratedAs(this.dartExpression, this.stored);
}

View File

@ -10,7 +10,7 @@ class DartColumnDeclaration implements DartDeclaration, ColumnDeclaration {
@override @override
final SourceRange declaration; final SourceRange declaration;
/// In the Dart api, columns declared via getters. /// In the Dart api, columns are declared via getters.
@override @override
final Element element; final Element element;

View File

@ -169,7 +169,8 @@ class MoorTable extends MoorEntityWithResultSet {
if (column.defaultArgument != null || if (column.defaultArgument != null ||
column.clientDefaultCode != null || column.clientDefaultCode != null ||
column.nullable) { column.nullable ||
column.isGenerated) {
// default value would be applied, so it's not required for inserts // default value would be applied, so it's not required for inserts
return false; return false;
} }

View File

@ -211,6 +211,9 @@ class DataClassWriter {
..write('final map = <String, Expression> {};'); ..write('final map = <String, Expression> {};');
for (final column in table.columns) { for (final column in table.columns) {
// Generated column - cannot be used for inserts or updates
if (column.isGenerated) continue;
// We include all columns that are not null. If nullToAbsent is false, we // We include all columns that are not null. If nullToAbsent is false, we
// also include null columns. When generating NNBD code, we can include // also include null columns. When generating NNBD code, we can include
// non-nullable columns without an additional null check. // non-nullable columns without an additional null check.
@ -266,6 +269,9 @@ class DataClassWriter {
..write('('); ..write('(');
for (final column in table.columns) { for (final column in table.columns) {
// Generated columns are not parts of companions.
if (column.isGenerated) continue;
final dartName = column.dartGetterName; final dartName = column.dartGetterName;
_buffer _buffer
..write(dartName) ..write(dartName)

View File

@ -97,6 +97,16 @@ abstract class TableOrViewWriter {
additionalParams['clientDefault'] = column.clientDefaultCode!; additionalParams['clientDefault'] = column.clientDefaultCode!;
} }
if (column.generatedAs != null) {
final generateAs = column.generatedAs!;
final code = generateAs.dartExpression;
if (code != null) {
additionalParams['generatedAs'] =
'GeneratedAs($code, ${generateAs.stored})';
}
}
final innerType = column.innerColumnType(options); final innerType = column.innerColumnType(options);
var type = 'GeneratedColumn<$innerType>'; var type = 'GeneratedColumn<$innerType>';
expressionBuffer expressionBuffer

View File

@ -10,6 +10,11 @@ class UpdateCompanionWriter {
late StringBuffer _buffer; late StringBuffer _buffer;
late final List<MoorColumn> columns = [
for (final column in table.columns)
if (!column.isGenerated) column,
];
UpdateCompanionWriter(this.table, this.scope) { UpdateCompanionWriter(this.table, this.scope) {
_buffer = scope.leaf(); _buffer = scope.leaf();
} }
@ -36,7 +41,7 @@ class UpdateCompanionWriter {
} }
void _writeFields() { void _writeFields() {
for (final column in table.columns) { for (final column in columns) {
final modifier = scope.options.fieldModifier; final modifier = scope.options.fieldModifier;
final type = column.dartTypeCode(scope.generationOptions); final type = column.dartTypeCode(scope.generationOptions);
_buffer.write('$modifier Value<$type> ${column.dartGetterName};\n'); _buffer.write('$modifier Value<$type> ${column.dartGetterName};\n');
@ -49,7 +54,7 @@ class UpdateCompanionWriter {
} }
_buffer.write('${table.getNameForCompanionClass(scope.options)}({'); _buffer.write('${table.getNameForCompanionClass(scope.options)}({');
for (final column in table.columns) { for (final column in columns) {
_buffer.write('this.${column.dartGetterName} = const Value.absent(),'); _buffer.write('this.${column.dartGetterName} = const Value.absent(),');
} }
@ -73,7 +78,7 @@ class UpdateCompanionWriter {
// @required String b}): a = Value(a), b = Value(b); // @required String b}): a = Value(a), b = Value(b);
// We don't need to use this. for the initializers, Dart figures that out. // We don't need to use this. for the initializers, Dart figures that out.
for (final column in table.columns) { for (final column in columns) {
final param = column.dartGetterName; final param = column.dartGetterName;
if (table.isColumnRequiredForInsert(column)) { if (table.isColumnRequiredForInsert(column)) {
@ -106,9 +111,8 @@ class UpdateCompanionWriter {
void _writeCustomConstructor() { void _writeCustomConstructor() {
// Prefer a .custom constructor, unless there already is a field called // Prefer a .custom constructor, unless there already is a field called
// "custom", in which case we'll use createCustom // "custom", in which case we'll use createCustom
final constructorName = table.columns final constructorName =
.map((e) => e.dartGetterName) columns.map((e) => e.dartGetterName).any((name) => name == 'custom')
.any((name) => name == 'custom')
? 'createCustom' ? 'createCustom'
: 'custom'; : 'custom';
@ -117,7 +121,7 @@ class UpdateCompanionWriter {
..write('static Insertable<$dartTypeName> $constructorName') ..write('static Insertable<$dartTypeName> $constructorName')
..write('({'); ..write('({');
for (final column in table.columns) { for (final column in columns) {
// todo (breaking change): This should not consider type converters. // todo (breaking change): This should not consider type converters.
final typeName = column.dartTypeCode(scope.generationOptions); final typeName = column.dartTypeCode(scope.generationOptions);
final type = scope.nullableType('Expression<$typeName>'); final type = scope.nullableType('Expression<$typeName>');
@ -128,7 +132,7 @@ class UpdateCompanionWriter {
..write('}) {\n') ..write('}) {\n')
..write('return RawValuesInsertable({'); ..write('return RawValuesInsertable({');
for (final column in table.columns) { for (final column in columns) {
_buffer _buffer
..write('if (${column.dartGetterName} != null)') ..write('if (${column.dartGetterName} != null)')
..write(asDartLiteral(column.name.name)) ..write(asDartLiteral(column.name.name))
@ -143,7 +147,7 @@ class UpdateCompanionWriter {
..write(table.getNameForCompanionClass(scope.options)) ..write(table.getNameForCompanionClass(scope.options))
..write(' copyWith({'); ..write(' copyWith({');
var first = true; var first = true;
for (final column in table.columns) { for (final column in columns) {
if (!first) { if (!first) {
_buffer.write(', '); _buffer.write(', ');
} }
@ -157,7 +161,7 @@ class UpdateCompanionWriter {
_buffer _buffer
..write('}) {\n') // ..write('}) {\n') //
..write('return ${table.getNameForCompanionClass(scope.options)}('); ..write('return ${table.getNameForCompanionClass(scope.options)}(');
for (final column in table.columns) { for (final column in columns) {
final name = column.dartGetterName; final name = column.dartGetterName;
_buffer.write('$name: $name ?? this.$name,'); _buffer.write('$name: $name ?? this.$name,');
} }
@ -173,7 +177,7 @@ class UpdateCompanionWriter {
const locals = {'map', 'nullToAbsent', 'converter'}; const locals = {'map', 'nullToAbsent', 'converter'};
for (final column in table.columns) { for (final column in columns) {
final getterName = column.thisIfNeeded(locals); final getterName = column.thisIfNeeded(locals);
_buffer.write('if ($getterName.present) {'); _buffer.write('if ($getterName.present) {');
@ -212,7 +216,7 @@ class UpdateCompanionWriter {
void _writeToString() { void _writeToString() {
overrideToString( overrideToString(
table.getNameForCompanionClass(scope.options), table.getNameForCompanionClass(scope.options),
[for (final column in table.columns) column.dartGetterName], [for (final column in columns) column.dartGetterName],
_buffer, _buffer,
); );
} }
@ -237,7 +241,7 @@ class UpdateCompanionWriter {
final column = final column =
table.columns.firstWhereOrNull((e) => e.dartGetterName == field.name); table.columns.firstWhereOrNull((e) => e.dartGetterName == field.name);
if (column != null) { if (column != null && !column.isGenerated) {
final dartName = column.dartGetterName; final dartName = column.dartGetterName;
_buffer.write('$dartName: Value (_object.$dartName),\n'); _buffer.write('$dartName: Value (_object.$dartName),\n');
} }