add(conduit): refactoring conduit core
This commit is contained in:
parent
50322e71b2
commit
1e1d0ad6a3
35 changed files with 6456 additions and 0 deletions
4
packages/database/lib/db.dart
Normal file
4
packages/database/lib/db.dart
Normal file
|
@ -0,0 +1,4 @@
|
|||
export 'src/managed/managed.dart';
|
||||
export 'src/persistent_store/persistent_store.dart';
|
||||
export 'src/query/query.dart';
|
||||
export 'src/schema/schema.dart';
|
310
packages/database/lib/src/managed/attributes.dart
Normal file
310
packages/database/lib/src/managed/attributes.dart
Normal file
|
@ -0,0 +1,310 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:meta/meta_meta.dart';
|
||||
|
||||
/// Annotation to configure the table definition of a [ManagedObject].
|
||||
///
|
||||
/// Adding this metadata to a table definition (`T` in `ManagedObject<T>`) configures the behavior of the underlying table.
|
||||
/// For example:
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {}
|
||||
///
|
||||
/// @Table(name: "_Account");
|
||||
/// class _User {
|
||||
/// @primaryKey
|
||||
/// int id;
|
||||
///
|
||||
/// String name;
|
||||
/// String email;
|
||||
/// }
|
||||
class Table {
|
||||
/// Default constructor.
|
||||
///
|
||||
/// If [name] is provided, the name of the underlying table will be its value. Otherwise,
|
||||
/// the name of the underlying table matches the name of the table definition class.
|
||||
///
|
||||
/// See also [Table.unique] for the behavior of [uniquePropertySet].
|
||||
const Table({
|
||||
this.useSnakeCaseName = false,
|
||||
this.name,
|
||||
this.uniquePropertySet,
|
||||
this.useSnakeCaseColumnName = false,
|
||||
});
|
||||
|
||||
/// Configures each instance of a table definition to be unique for the combination of [properties].
|
||||
///
|
||||
/// Adding this metadata to a table definition requires that all instances of this type
|
||||
/// must be unique for the combined properties in [properties]. [properties] must contain symbolic names of
|
||||
/// properties declared in the table definition, and those properties must be either attributes
|
||||
/// or belongs-to relationship properties. See [Table] for example.
|
||||
const Table.unique(List<Symbol> properties)
|
||||
: this(uniquePropertySet: properties);
|
||||
|
||||
/// Each instance of the associated table definition is unique for these properties.
|
||||
///
|
||||
/// null if not set.
|
||||
final List<Symbol>? uniquePropertySet;
|
||||
|
||||
/// Useful to indicate using new snake_case naming convention if [name] is not set
|
||||
/// This property defaults to false to avoid breaking change ensuring backward compatibility
|
||||
final bool useSnakeCaseName;
|
||||
|
||||
/// The name of the underlying database table.
|
||||
///
|
||||
/// If this value is not set, the name defaults to the name of the table definition class using snake_case naming convention without the prefix '_' underscore.
|
||||
final String? name;
|
||||
|
||||
/// Useful to indicate using new snake_case naming convention for columns.
|
||||
/// This property defaults to false to avoid breaking change ensuring backward compatibility
|
||||
///
|
||||
/// If a column is annotated with `@Column()` with a non-`null` value for
|
||||
/// `name` or `useSnakeCaseName`, that value takes precedent.
|
||||
final bool useSnakeCaseColumnName;
|
||||
}
|
||||
|
||||
/// Possible values for a delete rule in a [Relate].
|
||||
enum DeleteRule {
|
||||
/// Prevents a delete operation if the would-be deleted [ManagedObject] still has references to this relationship.
|
||||
restrict,
|
||||
|
||||
/// All objects with a foreign key reference to the deleted object will also be deleted.
|
||||
cascade,
|
||||
|
||||
/// All objects with a foreign key reference to the deleted object will have that reference nullified.
|
||||
nullify,
|
||||
|
||||
/// All objects with a foreign key reference to the deleted object will have that reference set to the column's default value.
|
||||
setDefault
|
||||
}
|
||||
|
||||
/// Metadata to configure property of [ManagedObject] as a foreign key column.
|
||||
///
|
||||
/// A property in a [ManagedObject]'s table definition with this metadata will map to a database column
|
||||
/// that has a foreign key reference to the related [ManagedObject]. Relationships are made up of two [ManagedObject]s, where each
|
||||
/// has a property that refers to the other. Only one of those properties may have this metadata. The property with this metadata
|
||||
/// resolves to a column in the database. The relationship property without this metadata resolves to a row or rows in the database.
|
||||
class Relate {
|
||||
/// Creates an instance of this type.
|
||||
const Relate(
|
||||
this.inversePropertyName, {
|
||||
this.onDelete = DeleteRule.nullify,
|
||||
this.isRequired = false,
|
||||
});
|
||||
|
||||
const Relate.deferred(DeleteRule onDelete, {bool isRequired = false})
|
||||
: this(_deferredSymbol, onDelete: onDelete, isRequired: isRequired);
|
||||
|
||||
/// The symbol for the property in the related [ManagedObject].
|
||||
///
|
||||
/// This value must be the symbol for the property in the related [ManagedObject]. This creates the link between
|
||||
/// two sides of a relationship between a [ManagedObject].
|
||||
final Symbol inversePropertyName;
|
||||
|
||||
/// The delete rule to use when a related instance is deleted.
|
||||
///
|
||||
/// This rule dictates how the database should handle deleting objects that have relationships. See [DeleteRule] for possible options.
|
||||
///
|
||||
/// If [isRequired] is true, this value may not be [DeleteRule.nullify]. This value defaults to [DeleteRule.nullify].
|
||||
final DeleteRule onDelete;
|
||||
|
||||
/// Whether or not this relationship is required.
|
||||
///
|
||||
/// By default, [Relate] properties are not required to support the default value of [onDelete].
|
||||
/// By setting this value to true, an instance of this entity cannot be created without a valid value for the relationship property.
|
||||
final bool isRequired;
|
||||
|
||||
bool get isDeferred => inversePropertyName == _deferredSymbol;
|
||||
|
||||
static const Symbol _deferredSymbol = #mdrDeferred;
|
||||
}
|
||||
|
||||
/// Metadata to describe the behavior of the underlying database column of a persistent property in [ManagedObject] subclasses.
|
||||
///
|
||||
/// By default, declaring a property in a table definition will make it a database column
|
||||
/// and its database column will be derived from the property's type.
|
||||
/// If the property needs additional directives - like indexing or uniqueness - it should be annotated with an instance of this class.
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {}
|
||||
/// class _User {
|
||||
/// @primaryKey
|
||||
/// int id;
|
||||
///
|
||||
/// @Column(indexed: true, unique: true)
|
||||
/// String email;
|
||||
/// }
|
||||
class Column {
|
||||
/// Creates an instance of this type.
|
||||
///
|
||||
/// [defaultValue] is sent as-is to the database, therefore, if the default value is the integer value 2,
|
||||
/// pass the string "2". If the default value is a string, it must also be wrapped in single quotes: "'defaultValue'".
|
||||
const Column({
|
||||
this.databaseType,
|
||||
bool primaryKey = false,
|
||||
bool nullable = false,
|
||||
this.defaultValue,
|
||||
bool unique = false,
|
||||
bool indexed = false,
|
||||
bool omitByDefault = false,
|
||||
this.autoincrement = false,
|
||||
this.validators = const [],
|
||||
this.useSnakeCaseName,
|
||||
this.name,
|
||||
}) : isPrimaryKey = primaryKey,
|
||||
isNullable = nullable,
|
||||
isUnique = unique,
|
||||
isIndexed = indexed,
|
||||
shouldOmitByDefault = omitByDefault;
|
||||
|
||||
/// When true, indicates that this property is the primary key.
|
||||
///
|
||||
/// Only one property of a class may have primaryKey equal to true.
|
||||
final bool isPrimaryKey;
|
||||
|
||||
/// The type of the field in the database.
|
||||
///
|
||||
/// By default, the database column type is inferred from the Dart type of the property, e.g. a Dart [String] is a PostgreSQL text type.
|
||||
/// This allows you to override the default type mapping for the annotated property.
|
||||
final ManagedPropertyType? databaseType;
|
||||
|
||||
/// Indicates whether or not the property can be null or not.
|
||||
///
|
||||
/// By default, properties are not nullable.
|
||||
final bool isNullable;
|
||||
|
||||
/// The default value of the property.
|
||||
///
|
||||
/// By default, a property does not have a default property. This is a String to be interpreted by the database driver. For example,
|
||||
/// a PostgreSQL datetime column that defaults to the current time:
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {}
|
||||
/// class _User {
|
||||
/// @Column(defaultValue: "now()")
|
||||
/// DateTime createdDate;
|
||||
///
|
||||
/// ...
|
||||
/// }
|
||||
final String? defaultValue;
|
||||
|
||||
/// Whether or not the property is unique among all instances.
|
||||
///
|
||||
/// By default, properties are not unique.
|
||||
final bool isUnique;
|
||||
|
||||
/// Whether or not the backing database should generate an index for this property.
|
||||
///
|
||||
/// By default, properties are not indexed. Properties that are used often in database queries should be indexed.
|
||||
final bool isIndexed;
|
||||
|
||||
/// Whether or not fetching an instance of this type should include this property.
|
||||
///
|
||||
/// By default, all properties on a [ManagedObject] are returned if not specified (unless they are has-one or has-many relationship properties).
|
||||
/// This flag will remove the associated property from the result set unless it is explicitly specified by [Query.returningProperties].
|
||||
final bool shouldOmitByDefault;
|
||||
|
||||
/// A sequence generator will be used to generate the next value for this column when a row is inserted.
|
||||
///
|
||||
/// When this flag is true, the database will generate a value for this column on insert.
|
||||
final bool autoincrement;
|
||||
|
||||
/// A list of validators to apply to the annotated property.
|
||||
///
|
||||
/// Validators in this list will be applied to the annotated property.
|
||||
///
|
||||
/// When the data model is compiled, this list is combined with any `Validate` annotations on the annotated property.
|
||||
///
|
||||
final List<Validate> validators;
|
||||
|
||||
/// Useful to indicate using new snake_case naming convention if [name] is not set
|
||||
///
|
||||
/// This property defaults to null to delegate to [Table.useSnakeCaseColumnName]
|
||||
/// The default value, `null`, indicates that the behavior should be
|
||||
/// acquired from the [Table.useSnakeCaseColumnName] annotation on the
|
||||
/// enclosing class.
|
||||
final bool? useSnakeCaseName;
|
||||
|
||||
/// The name of the underlying column in table.
|
||||
///
|
||||
/// If this value is not set, the name defaults to the name of the model attribute using snake_case naming convention.
|
||||
final String? name;
|
||||
}
|
||||
|
||||
/// An annotation used to specify how a Model is serialized in API responses.
|
||||
@Target({TargetKind.classType})
|
||||
class ResponseModel {
|
||||
const ResponseModel({this.includeIfNullField = true});
|
||||
|
||||
/// Whether the serializer should include fields with `null` values in the
|
||||
/// serialized Model output.
|
||||
///
|
||||
/// If `true` (the default), all fields in the Model are written to JSON, even if they are
|
||||
/// `null`.
|
||||
///
|
||||
/// If a field is annotated with `@ResponseKey()` with a non-`null` value for
|
||||
/// `includeIfNull`, that value takes precedent.
|
||||
final bool includeIfNullField;
|
||||
}
|
||||
|
||||
/// An annotation used to specify how a field is serialized in API responses.
|
||||
@Target({TargetKind.field, TargetKind.getter, TargetKind.setter})
|
||||
class ResponseKey {
|
||||
const ResponseKey({this.name, this.includeIfNull});
|
||||
|
||||
/// The name to be used when serializing this field.
|
||||
///
|
||||
/// If this value is not set, the name defaults to [Column.name].
|
||||
final String? name;
|
||||
|
||||
/// Whether the serializer should include the field with `null` value in the
|
||||
/// serialized output.
|
||||
///
|
||||
/// If `true`, the serializer should include the field in the serialized
|
||||
/// output, even if the value is `null`.
|
||||
///
|
||||
/// The default value, `null`, indicates that the behavior should be
|
||||
/// acquired from the [ResponseModel.includeIfNullField] annotation on the
|
||||
/// enclosing class.
|
||||
final bool? includeIfNull;
|
||||
}
|
||||
|
||||
/// Annotation for [ManagedObject] properties that allows them to participate in [ManagedObject.asMap] and/or [ManagedObject.readFromMap].
|
||||
///
|
||||
/// See constructor.
|
||||
class Serialize {
|
||||
/// Annotates a [ManagedObject] property so it can be serialized.
|
||||
///
|
||||
/// A [ManagedObject] property declaration with this metadata will have its value encoded/decoded when
|
||||
/// converting the managed object to and from a [Map].
|
||||
///
|
||||
/// If [input] is true, this property's value is set when converting from a map.
|
||||
///
|
||||
/// If [output] is true, this property is in the map created by [ManagedObject.asMap].
|
||||
/// This key is only included if the value is non-null.
|
||||
///
|
||||
/// Both [input] and [output] default to true.
|
||||
const Serialize({bool input = true, bool output = true})
|
||||
: isAvailableAsInput = input,
|
||||
isAvailableAsOutput = output;
|
||||
|
||||
/// See constructor.
|
||||
final bool isAvailableAsInput;
|
||||
|
||||
/// See constructor.
|
||||
final bool isAvailableAsOutput;
|
||||
}
|
||||
|
||||
/// Primary key annotation for a ManagedObject table definition property.
|
||||
///
|
||||
/// This annotation is a convenience for the following annotation:
|
||||
///
|
||||
/// @Column(primaryKey: true, databaseType: ManagedPropertyType.bigInteger, autoincrement: true)
|
||||
/// int id;
|
||||
///
|
||||
/// The annotated property type must be [int].
|
||||
///
|
||||
/// The validator [Validate.constant] is automatically applied to a property with this annotation.
|
||||
const Column primaryKey = Column(
|
||||
primaryKey: true,
|
||||
databaseType: ManagedPropertyType.bigInteger,
|
||||
autoincrement: true,
|
||||
validators: [Validate.constant()],
|
||||
);
|
192
packages/database/lib/src/managed/backing.dart
Normal file
192
packages/database/lib/src/managed/backing.dart
Normal file
|
@ -0,0 +1,192 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/managed/relationship_type.dart';
|
||||
|
||||
final ArgumentError _invalidValueConstruction = ArgumentError(
|
||||
"Invalid property access when building 'Query.values'. "
|
||||
"May only assign values to properties backed by a column of the table being inserted into. "
|
||||
"This prohibits 'ManagedObject' and 'ManagedSet' properties, except for 'ManagedObject' "
|
||||
"properties with a 'Relate' annotation. For 'Relate' properties, you may only set their "
|
||||
"primary key property.");
|
||||
|
||||
class ManagedValueBacking extends ManagedBacking {
|
||||
@override
|
||||
Map<String, dynamic> contents = {};
|
||||
|
||||
@override
|
||||
dynamic valueForProperty(ManagedPropertyDescription property) {
|
||||
return contents[property.name];
|
||||
}
|
||||
|
||||
@override
|
||||
void setValueForProperty(ManagedPropertyDescription property, dynamic value) {
|
||||
if (value != null) {
|
||||
if (!property.isAssignableWith(value)) {
|
||||
throw ValidationException(
|
||||
["invalid input value for '${property.name}'"],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
contents[property.name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
class ManagedForeignKeyBuilderBacking extends ManagedBacking {
|
||||
ManagedForeignKeyBuilderBacking();
|
||||
ManagedForeignKeyBuilderBacking.from(
|
||||
ManagedEntity entity,
|
||||
ManagedBacking backing,
|
||||
) {
|
||||
if (backing.contents.containsKey(entity.primaryKey)) {
|
||||
contents[entity.primaryKey] = backing.contents[entity.primaryKey];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> contents = {};
|
||||
|
||||
@override
|
||||
dynamic valueForProperty(ManagedPropertyDescription property) {
|
||||
if (property is ManagedAttributeDescription && property.isPrimaryKey) {
|
||||
return contents[property.name];
|
||||
}
|
||||
|
||||
throw _invalidValueConstruction;
|
||||
}
|
||||
|
||||
@override
|
||||
void setValueForProperty(ManagedPropertyDescription property, dynamic value) {
|
||||
if (property is ManagedAttributeDescription && property.isPrimaryKey) {
|
||||
contents[property.name] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
throw _invalidValueConstruction;
|
||||
}
|
||||
}
|
||||
|
||||
class ManagedBuilderBacking extends ManagedBacking {
|
||||
ManagedBuilderBacking();
|
||||
ManagedBuilderBacking.from(ManagedEntity entity, ManagedBacking original) {
|
||||
if (original is! ManagedValueBacking) {
|
||||
throw ArgumentError(
|
||||
"Invalid 'ManagedObject' assignment to 'Query.values'. Object must be created through default constructor.",
|
||||
);
|
||||
}
|
||||
|
||||
original.contents.forEach((key, value) {
|
||||
final prop = entity.properties[key];
|
||||
if (prop == null) {
|
||||
throw ArgumentError(
|
||||
"Invalid 'ManagedObject' assignment to 'Query.values'. Property '$key' does not exist for '${entity.name}'.",
|
||||
);
|
||||
}
|
||||
|
||||
if (prop is ManagedRelationshipDescription) {
|
||||
if (!prop.isBelongsTo) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setValueForProperty(prop, value);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> contents = {};
|
||||
|
||||
@override
|
||||
dynamic valueForProperty(ManagedPropertyDescription property) {
|
||||
if (property is ManagedRelationshipDescription) {
|
||||
if (!property.isBelongsTo) {
|
||||
throw _invalidValueConstruction;
|
||||
}
|
||||
|
||||
if (!contents.containsKey(property.name)) {
|
||||
contents[property.name] = property.inverse!.entity
|
||||
.instanceOf(backing: ManagedForeignKeyBuilderBacking());
|
||||
}
|
||||
}
|
||||
|
||||
return contents[property.name];
|
||||
}
|
||||
|
||||
@override
|
||||
void setValueForProperty(ManagedPropertyDescription property, dynamic value) {
|
||||
if (property is ManagedRelationshipDescription) {
|
||||
if (!property.isBelongsTo) {
|
||||
throw _invalidValueConstruction;
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
contents[property.name] = null;
|
||||
} else {
|
||||
final original = value as ManagedObject;
|
||||
final replacementBacking = ManagedForeignKeyBuilderBacking.from(
|
||||
original.entity,
|
||||
original.backing,
|
||||
);
|
||||
final replacement =
|
||||
original.entity.instanceOf(backing: replacementBacking);
|
||||
contents[property.name] = replacement;
|
||||
}
|
||||
} else {
|
||||
contents[property.name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ManagedAccessTrackingBacking extends ManagedBacking {
|
||||
List<KeyPath> keyPaths = [];
|
||||
KeyPath? workingKeyPath;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get contents => {};
|
||||
|
||||
@override
|
||||
dynamic valueForProperty(ManagedPropertyDescription property) {
|
||||
if (workingKeyPath != null) {
|
||||
workingKeyPath!.add(property);
|
||||
|
||||
return forward(property, workingKeyPath);
|
||||
}
|
||||
|
||||
final keyPath = KeyPath(property);
|
||||
keyPaths.add(keyPath);
|
||||
|
||||
return forward(property, keyPath);
|
||||
}
|
||||
|
||||
@override
|
||||
void setValueForProperty(ManagedPropertyDescription property, dynamic value) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
dynamic forward(ManagedPropertyDescription property, KeyPath? keyPath) {
|
||||
if (property is ManagedRelationshipDescription) {
|
||||
final tracker = ManagedAccessTrackingBacking()..workingKeyPath = keyPath;
|
||||
if (property.relationshipType == ManagedRelationshipType.hasMany) {
|
||||
return property.inverse!.entity.setOf([]);
|
||||
} else {
|
||||
return property.destinationEntity.instanceOf(backing: tracker);
|
||||
}
|
||||
} else if (property is ManagedAttributeDescription &&
|
||||
property.type!.kind == ManagedPropertyType.document) {
|
||||
return DocumentAccessTracker(keyPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentAccessTracker extends Document {
|
||||
DocumentAccessTracker(this.owner);
|
||||
|
||||
final KeyPath? owner;
|
||||
|
||||
@override
|
||||
dynamic operator [](dynamic keyOrIndex) {
|
||||
owner!.addDynamicElement(keyOrIndex);
|
||||
return this;
|
||||
}
|
||||
}
|
184
packages/database/lib/src/managed/context.dart
Normal file
184
packages/database/lib/src/managed/context.dart
Normal file
|
@ -0,0 +1,184 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/src/managed/data_model_manager.dart' as mm;
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/persistent_store/persistent_store.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// A service object that handles connecting to and sending queries to a database.
|
||||
///
|
||||
/// You create objects of this type to use the Conduit ORM. Create instances in [ApplicationChannel.prepare]
|
||||
/// and inject them into controllers that execute database queries.
|
||||
///
|
||||
/// A context contains two types of objects:
|
||||
///
|
||||
/// - [PersistentStore] : Maintains a connection to a specific database. Transfers data between your application and the database.
|
||||
/// - [ManagedDataModel] : Contains information about the [ManagedObject] subclasses in your application.
|
||||
///
|
||||
/// Example usage:
|
||||
///
|
||||
/// class Channel extends ApplicationChannel {
|
||||
/// ManagedContext context;
|
||||
///
|
||||
/// @override
|
||||
/// Future prepare() async {
|
||||
/// var store = new PostgreSQLPersistentStore(...);
|
||||
/// var dataModel = new ManagedDataModel.fromCurrentMirrorSystem();
|
||||
/// context = new ManagedContext(dataModel, store);
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// Controller get entryPoint {
|
||||
/// final router = new Router();
|
||||
/// router.route("/path").link(() => new DBController(context));
|
||||
/// return router;
|
||||
/// }
|
||||
/// }
|
||||
class ManagedContext implements APIComponentDocumenter {
|
||||
/// Creates an instance of [ManagedContext] from a [ManagedDataModel] and [PersistentStore].
|
||||
///
|
||||
/// This is the default constructor.
|
||||
///
|
||||
/// A [Query] is sent to the database described by [persistentStore]. A [Query] may only be executed
|
||||
/// on this context if its type is in [dataModel].
|
||||
ManagedContext(this.dataModel, this.persistentStore) {
|
||||
mm.add(dataModel!);
|
||||
_finalizer.attach(this, persistentStore, detach: this);
|
||||
}
|
||||
|
||||
/// Creates a child context from [parentContext].
|
||||
ManagedContext.childOf(ManagedContext parentContext)
|
||||
: persistentStore = parentContext.persistentStore,
|
||||
dataModel = parentContext.dataModel;
|
||||
|
||||
static final Finalizer<PersistentStore> _finalizer =
|
||||
Finalizer((store) async => store.close());
|
||||
|
||||
/// The persistent store that [Query]s on this context are executed through.
|
||||
PersistentStore persistentStore;
|
||||
|
||||
/// The data model containing the [ManagedEntity]s that describe the [ManagedObject]s this instance works with.
|
||||
final ManagedDataModel? dataModel;
|
||||
|
||||
/// Runs all [Query]s in [transactionBlock] within a database transaction.
|
||||
///
|
||||
/// Queries executed within [transactionBlock] will be executed as a database transaction.
|
||||
/// A [transactionBlock] is passed a [ManagedContext] that must be the target of all queries
|
||||
/// within the block. The context passed to the [transactionBlock] is *not* the same as
|
||||
/// the context the transaction was created from.
|
||||
///
|
||||
/// *You must not use the context this method was invoked on inside the transactionBlock.
|
||||
/// Doing so will deadlock your application.*
|
||||
///
|
||||
/// If an exception is encountered in [transactionBlock], any query that has already been
|
||||
/// executed will be rolled back and this method will rethrow the exception.
|
||||
///
|
||||
/// You may manually rollback a query by throwing a [Rollback] object. This will exit the
|
||||
/// [transactionBlock], roll back any changes made in the transaction, but this method will not
|
||||
/// throw.
|
||||
///
|
||||
/// TODO: the following statement is not true.
|
||||
/// Rollback takes a string but the transaction
|
||||
/// returns <T>. It would seem to be a better idea to still throw the manual Rollback
|
||||
/// so the user has a consistent method of handling the rollback. We could add a property
|
||||
/// to the Rollback class 'manual' which would be used to indicate a manual rollback.
|
||||
/// For the moment I've changed the return type to Future<void> as
|
||||
/// The parameter passed to [Rollback]'s constructor will be returned from this method
|
||||
/// so that the caller can determine why the transaction was rolled back.
|
||||
///
|
||||
/// Example usage:
|
||||
///
|
||||
/// await context.transaction((transaction) async {
|
||||
/// final q = new Query<Model>(transaction)
|
||||
/// ..values = someObject;
|
||||
/// await q.insert();
|
||||
/// ...
|
||||
/// });
|
||||
Future<T> transaction<T>(
|
||||
Future<T> Function(ManagedContext transaction) transactionBlock,
|
||||
) {
|
||||
return persistentStore.transaction(
|
||||
ManagedContext.childOf(this),
|
||||
transactionBlock,
|
||||
);
|
||||
}
|
||||
|
||||
/// Closes this context and release its underlying resources.
|
||||
///
|
||||
/// This method closes the connection to [persistentStore] and releases [dataModel].
|
||||
/// A context may not be reused once it has been closed.
|
||||
Future close() async {
|
||||
await persistentStore.close();
|
||||
_finalizer.detach(this);
|
||||
mm.remove(dataModel!);
|
||||
}
|
||||
|
||||
/// Returns an entity for a type from [dataModel].
|
||||
///
|
||||
/// See [ManagedDataModel.entityForType].
|
||||
ManagedEntity entityForType(Type type) {
|
||||
return dataModel!.entityForType(type);
|
||||
}
|
||||
|
||||
/// Inserts a single [object] into this context.
|
||||
///
|
||||
/// This method is equivalent shorthand for [Query.insert].
|
||||
Future<T> insertObject<T extends ManagedObject>(T object) {
|
||||
final query = Query<T>(this)..values = object;
|
||||
return query.insert();
|
||||
}
|
||||
|
||||
/// Inserts each object in [objects] into this context.
|
||||
///
|
||||
/// If any insertion fails, no objects will be inserted into the database and an exception
|
||||
/// is thrown.
|
||||
Future<List<T>> insertObjects<T extends ManagedObject>(
|
||||
List<T> objects,
|
||||
) async {
|
||||
return Query<T>(this).insertMany(objects);
|
||||
}
|
||||
|
||||
/// Returns an object of type [T] from this context if it exists, otherwise returns null.
|
||||
///
|
||||
/// If [T] cannot be inferred, an error is thrown. If [identifier] is not the same type as [T]'s primary key,
|
||||
/// null is returned.
|
||||
Future<T?> fetchObjectWithID<T extends ManagedObject>(
|
||||
dynamic identifier,
|
||||
) async {
|
||||
final entity = dataModel!.tryEntityForType(T);
|
||||
if (entity == null) {
|
||||
throw ArgumentError("Unknown entity '$T' in fetchObjectWithID. "
|
||||
"Provide a type to this method and ensure it is in this context's data model.");
|
||||
}
|
||||
|
||||
final primaryKey = entity.primaryKeyAttribute!;
|
||||
if (!primaryKey.type!.isAssignableWith(identifier)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final query = Query<T>(this)
|
||||
..where((o) => o[primaryKey.name]).equalTo(identifier);
|
||||
return query.fetchOne();
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) =>
|
||||
dataModel!.documentComponents(context);
|
||||
}
|
||||
|
||||
/// Throw this object to roll back a [ManagedContext.transaction].
|
||||
///
|
||||
/// When thrown in a transaction, it will cancel an in-progress transaction and rollback
|
||||
/// any changes it has made.
|
||||
class Rollback {
|
||||
/// Default constructor, takes a [reason] object that can be anything.
|
||||
///
|
||||
/// The parameter [reason] will be returned by [ManagedContext.transaction].
|
||||
Rollback(this.reason);
|
||||
|
||||
/// The reason this rollback occurred.
|
||||
///
|
||||
/// This value is returned from [ManagedContext.transaction] when this instance is thrown.
|
||||
final String reason;
|
||||
}
|
121
packages/database/lib/src/managed/data_model.dart
Normal file
121
packages/database/lib/src/managed/data_model.dart
Normal file
|
@ -0,0 +1,121 @@
|
|||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
|
||||
/// Instances of this class contain descriptions and metadata for mapping [ManagedObject]s to database rows.
|
||||
///
|
||||
/// An instance of this type must be used to initialize a [ManagedContext], and so are required to use [Query]s.
|
||||
///
|
||||
/// The [ManagedDataModel.fromCurrentMirrorSystem] constructor will reflect on an application's code and find
|
||||
/// all subclasses of [ManagedObject], building a [ManagedEntity] for each.
|
||||
///
|
||||
/// Most applications do not need to access instances of this type.
|
||||
///
|
||||
class ManagedDataModel extends Object implements APIComponentDocumenter {
|
||||
/// Creates an instance of [ManagedDataModel] from a list of types that extend [ManagedObject]. It is preferable
|
||||
/// to use [ManagedDataModel.fromCurrentMirrorSystem] over this method.
|
||||
///
|
||||
/// To register a class as a managed object within this data model, you must include its type in the list. Example:
|
||||
///
|
||||
/// new DataModel([User, Token, Post]);
|
||||
ManagedDataModel(List<Type> instanceTypes) {
|
||||
final runtimes = RuntimeContext.current.runtimes.iterable
|
||||
.whereType<ManagedEntityRuntime>()
|
||||
.toList();
|
||||
final expectedRuntimes = instanceTypes
|
||||
.map(
|
||||
(t) => runtimes.firstWhereOrNull((e) => e.entity.instanceType == t),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (expectedRuntimes.any((e) => e == null)) {
|
||||
throw ManagedDataModelError(
|
||||
"Data model types were not found!",
|
||||
);
|
||||
}
|
||||
|
||||
for (final runtime in expectedRuntimes) {
|
||||
_entities[runtime!.entity.instanceType] = runtime.entity;
|
||||
_tableDefinitionToEntityMap[runtime.entity.tableDefinition] =
|
||||
runtime.entity;
|
||||
}
|
||||
for (final runtime in expectedRuntimes) {
|
||||
runtime!.finalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an instance of a [ManagedDataModel] from all subclasses of [ManagedObject] in all libraries visible to the calling library.
|
||||
///
|
||||
/// This constructor will search every available package and file library that is visible to the library
|
||||
/// that runs this constructor for subclasses of [ManagedObject]. A [ManagedEntity] will be created
|
||||
/// and stored in this instance for every such class found.
|
||||
///
|
||||
/// Standard Dart libraries (prefixed with 'dart:') and URL-encoded libraries (prefixed with 'data:') are not searched.
|
||||
///
|
||||
/// This is the preferred method of instantiating this type.
|
||||
ManagedDataModel.fromCurrentMirrorSystem() {
|
||||
final runtimes = RuntimeContext.current.runtimes.iterable
|
||||
.whereType<ManagedEntityRuntime>();
|
||||
|
||||
for (final runtime in runtimes) {
|
||||
_entities[runtime.entity.instanceType] = runtime.entity;
|
||||
_tableDefinitionToEntityMap[runtime.entity.tableDefinition] =
|
||||
runtime.entity;
|
||||
}
|
||||
for (final runtime in runtimes) {
|
||||
runtime.finalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<ManagedEntity> get entities => _entities.values;
|
||||
final Map<Type, ManagedEntity> _entities = {};
|
||||
final Map<String, ManagedEntity> _tableDefinitionToEntityMap = {};
|
||||
|
||||
/// Returns a [ManagedEntity] for a [Type].
|
||||
///
|
||||
/// [type] may be either a subclass of [ManagedObject] or a [ManagedObject]'s table definition. For example, the following
|
||||
/// definition, you could retrieve its entity by passing MyModel or _MyModel as an argument to this method:
|
||||
///
|
||||
/// class MyModel extends ManagedObject<_MyModel> implements _MyModel {}
|
||||
/// class _MyModel {
|
||||
/// @primaryKey
|
||||
/// int id;
|
||||
/// }
|
||||
/// If the [type] has no known [ManagedEntity] then a [StateError] is thrown.
|
||||
/// Use [tryEntityForType] to test if an entity exists.
|
||||
ManagedEntity entityForType(Type type) {
|
||||
final entity = tryEntityForType(type);
|
||||
|
||||
if (entity == null) {
|
||||
throw StateError(
|
||||
"No entity found for '$type. Did you forget to create a 'ManagedContext'?",
|
||||
);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
ManagedEntity? tryEntityForType(Type type) =>
|
||||
_entities[type] ?? _tableDefinitionToEntityMap[type.toString()];
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) {
|
||||
for (final e in entities) {
|
||||
e.documentComponents(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thrown when a [ManagedDataModel] encounters an error.
|
||||
class ManagedDataModelError extends Error {
|
||||
ManagedDataModelError(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "Data Model Error: $message";
|
||||
}
|
||||
}
|
37
packages/database/lib/src/managed/data_model_manager.dart
Normal file
37
packages/database/lib/src/managed/data_model_manager.dart
Normal file
|
@ -0,0 +1,37 @@
|
|||
import 'package:protevus_database/src/managed/data_model.dart';
|
||||
import 'package:protevus_database/src/managed/entity.dart';
|
||||
|
||||
Map<ManagedDataModel, int> _dataModels = {};
|
||||
|
||||
ManagedEntity findEntity(
|
||||
Type type, {
|
||||
ManagedEntity Function()? orElse,
|
||||
}) {
|
||||
for (final d in _dataModels.keys) {
|
||||
final entity = d.tryEntityForType(type);
|
||||
if (entity != null) {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
if (orElse == null) {
|
||||
throw StateError(
|
||||
"No entity found for '$type. Did you forget to create a 'ManagedContext'?",
|
||||
);
|
||||
}
|
||||
|
||||
return orElse();
|
||||
}
|
||||
|
||||
void add(ManagedDataModel dataModel) {
|
||||
_dataModels.update(dataModel, (count) => count + 1, ifAbsent: () => 1);
|
||||
}
|
||||
|
||||
void remove(ManagedDataModel dataModel) {
|
||||
if (_dataModels[dataModel] != null) {
|
||||
_dataModels.update(dataModel, (count) => count - 1);
|
||||
if (_dataModels[dataModel]! < 1) {
|
||||
_dataModels.remove(dataModel);
|
||||
}
|
||||
}
|
||||
}
|
57
packages/database/lib/src/managed/document.dart
Normal file
57
packages/database/lib/src/managed/document.dart
Normal file
|
@ -0,0 +1,57 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
|
||||
/// Allows storage of unstructured data in a [ManagedObject] property.
|
||||
///
|
||||
/// [Document]s may be properties of [ManagedObject] table definition. They are a container
|
||||
/// for [data] that is a JSON-encodable [Map] or [List]. When storing a [Document] in a database column,
|
||||
/// [data] is JSON-encoded.
|
||||
///
|
||||
/// Use this type to store unstructured or 'schema-less' data. Example:
|
||||
///
|
||||
/// class Event extends ManagedObject<_Event> implements _Event {}
|
||||
/// class _Event {
|
||||
/// @primaryKey
|
||||
/// int id;
|
||||
///
|
||||
/// String type;
|
||||
///
|
||||
/// Document details;
|
||||
/// }
|
||||
class Document {
|
||||
/// Creates an instance with an optional initial [data].
|
||||
///
|
||||
/// If no argument is passed, [data] is null. Otherwise, it is the first argument.
|
||||
Document([this.data]);
|
||||
|
||||
/// The JSON-encodable data contained by this instance.
|
||||
///
|
||||
/// This value must be JSON-encodable.
|
||||
dynamic data;
|
||||
|
||||
/// Returns an element of [data] by index or key.
|
||||
///
|
||||
/// [keyOrIndex] may be a [String] or [int].
|
||||
///
|
||||
/// When [data] is a [Map], [keyOrIndex] must be a [String] and will return the object for the key
|
||||
/// in that map.
|
||||
///
|
||||
/// When [data] is a [List], [keyOrIndex] must be a [int] and will return the object at the index
|
||||
/// in that list.
|
||||
dynamic operator [](Object keyOrIndex) {
|
||||
return data[keyOrIndex];
|
||||
}
|
||||
|
||||
/// Sets an element of [data] by index or key.
|
||||
///
|
||||
/// [keyOrIndex] may be a [String] or [int]. [value] must be a JSON-encodable value.
|
||||
///
|
||||
/// When [data] is a [Map], [keyOrIndex] must be a [String] and will set [value] for the key
|
||||
/// [keyOrIndex].
|
||||
///
|
||||
/// When [data] is a [List], [keyOrIndex] must be a [int] and will set [value] for the index
|
||||
/// [keyOrIndex]. This index must be within the length of [data]. For all other [List] operations,
|
||||
/// you may cast [data] to [List].
|
||||
void operator []=(Object keyOrIndex, dynamic value) {
|
||||
data[keyOrIndex] = value;
|
||||
}
|
||||
}
|
382
packages/database/lib/src/managed/entity.dart
Normal file
382
packages/database/lib/src/managed/entity.dart
Normal file
|
@ -0,0 +1,382 @@
|
|||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/src/managed/backing.dart';
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/managed/relationship_type.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
|
||||
/// Mapping information between a table in a database and a [ManagedObject] object.
|
||||
///
|
||||
/// An entity defines the mapping between a database table and [ManagedObject] subclass. Entities
|
||||
/// are created by declaring [ManagedObject] subclasses and instantiating a [ManagedDataModel].
|
||||
/// In general, you do not need to use or create instances of this class.
|
||||
///
|
||||
/// An entity describes the properties that a subclass of [ManagedObject] will have and their representation in the underlying database.
|
||||
/// Each of these properties are represented by an instance of a [ManagedPropertyDescription] subclass. A property is either an attribute or a relationship.
|
||||
///
|
||||
/// Attribute values are scalar (see [ManagedPropertyType]) - [int], [String], [DateTime], [double] and [bool].
|
||||
/// Attributes are typically backed by a column in the underlying database for a [ManagedObject], but may also represent transient values
|
||||
/// defined by the [instanceType].
|
||||
/// Attributes are represented by [ManagedAttributeDescription].
|
||||
///
|
||||
/// The value of a relationship property is a reference to another [ManagedObject]. If a relationship property has [Relate] metadata,
|
||||
/// the property is backed be a foreign key column in the underlying database. Relationships are represented by [ManagedRelationshipDescription].
|
||||
class ManagedEntity implements APIComponentDocumenter {
|
||||
/// Creates an instance of this type..
|
||||
///
|
||||
/// You should never call this method directly, it will be called by [ManagedDataModel].
|
||||
ManagedEntity(this._tableName, this.instanceType, this.tableDefinition);
|
||||
|
||||
/// The name of this entity.
|
||||
///
|
||||
/// This name will match the name of [instanceType].
|
||||
String get name => instanceType.toString();
|
||||
|
||||
/// The type of instances represented by this entity.
|
||||
///
|
||||
/// Managed objects are made up of two components, a table definition and an instance type. Applications
|
||||
/// use instances of the instance type to work with queries and data from the database table this entity represents.
|
||||
final Type instanceType;
|
||||
|
||||
/// Set of callbacks that are implemented differently depending on compilation target.
|
||||
///
|
||||
/// If running in default mode (mirrors enabled), is a set of mirror operations. Otherwise,
|
||||
/// code generated.
|
||||
ManagedEntityRuntime get runtime =>
|
||||
RuntimeContext.current[instanceType] as ManagedEntityRuntime;
|
||||
|
||||
/// The name of type of persistent instances represented by this entity.
|
||||
///
|
||||
/// Managed objects are made up of two components, a table definition and an instance type.
|
||||
/// The system uses this type to define the mapping to the underlying database table.
|
||||
final String tableDefinition;
|
||||
|
||||
/// All attribute values of this entity.
|
||||
///
|
||||
/// An attribute maps to a single column or field in a database that is a scalar value, such as a string, integer, etc. or a
|
||||
/// transient property declared in the instance type.
|
||||
/// The keys are the case-sensitive name of the attribute. Values that represent a relationship to another object
|
||||
/// are not stored in [attributes].
|
||||
late Map<String, ManagedAttributeDescription?> attributes;
|
||||
|
||||
/// All relationship values of this entity.
|
||||
///
|
||||
/// A relationship represents a value that is another [ManagedObject] or [ManagedSet] of [ManagedObject]s. Not all relationships
|
||||
/// correspond to a column or field in a database, only those with [Relate] metadata (see also [ManagedRelationshipType.belongsTo]). In
|
||||
/// this case, the underlying database column is a foreign key reference. The underlying database does not have storage
|
||||
/// for [ManagedRelationshipType.hasMany] or [ManagedRelationshipType.hasOne] properties, as those values are derived by the foreign key reference
|
||||
/// on the inverse relationship property.
|
||||
/// Keys are the case-sensitive name of the relationship.
|
||||
late Map<String, ManagedRelationshipDescription?> relationships;
|
||||
|
||||
/// All properties (relationships and attributes) of this entity.
|
||||
///
|
||||
/// The string key is the name of the property, case-sensitive. Values will be instances of either [ManagedAttributeDescription]
|
||||
/// or [ManagedRelationshipDescription]. This is the concatenation of [attributes] and [relationships].
|
||||
Map<String, ManagedPropertyDescription?> get properties {
|
||||
final all = Map<String, ManagedPropertyDescription?>.from(attributes);
|
||||
all.addAll(relationships);
|
||||
return all;
|
||||
}
|
||||
|
||||
/// Set of properties that, together, are unique for each instance of this entity.
|
||||
///
|
||||
/// If non-null, each instance of this entity is unique for the combination of values
|
||||
/// for these properties. Instances may have the same values for each property in [uniquePropertySet],
|
||||
/// but cannot have the same value for all properties in [uniquePropertySet]. This differs from setting
|
||||
/// a single property as unique with [Column], where each instance has
|
||||
/// a unique value for that property.
|
||||
///
|
||||
/// This value is set by adding [Table] to the table definition of a [ManagedObject].
|
||||
List<ManagedPropertyDescription>? uniquePropertySet;
|
||||
|
||||
/// List of [ManagedValidator]s for attributes of this entity.
|
||||
///
|
||||
/// All validators for all [attributes] in one, flat list. Order is undefined.
|
||||
late List<ManagedValidator> validators;
|
||||
|
||||
/// The list of default property names of this object.
|
||||
///
|
||||
/// By default, a [Query] will fetch the properties in this list. You may specify
|
||||
/// a different set of properties by setting the [Query.returningProperties] value. The default
|
||||
/// set of properties is a list of all attributes that do not have the [Column.shouldOmitByDefault] flag
|
||||
/// set in their [Column] and all [ManagedRelationshipType.belongsTo] relationships.
|
||||
///
|
||||
/// This list cannot be modified.
|
||||
List<String>? get defaultProperties {
|
||||
if (_defaultProperties == null) {
|
||||
final elements = <String?>[];
|
||||
elements.addAll(
|
||||
attributes.values
|
||||
.where((prop) => prop!.isIncludedInDefaultResultSet)
|
||||
.where((prop) => !prop!.isTransient)
|
||||
.map((prop) => prop!.name),
|
||||
);
|
||||
|
||||
elements.addAll(
|
||||
relationships.values
|
||||
.where(
|
||||
(prop) =>
|
||||
prop!.isIncludedInDefaultResultSet &&
|
||||
prop.relationshipType == ManagedRelationshipType.belongsTo,
|
||||
)
|
||||
.map((prop) => prop!.name),
|
||||
);
|
||||
_defaultProperties = List.unmodifiable(elements);
|
||||
}
|
||||
return _defaultProperties;
|
||||
}
|
||||
|
||||
/// Name of primary key property.
|
||||
///
|
||||
/// This is determined by the attribute with the [primaryKey] annotation.
|
||||
late String primaryKey;
|
||||
|
||||
ManagedAttributeDescription? get primaryKeyAttribute {
|
||||
return attributes[primaryKey];
|
||||
}
|
||||
|
||||
/// A map from accessor symbol name to property name.
|
||||
///
|
||||
/// This map should not be modified.
|
||||
late Map<Symbol, String> symbolMap;
|
||||
|
||||
/// Name of table in database this entity maps to.
|
||||
///
|
||||
/// By default, the table will be named by the table definition, e.g., a managed object declared as so will have a [tableName] of '_User'.
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {}
|
||||
/// class _User { ... }
|
||||
///
|
||||
/// You may implement the static method [tableName] on the table definition of a [ManagedObject] to return a [String] table
|
||||
/// name override this default.
|
||||
String get tableName {
|
||||
return _tableName;
|
||||
}
|
||||
|
||||
final String _tableName;
|
||||
List<String>? _defaultProperties;
|
||||
|
||||
/// Derived from this' [tableName].
|
||||
@override
|
||||
int get hashCode {
|
||||
return tableName.hashCode;
|
||||
}
|
||||
|
||||
/// Creates a new instance of this entity's instance type.
|
||||
///
|
||||
/// By default, the returned object will use a normal value backing map.
|
||||
/// If [backing] is non-null, it will be the backing map of the returned object.
|
||||
T instanceOf<T extends ManagedObject>({ManagedBacking? backing}) {
|
||||
if (backing != null) {
|
||||
return (runtime.instanceOfImplementation(backing: backing)..entity = this)
|
||||
as T;
|
||||
}
|
||||
return (runtime.instanceOfImplementation()..entity = this) as T;
|
||||
}
|
||||
|
||||
ManagedSet<T>? setOf<T extends ManagedObject>(Iterable<dynamic> objects) {
|
||||
return runtime.setOfImplementation(objects) as ManagedSet<T>?;
|
||||
}
|
||||
|
||||
/// Returns an attribute in this entity for a property selector.
|
||||
///
|
||||
/// Invokes [identifyProperties] with [propertyIdentifier], and ensures that a single attribute
|
||||
/// on this entity was selected. Returns that attribute.
|
||||
ManagedAttributeDescription identifyAttribute<T, U extends ManagedObject>(
|
||||
T Function(U x) propertyIdentifier,
|
||||
) {
|
||||
final keyPaths = identifyProperties(propertyIdentifier);
|
||||
if (keyPaths.length != 1) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot access more than one property for this operation.",
|
||||
);
|
||||
}
|
||||
|
||||
final firstKeyPath = keyPaths.first;
|
||||
if (firstKeyPath.dynamicElements != null) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot access subdocuments for this operation.",
|
||||
);
|
||||
}
|
||||
|
||||
final elements = firstKeyPath.path;
|
||||
if (elements.length > 1) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot use relationships for this operation.",
|
||||
);
|
||||
}
|
||||
|
||||
final propertyName = elements.first!.name;
|
||||
final attribute = attributes[propertyName];
|
||||
if (attribute == null) {
|
||||
if (relationships.containsKey(propertyName)) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selection. Property '$propertyName' on "
|
||||
"'$name' "
|
||||
"is a relationship and cannot be selected for this operation.");
|
||||
} else {
|
||||
throw ArgumentError(
|
||||
"Invalid property selection. Column '$propertyName' does not "
|
||||
"exist on table '$tableName'.");
|
||||
}
|
||||
}
|
||||
|
||||
return attribute;
|
||||
}
|
||||
|
||||
/// Returns a relationship in this entity for a property selector.
|
||||
///
|
||||
/// Invokes [identifyProperties] with [propertyIdentifier], and ensures that a single relationship
|
||||
/// on this entity was selected. Returns that relationship.
|
||||
ManagedRelationshipDescription
|
||||
identifyRelationship<T, U extends ManagedObject>(
|
||||
T Function(U x) propertyIdentifier,
|
||||
) {
|
||||
final keyPaths = identifyProperties(propertyIdentifier);
|
||||
if (keyPaths.length != 1) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot access more than one property for this operation.",
|
||||
);
|
||||
}
|
||||
|
||||
final firstKeyPath = keyPaths.first;
|
||||
if (firstKeyPath.dynamicElements != null) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot access subdocuments for this operation.",
|
||||
);
|
||||
}
|
||||
|
||||
final elements = firstKeyPath.path;
|
||||
if (elements.length > 1) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot identify a nested relationship for this operation.",
|
||||
);
|
||||
}
|
||||
|
||||
final propertyName = elements.first!.name;
|
||||
final desc = relationships[propertyName];
|
||||
if (desc == null) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selection. Relationship named '$propertyName' on table '$tableName' is not a relationship.",
|
||||
);
|
||||
}
|
||||
|
||||
return desc;
|
||||
}
|
||||
|
||||
/// Returns a property selected by [propertyIdentifier].
|
||||
///
|
||||
/// Invokes [identifyProperties] with [propertyIdentifier], and ensures that a single property
|
||||
/// on this entity was selected. Returns that property.
|
||||
KeyPath identifyProperty<T, U extends ManagedObject>(
|
||||
T Function(U x) propertyIdentifier,
|
||||
) {
|
||||
final properties = identifyProperties(propertyIdentifier);
|
||||
if (properties.length != 1) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Must reference a single property only.",
|
||||
);
|
||||
}
|
||||
|
||||
return properties.first;
|
||||
}
|
||||
|
||||
/// Returns a list of properties selected by [propertiesIdentifier].
|
||||
///
|
||||
/// Each selected property in [propertiesIdentifier] is returned in a [KeyPath] object that fully identifies the
|
||||
/// property relative to this entity.
|
||||
List<KeyPath> identifyProperties<T, U extends ManagedObject>(
|
||||
T Function(U x) propertiesIdentifier,
|
||||
) {
|
||||
final tracker = ManagedAccessTrackingBacking();
|
||||
final obj = instanceOf<U>(backing: tracker);
|
||||
propertiesIdentifier(obj);
|
||||
|
||||
return tracker.keyPaths;
|
||||
}
|
||||
|
||||
APISchemaObject document(APIDocumentContext context) {
|
||||
final schemaProperties = <String, APISchemaObject>{};
|
||||
final obj = APISchemaObject.object(schemaProperties)..title = name;
|
||||
|
||||
final buffer = StringBuffer();
|
||||
if (uniquePropertySet != null) {
|
||||
final propString =
|
||||
uniquePropertySet!.map((s) => "'${s.name}'").join(", ");
|
||||
buffer.writeln(
|
||||
"No two objects may have the same value for all of: $propString.",
|
||||
);
|
||||
}
|
||||
|
||||
obj.description = buffer.toString();
|
||||
|
||||
properties.forEach((name, def) {
|
||||
if (def is ManagedAttributeDescription &&
|
||||
!def.isIncludedInDefaultResultSet &&
|
||||
!def.isTransient) {
|
||||
return;
|
||||
}
|
||||
|
||||
final schemaProperty = def!.documentSchemaObject(context);
|
||||
schemaProperties[name] = schemaProperty;
|
||||
});
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/// Two entities are considered equal if they have the same [tableName].
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is ManagedEntity && tableName == other.tableName;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln("Entity: $tableName");
|
||||
|
||||
buf.writeln("Attributes:");
|
||||
attributes.forEach((name, attr) {
|
||||
buf.writeln("\t$attr");
|
||||
});
|
||||
|
||||
buf.writeln("Relationships:");
|
||||
relationships.forEach((name, rel) {
|
||||
buf.writeln("\t$rel");
|
||||
});
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) {
|
||||
final obj = document(context);
|
||||
context.schema.register(name, obj, representation: instanceType);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ManagedEntityRuntime {
|
||||
void finalize(ManagedDataModel dataModel) {}
|
||||
|
||||
ManagedEntity get entity;
|
||||
|
||||
ManagedObject instanceOfImplementation({ManagedBacking? backing});
|
||||
|
||||
ManagedSet setOfImplementation(Iterable<dynamic> objects);
|
||||
|
||||
void setTransientValueForKey(ManagedObject object, String key, dynamic value);
|
||||
|
||||
dynamic getTransientValueForKey(ManagedObject object, String? key);
|
||||
|
||||
bool isValueInstanceOf(dynamic value);
|
||||
|
||||
bool isValueListOf(dynamic value);
|
||||
|
||||
String? getPropertyName(Invocation invocation, ManagedEntity entity);
|
||||
|
||||
dynamic dynamicConvertFromPrimitiveValue(
|
||||
ManagedPropertyDescription property,
|
||||
dynamic value,
|
||||
);
|
||||
}
|
8
packages/database/lib/src/managed/exception.dart
Normal file
8
packages/database/lib/src/managed/exception.dart
Normal file
|
@ -0,0 +1,8 @@
|
|||
import 'package:protevus_http/src/serializable.dart';
|
||||
|
||||
/// An exception thrown when an ORM property validator is violated.
|
||||
///
|
||||
/// Behaves the same as [SerializableException].
|
||||
class ValidationException extends SerializableException {
|
||||
ValidationException(super.errors);
|
||||
}
|
27
packages/database/lib/src/managed/key_path.dart
Normal file
27
packages/database/lib/src/managed/key_path.dart
Normal file
|
@ -0,0 +1,27 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
|
||||
class KeyPath {
|
||||
KeyPath(ManagedPropertyDescription? root) : path = [root];
|
||||
|
||||
KeyPath.byRemovingFirstNKeys(KeyPath original, int offset)
|
||||
: path = original.path.sublist(offset);
|
||||
|
||||
KeyPath.byAddingKey(KeyPath original, ManagedPropertyDescription key)
|
||||
: path = List.from(original.path)..add(key);
|
||||
|
||||
final List<ManagedPropertyDescription?> path;
|
||||
List<dynamic>? dynamicElements;
|
||||
|
||||
ManagedPropertyDescription? operator [](int index) => path[index];
|
||||
|
||||
int get length => path.length;
|
||||
|
||||
void add(ManagedPropertyDescription element) {
|
||||
path.add(element);
|
||||
}
|
||||
|
||||
void addDynamicElement(dynamic element) {
|
||||
dynamicElements ??= [];
|
||||
dynamicElements!.add(element);
|
||||
}
|
||||
}
|
13
packages/database/lib/src/managed/managed.dart
Normal file
13
packages/database/lib/src/managed/managed.dart
Normal file
|
@ -0,0 +1,13 @@
|
|||
export 'attributes.dart';
|
||||
export 'context.dart';
|
||||
export 'data_model.dart';
|
||||
export 'document.dart';
|
||||
export 'entity.dart';
|
||||
export 'exception.dart';
|
||||
export 'object.dart';
|
||||
export 'property_description.dart';
|
||||
export 'set.dart';
|
||||
export 'type.dart';
|
||||
export 'validation/managed.dart';
|
||||
export 'validation/metadata.dart';
|
||||
export 'key_path.dart';
|
304
packages/database/lib/src/managed/object.dart
Normal file
304
packages/database/lib/src/managed/object.dart
Normal file
|
@ -0,0 +1,304 @@
|
|||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/src/managed/backing.dart';
|
||||
import 'package:protevus_database/src/managed/data_model_manager.dart' as mm;
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_http/src/serializable.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
/// Instances of this class provide storage for [ManagedObject]s.
|
||||
///
|
||||
/// This class is primarily used internally.
|
||||
///
|
||||
/// A [ManagedObject] stores properties declared by its type argument in instances of this type.
|
||||
/// Values are validated against the [ManagedObject.entity].
|
||||
///
|
||||
/// Instances of this type only store properties for which a value has been explicitly set. This allows
|
||||
/// serialization classes to omit unset values from the serialized values. Therefore, instances of this class
|
||||
/// provide behavior that can differentiate between a property being the null value and a property simply not being
|
||||
/// set. (Therefore, you must use [removeProperty] instead of setting a value to null to really remove it from instances
|
||||
/// of this type.)
|
||||
///
|
||||
/// Conduit implements concrete subclasses of this class to provide behavior for property storage
|
||||
/// and query-building.
|
||||
abstract class ManagedBacking {
|
||||
/// Retrieve a property by its entity and name.
|
||||
dynamic valueForProperty(ManagedPropertyDescription property);
|
||||
|
||||
/// Sets a property by its entity and name.
|
||||
void setValueForProperty(ManagedPropertyDescription property, dynamic value);
|
||||
|
||||
/// Removes a property from this instance.
|
||||
///
|
||||
/// Use this method to use any reference of a property from this instance.
|
||||
void removeProperty(String propertyName) {
|
||||
contents.remove(propertyName);
|
||||
}
|
||||
|
||||
/// A map of all set values of this instance.
|
||||
Map<String, dynamic> get contents;
|
||||
}
|
||||
|
||||
/// An object that represents a database row.
|
||||
///
|
||||
/// This class must be subclassed. A subclass is declared for each table in a database. These subclasses
|
||||
/// create the data model of an application.
|
||||
///
|
||||
/// A managed object is declared in two parts, the subclass and its table definition.
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {
|
||||
/// String name;
|
||||
/// }
|
||||
/// class _User {
|
||||
/// @primaryKey
|
||||
/// int id;
|
||||
///
|
||||
/// @Column(indexed: true)
|
||||
/// String email;
|
||||
/// }
|
||||
///
|
||||
/// Table definitions are plain Dart objects that represent a database table. Each property is a column in the database.
|
||||
///
|
||||
/// A subclass of this type must implement its table definition and use it as the type argument of [ManagedObject]. Properties and methods
|
||||
/// declared in the subclass (also called the 'instance type') are not stored in the database.
|
||||
///
|
||||
/// See more documentation on defining a data model at http://conduit.io/docs/db/modeling_data/
|
||||
abstract class ManagedObject<T> extends Serializable {
|
||||
/// IMPROVEMENT: Cache of entity.properties to reduce property loading time
|
||||
late Map<String, ManagedPropertyDescription?> properties = entity.properties;
|
||||
|
||||
/// Cache of entity.properties using ResponseKey name as key, in case no ResponseKey is set then default property name is used as key
|
||||
late Map<String, ManagedPropertyDescription?> responseKeyProperties = {
|
||||
for (final key in properties.keys) mapKeyName(key): properties[key]
|
||||
};
|
||||
|
||||
late final bool modelFieldIncludeIfNull = properties.isEmpty ||
|
||||
(properties.values.first?.responseModel?.includeIfNullField ?? true);
|
||||
|
||||
String mapKeyName(String propertyName) {
|
||||
final property = properties[propertyName];
|
||||
return property?.responseKey?.name ?? property?.name ?? propertyName;
|
||||
}
|
||||
|
||||
static bool get shouldAutomaticallyDocument => false;
|
||||
|
||||
/// The [ManagedEntity] this instance is described by.
|
||||
ManagedEntity entity = mm.findEntity(T);
|
||||
|
||||
/// The persistent values of this object.
|
||||
///
|
||||
/// Values stored by this object are stored in [backing]. A backing is a [Map], where each key
|
||||
/// is a property name of this object. A backing adds some access logic to storing and retrieving
|
||||
/// its key-value pairs.
|
||||
///
|
||||
/// You rarely need to use [backing] directly. There are many implementations of [ManagedBacking]
|
||||
/// for fulfilling the behavior of the ORM, so you cannot rely on its behavior.
|
||||
ManagedBacking backing = ManagedValueBacking();
|
||||
|
||||
/// Retrieves a value by property name from [backing].
|
||||
dynamic operator [](String propertyName) {
|
||||
final prop = properties[propertyName];
|
||||
if (prop == null) {
|
||||
throw ArgumentError("Invalid property access for '${entity.name}'. "
|
||||
"Property '$propertyName' does not exist on '${entity.name}'.");
|
||||
}
|
||||
|
||||
return backing.valueForProperty(prop);
|
||||
}
|
||||
|
||||
/// Sets a value by property name in [backing].
|
||||
void operator []=(String? propertyName, dynamic value) {
|
||||
final prop = properties[propertyName];
|
||||
if (prop == null) {
|
||||
throw ArgumentError("Invalid property access for '${entity.name}'. "
|
||||
"Property '$propertyName' does not exist on '${entity.name}'.");
|
||||
}
|
||||
|
||||
backing.setValueForProperty(prop, value);
|
||||
}
|
||||
|
||||
/// Removes a property from [backing].
|
||||
///
|
||||
/// This will remove a value from the backing map.
|
||||
void removePropertyFromBackingMap(String propertyName) {
|
||||
backing.removeProperty(propertyName);
|
||||
}
|
||||
|
||||
/// Removes multiple properties from [backing].
|
||||
void removePropertiesFromBackingMap(List<String> propertyNames) {
|
||||
for (final propertyName in propertyNames) {
|
||||
backing.removeProperty(propertyName);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether or not a property has been set in this instances' [backing].
|
||||
bool hasValueForProperty(String propertyName) {
|
||||
return backing.contents.containsKey(propertyName);
|
||||
}
|
||||
|
||||
/// Callback to modify an object prior to updating it with a [Query].
|
||||
///
|
||||
/// Subclasses of this type may override this method to set or modify values prior to being updated
|
||||
/// via [Query.update] or [Query.updateOne]. It is automatically invoked by [Query.update] and [Query.updateOne].
|
||||
///
|
||||
/// This method is invoked prior to validation and therefore any values modified in this method
|
||||
/// are subject to the validation behavior of this instance.
|
||||
///
|
||||
/// An example implementation would set the 'updatedDate' of an object each time it was updated:
|
||||
///
|
||||
/// @override
|
||||
/// void willUpdate() {
|
||||
/// updatedDate = new DateTime.now().toUtc();
|
||||
/// }
|
||||
///
|
||||
/// This method is only invoked when a query is configured by its [Query.values]. This method is not invoked
|
||||
/// if [Query.valueMap] is used to configure a query.
|
||||
void willUpdate() {}
|
||||
|
||||
/// Callback to modify an object prior to inserting it with a [Query].
|
||||
///
|
||||
/// Subclasses of this type may override this method to set or modify values prior to being inserted
|
||||
/// via [Query.insert]. It is automatically invoked by [Query.insert].
|
||||
///
|
||||
/// This method is invoked prior to validation and therefore any values modified in this method
|
||||
/// are subject to the validation behavior of this instance.
|
||||
///
|
||||
/// An example implementation would set the 'createdDate' of an object when it is first created
|
||||
///
|
||||
/// @override
|
||||
/// void willInsert() {
|
||||
/// createdDate = new DateTime.now().toUtc();
|
||||
/// }
|
||||
///
|
||||
/// This method is only invoked when a query is configured by its [Query.values]. This method is not invoked
|
||||
/// if [Query.valueMap] is used to configure a query.
|
||||
void willInsert() {}
|
||||
|
||||
/// Validates an object according to its property [Validate] metadata.
|
||||
///
|
||||
/// This method is invoked by [Query] when inserting or updating an instance of this type. By default,
|
||||
/// this method runs all of the [Validate] metadata for each property of this instance's persistent type. See [Validate]
|
||||
/// for more information. If validations succeed, the returned context [ValidationContext.isValid] will be true. Otherwise,
|
||||
/// it is false and all errors are available in [ValidationContext.errors].
|
||||
///
|
||||
/// This method returns the result of [ManagedValidator.run]. You may override this method to provide additional validation
|
||||
/// prior to insertion or deletion. If you override this method, you *must* invoke the super implementation to
|
||||
/// allow [Validate] annotations to run, e.g.:
|
||||
///
|
||||
/// ValidationContext validate({Validating forEvent: Validating.insert}) {
|
||||
/// var context = super(forEvent: forEvent);
|
||||
///
|
||||
/// if (a + b > 10) {
|
||||
/// context.addError("a + b > 10");
|
||||
/// }
|
||||
///
|
||||
/// return context;
|
||||
/// }
|
||||
@mustCallSuper
|
||||
ValidationContext validate({Validating forEvent = Validating.insert}) {
|
||||
return ManagedValidator.run(this, event: forEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
final propertyName = entity.runtime.getPropertyName(invocation, entity);
|
||||
if (propertyName != null) {
|
||||
if (invocation.isGetter) {
|
||||
return this[propertyName];
|
||||
} else if (invocation.isSetter) {
|
||||
this[propertyName] = invocation.positionalArguments.first;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
throw NoSuchMethodError.withInvocation(this, invocation);
|
||||
}
|
||||
|
||||
@override
|
||||
void readFromMap(Map<String, dynamic> object) {
|
||||
object.forEach((key, v) {
|
||||
final property = responseKeyProperties[key];
|
||||
if (property == null) {
|
||||
throw ValidationException(["invalid input key '$key'"]);
|
||||
}
|
||||
if (property.isPrivate) {
|
||||
throw ValidationException(["invalid input key '$key'"]);
|
||||
}
|
||||
|
||||
if (property is ManagedAttributeDescription) {
|
||||
if (!property.isTransient) {
|
||||
backing.setValueForProperty(
|
||||
property,
|
||||
property.convertFromPrimitiveValue(v),
|
||||
);
|
||||
} else {
|
||||
if (!property.transientStatus!.isAvailableAsInput) {
|
||||
throw ValidationException(["invalid input key '$key'"]);
|
||||
}
|
||||
|
||||
final decodedValue = property.convertFromPrimitiveValue(v);
|
||||
|
||||
if (!property.isAssignableWith(decodedValue)) {
|
||||
throw ValidationException(["invalid input type for key '$key'"]);
|
||||
}
|
||||
|
||||
entity.runtime
|
||||
.setTransientValueForKey(this, property.name, decodedValue);
|
||||
}
|
||||
} else {
|
||||
backing.setValueForProperty(
|
||||
property,
|
||||
property.convertFromPrimitiveValue(v),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Converts this instance into a serializable map.
|
||||
///
|
||||
/// This method returns a map of the key-values pairs in this instance. This value is typically converted into a transmission format like JSON.
|
||||
///
|
||||
/// Only properties present in [backing] are serialized, otherwise, they are omitted from the map. If a property is present in [backing] and the value is null,
|
||||
/// the value null will be serialized for that property key.
|
||||
///
|
||||
/// Usage:
|
||||
/// var json = json.encode(model.asMap());
|
||||
@override
|
||||
Map<String, dynamic> asMap() {
|
||||
final outputMap = <String, dynamic>{};
|
||||
|
||||
backing.contents.forEach((k, v) {
|
||||
if (!_isPropertyPrivate(k)) {
|
||||
final property = properties[k];
|
||||
final value = property!.convertToPrimitiveValue(v);
|
||||
if (value == null && !_includeIfNull(property)) {
|
||||
return;
|
||||
}
|
||||
outputMap[mapKeyName(k)] = value;
|
||||
}
|
||||
});
|
||||
|
||||
entity.attributes.values
|
||||
.where((attr) => attr!.transientStatus?.isAvailableAsOutput ?? false)
|
||||
.forEach((attr) {
|
||||
final value = entity.runtime.getTransientValueForKey(this, attr!.name);
|
||||
if (value != null) {
|
||||
outputMap[mapKeyName(attr.responseKey?.name ?? attr.name)] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return outputMap;
|
||||
}
|
||||
|
||||
@override
|
||||
APISchemaObject documentSchema(APIDocumentContext context) =>
|
||||
entity.document(context);
|
||||
|
||||
static bool _isPropertyPrivate(String propertyName) =>
|
||||
propertyName.startsWith("_");
|
||||
|
||||
bool _includeIfNull(ManagedPropertyDescription property) =>
|
||||
property.responseKey?.includeIfNull ?? modelFieldIncludeIfNull;
|
||||
}
|
582
packages/database/lib/src/managed/property_description.dart
Normal file
582
packages/database/lib/src/managed/property_description.dart
Normal file
|
@ -0,0 +1,582 @@
|
|||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/managed/relationship_type.dart';
|
||||
import 'package:protevus_database/src/persistent_store/persistent_store.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
|
||||
/// Contains database column information and metadata for a property of a [ManagedObject] object.
|
||||
///
|
||||
/// Each property a [ManagedObject] object manages is described by an instance of [ManagedPropertyDescription], which contains useful information
|
||||
/// about the property such as its name and type. Those properties are represented by concrete subclasses of this class, [ManagedRelationshipDescription]
|
||||
/// and [ManagedAttributeDescription].
|
||||
abstract class ManagedPropertyDescription {
|
||||
ManagedPropertyDescription(
|
||||
this.entity,
|
||||
this.name,
|
||||
this.type,
|
||||
this.declaredType, {
|
||||
bool unique = false,
|
||||
bool indexed = false,
|
||||
bool nullable = false,
|
||||
bool includedInDefaultResultSet = true,
|
||||
this.autoincrement = false,
|
||||
List<ManagedValidator> validators = const [],
|
||||
this.responseModel,
|
||||
this.responseKey,
|
||||
}) : isUnique = unique,
|
||||
isIndexed = indexed,
|
||||
isNullable = nullable,
|
||||
isIncludedInDefaultResultSet = includedInDefaultResultSet,
|
||||
_validators = validators {
|
||||
for (final v in _validators) {
|
||||
v.property = this;
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to the [ManagedEntity] that contains this property.
|
||||
final ManagedEntity entity;
|
||||
|
||||
/// The value type of this property.
|
||||
///
|
||||
/// Will indicate the Dart type and database column type of this property.
|
||||
final ManagedType? type;
|
||||
|
||||
/// The identifying name of this property.
|
||||
final String name;
|
||||
|
||||
/// Whether or not this property must be unique to across all instances represented by [entity].
|
||||
///
|
||||
/// Defaults to false.
|
||||
final bool isUnique;
|
||||
|
||||
/// Whether or not this property should be indexed by a [PersistentStore].
|
||||
///
|
||||
/// Defaults to false.
|
||||
final bool isIndexed;
|
||||
|
||||
/// Whether or not this property can be null.
|
||||
///
|
||||
/// Defaults to false.
|
||||
final bool isNullable;
|
||||
|
||||
/// Whether or not this property is returned in the default set of [Query.returningProperties].
|
||||
///
|
||||
/// This defaults to true. If true, when executing a [Query] that does not explicitly specify [Query.returningProperties],
|
||||
/// this property will be returned. If false, you must explicitly specify this property in [Query.returningProperties] to retrieve it from persistent storage.
|
||||
final bool isIncludedInDefaultResultSet;
|
||||
|
||||
/// Whether or not this property should use an auto-incrementing scheme.
|
||||
///
|
||||
/// By default, false. When true, it signals to the [PersistentStore] that this property should automatically be assigned a value
|
||||
/// by the database.
|
||||
final bool autoincrement;
|
||||
|
||||
/// Whether or not this attribute is private or not.
|
||||
///
|
||||
/// Private variables are prefixed with `_` (underscores). This properties are not read
|
||||
/// or written to maps and cannot be accessed from outside the class.
|
||||
///
|
||||
/// This flag is not included in schemas documents used by database migrations and other tools.
|
||||
bool get isPrivate {
|
||||
return name.startsWith("_");
|
||||
}
|
||||
|
||||
/// [ManagedValidator]s for this instance.
|
||||
List<ManagedValidator> get validators => _validators;
|
||||
|
||||
final List<ManagedValidator> _validators;
|
||||
|
||||
final ResponseModel? responseModel;
|
||||
final ResponseKey? responseKey;
|
||||
|
||||
/// Whether or not a the argument can be assigned to this property.
|
||||
bool isAssignableWith(dynamic dartValue) => type!.isAssignableWith(dartValue);
|
||||
|
||||
/// Converts a value from a more complex value into a primitive value according to this instance's definition.
|
||||
///
|
||||
/// This method takes a Dart representation of a value and converts it to something that can
|
||||
/// be used elsewhere (e.g. an HTTP body or database query). How this value is computed
|
||||
/// depends on this instance's definition.
|
||||
dynamic convertToPrimitiveValue(dynamic value);
|
||||
|
||||
/// Converts a value to a more complex value from a primitive value according to this instance's definition.
|
||||
///
|
||||
/// This method takes a non-Dart representation of a value (e.g. an HTTP body or database query)
|
||||
/// and turns it into a Dart representation . How this value is computed
|
||||
/// depends on this instance's definition.
|
||||
dynamic convertFromPrimitiveValue(dynamic value);
|
||||
|
||||
/// The type of the variable that this property represents.
|
||||
final Type? declaredType;
|
||||
|
||||
/// Returns an [APISchemaObject] that represents this property.
|
||||
///
|
||||
/// Used during documentation.
|
||||
APISchemaObject documentSchemaObject(APIDocumentContext context);
|
||||
|
||||
static APISchemaObject _typedSchemaObject(ManagedType type) {
|
||||
switch (type.kind) {
|
||||
case ManagedPropertyType.integer:
|
||||
return APISchemaObject.integer();
|
||||
case ManagedPropertyType.bigInteger:
|
||||
return APISchemaObject.integer();
|
||||
case ManagedPropertyType.doublePrecision:
|
||||
return APISchemaObject.number();
|
||||
case ManagedPropertyType.string:
|
||||
return APISchemaObject.string();
|
||||
case ManagedPropertyType.datetime:
|
||||
return APISchemaObject.string(format: "date-time");
|
||||
case ManagedPropertyType.boolean:
|
||||
return APISchemaObject.boolean();
|
||||
case ManagedPropertyType.list:
|
||||
return APISchemaObject.array(
|
||||
ofSchema: _typedSchemaObject(type.elements!),
|
||||
);
|
||||
case ManagedPropertyType.map:
|
||||
return APISchemaObject.map(
|
||||
ofSchema: _typedSchemaObject(type.elements!),
|
||||
);
|
||||
case ManagedPropertyType.document:
|
||||
return APISchemaObject.freeForm();
|
||||
}
|
||||
|
||||
// throw UnsupportedError("Unsupported type '$type' when documenting entity.");
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the specifics of database columns in [ManagedObject]s as indicated by [Column].
|
||||
///
|
||||
/// This class is used internally to manage data models. For specifying these attributes,
|
||||
/// see [Column].
|
||||
///
|
||||
/// Attributes are the scalar values of a [ManagedObject] (as opposed to relationship values,
|
||||
/// which are [ManagedRelationshipDescription] instances).
|
||||
///
|
||||
/// Each scalar property [ManagedObject] object persists is described by an instance of [ManagedAttributeDescription]. This class
|
||||
/// adds two properties to [ManagedPropertyDescription] that are only valid for non-relationship types, [isPrimaryKey] and [defaultValue].
|
||||
class ManagedAttributeDescription extends ManagedPropertyDescription {
|
||||
ManagedAttributeDescription(
|
||||
super.entity,
|
||||
super.name,
|
||||
ManagedType super.type,
|
||||
super.declaredType, {
|
||||
this.transientStatus,
|
||||
bool primaryKey = false,
|
||||
this.defaultValue,
|
||||
super.unique,
|
||||
super.indexed,
|
||||
super.nullable,
|
||||
super.includedInDefaultResultSet,
|
||||
super.autoincrement,
|
||||
super.validators,
|
||||
super.responseModel,
|
||||
super.responseKey,
|
||||
}) : isPrimaryKey = primaryKey;
|
||||
|
||||
ManagedAttributeDescription.transient(
|
||||
super.entity,
|
||||
super.name,
|
||||
ManagedType super.type,
|
||||
Type super.declaredType,
|
||||
this.transientStatus, {
|
||||
super.responseKey,
|
||||
}) : isPrimaryKey = false,
|
||||
defaultValue = null,
|
||||
super(
|
||||
unique: false,
|
||||
indexed: false,
|
||||
nullable: false,
|
||||
includedInDefaultResultSet: false,
|
||||
autoincrement: false,
|
||||
validators: [],
|
||||
);
|
||||
|
||||
static ManagedAttributeDescription make<T>(
|
||||
ManagedEntity entity,
|
||||
String name,
|
||||
ManagedType type, {
|
||||
Serialize? transientStatus,
|
||||
bool primaryKey = false,
|
||||
String? defaultValue,
|
||||
bool unique = false,
|
||||
bool indexed = false,
|
||||
bool nullable = false,
|
||||
bool includedInDefaultResultSet = true,
|
||||
bool autoincrement = false,
|
||||
List<ManagedValidator> validators = const [],
|
||||
ResponseKey? responseKey,
|
||||
ResponseModel? responseModel,
|
||||
}) {
|
||||
return ManagedAttributeDescription(
|
||||
entity,
|
||||
name,
|
||||
type,
|
||||
T,
|
||||
transientStatus: transientStatus,
|
||||
primaryKey: primaryKey,
|
||||
defaultValue: defaultValue,
|
||||
unique: unique,
|
||||
indexed: indexed,
|
||||
nullable: nullable,
|
||||
includedInDefaultResultSet: includedInDefaultResultSet,
|
||||
autoincrement: autoincrement,
|
||||
validators: validators,
|
||||
responseKey: responseKey,
|
||||
responseModel: responseModel,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether or not this attribute is the primary key for its [ManagedEntity].
|
||||
///
|
||||
/// Defaults to false.
|
||||
final bool isPrimaryKey;
|
||||
|
||||
/// The default value for this attribute.
|
||||
///
|
||||
/// By default, null. This value is a String, so the underlying persistent store is responsible for parsing it. This allows for default values
|
||||
/// that aren't constant values, such as database function calls.
|
||||
final String? defaultValue;
|
||||
|
||||
/// Whether or not this attribute is backed directly by the database.
|
||||
///
|
||||
/// If [transientStatus] is non-null, this value will be true. Otherwise, the attribute is backed by a database field/column.
|
||||
bool get isTransient => transientStatus != null;
|
||||
|
||||
/// Contains lookup table for string value of an enumeration to the enumerated value.
|
||||
///
|
||||
/// Value is null when this attribute does not represent an enumerated type.
|
||||
///
|
||||
/// If `enum Options { option1, option2 }` then this map contains:
|
||||
///
|
||||
/// {
|
||||
/// "option1": Options.option1,
|
||||
/// "option2": Options.option2
|
||||
/// }
|
||||
///
|
||||
Map<String, dynamic> get enumerationValueMap => type!.enumerationMap;
|
||||
|
||||
/// The validity of a transient attribute as input, output or both.
|
||||
///
|
||||
/// If this property is non-null, the attribute is transient (not backed by a database field/column).
|
||||
final Serialize? transientStatus;
|
||||
|
||||
/// Whether or not this attribute is represented by a Dart enum.
|
||||
bool get isEnumeratedValue => enumerationValueMap.isNotEmpty;
|
||||
|
||||
@override
|
||||
APISchemaObject documentSchemaObject(APIDocumentContext context) {
|
||||
final prop = ManagedPropertyDescription._typedSchemaObject(type!)
|
||||
..title = name;
|
||||
final buf = StringBuffer();
|
||||
|
||||
// Add'l schema info
|
||||
prop.isNullable = isNullable;
|
||||
for (final v in validators) {
|
||||
v.definition.constrainSchemaObject(context, prop);
|
||||
}
|
||||
|
||||
if (isEnumeratedValue) {
|
||||
prop.enumerated = prop.enumerated!.map(convertToPrimitiveValue).toList();
|
||||
}
|
||||
|
||||
if (isTransient) {
|
||||
if (transientStatus!.isAvailableAsInput &&
|
||||
!transientStatus!.isAvailableAsOutput) {
|
||||
prop.isWriteOnly = true;
|
||||
} else if (!transientStatus!.isAvailableAsInput &&
|
||||
transientStatus!.isAvailableAsOutput) {
|
||||
prop.isReadOnly = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isUnique) {
|
||||
buf.writeln("No two objects may have the same value for this field.");
|
||||
}
|
||||
|
||||
if (isPrimaryKey) {
|
||||
buf.writeln("This is the primary identifier for this object.");
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
prop.defaultValue = defaultValue;
|
||||
}
|
||||
|
||||
if (buf.isNotEmpty) {
|
||||
prop.description = buf.toString();
|
||||
}
|
||||
|
||||
return prop;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final flagBuffer = StringBuffer();
|
||||
if (isPrimaryKey) {
|
||||
flagBuffer.write("primary_key ");
|
||||
}
|
||||
if (isTransient) {
|
||||
flagBuffer.write("transient ");
|
||||
}
|
||||
if (autoincrement) {
|
||||
flagBuffer.write("autoincrementing ");
|
||||
}
|
||||
if (isUnique) {
|
||||
flagBuffer.write("unique ");
|
||||
}
|
||||
if (defaultValue != null) {
|
||||
flagBuffer.write("defaults to $defaultValue ");
|
||||
}
|
||||
if (isIndexed) {
|
||||
flagBuffer.write("indexed ");
|
||||
}
|
||||
if (isNullable) {
|
||||
flagBuffer.write("nullable ");
|
||||
} else {
|
||||
flagBuffer.write("required ");
|
||||
}
|
||||
|
||||
return "- $name | $type | Flags: $flagBuffer";
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic convertToPrimitiveValue(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type!.kind == ManagedPropertyType.datetime && value is DateTime) {
|
||||
return value.toIso8601String();
|
||||
} else if (isEnumeratedValue) {
|
||||
// todo: optimize?
|
||||
return value.toString().split(".").last;
|
||||
} else if (type!.kind == ManagedPropertyType.document &&
|
||||
value is Document) {
|
||||
return value.data;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic convertFromPrimitiveValue(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type!.kind == ManagedPropertyType.datetime) {
|
||||
if (value is! String) {
|
||||
throw ValidationException(["invalid input value for '$name'"]);
|
||||
}
|
||||
return DateTime.parse(value);
|
||||
} else if (type!.kind == ManagedPropertyType.doublePrecision) {
|
||||
if (value is! num) {
|
||||
throw ValidationException(["invalid input value for '$name'"]);
|
||||
}
|
||||
return value.toDouble();
|
||||
} else if (isEnumeratedValue) {
|
||||
if (!enumerationValueMap.containsKey(value)) {
|
||||
throw ValidationException(["invalid option for key '$name'"]);
|
||||
}
|
||||
return enumerationValueMap[value];
|
||||
} else if (type!.kind == ManagedPropertyType.document) {
|
||||
return Document(value);
|
||||
} else if (type!.kind == ManagedPropertyType.list ||
|
||||
type!.kind == ManagedPropertyType.map) {
|
||||
try {
|
||||
return entity.runtime.dynamicConvertFromPrimitiveValue(this, value);
|
||||
} on TypeCoercionException {
|
||||
throw ValidationException(["invalid input value for '$name'"]);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Contains information for a relationship property of a [ManagedObject].
|
||||
class ManagedRelationshipDescription extends ManagedPropertyDescription {
|
||||
ManagedRelationshipDescription(
|
||||
super.entity,
|
||||
super.name,
|
||||
super.type,
|
||||
super.declaredType,
|
||||
this.destinationEntity,
|
||||
this.deleteRule,
|
||||
this.relationshipType,
|
||||
this.inverseKey, {
|
||||
super.unique,
|
||||
super.indexed,
|
||||
super.nullable,
|
||||
super.includedInDefaultResultSet,
|
||||
super.validators = const [],
|
||||
super.responseModel,
|
||||
super.responseKey,
|
||||
});
|
||||
|
||||
static ManagedRelationshipDescription make<T>(
|
||||
ManagedEntity entity,
|
||||
String name,
|
||||
ManagedType? type,
|
||||
ManagedEntity destinationEntity,
|
||||
DeleteRule? deleteRule,
|
||||
ManagedRelationshipType relationshipType,
|
||||
String inverseKey, {
|
||||
bool unique = false,
|
||||
bool indexed = false,
|
||||
bool nullable = false,
|
||||
bool includedInDefaultResultSet = true,
|
||||
List<ManagedValidator> validators = const [],
|
||||
ResponseKey? responseKey,
|
||||
ResponseModel? responseModel,
|
||||
}) {
|
||||
return ManagedRelationshipDescription(
|
||||
entity,
|
||||
name,
|
||||
type,
|
||||
T,
|
||||
destinationEntity,
|
||||
deleteRule,
|
||||
relationshipType,
|
||||
inverseKey,
|
||||
unique: unique,
|
||||
indexed: indexed,
|
||||
nullable: nullable,
|
||||
includedInDefaultResultSet: includedInDefaultResultSet,
|
||||
validators: validators,
|
||||
responseKey: responseKey,
|
||||
responseModel: responseModel,
|
||||
);
|
||||
}
|
||||
|
||||
/// The entity that this relationship's instances are represented by.
|
||||
final ManagedEntity destinationEntity;
|
||||
|
||||
/// The delete rule for this relationship.
|
||||
final DeleteRule? deleteRule;
|
||||
|
||||
/// The type of relationship.
|
||||
final ManagedRelationshipType relationshipType;
|
||||
|
||||
/// The name of the [ManagedRelationshipDescription] on [destinationEntity] that represents the inverse of this relationship.
|
||||
final String inverseKey;
|
||||
|
||||
/// The [ManagedRelationshipDescription] on [destinationEntity] that represents the inverse of this relationship.
|
||||
ManagedRelationshipDescription? get inverse =>
|
||||
destinationEntity.relationships[inverseKey];
|
||||
|
||||
/// Whether or not this relationship is on the belonging side.
|
||||
bool get isBelongsTo => relationshipType == ManagedRelationshipType.belongsTo;
|
||||
|
||||
/// Whether or not a the argument can be assigned to this property.
|
||||
@override
|
||||
bool isAssignableWith(dynamic dartValue) {
|
||||
if (relationshipType == ManagedRelationshipType.hasMany) {
|
||||
return destinationEntity.runtime.isValueListOf(dartValue);
|
||||
}
|
||||
return destinationEntity.runtime.isValueInstanceOf(dartValue);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic convertToPrimitiveValue(dynamic value) {
|
||||
if (value is ManagedSet) {
|
||||
return value
|
||||
.map((ManagedObject innerValue) => innerValue.asMap())
|
||||
.toList();
|
||||
} else if (value is ManagedObject) {
|
||||
// If we're only fetching the foreign key, don't do a full asMap
|
||||
if (relationshipType == ManagedRelationshipType.belongsTo &&
|
||||
value.backing.contents.length == 1 &&
|
||||
value.backing.contents.containsKey(destinationEntity.primaryKey)) {
|
||||
return <String, Object>{
|
||||
destinationEntity.primaryKey: value[destinationEntity.primaryKey]
|
||||
};
|
||||
}
|
||||
|
||||
return value.asMap();
|
||||
} else if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw StateError(
|
||||
"Invalid relationship assigment. Relationship '$entity.$name' is not a 'ManagedSet' or 'ManagedObject'.",
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic convertFromPrimitiveValue(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (relationshipType == ManagedRelationshipType.belongsTo ||
|
||||
relationshipType == ManagedRelationshipType.hasOne) {
|
||||
if (value is! Map<String, dynamic>) {
|
||||
throw ValidationException(["invalid input type for '$name'"]);
|
||||
}
|
||||
|
||||
final instance = destinationEntity.instanceOf()..readFromMap(value);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/* else if (relationshipType == ManagedRelationshipType.hasMany) { */
|
||||
|
||||
if (value is! List) {
|
||||
throw ValidationException(["invalid input type for '$name'"]);
|
||||
}
|
||||
|
||||
ManagedObject instantiator(dynamic m) {
|
||||
if (m is! Map<String, dynamic>) {
|
||||
throw ValidationException(["invalid input type for '$name'"]);
|
||||
}
|
||||
final instance = destinationEntity.instanceOf()..readFromMap(m);
|
||||
return instance;
|
||||
}
|
||||
|
||||
return destinationEntity.setOf(value.map(instantiator));
|
||||
}
|
||||
|
||||
@override
|
||||
APISchemaObject documentSchemaObject(APIDocumentContext context) {
|
||||
final relatedType =
|
||||
context.schema.getObjectWithType(inverse!.entity.instanceType);
|
||||
|
||||
if (relationshipType == ManagedRelationshipType.hasMany) {
|
||||
return APISchemaObject.array(ofSchema: relatedType)
|
||||
..isReadOnly = true
|
||||
..isNullable = true;
|
||||
} else if (relationshipType == ManagedRelationshipType.hasOne) {
|
||||
return relatedType
|
||||
..isReadOnly = true
|
||||
..isNullable = true;
|
||||
}
|
||||
|
||||
final destPk = destinationEntity.primaryKeyAttribute!;
|
||||
return APISchemaObject.object({
|
||||
destPk.name: ManagedPropertyDescription._typedSchemaObject(destPk.type!)
|
||||
})
|
||||
..title = name;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
var relTypeString = "has-one";
|
||||
switch (relationshipType) {
|
||||
case ManagedRelationshipType.belongsTo:
|
||||
relTypeString = "belongs to";
|
||||
break;
|
||||
case ManagedRelationshipType.hasMany:
|
||||
relTypeString = "has-many";
|
||||
break;
|
||||
case ManagedRelationshipType.hasOne:
|
||||
relTypeString = "has-a";
|
||||
break;
|
||||
// case null:
|
||||
// relTypeString = 'Not set';
|
||||
// break;
|
||||
}
|
||||
return "- $name -> '${destinationEntity.name}' | Type: $relTypeString | Inverse: $inverseKey";
|
||||
}
|
||||
}
|
2
packages/database/lib/src/managed/relationship_type.dart
Normal file
2
packages/database/lib/src/managed/relationship_type.dart
Normal file
|
@ -0,0 +1,2 @@
|
|||
/// The possible database relationships.
|
||||
enum ManagedRelationshipType { hasOne, hasMany, belongsTo }
|
71
packages/database/lib/src/managed/set.dart
Normal file
71
packages/database/lib/src/managed/set.dart
Normal file
|
@ -0,0 +1,71 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
|
||||
/// Instances of this type contain zero or more instances of [ManagedObject] and represent has-many relationships.
|
||||
///
|
||||
/// 'Has many' relationship properties in [ManagedObject]s are represented by this type. [ManagedSet]s properties may only be declared in the persistent
|
||||
/// type of a [ManagedObject]. Example usage:
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {}
|
||||
/// class _User {
|
||||
/// ...
|
||||
/// ManagedSet<Post> posts;
|
||||
/// }
|
||||
///
|
||||
/// class Post extends ManagedObject<_Post> implements _Post {}
|
||||
/// class _Post {
|
||||
/// ...
|
||||
/// @Relate(#posts)
|
||||
/// User user;
|
||||
/// }
|
||||
class ManagedSet<InstanceType extends ManagedObject> extends Object
|
||||
with ListMixin<InstanceType> {
|
||||
/// Creates an empty [ManagedSet].
|
||||
ManagedSet() {
|
||||
_innerValues = [];
|
||||
}
|
||||
|
||||
/// Creates a [ManagedSet] from an [Iterable] of [InstanceType]s.
|
||||
ManagedSet.from(Iterable<InstanceType> items) {
|
||||
_innerValues = items.toList();
|
||||
}
|
||||
|
||||
/// Creates a [ManagedSet] from an [Iterable] of [dynamic]s.
|
||||
ManagedSet.fromDynamic(Iterable<dynamic> items) {
|
||||
_innerValues = List<InstanceType>.from(items);
|
||||
}
|
||||
|
||||
late final List<InstanceType> _innerValues;
|
||||
|
||||
/// The number of elements in this set.
|
||||
@override
|
||||
int get length => _innerValues.length;
|
||||
|
||||
@override
|
||||
set length(int newLength) {
|
||||
_innerValues.length = newLength;
|
||||
}
|
||||
|
||||
/// Adds an [InstanceType] to this set.
|
||||
@override
|
||||
void add(InstanceType item) {
|
||||
_innerValues.add(item);
|
||||
}
|
||||
|
||||
/// Adds an [Iterable] of [InstanceType] to this set.
|
||||
@override
|
||||
void addAll(Iterable<InstanceType> items) {
|
||||
_innerValues.addAll(items);
|
||||
}
|
||||
|
||||
/// Retrieves an [InstanceType] from this set by an index.
|
||||
@override
|
||||
InstanceType operator [](int index) => _innerValues[index];
|
||||
|
||||
/// Set an [InstanceType] in this set by an index.
|
||||
@override
|
||||
void operator []=(int index, InstanceType value) {
|
||||
_innerValues[index] = value;
|
||||
}
|
||||
}
|
128
packages/database/lib/src/managed/type.dart
Normal file
128
packages/database/lib/src/managed/type.dart
Normal file
|
@ -0,0 +1,128 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
|
||||
/// Possible data types for [ManagedEntity] attributes.
|
||||
enum ManagedPropertyType {
|
||||
/// Represented by instances of [int].
|
||||
integer,
|
||||
|
||||
/// Represented by instances of [int].
|
||||
bigInteger,
|
||||
|
||||
/// Represented by instances of [String].
|
||||
string,
|
||||
|
||||
/// Represented by instances of [DateTime].
|
||||
datetime,
|
||||
|
||||
/// Represented by instances of [bool].
|
||||
boolean,
|
||||
|
||||
/// Represented by instances of [double].
|
||||
doublePrecision,
|
||||
|
||||
/// Represented by instances of [Map].
|
||||
map,
|
||||
|
||||
/// Represented by instances of [List].
|
||||
list,
|
||||
|
||||
/// Represented by instances of [Document]
|
||||
document
|
||||
}
|
||||
|
||||
/// Complex type storage for [ManagedEntity] attributes.
|
||||
class ManagedType {
|
||||
/// Creates a new instance.
|
||||
///
|
||||
/// [type] must be representable by [ManagedPropertyType].
|
||||
ManagedType(this.type, this.kind, this.elements, this.enumerationMap);
|
||||
|
||||
static ManagedType make<T>(
|
||||
ManagedPropertyType kind,
|
||||
ManagedType? elements,
|
||||
Map<String, dynamic> enumerationMap,
|
||||
) {
|
||||
return ManagedType(T, kind, elements, enumerationMap);
|
||||
}
|
||||
|
||||
/// The primitive kind of this type.
|
||||
///
|
||||
/// All types have a kind. If kind is a map or list, it will also have [elements].
|
||||
final ManagedPropertyType kind;
|
||||
|
||||
/// The primitive kind of each element of this type.
|
||||
///
|
||||
/// If [kind] is a collection (map or list), this value stores the type of each element in the collection.
|
||||
/// Keys of map types are always [String].
|
||||
final ManagedType? elements;
|
||||
|
||||
/// Dart representation of this type.
|
||||
final Type type;
|
||||
|
||||
/// Whether this is an enum type.
|
||||
bool get isEnumerated => enumerationMap.isNotEmpty;
|
||||
|
||||
/// For enumerated types, this is a map of the name of the option to its Dart enum type.
|
||||
final Map<String, dynamic> enumerationMap;
|
||||
|
||||
/// Whether [dartValue] can be assigned to properties with this type.
|
||||
bool isAssignableWith(dynamic dartValue) {
|
||||
if (dartValue == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case ManagedPropertyType.bigInteger:
|
||||
return dartValue is int;
|
||||
case ManagedPropertyType.integer:
|
||||
return dartValue is int;
|
||||
case ManagedPropertyType.boolean:
|
||||
return dartValue is bool;
|
||||
case ManagedPropertyType.datetime:
|
||||
return dartValue is DateTime;
|
||||
case ManagedPropertyType.doublePrecision:
|
||||
return dartValue is double;
|
||||
case ManagedPropertyType.map:
|
||||
return dartValue is Map<String, dynamic>;
|
||||
case ManagedPropertyType.list:
|
||||
return dartValue is List<dynamic>;
|
||||
case ManagedPropertyType.document:
|
||||
return dartValue is Document;
|
||||
case ManagedPropertyType.string:
|
||||
{
|
||||
if (enumerationMap.isNotEmpty) {
|
||||
return enumerationMap.values.contains(dartValue);
|
||||
}
|
||||
return dartValue is String;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "$kind";
|
||||
}
|
||||
|
||||
static List<Type> get supportedDartTypes {
|
||||
return [String, DateTime, bool, int, double, Document];
|
||||
}
|
||||
|
||||
static ManagedPropertyType get integer => ManagedPropertyType.integer;
|
||||
|
||||
static ManagedPropertyType get bigInteger => ManagedPropertyType.bigInteger;
|
||||
|
||||
static ManagedPropertyType get string => ManagedPropertyType.string;
|
||||
|
||||
static ManagedPropertyType get datetime => ManagedPropertyType.datetime;
|
||||
|
||||
static ManagedPropertyType get boolean => ManagedPropertyType.boolean;
|
||||
|
||||
static ManagedPropertyType get doublePrecision =>
|
||||
ManagedPropertyType.doublePrecision;
|
||||
|
||||
static ManagedPropertyType get map => ManagedPropertyType.map;
|
||||
|
||||
static ManagedPropertyType get list => ManagedPropertyType.list;
|
||||
|
||||
static ManagedPropertyType get document => ManagedPropertyType.document;
|
||||
}
|
65
packages/database/lib/src/managed/validation/impl.dart
Normal file
65
packages/database/lib/src/managed/validation/impl.dart
Normal file
|
@ -0,0 +1,65 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
|
||||
enum ValidateType { regex, comparison, length, present, absent, oneOf }
|
||||
|
||||
enum ValidationOperator {
|
||||
equalTo,
|
||||
lessThan,
|
||||
lessThanEqualTo,
|
||||
greaterThan,
|
||||
greaterThanEqualTo
|
||||
}
|
||||
|
||||
class ValidationExpression {
|
||||
ValidationExpression(this.operator, this.value);
|
||||
|
||||
final ValidationOperator operator;
|
||||
dynamic value;
|
||||
|
||||
void compare(ValidationContext context, dynamic input) {
|
||||
final comparisonValue = value as Comparable?;
|
||||
|
||||
switch (operator) {
|
||||
case ValidationOperator.equalTo:
|
||||
{
|
||||
if (comparisonValue!.compareTo(input) != 0) {
|
||||
context.addError("must be equal to '$comparisonValue'.");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidationOperator.greaterThan:
|
||||
{
|
||||
if (comparisonValue!.compareTo(input) >= 0) {
|
||||
context.addError("must be greater than '$comparisonValue'.");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ValidationOperator.greaterThanEqualTo:
|
||||
{
|
||||
if (comparisonValue!.compareTo(input) > 0) {
|
||||
context.addError(
|
||||
"must be greater than or equal to '$comparisonValue'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ValidationOperator.lessThan:
|
||||
{
|
||||
if (comparisonValue!.compareTo(input) <= 0) {
|
||||
context.addError("must be less than to '$comparisonValue'.");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidationOperator.lessThanEqualTo:
|
||||
{
|
||||
if (comparisonValue!.compareTo(input) < 0) {
|
||||
context
|
||||
.addError("must be less than or equal to '$comparisonValue'.");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
105
packages/database/lib/src/managed/validation/managed.dart
Normal file
105
packages/database/lib/src/managed/validation/managed.dart
Normal file
|
@ -0,0 +1,105 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/managed/validation/impl.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// Validates properties of [ManagedObject] before an insert or update [Query].
|
||||
///
|
||||
/// Instances of this type are created during [ManagedDataModel] compilation.
|
||||
class ManagedValidator {
|
||||
ManagedValidator(this.definition, this.state);
|
||||
|
||||
/// Executes all [Validate]s for [object].
|
||||
///
|
||||
/// Validates the properties of [object] according to its validator annotations. Validators
|
||||
/// are added to properties using [Validate] metadata.
|
||||
///
|
||||
/// This method does not invoke [ManagedObject.validate] - any customization provided
|
||||
/// by a [ManagedObject] subclass that overrides this method will not be invoked.
|
||||
static ValidationContext run(
|
||||
ManagedObject object, {
|
||||
Validating event = Validating.insert,
|
||||
}) {
|
||||
final context = ValidationContext();
|
||||
|
||||
for (final validator in object.entity.validators) {
|
||||
context.property = validator.property;
|
||||
context.event = event;
|
||||
context.state = validator.state;
|
||||
if (!validator.definition.runOnInsert && event == Validating.insert) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validator.definition.runOnUpdate && event == Validating.update) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var contents = object.backing.contents;
|
||||
String key = validator.property!.name;
|
||||
|
||||
if (validator.definition.type == ValidateType.present) {
|
||||
if (validator.property is ManagedRelationshipDescription) {
|
||||
final inner = object[validator.property!.name] as ManagedObject?;
|
||||
if (inner == null ||
|
||||
!inner.backing.contents.containsKey(inner.entity.primaryKey)) {
|
||||
context.addError("key '${validator.property!.name}' is required "
|
||||
"for ${_getEventName(event)}s.");
|
||||
}
|
||||
} else if (!contents.containsKey(key)) {
|
||||
context.addError("key '${validator.property!.name}' is required "
|
||||
"for ${_getEventName(event)}s.");
|
||||
}
|
||||
} else if (validator.definition.type == ValidateType.absent) {
|
||||
if (validator.property is ManagedRelationshipDescription) {
|
||||
final inner = object[validator.property!.name] as ManagedObject?;
|
||||
if (inner != null) {
|
||||
context.addError("key '${validator.property!.name}' is not allowed "
|
||||
"for ${_getEventName(event)}s.");
|
||||
}
|
||||
} else if (contents.containsKey(key)) {
|
||||
context.addError("key '${validator.property!.name}' is not allowed "
|
||||
"for ${_getEventName(event)}s.");
|
||||
}
|
||||
} else {
|
||||
if (validator.property is ManagedRelationshipDescription) {
|
||||
final inner = object[validator.property!.name] as ManagedObject?;
|
||||
if (inner == null ||
|
||||
inner.backing.contents[inner.entity.primaryKey] == null) {
|
||||
continue;
|
||||
}
|
||||
contents = inner.backing.contents;
|
||||
key = inner.entity.primaryKey;
|
||||
}
|
||||
|
||||
final value = contents[key];
|
||||
if (value != null) {
|
||||
validator.validate(context, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/// The property being validated.
|
||||
ManagedPropertyDescription? property;
|
||||
|
||||
/// The metadata associated with this instance.
|
||||
final Validate definition;
|
||||
|
||||
final dynamic state;
|
||||
|
||||
void validate(ValidationContext context, dynamic value) {
|
||||
definition.validate(context, value);
|
||||
}
|
||||
|
||||
static String _getEventName(Validating op) {
|
||||
switch (op) {
|
||||
case Validating.insert:
|
||||
return "insert";
|
||||
case Validating.update:
|
||||
return "update";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
650
packages/database/lib/src/managed/validation/metadata.dart
Normal file
650
packages/database/lib/src/managed/validation/metadata.dart
Normal file
|
@ -0,0 +1,650 @@
|
|||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/db.dart';
|
||||
import 'package:protevus_database/src/managed/validation/impl.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
|
||||
/// Types of operations [ManagedValidator]s will be triggered for.
|
||||
enum Validating { update, insert }
|
||||
|
||||
/// Information about a validation being performed.
|
||||
class ValidationContext {
|
||||
/// Whether this validation is occurring during update or insert.
|
||||
late Validating event;
|
||||
|
||||
/// The property being validated.
|
||||
ManagedPropertyDescription? property;
|
||||
|
||||
/// State associated with the validator being run.
|
||||
///
|
||||
/// Use this property in a custom validator to access compiled state. Compiled state
|
||||
/// is a value that has been computed from the arguments to the validator. For example,
|
||||
/// a 'greater than 1' validator, the state is an expression object that evaluates
|
||||
/// a value is greater than 1.
|
||||
///
|
||||
/// Set this property by returning the desired value from [Validate.compare].
|
||||
dynamic state;
|
||||
|
||||
/// Errors that have occurred in this context.
|
||||
List<String> errors = [];
|
||||
|
||||
/// Adds a validation error to the context.
|
||||
///
|
||||
/// A validation will fail if this method is invoked.
|
||||
void addError(String reason) {
|
||||
final p = property;
|
||||
if (p is ManagedRelationshipDescription) {
|
||||
errors.add(
|
||||
"${p.entity.name}.${p.name}.${p.destinationEntity.primaryKey}: $reason",
|
||||
);
|
||||
} else {
|
||||
errors.add("${p!.entity.name}.${p.name}: $reason");
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this validation context passed all validations.
|
||||
bool get isValid => errors.isEmpty;
|
||||
}
|
||||
|
||||
/// An error thrown during validator compilation.
|
||||
///
|
||||
/// If you override [Validate.compile], throw errors of this type if a validator
|
||||
/// is applied to an invalid property.
|
||||
class ValidateCompilationError extends Error {
|
||||
ValidateCompilationError(this.reason);
|
||||
|
||||
final String reason;
|
||||
}
|
||||
|
||||
/// Add as metadata to persistent properties to validate their values before insertion or updating.
|
||||
///
|
||||
/// When executing update or insert queries, any properties with this metadata will be validated
|
||||
/// against the condition declared by this instance. Example:
|
||||
///
|
||||
/// class Person extends ManagedObject<_Person> implements _Person {}
|
||||
/// class _Person {
|
||||
/// @primaryKey
|
||||
/// int id;
|
||||
///
|
||||
/// @Validate.length(greaterThan: 10)
|
||||
/// String name;
|
||||
/// }
|
||||
///
|
||||
/// Properties may have more than one metadata of this type. All validations must pass
|
||||
/// for an insert or update to be valid.
|
||||
///
|
||||
/// By default, validations occur on update and insert queries. Constructors have arguments
|
||||
/// for only running a validation on insert or update. See [runOnUpdate] and [runOnInsert].
|
||||
///
|
||||
/// This class may be subclassed to create custom validations. Subclasses must override [validate].
|
||||
class Validate {
|
||||
/// Invoke this constructor when creating custom subclasses.
|
||||
///
|
||||
/// This constructor is used so that subclasses can pass [onUpdate] and [onInsert].
|
||||
/// Example:
|
||||
/// class CustomValidate extends Validate<String> {
|
||||
/// CustomValidate({bool onUpdate: true, bool onInsert: true})
|
||||
/// : super(onUpdate: onUpdate, onInsert: onInsert);
|
||||
///
|
||||
/// bool validate(
|
||||
/// ValidateOperation operation,
|
||||
/// ManagedAttributeDescription property,
|
||||
/// String value,
|
||||
/// List<String> errors) {
|
||||
/// return someCondition;
|
||||
/// }
|
||||
/// }
|
||||
const Validate({bool onUpdate = true, bool onInsert = true})
|
||||
: runOnUpdate = onUpdate,
|
||||
runOnInsert = onInsert,
|
||||
_value = null,
|
||||
_lessThan = null,
|
||||
_lessThanEqualTo = null,
|
||||
_greaterThan = null,
|
||||
_greaterThanEqualTo = null,
|
||||
_equalTo = null,
|
||||
type = null;
|
||||
|
||||
const Validate._({
|
||||
bool onUpdate = true,
|
||||
bool onInsert = true,
|
||||
ValidateType? validator,
|
||||
dynamic value,
|
||||
Comparable? greaterThan,
|
||||
Comparable? greaterThanEqualTo,
|
||||
Comparable? equalTo,
|
||||
Comparable? lessThan,
|
||||
Comparable? lessThanEqualTo,
|
||||
}) : runOnUpdate = onUpdate,
|
||||
runOnInsert = onInsert,
|
||||
type = validator,
|
||||
_value = value,
|
||||
_greaterThan = greaterThan,
|
||||
_greaterThanEqualTo = greaterThanEqualTo,
|
||||
_equalTo = equalTo,
|
||||
_lessThan = lessThan,
|
||||
_lessThanEqualTo = lessThanEqualTo;
|
||||
|
||||
/// A validator for matching an input String against a regular expression.
|
||||
///
|
||||
/// Values passing through validators of this type must match a regular expression
|
||||
/// created by [pattern]. See [RegExp] in the Dart standard library for behavior.
|
||||
///
|
||||
/// This validator is only valid for [String] properties.
|
||||
///
|
||||
/// If [onUpdate] is true (the default), this validation is run on update queries.
|
||||
/// If [onInsert] is true (the default), this validation is run on insert queries.
|
||||
const Validate.matches(
|
||||
String pattern, {
|
||||
bool onUpdate = true,
|
||||
bool onInsert = true,
|
||||
}) : this._(
|
||||
value: pattern,
|
||||
onUpdate: onUpdate,
|
||||
onInsert: onInsert,
|
||||
validator: ValidateType.regex,
|
||||
);
|
||||
|
||||
/// A validator for comparing a value.
|
||||
///
|
||||
/// Values passing through validators of this type must be [lessThan],
|
||||
/// [greaterThan], [lessThanEqualTo], [equalTo], or [greaterThanEqualTo
|
||||
/// to the value provided for each argument.
|
||||
///
|
||||
/// Any argument not specified is not evaluated. A typical validator
|
||||
/// only uses one argument:
|
||||
///
|
||||
/// @Validate.compare(lessThan: 10.0)
|
||||
/// double value;
|
||||
///
|
||||
/// All provided arguments are evaluated. Therefore, the following
|
||||
/// requires an input value to be between 6 and 10:
|
||||
///
|
||||
/// @Validate.compare(greaterThanEqualTo: 6, lessThanEqualTo: 10)
|
||||
/// int value;
|
||||
///
|
||||
/// This validator can be used for [String], [double], [int] and [DateTime] properties.
|
||||
///
|
||||
/// When creating a validator for [DateTime] properties, the value for an argument
|
||||
/// is a [String] that will be parsed by [DateTime.parse].
|
||||
///
|
||||
/// @Validate.compare(greaterThan: "2017-02-11T00:30:00Z")
|
||||
/// DateTime date;
|
||||
///
|
||||
/// If [onUpdate] is true (the default), this validation is run on update queries.
|
||||
/// If [onInsert] is true (the default), this validation is run on insert queries.
|
||||
const Validate.compare({
|
||||
Comparable? lessThan,
|
||||
Comparable? greaterThan,
|
||||
Comparable? equalTo,
|
||||
Comparable? greaterThanEqualTo,
|
||||
Comparable? lessThanEqualTo,
|
||||
bool onUpdate = true,
|
||||
bool onInsert = true,
|
||||
}) : this._(
|
||||
lessThan: lessThan,
|
||||
lessThanEqualTo: lessThanEqualTo,
|
||||
greaterThan: greaterThan,
|
||||
greaterThanEqualTo: greaterThanEqualTo,
|
||||
equalTo: equalTo,
|
||||
onUpdate: onUpdate,
|
||||
onInsert: onInsert,
|
||||
validator: ValidateType.comparison,
|
||||
);
|
||||
|
||||
/// A validator for validating the length of a [String].
|
||||
///
|
||||
/// Values passing through validators of this type must a [String] with a length that is[lessThan],
|
||||
/// [greaterThan], [lessThanEqualTo], [equalTo], or [greaterThanEqualTo
|
||||
/// to the value provided for each argument.
|
||||
///
|
||||
/// Any argument not specified is not evaluated. A typical validator
|
||||
/// only uses one argument:
|
||||
///
|
||||
/// @Validate.length(lessThan: 10)
|
||||
/// String foo;
|
||||
///
|
||||
/// All provided arguments are evaluated. Therefore, the following
|
||||
/// requires an input string to have a length to be between 6 and 10:
|
||||
///
|
||||
/// @Validate.length(greaterThanEqualTo: 6, lessThanEqualTo: 10)
|
||||
/// String foo;
|
||||
///
|
||||
/// If [onUpdate] is true (the default), this validation is run on update queries.
|
||||
/// If [onInsert] is true (the default), this validation is run on insert queries.
|
||||
const Validate.length({
|
||||
int? lessThan,
|
||||
int? greaterThan,
|
||||
int? equalTo,
|
||||
int? greaterThanEqualTo,
|
||||
int? lessThanEqualTo,
|
||||
bool onUpdate = true,
|
||||
bool onInsert = true,
|
||||
}) : this._(
|
||||
lessThan: lessThan,
|
||||
lessThanEqualTo: lessThanEqualTo,
|
||||
greaterThan: greaterThan,
|
||||
greaterThanEqualTo: greaterThanEqualTo,
|
||||
equalTo: equalTo,
|
||||
onUpdate: onUpdate,
|
||||
onInsert: onInsert,
|
||||
validator: ValidateType.length,
|
||||
);
|
||||
|
||||
/// A validator for ensuring a property always has a value when being inserted or updated.
|
||||
///
|
||||
/// This metadata requires that a property must be set in [Query.values] before an update
|
||||
/// or insert. The value may be null, if the property's [Column.isNullable] allow it.
|
||||
///
|
||||
/// If [onUpdate] is true (the default), this validation requires a property to be present for update queries.
|
||||
/// If [onInsert] is true (the default), this validation requires a property to be present for insert queries.
|
||||
const Validate.present({bool onUpdate = true, bool onInsert = true})
|
||||
: this._(
|
||||
onUpdate: onUpdate,
|
||||
onInsert: onInsert,
|
||||
validator: ValidateType.present,
|
||||
);
|
||||
|
||||
/// A validator for ensuring a property does not have a value when being inserted or updated.
|
||||
///
|
||||
/// This metadata requires that a property must NOT be set in [Query.values] before an update
|
||||
/// or insert.
|
||||
///
|
||||
/// This validation is used to restrict input during either an insert or update query. For example,
|
||||
/// a 'dateCreated' property would use this validator to ensure that property isn't set during an update.
|
||||
///
|
||||
/// @Validate.absent(onUpdate: true, onInsert: false)
|
||||
/// DateTime dateCreated;
|
||||
///
|
||||
/// If [onUpdate] is true (the default), this validation requires a property to be absent for update queries.
|
||||
/// If [onInsert] is true (the default), this validation requires a property to be absent for insert queries.
|
||||
const Validate.absent({bool onUpdate = true, bool onInsert = true})
|
||||
: this._(
|
||||
onUpdate: onUpdate,
|
||||
onInsert: onInsert,
|
||||
validator: ValidateType.absent,
|
||||
);
|
||||
|
||||
/// A validator for ensuring a value is one of a set of values.
|
||||
///
|
||||
/// An input value must be one of [values].
|
||||
///
|
||||
/// [values] must be homogenous - every value must be the same type -
|
||||
/// and the property with this metadata must also match the type
|
||||
/// of the objects in [values].
|
||||
///
|
||||
/// This validator can be used for [String] and [int] properties.
|
||||
///
|
||||
/// @Validate.oneOf(const ["A", "B", "C")
|
||||
/// String foo;
|
||||
///
|
||||
/// If [onUpdate] is true (the default), this validation is run on update queries.
|
||||
/// If [onInsert] is true (the default), this validation is run on insert queries.
|
||||
const Validate.oneOf(
|
||||
List<dynamic> values, {
|
||||
bool onUpdate = true,
|
||||
bool onInsert = true,
|
||||
}) : this._(
|
||||
value: values,
|
||||
onUpdate: onUpdate,
|
||||
onInsert: onInsert,
|
||||
validator: ValidateType.oneOf,
|
||||
);
|
||||
|
||||
/// A validator that ensures a value cannot be modified after insertion.
|
||||
///
|
||||
/// This is equivalent to `Validate.absent(onUpdate: true, onInsert: false).
|
||||
const Validate.constant() : this.absent(onUpdate: true, onInsert: false);
|
||||
|
||||
/// Whether or not this validation is checked on update queries.
|
||||
final bool runOnUpdate;
|
||||
|
||||
/// Whether or not this validation is checked on insert queries.
|
||||
final bool runOnInsert;
|
||||
|
||||
final dynamic _value;
|
||||
final Comparable? _greaterThan;
|
||||
final Comparable? _greaterThanEqualTo;
|
||||
final Comparable? _equalTo;
|
||||
final Comparable? _lessThan;
|
||||
final Comparable? _lessThanEqualTo;
|
||||
final ValidateType? type;
|
||||
|
||||
/// Subclasses override this method to perform any one-time initialization tasks and check for correctness.
|
||||
///
|
||||
/// Use this method to ensure a validator is being applied to a property correctly. For example, a
|
||||
/// [Validate.compare] builds a list of expressions and ensures each expression's values are the
|
||||
/// same type as the property being validated.
|
||||
///
|
||||
/// The value returned from this method is available in [ValidationContext.state] when this
|
||||
/// instance's [validate] method is called.
|
||||
///
|
||||
/// [typeBeingValidated] is the type of the property being validated. If [relationshipInverseType] is not-null,
|
||||
/// it is a [ManagedObject] subclass and [typeBeingValidated] is the type of its primary key.
|
||||
///
|
||||
/// If compilation fails, throw a [ValidateCompilationError] with a message describing the issue. The entity
|
||||
/// and property will automatically be added to the error.
|
||||
dynamic compile(
|
||||
ManagedType typeBeingValidated, {
|
||||
Type? relationshipInverseType,
|
||||
}) {
|
||||
switch (type) {
|
||||
case ValidateType.absent:
|
||||
return null;
|
||||
case ValidateType.present:
|
||||
return null;
|
||||
case ValidateType.oneOf:
|
||||
{
|
||||
return _oneOfCompiler(
|
||||
typeBeingValidated,
|
||||
relationshipInverseType: relationshipInverseType,
|
||||
);
|
||||
}
|
||||
case ValidateType.comparison:
|
||||
return _comparisonCompiler(
|
||||
typeBeingValidated,
|
||||
relationshipInverseType: relationshipInverseType,
|
||||
);
|
||||
case ValidateType.regex:
|
||||
return _regexCompiler(
|
||||
typeBeingValidated,
|
||||
relationshipInverseType: relationshipInverseType,
|
||||
);
|
||||
case ValidateType.length:
|
||||
return _lengthCompiler(
|
||||
typeBeingValidated,
|
||||
relationshipInverseType: relationshipInverseType,
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the [input] value.
|
||||
///
|
||||
/// Subclasses override this method to provide validation behavior.
|
||||
///
|
||||
/// [input] is the value being validated. If the value is invalid, the reason
|
||||
/// is added to [context] via [ValidationContext.addError].
|
||||
///
|
||||
/// Additional information about the validation event and the attribute being evaluated
|
||||
/// is available in [context].
|
||||
/// in [context].
|
||||
///
|
||||
/// This method is not run when [input] is null.
|
||||
///
|
||||
/// The type of [input] will have already been type-checked prior to executing this method.
|
||||
void validate(ValidationContext context, dynamic input) {
|
||||
switch (type!) {
|
||||
case ValidateType.absent:
|
||||
{}
|
||||
break;
|
||||
case ValidateType.present:
|
||||
{}
|
||||
break;
|
||||
case ValidateType.comparison:
|
||||
{
|
||||
final expressions = context.state as List<ValidationExpression>;
|
||||
for (final expr in expressions) {
|
||||
expr.compare(context, input);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidateType.regex:
|
||||
{
|
||||
final regex = context.state as RegExp;
|
||||
if (!regex.hasMatch(input as String)) {
|
||||
context.addError("does not match pattern ${regex.pattern}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidateType.oneOf:
|
||||
{
|
||||
final options = context.state as List<dynamic>;
|
||||
if (options.every((v) => input != v)) {
|
||||
context.addError(
|
||||
"must be one of: ${options.map((v) => "'$v'").join(",")}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidateType.length:
|
||||
{
|
||||
final expressions = context.state as List<ValidationExpression>;
|
||||
for (final expr in expressions) {
|
||||
expr.compare(context, (input as String).length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds constraints to an [APISchemaObject] imposed by this validator.
|
||||
///
|
||||
/// Used during documentation process. When creating custom validator subclasses, override this method
|
||||
/// to modify [object] for any constraints the validator imposes.
|
||||
void constrainSchemaObject(
|
||||
APIDocumentContext context,
|
||||
APISchemaObject object,
|
||||
) {
|
||||
switch (type!) {
|
||||
case ValidateType.regex:
|
||||
{
|
||||
object.pattern = _value as String?;
|
||||
}
|
||||
break;
|
||||
case ValidateType.comparison:
|
||||
{
|
||||
if (_greaterThan is num) {
|
||||
object.exclusiveMinimum = true;
|
||||
object.minimum = _greaterThan as num?;
|
||||
} else if (_greaterThanEqualTo is num) {
|
||||
object.exclusiveMinimum = false;
|
||||
object.minimum = _greaterThanEqualTo as num?;
|
||||
}
|
||||
|
||||
if (_lessThan is num) {
|
||||
object.exclusiveMaximum = true;
|
||||
object.maximum = _lessThan as num?;
|
||||
} else if (_lessThanEqualTo is num) {
|
||||
object.exclusiveMaximum = false;
|
||||
object.maximum = _lessThanEqualTo as num?;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidateType.length:
|
||||
{
|
||||
if (_equalTo != null) {
|
||||
object.maxLength = _equalTo as int;
|
||||
object.minLength = _equalTo;
|
||||
} else {
|
||||
if (_greaterThan is int) {
|
||||
object.minLength = 1 + (_greaterThan);
|
||||
} else if (_greaterThanEqualTo is int) {
|
||||
object.minLength = _greaterThanEqualTo as int?;
|
||||
}
|
||||
|
||||
if (_lessThan is int) {
|
||||
object.maxLength = (-1) + (_lessThan);
|
||||
} else if (_lessThanEqualTo != null) {
|
||||
object.maximum = _lessThanEqualTo as int?;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidateType.present:
|
||||
{}
|
||||
break;
|
||||
case ValidateType.absent:
|
||||
{}
|
||||
break;
|
||||
case ValidateType.oneOf:
|
||||
{
|
||||
object.enumerated = _value as List<dynamic>?;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _oneOfCompiler(
|
||||
ManagedType typeBeingValidated, {
|
||||
Type? relationshipInverseType,
|
||||
}) {
|
||||
if (_value is! List) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.oneOf value must be a List<T>, where T is the type of the property being validated.",
|
||||
);
|
||||
}
|
||||
|
||||
final options = _value;
|
||||
final supportedOneOfTypes = [
|
||||
ManagedPropertyType.string,
|
||||
ManagedPropertyType.integer,
|
||||
ManagedPropertyType.bigInteger
|
||||
];
|
||||
if (!supportedOneOfTypes.contains(typeBeingValidated.kind) ||
|
||||
relationshipInverseType != null) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.oneOf is only valid for String or int types.",
|
||||
);
|
||||
}
|
||||
|
||||
if (options.any((v) => !typeBeingValidated.isAssignableWith(v))) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.oneOf value must be a List<T>, where T is the type of the property being validated.",
|
||||
);
|
||||
}
|
||||
|
||||
if (options.isEmpty) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.oneOf must have at least one element.",
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
List<ValidationExpression> get _expressions {
|
||||
final comparisons = <ValidationExpression>[];
|
||||
if (_equalTo != null) {
|
||||
comparisons
|
||||
.add(ValidationExpression(ValidationOperator.equalTo, _equalTo));
|
||||
}
|
||||
if (_lessThan != null) {
|
||||
comparisons
|
||||
.add(ValidationExpression(ValidationOperator.lessThan, _lessThan));
|
||||
}
|
||||
if (_lessThanEqualTo != null) {
|
||||
comparisons.add(
|
||||
ValidationExpression(
|
||||
ValidationOperator.lessThanEqualTo,
|
||||
_lessThanEqualTo,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_greaterThan != null) {
|
||||
comparisons.add(
|
||||
ValidationExpression(ValidationOperator.greaterThan, _greaterThan),
|
||||
);
|
||||
}
|
||||
if (_greaterThanEqualTo != null) {
|
||||
comparisons.add(
|
||||
ValidationExpression(
|
||||
ValidationOperator.greaterThanEqualTo,
|
||||
_greaterThanEqualTo,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return comparisons;
|
||||
}
|
||||
|
||||
dynamic _comparisonCompiler(
|
||||
ManagedType? typeBeingValidated, {
|
||||
Type? relationshipInverseType,
|
||||
}) {
|
||||
final exprs = _expressions;
|
||||
for (final expr in exprs) {
|
||||
expr.value = _parseComparisonValue(
|
||||
expr.value,
|
||||
typeBeingValidated,
|
||||
relationshipInverseType: relationshipInverseType,
|
||||
);
|
||||
}
|
||||
return exprs;
|
||||
}
|
||||
|
||||
Comparable? _parseComparisonValue(
|
||||
dynamic referenceValue,
|
||||
ManagedType? typeBeingValidated, {
|
||||
Type? relationshipInverseType,
|
||||
}) {
|
||||
if (typeBeingValidated?.kind == ManagedPropertyType.datetime) {
|
||||
if (referenceValue is String) {
|
||||
try {
|
||||
return DateTime.parse(referenceValue);
|
||||
} on FormatException {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.compare value '$referenceValue' cannot be parsed as expected DateTime type.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw ValidateCompilationError(
|
||||
"Validate.compare value '$referenceValue' is not expected DateTime type.",
|
||||
);
|
||||
}
|
||||
|
||||
if (relationshipInverseType == null) {
|
||||
if (!typeBeingValidated!.isAssignableWith(referenceValue)) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.compare value '$referenceValue' is not assignable to type of attribute being validated.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!typeBeingValidated!.isAssignableWith(referenceValue)) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.compare value '$referenceValue' is not assignable to primary key type of relationship being validated.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return referenceValue as Comparable?;
|
||||
}
|
||||
|
||||
dynamic _regexCompiler(
|
||||
ManagedType? typeBeingValidated, {
|
||||
Type? relationshipInverseType,
|
||||
}) {
|
||||
if (typeBeingValidated?.kind != ManagedPropertyType.string) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.matches is only valid for 'String' properties.",
|
||||
);
|
||||
}
|
||||
|
||||
if (_value is! String) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.matches argument must be 'String'.",
|
||||
);
|
||||
}
|
||||
|
||||
return RegExp(_value);
|
||||
}
|
||||
|
||||
dynamic _lengthCompiler(
|
||||
ManagedType typeBeingValidated, {
|
||||
Type? relationshipInverseType,
|
||||
}) {
|
||||
if (typeBeingValidated.kind != ManagedPropertyType.string) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.length is only valid for 'String' properties.",
|
||||
);
|
||||
}
|
||||
final expressions = _expressions;
|
||||
if (expressions.any((v) => v.value is! int)) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.length arguments must be 'int's.",
|
||||
);
|
||||
}
|
||||
return expressions;
|
||||
}
|
||||
}
|
100
packages/database/lib/src/persistent_store/persistent_store.dart
Normal file
100
packages/database/lib/src/persistent_store/persistent_store.dart
Normal file
|
@ -0,0 +1,100 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_database/src/managed/context.dart';
|
||||
import 'package:protevus_database/src/managed/entity.dart';
|
||||
import 'package:protevus_database/src/managed/object.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_database/src/schema/schema.dart';
|
||||
|
||||
enum PersistentStoreQueryReturnType { rowCount, rows }
|
||||
|
||||
/// An interface for implementing persistent storage.
|
||||
///
|
||||
/// You rarely need to use this class directly. See [Query] for how to interact with instances of this class.
|
||||
/// Implementors of this class serve as the bridge between [Query]s and a specific database.
|
||||
abstract class PersistentStore {
|
||||
/// Creates a new database-specific [Query].
|
||||
///
|
||||
/// Subclasses override this method to provide a concrete implementation of [Query]
|
||||
/// specific to this type. Objects returned from this method must implement [Query]. They
|
||||
/// should mixin [QueryMixin] to most of the behavior provided by a query.
|
||||
Query<T> newQuery<T extends ManagedObject>(
|
||||
ManagedContext context,
|
||||
ManagedEntity entity, {
|
||||
T? values,
|
||||
});
|
||||
|
||||
/// Executes an arbitrary command.
|
||||
Future execute(String sql, {Map<String, dynamic>? substitutionValues});
|
||||
|
||||
Future<dynamic> executeQuery(
|
||||
String formatString,
|
||||
Map<String, dynamic> values,
|
||||
int timeoutInSeconds, {
|
||||
PersistentStoreQueryReturnType? returnType,
|
||||
});
|
||||
|
||||
Future<T> transaction<T>(
|
||||
ManagedContext transactionContext,
|
||||
Future<T> Function(ManagedContext transaction) transactionBlock,
|
||||
);
|
||||
|
||||
/// Closes the underlying database connection.
|
||||
Future close();
|
||||
|
||||
// -- Schema Ops --
|
||||
|
||||
List<String> createTable(SchemaTable table, {bool isTemporary = false});
|
||||
|
||||
List<String> renameTable(SchemaTable table, String name);
|
||||
|
||||
List<String> deleteTable(SchemaTable table);
|
||||
|
||||
List<String> addTableUniqueColumnSet(SchemaTable table);
|
||||
|
||||
List<String> deleteTableUniqueColumnSet(SchemaTable table);
|
||||
|
||||
List<String> addColumn(
|
||||
SchemaTable table,
|
||||
SchemaColumn column, {
|
||||
String? unencodedInitialValue,
|
||||
});
|
||||
|
||||
List<String> deleteColumn(SchemaTable table, SchemaColumn column);
|
||||
|
||||
List<String> renameColumn(
|
||||
SchemaTable table,
|
||||
SchemaColumn column,
|
||||
String name,
|
||||
);
|
||||
|
||||
List<String> alterColumnNullability(
|
||||
SchemaTable table,
|
||||
SchemaColumn column,
|
||||
String? unencodedInitialValue,
|
||||
);
|
||||
|
||||
List<String> alterColumnUniqueness(SchemaTable table, SchemaColumn column);
|
||||
|
||||
List<String> alterColumnDefaultValue(SchemaTable table, SchemaColumn column);
|
||||
|
||||
List<String> alterColumnDeleteRule(SchemaTable table, SchemaColumn column);
|
||||
|
||||
List<String> addIndexToColumn(SchemaTable table, SchemaColumn column);
|
||||
|
||||
List<String> renameIndex(
|
||||
SchemaTable table,
|
||||
SchemaColumn column,
|
||||
String newIndexName,
|
||||
);
|
||||
|
||||
List<String> deleteIndexFromColumn(SchemaTable table, SchemaColumn column);
|
||||
|
||||
Future<int> get schemaVersion;
|
||||
|
||||
Future<Schema> upgrade(
|
||||
Schema fromSchema,
|
||||
List<Migration> withMigrations, {
|
||||
bool temporary = false,
|
||||
});
|
||||
}
|
91
packages/database/lib/src/query/error.dart
Normal file
91
packages/database/lib/src/query/error.dart
Normal file
|
@ -0,0 +1,91 @@
|
|||
import 'package:protevus_database/src/persistent_store/persistent_store.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// An exception describing an issue with a query.
|
||||
///
|
||||
/// A suggested HTTP status code based on the type of exception will always be available.
|
||||
class QueryException<T> implements HandlerException {
|
||||
QueryException(
|
||||
this.event, {
|
||||
this.message,
|
||||
this.underlyingException,
|
||||
this.offendingItems,
|
||||
});
|
||||
|
||||
QueryException.input(
|
||||
this.message,
|
||||
this.offendingItems, {
|
||||
this.underlyingException,
|
||||
}) : event = QueryExceptionEvent.input;
|
||||
QueryException.transport(this.message, {this.underlyingException})
|
||||
: event = QueryExceptionEvent.transport,
|
||||
offendingItems = null;
|
||||
QueryException.conflict(
|
||||
this.message,
|
||||
this.offendingItems, {
|
||||
this.underlyingException,
|
||||
}) : event = QueryExceptionEvent.conflict;
|
||||
|
||||
final String? message;
|
||||
|
||||
/// The exception generated by the [PersistentStore] or other mechanism that caused [Query] to fail.
|
||||
final T? underlyingException;
|
||||
|
||||
/// The type of event that caused this exception.
|
||||
final QueryExceptionEvent event;
|
||||
|
||||
final List<String>? offendingItems;
|
||||
|
||||
@override
|
||||
Response get response {
|
||||
return Response(_getStatus(event), null, _getBody(message, offendingItems));
|
||||
}
|
||||
|
||||
static Map<String, String> _getBody(
|
||||
String? message,
|
||||
List<String>? offendingItems,
|
||||
) {
|
||||
final body = {
|
||||
"error": message ?? "query failed",
|
||||
};
|
||||
|
||||
if (offendingItems != null && offendingItems.isNotEmpty) {
|
||||
body["detail"] = "Offending Items: ${offendingItems.join(", ")}";
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
static int _getStatus(QueryExceptionEvent event) {
|
||||
switch (event) {
|
||||
case QueryExceptionEvent.input:
|
||||
return 400;
|
||||
case QueryExceptionEvent.transport:
|
||||
return 503;
|
||||
case QueryExceptionEvent.conflict:
|
||||
return 409;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => "Query failed: $message. Reason: $underlyingException";
|
||||
}
|
||||
|
||||
/// Categorizations of query failures for [QueryException].
|
||||
enum QueryExceptionEvent {
|
||||
/// This event is used when the underlying [PersistentStore] reports that a unique constraint was violated.
|
||||
///
|
||||
/// [Controller]s interpret this exception to return a status code 409 by default.
|
||||
conflict,
|
||||
|
||||
/// This event is used when the underlying [PersistentStore] cannot reach its database.
|
||||
///
|
||||
/// [Controller]s interpret this exception to return a status code 503 by default.
|
||||
transport,
|
||||
|
||||
/// This event is used when the underlying [PersistentStore] reports an issue with the data used in a [Query].
|
||||
///
|
||||
/// [Controller]s interpret this exception to return a status code 400 by default.
|
||||
input,
|
||||
}
|
431
packages/database/lib/src/query/matcher_expression.dart
Normal file
431
packages/database/lib/src/query/matcher_expression.dart
Normal file
|
@ -0,0 +1,431 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// Contains binary logic operations to be applied to a [QueryExpression].
|
||||
class QueryExpressionJunction<T, InstanceType> {
|
||||
QueryExpressionJunction._(this.lhs);
|
||||
|
||||
final QueryExpression<T, InstanceType> lhs;
|
||||
}
|
||||
|
||||
/// Contains methods for adding logical expressions to properties when building a [Query].
|
||||
///
|
||||
/// You do not create instances of this type directly, but instead are returned an instance when selecting a property
|
||||
/// of an object in [Query.where]. You invoke methods from this type to add an expression to the query for the selected property.
|
||||
/// Example:
|
||||
///
|
||||
/// final query = new Query<Employee>()
|
||||
/// ..where((e) => e.name).equalTo("Bob");
|
||||
///
|
||||
class QueryExpression<T, InstanceType> {
|
||||
QueryExpression(this.keyPath);
|
||||
|
||||
QueryExpression.byAddingKey(
|
||||
QueryExpression<T, InstanceType> original,
|
||||
ManagedPropertyDescription byAdding,
|
||||
) : keyPath = KeyPath.byAddingKey(original.keyPath, byAdding),
|
||||
_expression = original.expression;
|
||||
|
||||
final KeyPath keyPath;
|
||||
|
||||
// todo: This needs to be extended to an expr tree
|
||||
PredicateExpression? get expression => _expression;
|
||||
|
||||
set expression(PredicateExpression? expr) {
|
||||
if (_invertNext) {
|
||||
_expression = expr!.inverse;
|
||||
_invertNext = false;
|
||||
} else {
|
||||
_expression = expr;
|
||||
}
|
||||
}
|
||||
|
||||
bool _invertNext = false;
|
||||
PredicateExpression? _expression;
|
||||
|
||||
QueryExpressionJunction<T, InstanceType> _createJunction() =>
|
||||
QueryExpressionJunction<T, InstanceType>._(this);
|
||||
|
||||
/// Inverts the next expression.
|
||||
///
|
||||
/// You use this method to apply an inversion to the expression that follows. For example,
|
||||
/// the following example would only return objects where the 'id' is *not* equal to '5'.
|
||||
///
|
||||
/// final query = new Query<Employee>()
|
||||
/// ..where((e) => e.name).not.equalTo("Bob");
|
||||
QueryExpression<T, InstanceType> get not {
|
||||
_invertNext = !_invertNext;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Adds an equality expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is equal to [value].
|
||||
///
|
||||
/// This method can be used on [int], [String], [bool], [double] and [DateTime] types.
|
||||
///
|
||||
/// If [value] is [String], the flag [caseSensitive] controls whether or not equality is case-sensitively compared.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// final query = new Query<User>()
|
||||
/// ..where((u) => u.id ).equalTo(1);
|
||||
///
|
||||
QueryExpressionJunction<T, InstanceType> equalTo(
|
||||
T value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
if (value is String) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.equals,
|
||||
caseSensitive: caseSensitive,
|
||||
allowSpecialCharacters: false,
|
||||
);
|
||||
} else {
|
||||
expression = ComparisonExpression(value, PredicateOperator.equalTo);
|
||||
}
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'not equal' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is *not* equal to [value].
|
||||
///
|
||||
/// This method can be used on [int], [String], [bool], [double] and [DateTime] types.
|
||||
///
|
||||
/// If [value] is [String], the flag [caseSensitive] controls whether or not equality is case-sensitively compared.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// final query = new Query<Employee>()
|
||||
/// ..where((e) => e.id).notEqualTo(60000);
|
||||
///
|
||||
QueryExpressionJunction<T, InstanceType> notEqualTo(
|
||||
T value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
if (value is String) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.equals,
|
||||
caseSensitive: caseSensitive,
|
||||
invertOperator: true,
|
||||
allowSpecialCharacters: false,
|
||||
);
|
||||
} else {
|
||||
expression = ComparisonExpression(value, PredicateOperator.notEqual);
|
||||
}
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a like expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is like [value].
|
||||
///
|
||||
/// For more documentation on postgres pattern matching, see
|
||||
/// https://www.postgresql.org/docs/10/functions-matching.html.
|
||||
///
|
||||
/// This method can be used on [String] types.
|
||||
///
|
||||
/// The flag [caseSensitive] controls whether strings are compared case-sensitively.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// final query = new Query<User>()
|
||||
/// ..where((u) => u.name ).like("bob");
|
||||
///
|
||||
QueryExpressionJunction<T, InstanceType> like(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.equals,
|
||||
caseSensitive: caseSensitive,
|
||||
);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'not like' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is *not* like [value].
|
||||
///
|
||||
/// For more documentation on postgres pattern matching, see
|
||||
/// https://www.postgresql.org/docs/10/functions-matching.html.
|
||||
///
|
||||
/// This method can be used on [String] types.
|
||||
///
|
||||
/// The flag [caseSensitive] controls whether strings are compared case-sensitively.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// final query = new Query<Employee>()
|
||||
/// ..where((e) => e.id).notEqualTo(60000);
|
||||
///
|
||||
QueryExpressionJunction<T, InstanceType> notLike(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.equals,
|
||||
caseSensitive: caseSensitive,
|
||||
invertOperator: true,
|
||||
);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'greater than' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is greater than [value].
|
||||
///
|
||||
/// This method can be used on [int], [String], [double] and [DateTime] types. For [DateTime] properties,
|
||||
/// this method selects rows where the assigned property is 'later than' [value]. For [String] properties,
|
||||
/// rows are selected if the value is alphabetically 'after' [value].
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.salary).greaterThan(60000);
|
||||
QueryExpressionJunction<T, InstanceType> greaterThan(T value) {
|
||||
expression = ComparisonExpression(value, PredicateOperator.greaterThan);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'greater than or equal to' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is greater than [value].
|
||||
///
|
||||
/// This method can be used on [int], [String], [double] and [DateTime] types. For [DateTime] properties,
|
||||
/// this method selects rows where the assigned property is 'later than or the same time as' [value]. For [String] properties,
|
||||
/// rows are selected if the value is alphabetically 'after or the same as' [value].
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.salary).greaterThanEqualTo(60000);
|
||||
QueryExpressionJunction<T, InstanceType> greaterThanEqualTo(T value) {
|
||||
expression =
|
||||
ComparisonExpression(value, PredicateOperator.greaterThanEqualTo);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'less than' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is less than [value].
|
||||
///
|
||||
/// This method can be used on [int], [String], [double] and [DateTime] types. For [DateTime] properties,
|
||||
/// this method selects rows where the assigned property is 'earlier than' [value]. For [String] properties,
|
||||
/// rows are selected if the value is alphabetically 'before' [value].
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.salary).lessThan(60000);
|
||||
QueryExpressionJunction<T, InstanceType> lessThan(T value) {
|
||||
expression = ComparisonExpression(value, PredicateOperator.lessThan);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'less than or equal to' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is less than or equal to [value].
|
||||
///
|
||||
/// This method can be used on [int], [String], [double] and [DateTime] types. For [DateTime] properties,
|
||||
/// this method selects rows where the assigned property is 'earlier than or the same time as' [value]. For [String] properties,
|
||||
/// rows are selected if the value is alphabetically 'before or the same as' [value].
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.salary).lessThanEqualTo(60000);
|
||||
QueryExpressionJunction<T, InstanceType> lessThanEqualTo(T value) {
|
||||
expression = ComparisonExpression(value, PredicateOperator.lessThanEqualTo);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'contains string' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property contains the string [value].
|
||||
///
|
||||
/// This method can be used on [String] types. The substring [value] must be found in the stored string.
|
||||
/// The flag [caseSensitive] controls whether strings are compared case-sensitively.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((s) => s.title).contains("Director");
|
||||
///
|
||||
QueryExpressionJunction<T, InstanceType> contains(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.contains,
|
||||
caseSensitive: caseSensitive,
|
||||
allowSpecialCharacters: false,
|
||||
);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'begins with string' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is begins with the string [value].
|
||||
///
|
||||
/// This method can be used on [String] types. The flag [caseSensitive] controls whether strings are compared case-sensitively.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((s) => s.name).beginsWith("B");
|
||||
QueryExpressionJunction<T, InstanceType> beginsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.beginsWith,
|
||||
caseSensitive: caseSensitive,
|
||||
allowSpecialCharacters: false,
|
||||
);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'ends with string' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is ends with the string [value].
|
||||
///
|
||||
/// This method can be used on [String] types. The flag [caseSensitive] controls whether strings are compared case-sensitively.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.name).endsWith("son");
|
||||
QueryExpressionJunction<T, InstanceType> endsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.endsWith,
|
||||
caseSensitive: caseSensitive,
|
||||
allowSpecialCharacters: false,
|
||||
);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'equal to one of' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is equal to one of the [values].
|
||||
///
|
||||
/// This method can be used on [String], [int], [double], [bool] and [DateTime] types.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.department).oneOf(["Engineering", "HR"]);
|
||||
QueryExpressionJunction<T, InstanceType> oneOf(Iterable<T> values) {
|
||||
if (values.isEmpty) {
|
||||
throw ArgumentError(
|
||||
"'Query.where.oneOf' cannot be the empty set or null.",
|
||||
);
|
||||
}
|
||||
expression = SetMembershipExpression(values.toList());
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'between two values' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is between than [lhs] and [rhs].
|
||||
///
|
||||
/// This method can be used on [int], [String], [double] and [DateTime] types. For [DateTime] properties,
|
||||
/// this method selects rows where the assigned property is 'later than' [lhs] and 'earlier than' [rhs]. For [String] properties,
|
||||
/// rows are selected if the value is alphabetically 'after' [lhs] and 'before' [rhs].
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.salary).between(80000, 100000);
|
||||
QueryExpressionJunction<T, InstanceType> between(T lhs, T rhs) {
|
||||
expression = RangeExpression(lhs, rhs);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'outside of the range crated by two values' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is not within the range established by [lhs] to [rhs].
|
||||
///
|
||||
/// This method can be used on [int], [String], [double] and [DateTime] types. For [DateTime] properties,
|
||||
/// this method selects rows where the assigned property is 'later than' [rhs] and 'earlier than' [lhs]. For [String] properties,
|
||||
/// rows are selected if the value is alphabetically 'before' [lhs] and 'after' [rhs].
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.salary).outsideOf(80000, 100000);
|
||||
QueryExpressionJunction<T, InstanceType> outsideOf(T lhs, T rhs) {
|
||||
expression = RangeExpression(lhs, rhs, within: false);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds an equality expression for foreign key columns to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected object's primary key is equal to [identifier].
|
||||
///
|
||||
/// This method may only be used on belongs-to relationships; i.e., those that have a [Relate] annotation.
|
||||
/// The type of [identifier] must match the primary key type of the selected object this expression is being applied to.
|
||||
///
|
||||
/// var q = new Query<Employee>()
|
||||
/// ..where((e) => e.manager).identifiedBy(5);
|
||||
QueryExpressionJunction<T, InstanceType> identifiedBy(dynamic identifier) {
|
||||
expression = ComparisonExpression(identifier, PredicateOperator.equalTo);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'null check' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is null.
|
||||
///
|
||||
/// This method can be applied to any property type.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var q = new Query<Employee>()
|
||||
/// ..where((e) => e.manager).isNull();
|
||||
QueryExpressionJunction<T, InstanceType> isNull() {
|
||||
expression = const NullCheckExpression();
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'not null check' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is not null.
|
||||
///
|
||||
/// This method can be applied to any property type.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var q = new Query<Employee>()
|
||||
/// ..where((e) => e.manager).isNotNull();
|
||||
QueryExpressionJunction<T, InstanceType> isNotNull() {
|
||||
expression = const NullCheckExpression(shouldBeNull: false);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
}
|
191
packages/database/lib/src/query/mixin.dart
Normal file
191
packages/database/lib/src/query/mixin.dart
Normal file
|
@ -0,0 +1,191 @@
|
|||
import 'package:protevus_database/src/managed/backing.dart';
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/managed/relationship_type.dart';
|
||||
import 'package:protevus_database/src/query/page.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_database/src/query/sort_descriptor.dart';
|
||||
|
||||
mixin QueryMixin<InstanceType extends ManagedObject>
|
||||
implements Query<InstanceType> {
|
||||
@override
|
||||
int offset = 0;
|
||||
|
||||
@override
|
||||
int fetchLimit = 0;
|
||||
|
||||
@override
|
||||
int timeoutInSeconds = 30;
|
||||
|
||||
@override
|
||||
bool canModifyAllInstances = false;
|
||||
|
||||
@override
|
||||
Map<String, dynamic>? valueMap;
|
||||
|
||||
@override
|
||||
QueryPredicate? predicate;
|
||||
|
||||
@override
|
||||
QuerySortPredicate? sortPredicate;
|
||||
|
||||
QueryPage? pageDescriptor;
|
||||
final List<QuerySortDescriptor> sortDescriptors = <QuerySortDescriptor>[];
|
||||
final Map<ManagedRelationshipDescription, Query> subQueries = {};
|
||||
|
||||
QueryMixin? _parentQuery;
|
||||
List<QueryExpression<dynamic, dynamic>> expressions = [];
|
||||
InstanceType? _valueObject;
|
||||
|
||||
List<KeyPath>? _propertiesToFetch;
|
||||
|
||||
List<KeyPath> get propertiesToFetch =>
|
||||
_propertiesToFetch ??
|
||||
entity.defaultProperties!
|
||||
.map((k) => KeyPath(entity.properties[k]))
|
||||
.toList();
|
||||
|
||||
@override
|
||||
InstanceType get values {
|
||||
if (_valueObject == null) {
|
||||
_valueObject = entity.instanceOf() as InstanceType?;
|
||||
_valueObject!.backing = ManagedBuilderBacking.from(
|
||||
_valueObject!.entity,
|
||||
_valueObject!.backing,
|
||||
);
|
||||
}
|
||||
return _valueObject!;
|
||||
}
|
||||
|
||||
@override
|
||||
set values(InstanceType? obj) {
|
||||
if (obj == null) {
|
||||
_valueObject = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_valueObject = entity.instanceOf(
|
||||
backing: ManagedBuilderBacking.from(entity, obj.backing),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
QueryExpression<T, InstanceType> where<T>(
|
||||
T Function(InstanceType x) propertyIdentifier,
|
||||
) {
|
||||
final properties = entity.identifyProperties(propertyIdentifier);
|
||||
if (properties.length != 1) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Must reference a single property only.",
|
||||
);
|
||||
}
|
||||
|
||||
final expr = QueryExpression<T, InstanceType>(properties.first);
|
||||
expressions.add(expr);
|
||||
return expr;
|
||||
}
|
||||
|
||||
@override
|
||||
Query<T> join<T extends ManagedObject>({
|
||||
T? Function(InstanceType x)? object,
|
||||
ManagedSet<T>? Function(InstanceType x)? set,
|
||||
}) {
|
||||
final relationship = object ?? set!;
|
||||
final desc = entity.identifyRelationship(relationship);
|
||||
|
||||
return _createSubquery<T>(desc);
|
||||
}
|
||||
|
||||
@override
|
||||
void pageBy<T>(
|
||||
T Function(InstanceType x) propertyIdentifier,
|
||||
QuerySortOrder order, {
|
||||
T? boundingValue,
|
||||
}) {
|
||||
final attribute = entity.identifyAttribute(propertyIdentifier);
|
||||
pageDescriptor =
|
||||
QueryPage(order, attribute.name, boundingValue: boundingValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void sortBy<T>(
|
||||
T Function(InstanceType x) propertyIdentifier,
|
||||
QuerySortOrder order,
|
||||
) {
|
||||
final attribute = entity.identifyAttribute(propertyIdentifier);
|
||||
|
||||
sortDescriptors.add(QuerySortDescriptor(attribute.name, order));
|
||||
}
|
||||
|
||||
@override
|
||||
void returningProperties(
|
||||
List<dynamic> Function(InstanceType x) propertyIdentifiers,
|
||||
) {
|
||||
final properties = entity.identifyProperties(propertyIdentifiers);
|
||||
|
||||
if (properties.any(
|
||||
(kp) => kp.path.any(
|
||||
(p) =>
|
||||
p is ManagedRelationshipDescription &&
|
||||
p.relationshipType != ManagedRelationshipType.belongsTo,
|
||||
),
|
||||
)) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot select has-many or has-one relationship properties. Use join instead.",
|
||||
);
|
||||
}
|
||||
|
||||
_propertiesToFetch = entity.identifyProperties(propertyIdentifiers);
|
||||
}
|
||||
|
||||
void validateInput(Validating op) {
|
||||
if (valueMap == null) {
|
||||
if (op == Validating.insert) {
|
||||
values.willInsert();
|
||||
} else if (op == Validating.update) {
|
||||
values.willUpdate();
|
||||
}
|
||||
|
||||
final ctx = values.validate(forEvent: op);
|
||||
if (!ctx.isValid) {
|
||||
throw ValidationException(ctx.errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Query<T> _createSubquery<T extends ManagedObject>(
|
||||
ManagedRelationshipDescription fromRelationship,
|
||||
) {
|
||||
if (subQueries.containsKey(fromRelationship)) {
|
||||
throw StateError(
|
||||
"Invalid query. Cannot join same property more than once.",
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure we don't cyclically join
|
||||
var parent = _parentQuery;
|
||||
while (parent != null) {
|
||||
if (parent.subQueries.containsKey(fromRelationship.inverse)) {
|
||||
final validJoins = fromRelationship.entity.relationships.values
|
||||
.where((r) => !identical(r, fromRelationship))
|
||||
.map((r) => "'${r!.name}'")
|
||||
.join(", ");
|
||||
|
||||
throw StateError(
|
||||
"Invalid query construction. This query joins '${fromRelationship.entity.tableName}' "
|
||||
"with '${fromRelationship.inverse!.entity.tableName}' on property '${fromRelationship.name}'. "
|
||||
"However, '${fromRelationship.inverse!.entity.tableName}' "
|
||||
"has also joined '${fromRelationship.entity.tableName}' on this property's inverse "
|
||||
"'${fromRelationship.inverse!.name}' earlier in the 'Query'. "
|
||||
"Perhaps you meant to join on another property, such as: $validJoins?");
|
||||
}
|
||||
|
||||
parent = parent._parentQuery;
|
||||
}
|
||||
|
||||
final subquery = Query<T>(context);
|
||||
(subquery as QueryMixin)._parentQuery = this;
|
||||
subQueries[fromRelationship] = subquery;
|
||||
|
||||
return subquery;
|
||||
}
|
||||
}
|
40
packages/database/lib/src/query/page.dart
Normal file
40
packages/database/lib/src/query/page.dart
Normal file
|
@ -0,0 +1,40 @@
|
|||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// A description of a page of results to be applied to a [Query].
|
||||
///
|
||||
/// [QueryPage]s are a convenient way of accomplishing paging through a large
|
||||
/// set of values. A page has the property to page on, the order in which the table is being
|
||||
/// paged and a value that indicates where in the ordered list of results the paging should start from.
|
||||
///
|
||||
/// Paging conceptually works by putting all of the rows in a table into an order. This order is determined by
|
||||
/// applying [order] to the values of [propertyName]. Once this order is defined, the position in that ordered list
|
||||
/// is found by going to the row (or rows) where [boundingValue] is eclipsed. That is, the point where row N
|
||||
/// has a value for [propertyName] that is less than or equal to [boundingValue] and row N + 1 has a value that is greater than
|
||||
/// [boundingValue]. The rows returned will start at row N + 1, ignoring rows 0 - N.
|
||||
///
|
||||
/// A query page should be used in conjunction with [Query.fetchLimit].
|
||||
class QueryPage {
|
||||
QueryPage(this.order, this.propertyName, {this.boundingValue});
|
||||
|
||||
/// The order in which rows should be in before the page of values is searched for.
|
||||
///
|
||||
/// The rows of a database table will be sorted according to this order on the column backing [propertyName] prior
|
||||
/// to this page being fetched.
|
||||
QuerySortOrder order;
|
||||
|
||||
/// The property of the model object to page on.
|
||||
///
|
||||
/// This property must have an inherent order, such as an [int] or [DateTime]. The database must be able to compare the values of this property using comparison operator '<' and '>'.
|
||||
String propertyName;
|
||||
|
||||
/// The point within an ordered set of result values in which rows will begin being fetched from.
|
||||
///
|
||||
/// After the table has been ordered by its [propertyName] and [order], the point in that ordered table
|
||||
/// is found where a row goes from being less than or equal to this value to greater than or equal to this value.
|
||||
/// Page results start at the row where this comparison changes.
|
||||
///
|
||||
/// Rows with a value equal to this value are not included in the data set. This value may be null. When this value is null,
|
||||
/// the [boundingValue] is set to be the just outside the first or last element of the ordered database table, depending on the direction.
|
||||
/// This allows for query pages that fetch the first or last page of elements when the starting/ending value is not known.
|
||||
dynamic boundingValue;
|
||||
}
|
203
packages/database/lib/src/query/predicate.dart
Normal file
203
packages/database/lib/src/query/predicate.dart
Normal file
|
@ -0,0 +1,203 @@
|
|||
import 'package:protevus_database/src/persistent_store/persistent_store.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// A predicate contains instructions for filtering rows when performing a [Query].
|
||||
///
|
||||
/// Predicates currently are the WHERE clause in a SQL statement and are used verbatim
|
||||
/// by the [PersistentStore]. In general, you should use [Query.where] instead of using this class directly, as [Query.where] will
|
||||
/// use the underlying [PersistentStore] to generate a [QueryPredicate] for you.
|
||||
///
|
||||
/// A predicate has a format and parameters. The format is the [String] that comes after WHERE in a SQL query. The format may
|
||||
/// have parameterized values, for which the corresponding value is in the [parameters] map. A parameter is prefixed with '@' in the format string. Currently,
|
||||
/// the format string's parameter syntax is defined by the [PersistentStore] it is used on. An example of that format:
|
||||
///
|
||||
/// var predicate = new QueryPredicate("x = @xValue", {"xValue" : 5});
|
||||
class QueryPredicate {
|
||||
/// Default constructor
|
||||
///
|
||||
/// The [format] and [parameters] of this predicate. [parameters] may be null.
|
||||
QueryPredicate(this.format, [this.parameters = const {}]);
|
||||
|
||||
/// Creates an empty predicate.
|
||||
///
|
||||
/// The format string is the empty string and parameters is the empty map.
|
||||
QueryPredicate.empty()
|
||||
: format = "",
|
||||
parameters = {};
|
||||
|
||||
/// Combines [predicates] with 'AND' keyword.
|
||||
///
|
||||
/// The [format] of the return value is produced by joining together each [predicates]
|
||||
/// [format] string with 'AND'. Each [parameters] from individual [predicates] is combined
|
||||
/// into the returned [parameters].
|
||||
///
|
||||
/// If there are duplicate parameter names in [predicates], they will be disambiguated by suffixing
|
||||
/// the parameter name in both [format] and [parameters] with a unique integer.
|
||||
///
|
||||
/// If [predicates] is null or empty, an empty predicate is returned. If [predicates] contains only
|
||||
/// one predicate, that predicate is returned.
|
||||
factory QueryPredicate.and(Iterable<QueryPredicate> predicates) {
|
||||
final predicateList = predicates.where((p) => p.format.isNotEmpty).toList();
|
||||
|
||||
if (predicateList.isEmpty) {
|
||||
return QueryPredicate.empty();
|
||||
}
|
||||
|
||||
if (predicateList.length == 1) {
|
||||
return predicateList.first;
|
||||
}
|
||||
|
||||
// If we have duplicate keys anywhere, we need to disambiguate them.
|
||||
int dupeCounter = 0;
|
||||
final allFormatStrings = [];
|
||||
final valueMap = <String, dynamic>{};
|
||||
for (final predicate in predicateList) {
|
||||
final duplicateKeys = predicate.parameters.keys
|
||||
.where((k) => valueMap.keys.contains(k))
|
||||
.toList();
|
||||
|
||||
if (duplicateKeys.isNotEmpty) {
|
||||
var fmt = predicate.format;
|
||||
final Map<String, String> dupeMap = {};
|
||||
for (final key in duplicateKeys) {
|
||||
final replacementKey = "$key$dupeCounter";
|
||||
fmt = fmt.replaceAll("@$key", "@$replacementKey");
|
||||
dupeMap[key] = replacementKey;
|
||||
dupeCounter++;
|
||||
}
|
||||
|
||||
allFormatStrings.add(fmt);
|
||||
predicate.parameters.forEach((key, value) {
|
||||
valueMap[dupeMap[key] ?? key] = value;
|
||||
});
|
||||
} else {
|
||||
allFormatStrings.add(predicate.format);
|
||||
valueMap.addAll(predicate.parameters);
|
||||
}
|
||||
}
|
||||
|
||||
final predicateFormat = "(${allFormatStrings.join(" AND ")})";
|
||||
return QueryPredicate(predicateFormat, valueMap);
|
||||
}
|
||||
|
||||
/// The string format of the this predicate.
|
||||
///
|
||||
/// This is the predicate text. Do not write dynamic values directly to the format string, instead, prefix an identifier with @
|
||||
/// and add that identifier to the [parameters] map.
|
||||
String format;
|
||||
|
||||
/// A map of values to replace in the format string at execution time.
|
||||
///
|
||||
/// Input values should not be in the format string, but instead provided in this map.
|
||||
/// Keys of this map will be searched for in the format string and be replaced by the value in this map.
|
||||
Map<String, dynamic> parameters;
|
||||
}
|
||||
|
||||
/// The operator in a comparison matcher.
|
||||
enum PredicateOperator {
|
||||
lessThan,
|
||||
greaterThan,
|
||||
notEqual,
|
||||
lessThanEqualTo,
|
||||
greaterThanEqualTo,
|
||||
equalTo
|
||||
}
|
||||
|
||||
class ComparisonExpression implements PredicateExpression {
|
||||
const ComparisonExpression(this.value, this.operator);
|
||||
|
||||
final dynamic value;
|
||||
final PredicateOperator operator;
|
||||
|
||||
@override
|
||||
PredicateExpression get inverse {
|
||||
return ComparisonExpression(value, inverseOperator);
|
||||
}
|
||||
|
||||
PredicateOperator get inverseOperator {
|
||||
switch (operator) {
|
||||
case PredicateOperator.lessThan:
|
||||
return PredicateOperator.greaterThanEqualTo;
|
||||
case PredicateOperator.greaterThan:
|
||||
return PredicateOperator.lessThanEqualTo;
|
||||
case PredicateOperator.notEqual:
|
||||
return PredicateOperator.equalTo;
|
||||
case PredicateOperator.lessThanEqualTo:
|
||||
return PredicateOperator.greaterThan;
|
||||
case PredicateOperator.greaterThanEqualTo:
|
||||
return PredicateOperator.lessThan;
|
||||
case PredicateOperator.equalTo:
|
||||
return PredicateOperator.notEqual;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator in a string matcher.
|
||||
enum PredicateStringOperator { beginsWith, contains, endsWith, equals }
|
||||
|
||||
abstract class PredicateExpression {
|
||||
PredicateExpression get inverse;
|
||||
}
|
||||
|
||||
class RangeExpression implements PredicateExpression {
|
||||
const RangeExpression(this.lhs, this.rhs, {this.within = true});
|
||||
|
||||
final bool within;
|
||||
final dynamic lhs;
|
||||
final dynamic rhs;
|
||||
|
||||
@override
|
||||
PredicateExpression get inverse {
|
||||
return RangeExpression(lhs, rhs, within: !within);
|
||||
}
|
||||
}
|
||||
|
||||
class NullCheckExpression implements PredicateExpression {
|
||||
const NullCheckExpression({this.shouldBeNull = true});
|
||||
|
||||
final bool shouldBeNull;
|
||||
|
||||
@override
|
||||
PredicateExpression get inverse {
|
||||
return NullCheckExpression(shouldBeNull: !shouldBeNull);
|
||||
}
|
||||
}
|
||||
|
||||
class SetMembershipExpression implements PredicateExpression {
|
||||
const SetMembershipExpression(this.values, {this.within = true});
|
||||
|
||||
final List<dynamic> values;
|
||||
final bool within;
|
||||
|
||||
@override
|
||||
PredicateExpression get inverse {
|
||||
return SetMembershipExpression(values, within: !within);
|
||||
}
|
||||
}
|
||||
|
||||
class StringExpression implements PredicateExpression {
|
||||
const StringExpression(
|
||||
this.value,
|
||||
this.operator, {
|
||||
this.caseSensitive = true,
|
||||
this.invertOperator = false,
|
||||
this.allowSpecialCharacters = true,
|
||||
});
|
||||
|
||||
final PredicateStringOperator operator;
|
||||
final bool invertOperator;
|
||||
final bool caseSensitive;
|
||||
final bool allowSpecialCharacters;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
PredicateExpression get inverse {
|
||||
return StringExpression(
|
||||
value,
|
||||
operator,
|
||||
caseSensitive: caseSensitive,
|
||||
invertOperator: !invertOperator,
|
||||
allowSpecialCharacters: allowSpecialCharacters,
|
||||
);
|
||||
}
|
||||
}
|
425
packages/database/lib/src/query/query.dart
Normal file
425
packages/database/lib/src/query/query.dart
Normal file
|
@ -0,0 +1,425 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/query/error.dart';
|
||||
import 'package:protevus_database/src/query/matcher_expression.dart';
|
||||
import 'package:protevus_database/src/query/predicate.dart';
|
||||
import 'package:protevus_database/src/query/reduce.dart';
|
||||
import 'package:protevus_database/src/query/sort_predicate.dart';
|
||||
|
||||
export 'error.dart';
|
||||
export 'matcher_expression.dart';
|
||||
export 'mixin.dart';
|
||||
export 'predicate.dart';
|
||||
export 'reduce.dart';
|
||||
export 'sort_predicate.dart';
|
||||
|
||||
/// An object for configuring and executing a database query.
|
||||
///
|
||||
/// Queries are used to fetch, update, insert, delete and count [InstanceType]s in a database.
|
||||
/// [InstanceType] must be a [ManagedObject].
|
||||
///
|
||||
/// final query = Query<Employee>()
|
||||
/// ..where((e) => e.salary).greaterThan(50000);
|
||||
/// final employees = await query.fetch();
|
||||
abstract class Query<InstanceType extends ManagedObject> {
|
||||
/// Creates a new [Query].
|
||||
///
|
||||
/// The query will be sent to the database described by [context].
|
||||
/// For insert or update queries, you may provide [values] through this constructor
|
||||
/// or set the field of the same name later. If set in the constructor,
|
||||
/// [InstanceType] is inferred.
|
||||
factory Query(ManagedContext context, {InstanceType? values}) {
|
||||
final entity = context.dataModel!.tryEntityForType(InstanceType);
|
||||
if (entity == null) {
|
||||
throw ArgumentError(
|
||||
"Invalid context. The data model of 'context' does not contain '$InstanceType'.",
|
||||
);
|
||||
}
|
||||
|
||||
return context.persistentStore.newQuery<InstanceType>(
|
||||
context,
|
||||
entity,
|
||||
values: values,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a new [Query] without a static type.
|
||||
///
|
||||
/// This method is used when generating queries dynamically from runtime values,
|
||||
/// where the static type argument cannot be defined. Behaves just like the unnamed constructor.
|
||||
///
|
||||
/// If [entity] is not in [context]'s [ManagedContext.dataModel], throws a internal failure [QueryException].
|
||||
factory Query.forEntity(ManagedEntity entity, ManagedContext context) {
|
||||
if (!context.dataModel!.entities.any((e) => identical(entity, e))) {
|
||||
throw StateError(
|
||||
"Invalid query construction. Entity for '${entity.tableName}' is from different context than specified for query.",
|
||||
);
|
||||
}
|
||||
|
||||
return context.persistentStore.newQuery<InstanceType>(context, entity);
|
||||
}
|
||||
|
||||
/// Inserts a single [object] into the database managed by [context].
|
||||
///
|
||||
/// This is equivalent to creating a [Query], assigning [object] to [values], and invoking [insert].
|
||||
static Future<T> insertObject<T extends ManagedObject>(
|
||||
ManagedContext context,
|
||||
T object,
|
||||
) {
|
||||
return context.insertObject(object);
|
||||
}
|
||||
|
||||
/// Inserts each object in [objects] into the database managed by [context] in a single transaction.
|
||||
///
|
||||
/// This currently has no Query instance equivalent
|
||||
static Future<List<T>> insertObjects<T extends ManagedObject>(
|
||||
ManagedContext context,
|
||||
List<T> objects,
|
||||
) async {
|
||||
return context.insertObjects(objects);
|
||||
}
|
||||
|
||||
/// Configures this instance to fetch a relationship property identified by [object] or [set].
|
||||
///
|
||||
/// By default, objects returned by [Query.fetch] do not have their relationship properties populated. (In other words,
|
||||
/// [ManagedObject] and [ManagedSet] properties are null.) This method configures this instance to conduct a SQL join,
|
||||
/// allowing it to fetch relationship properties for the returned instances.
|
||||
///
|
||||
/// Consider a [ManagedObject] subclass with the following relationship properties as an example:
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {}
|
||||
/// class _User {
|
||||
/// Profile profile;
|
||||
/// ManagedSet<Note> notes;
|
||||
/// }
|
||||
///
|
||||
/// To fetch an object and one of its has-one properties, use the [object] closure:
|
||||
///
|
||||
/// var query = Query<User>()
|
||||
/// ..join(object: (u) => u.profile);
|
||||
///
|
||||
/// To fetch an object and its has-many properties, use the [set] closure:
|
||||
///
|
||||
/// var query = Query<User>()
|
||||
/// ..join(set: (u) => u.notes);
|
||||
///
|
||||
/// Both [object] and [set] are passed an empty instance of the type being queried. [object] must return a has-one property (a [ManagedObject] subclass)
|
||||
/// of the object it is passed. [set] must return a has-many property (a [ManagedSet]) of the object it is passed.
|
||||
///
|
||||
/// Multiple relationship properties can be included by invoking this method multiple times with different properties, e.g.:
|
||||
///
|
||||
/// var query = Query<User>()
|
||||
/// ..join(object: (u) => u.profile)
|
||||
/// ..join(set: (u) => u.notes);
|
||||
///
|
||||
/// This method also returns a new instance of [Query], where [InstanceType] is is the type of the relationship property. This can be used
|
||||
/// to configure which properties are returned for the related objects and to filter a [ManagedSet] relationship property. For example:
|
||||
///
|
||||
/// var query = Query<User>();
|
||||
/// var subquery = query.join(set: (u) => u.notes)
|
||||
/// ..where.dateCreatedAt = whereGreaterThan(someDate);
|
||||
///
|
||||
/// This mechanism only works on [fetch] and [fetchOne] execution methods. You *must not* execute a subquery created by this method.
|
||||
Query<T> join<T extends ManagedObject>({
|
||||
T? Function(InstanceType x)? object,
|
||||
ManagedSet<T>? Function(InstanceType x)? set,
|
||||
});
|
||||
|
||||
/// Configures this instance to fetch a section of a larger result set.
|
||||
///
|
||||
/// This method provides an effective mechanism for paging a result set by ordering rows
|
||||
/// by some property, offsetting into that ordered set and returning rows starting from that offset.
|
||||
/// The [fetchLimit] of this instance must also be configured to limit the size of the page.
|
||||
///
|
||||
/// The property that determines order is identified by [propertyIdentifier]. This closure must
|
||||
/// return a property of of [InstanceType]. The order is determined by [order]. When fetching
|
||||
/// the 'first' page of results, no value is passed for [boundingValue]. As later pages are fetched,
|
||||
/// the value of the paging property for the last returned object in the previous result set is used
|
||||
/// as [boundingValue]. For example:
|
||||
///
|
||||
/// var recentHireQuery = Query<Employee>()
|
||||
/// ..pageBy((e) => e.hireDate, QuerySortOrder.descending);
|
||||
/// var recentHires = await recentHireQuery.fetch();
|
||||
///
|
||||
/// var nextRecentHireQuery = Query<Employee>()
|
||||
/// ..pageBy((e) => e.hireDate, QuerySortOrder.descending,
|
||||
/// boundingValue: recentHires.last.hireDate);
|
||||
///
|
||||
/// Note that internally, [pageBy] adds a matcher to [where] and adds a high-priority [sortBy].
|
||||
/// Adding multiple [pageBy]s to an instance has undefined behavior.
|
||||
void pageBy<T>(
|
||||
T Function(InstanceType x) propertyIdentifier,
|
||||
QuerySortOrder order, {
|
||||
T? boundingValue,
|
||||
});
|
||||
|
||||
/// Configures this instance to sort its results by some property and order.
|
||||
///
|
||||
/// This method will have the database perform a sort by some property identified by [propertyIdentifier].
|
||||
/// [propertyIdentifier] must return a scalar property of [InstanceType] that can be compared. The [order]
|
||||
/// indicates the order the returned rows will be in. Multiple [sortBy]s may be invoked on an instance;
|
||||
/// the order in which they are added indicates sort precedence. Example:
|
||||
///
|
||||
/// var query = Query<Employee>()
|
||||
/// ..sortBy((e) => e.name, QuerySortOrder.ascending);
|
||||
void sortBy<T>(
|
||||
T Function(InstanceType x) propertyIdentifier,
|
||||
QuerySortOrder order,
|
||||
);
|
||||
|
||||
/// The [ManagedEntity] of the [InstanceType].
|
||||
ManagedEntity get entity;
|
||||
|
||||
/// The [ManagedContext] this query will be executed on.
|
||||
ManagedContext get context;
|
||||
|
||||
/// Returns a new object that can execute functions like sum, average, maximum, etc.
|
||||
///
|
||||
/// The methods of this object will execute an aggregate function on the database table.
|
||||
/// For example, this property can be used to find the average age of all users.
|
||||
///
|
||||
/// var query = Query<User>();
|
||||
/// var averageAge = await query.reduce.average((user) => user.age);
|
||||
///
|
||||
/// Any where clauses established by [where] or [predicate] will impact the rows evaluated
|
||||
/// and therefore the value returned from this object's instance methods.
|
||||
///
|
||||
/// Always returns a new instance of [QueryReduceOperation]. The returned object is permanently
|
||||
/// associated with this instance. Any changes to this instance (i.e., modifying [where]) will impact the
|
||||
/// result.
|
||||
QueryReduceOperation<InstanceType> get reduce;
|
||||
|
||||
/// Selects a property from the object being queried to add a filtering expression.
|
||||
///
|
||||
/// You use this property to add filtering expression to a query. The expressions are added to the SQL WHERE clause
|
||||
/// of the generated query.
|
||||
///
|
||||
/// You provide a closure for [propertyIdentifier] that returns a property of its argument. Its argument is always
|
||||
/// an empty instance of the object being queried. You invoke methods like [QueryExpression.lessThan] on the
|
||||
/// object returned from this method to add an expression to this query.
|
||||
///
|
||||
/// final query = Query<Employee>()
|
||||
/// ..where((e) => e.name).equalTo("Bob");
|
||||
///
|
||||
/// You may select properties of relationships using this method.
|
||||
///
|
||||
/// final query = Query<Employee>()
|
||||
/// ..where((e) => e.manager.name).equalTo("Sally");
|
||||
///
|
||||
QueryExpression<T, InstanceType> where<T>(
|
||||
T Function(InstanceType x) propertyIdentifier,
|
||||
);
|
||||
|
||||
/// Confirms that a query has no predicate before executing it.
|
||||
///
|
||||
/// This is a safety measure for update and delete queries to prevent accidentally updating or deleting every row.
|
||||
/// This flag defaults to false, meaning that if this query is either an update or a delete, but contains no predicate,
|
||||
/// it will fail. If a query is meant to update or delete every row on a table, you may set this to true to allow this query to proceed.
|
||||
bool canModifyAllInstances = false;
|
||||
|
||||
/// Number of seconds before a Query times out.
|
||||
///
|
||||
/// A Query will fail and throw a [QueryException] if [timeoutInSeconds] seconds elapse before the query completes.
|
||||
/// Defaults to 30 seconds.
|
||||
int timeoutInSeconds = 30;
|
||||
|
||||
/// Limits the number of objects returned from the Query.
|
||||
///
|
||||
/// Defaults to 0. When zero, there is no limit to the number of objects returned from the Query.
|
||||
/// This value should be set when using [pageBy] to limit the page size.
|
||||
int fetchLimit = 0;
|
||||
|
||||
/// Offsets the rows returned.
|
||||
///
|
||||
/// The set of rows returned will exclude the first [offset] number of rows selected in the query. Do not
|
||||
/// set this property when using [pageBy].
|
||||
int offset = 0;
|
||||
|
||||
/// A predicate for filtering the result or operation set.
|
||||
///
|
||||
/// A predicate will identify the rows being accessed, see [QueryPredicate] for more details. Prefer to use
|
||||
/// [where] instead of this property directly.
|
||||
QueryPredicate? predicate;
|
||||
|
||||
/// A predicate for sorting the result.
|
||||
///
|
||||
/// A predicate will identify the rows being accessed, see [QuerySortPredicate] for more details. Prefer to use
|
||||
/// [sortBy] instead of this property directly.
|
||||
QuerySortPredicate? sortPredicate;
|
||||
|
||||
/// Values to be used when inserting or updating an object.
|
||||
///
|
||||
/// This method is an unsafe version of [values]. Prefer to use [values] instead.
|
||||
/// Keys in this map must be the name of a property of [InstanceType], otherwise an exception
|
||||
/// is thrown. Values provided in this map are not run through any [Validate] annotations
|
||||
/// declared by the [InstanceType].
|
||||
///
|
||||
/// Do not set this property and [values] on the same query. If both this property and [values] are set,
|
||||
/// the behavior is undefined.
|
||||
Map<String, dynamic>? valueMap;
|
||||
|
||||
/// Values to be sent to database during an [update] or [insert] query.
|
||||
///
|
||||
/// You set values for the properties of this object to insert a row or update one or more rows.
|
||||
/// This property is the same type as the type being inserted or updated. [values] is empty (but not null)
|
||||
/// when a [Query] is first created, therefore, you do not have to assign an instance to it and may set
|
||||
/// values for its properties immediately:
|
||||
///
|
||||
/// var q = Query<User>()
|
||||
/// ..values.name = 'Joe'
|
||||
/// ..values.job = 'programmer';
|
||||
/// await q.insert();
|
||||
///
|
||||
/// You may only set values for properties that are backed by a column. This includes most properties, except
|
||||
/// all [ManagedSet] properties and [ManagedObject] properties that do not have a [Relate] annotation. If you attempt
|
||||
/// to set a property that isn't allowed on [values], an error is thrown.
|
||||
///
|
||||
/// If a property of [values] is a [ManagedObject] with a [Relate] annotation,
|
||||
/// you may provide a value for its primary key property. This value will be
|
||||
/// stored in the foreign key column that backs the property. You may set
|
||||
/// properties of this type immediately, without having to create an instance
|
||||
/// of the related type:
|
||||
///
|
||||
/// // Assumes that Employee is declared with the following property:
|
||||
/// // @Relate(#employees)
|
||||
/// // Manager manager;
|
||||
///
|
||||
/// final q = Query<Employee>()
|
||||
/// ..values.name = "Sally"
|
||||
/// ..values.manager.id = 10;
|
||||
/// await q.insert();
|
||||
///
|
||||
/// WARNING: You may replace this property with a new instance of [InstanceType].
|
||||
/// When doing so, a copy of the object is created and assigned to this property.
|
||||
///
|
||||
/// final o = SomeObject()
|
||||
/// ..id = 1;
|
||||
/// final q = Query<SomeObject>()
|
||||
/// ..values = o;
|
||||
///
|
||||
/// o.id = 2;
|
||||
/// assert(q.values.id == 1); // true
|
||||
///
|
||||
late InstanceType values;
|
||||
|
||||
/// Configures the list of properties to be fetched for [InstanceType].
|
||||
///
|
||||
/// This method configures which properties will be populated for [InstanceType] when returned
|
||||
/// from this query. This impacts all query execution methods that return [InstanceType] or [List] of [InstanceType].
|
||||
///
|
||||
/// The following example would configure this instance to fetch the 'id' and 'name' for each returned 'Employee':
|
||||
///
|
||||
/// var q = Query<Employee>()
|
||||
/// ..returningProperties((employee) => [employee.id, employee.name]);
|
||||
///
|
||||
/// Note that if the primary key property of an object is omitted from this list, it will be added when this
|
||||
/// instance executes. If the primary key value should not be sent back as part of an API response,
|
||||
/// it can be stripped from the returned object(s) with [ManagedObject.removePropertyFromBackingMap].
|
||||
///
|
||||
/// If this method is not invoked, the properties defined by [ManagedEntity.defaultProperties] are returned.
|
||||
void returningProperties(
|
||||
List<dynamic> Function(InstanceType x) propertyIdentifiers,
|
||||
);
|
||||
|
||||
/// Inserts an [InstanceType] into the underlying database.
|
||||
///
|
||||
/// The [Query] must have its [values] or [valueMap] property set. This operation will
|
||||
/// insert a row with the data supplied in those fields to the database in [context]. The return value is
|
||||
/// a [Future] that completes with the newly inserted [InstanceType]. Example:
|
||||
///
|
||||
/// var q = Query<User>()
|
||||
/// ..values.name = "Joe";
|
||||
/// var newUser = await q.insert();
|
||||
///
|
||||
/// If the [InstanceType] has properties with [Validate] metadata, those validations
|
||||
/// will be executed prior to sending the query to the database.
|
||||
///
|
||||
/// The method guaranties that exactly one row will be inserted and returned
|
||||
/// or an exception will be thrown and the row will not be written to the database.
|
||||
Future<InstanceType> insert();
|
||||
|
||||
/// Inserts an [InstanceType]s into the underlying database.
|
||||
///
|
||||
/// The [Query] must not have its [values] nor [valueMap] property set. This
|
||||
/// operation will insert a row for each item in [objects] to the database in
|
||||
/// [context]. The return value is a [Future] that completes with the newly
|
||||
/// inserted [InstanceType]s. Example:
|
||||
///
|
||||
/// final users = [
|
||||
/// User()..email = 'user1@example.dev',
|
||||
/// User()..email = 'user2@example.dev',
|
||||
/// ];
|
||||
/// final q = Query<User>();
|
||||
/// var newUsers = await q.insertMany(users);
|
||||
///
|
||||
/// If the [InstanceType] has properties with [Validate] metadata, those
|
||||
/// validations will be executed prior to sending the query to the database.
|
||||
///
|
||||
/// The method guaranties that either all rows will be inserted and returned
|
||||
/// or exception will be thrown and non of the rows will be written to the database.
|
||||
Future<List<InstanceType>> insertMany(List<InstanceType> objects);
|
||||
|
||||
/// Updates [InstanceType]s in the underlying database.
|
||||
///
|
||||
/// The [Query] must have its [values] or [valueMap] property set and should likely have its [predicate] or [where] set as well. This operation will
|
||||
/// update each row that matches the conditions in [predicate]/[where] with the values from [values] or [valueMap]. If no [where] or [predicate] is set,
|
||||
/// this method will throw an exception by default, assuming that you don't typically want to update every row in a database table. To specify otherwise,
|
||||
/// set [canModifyAllInstances] to true.
|
||||
/// The return value is a [Future] that completes with the any updated [InstanceType]s. Example:
|
||||
///
|
||||
/// var q = Query<User>()
|
||||
/// ..where.name = "Fred"
|
||||
/// ..values.name = "Joe";
|
||||
/// var usersNamedFredNowNamedJoe = await q.update();
|
||||
///
|
||||
/// If the [InstanceType] has properties with [Validate] metadata, those validations
|
||||
/// will be executed prior to sending the query to the database.
|
||||
Future<List<InstanceType>> update();
|
||||
|
||||
/// Updates an [InstanceType] in the underlying database.
|
||||
///
|
||||
/// This method works the same as [update], except it may only update one row in the underlying database. If this method
|
||||
/// ends up modifying multiple rows, an exception is thrown.
|
||||
///
|
||||
/// If the [InstanceType] has properties with [Validate] metadata, those validations
|
||||
/// will be executed prior to sending the query to the database.
|
||||
Future<InstanceType?> updateOne();
|
||||
|
||||
/// Fetches [InstanceType]s from the database.
|
||||
///
|
||||
/// This operation will return all [InstanceType]s from the database, filtered by [predicate]/[where]. Example:
|
||||
///
|
||||
/// var q = Query<User>();
|
||||
/// var allUsers = q.fetch();
|
||||
///
|
||||
Future<List<InstanceType>> fetch();
|
||||
|
||||
/// Fetches a single [InstanceType] from the database.
|
||||
///
|
||||
/// This method behaves the same as [fetch], but limits the results to a single object.
|
||||
Future<InstanceType?> fetchOne();
|
||||
|
||||
/// Deletes [InstanceType]s from the underlying database.
|
||||
///
|
||||
/// This method will delete rows identified by [predicate]/[where]. If no [where] or [predicate] is set,
|
||||
/// this method will throw an exception by default, assuming that you don't typically want to delete every row in a database table. To specify otherwise,
|
||||
/// set [canModifyAllInstances] to true.
|
||||
///
|
||||
/// This method will return the number of rows deleted.
|
||||
/// Example:
|
||||
///
|
||||
/// var q = Query<User>()
|
||||
/// ..where.id = whereEqualTo(1);
|
||||
/// var deletedCount = await q.delete();
|
||||
Future<int> delete();
|
||||
}
|
||||
|
||||
/// Order value for [Query.pageBy] and [Query.sortBy].
|
||||
enum QuerySortOrder {
|
||||
/// Ascending order. Example: 1, 2, 3, 4, ...
|
||||
ascending,
|
||||
|
||||
/// Descending order. Example: 4, 3, 2, 1, ...
|
||||
descending
|
||||
}
|
62
packages/database/lib/src/query/reduce.dart
Normal file
62
packages/database/lib/src/query/reduce.dart
Normal file
|
@ -0,0 +1,62 @@
|
|||
import 'dart:async';
|
||||
import 'package:protevus_database/src/managed/object.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// Executes aggregate functions like average, count, sum, etc.
|
||||
///
|
||||
/// See instance methods for available aggregate functions.
|
||||
///
|
||||
/// See [Query.reduce] for more details on usage.
|
||||
abstract class QueryReduceOperation<T extends ManagedObject> {
|
||||
/// Computes the average of some [ManagedObject] property.
|
||||
///
|
||||
/// [selector] identifies the property being averaged, e.g.
|
||||
///
|
||||
/// var query = Query<User>();
|
||||
/// var averageAge = await query.reduce.average((user) => user.age);
|
||||
///
|
||||
/// The property must be an attribute and its type must be an [num], i.e. [int] or [double].
|
||||
Future<double?> average(num? Function(T object) selector);
|
||||
|
||||
/// Counts the number of [ManagedObject] instances in the database.
|
||||
///
|
||||
/// Note: this can be an expensive query. Consult the documentation
|
||||
/// for the underlying database.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = Query<User>();
|
||||
/// var totalUsers = await query.reduce.count();
|
||||
///
|
||||
Future<int> count();
|
||||
|
||||
/// Finds the maximum of some [ManagedObject] property.
|
||||
///
|
||||
/// [selector] identifies the property being evaluated, e.g.
|
||||
///
|
||||
/// var query = Query<User>();
|
||||
/// var oldestUser = await query.reduce.maximum((user) => user.age);
|
||||
///
|
||||
/// The property must be an attribute and its type must be [String], [int], [double], or [DateTime].
|
||||
Future<U?> maximum<U>(U? Function(T object) selector);
|
||||
|
||||
/// Finds the minimum of some [ManagedObject] property.
|
||||
///
|
||||
/// [selector] identifies the property being evaluated, e.g.
|
||||
///
|
||||
/// var query = new Query<User>();
|
||||
/// var youngestUser = await query.reduce.minimum((user) => user.age);
|
||||
///
|
||||
/// The property must be an attribute and its type must be [String], [int], [double], or [DateTime].
|
||||
Future<U?> minimum<U>(U? Function(T object) selector);
|
||||
|
||||
/// Finds the sum of some [ManagedObject] property.
|
||||
///
|
||||
/// [selector] identifies the property being evaluated, e.g.
|
||||
///
|
||||
/// var query = new Query<User>();
|
||||
/// var yearsLivesByAllUsers = await query.reduce.sum((user) => user.age);
|
||||
///
|
||||
/// The property must be an attribute and its type must be an [num], i.e. [int] or [double].
|
||||
Future<U?> sum<U extends num>(U? Function(T object) selector);
|
||||
}
|
16
packages/database/lib/src/query/sort_descriptor.dart
Normal file
16
packages/database/lib/src/query/sort_descriptor.dart
Normal file
|
@ -0,0 +1,16 @@
|
|||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// The order in which a collection of objects should be sorted when returned from a database.
|
||||
///
|
||||
/// See [Query.sortBy] and [Query.pageBy] for more details.
|
||||
class QuerySortDescriptor {
|
||||
QuerySortDescriptor(this.key, this.order);
|
||||
|
||||
/// The name of a property to sort by.
|
||||
String key;
|
||||
|
||||
/// The order in which values should be sorted.
|
||||
///
|
||||
/// See [QuerySortOrder] for possible values.
|
||||
QuerySortOrder order;
|
||||
}
|
17
packages/database/lib/src/query/sort_predicate.dart
Normal file
17
packages/database/lib/src/query/sort_predicate.dart
Normal file
|
@ -0,0 +1,17 @@
|
|||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// The order in which a collection of objects should be sorted when returned from a database.
|
||||
class QuerySortPredicate {
|
||||
QuerySortPredicate(
|
||||
this.predicate,
|
||||
this.order,
|
||||
);
|
||||
|
||||
/// The name of a property to sort by.
|
||||
String predicate;
|
||||
|
||||
/// The order in which values should be sorted.
|
||||
///
|
||||
/// See [QuerySortOrder] for possible values.
|
||||
QuerySortOrder order;
|
||||
}
|
88
packages/database/lib/src/schema/migration.dart
Normal file
88
packages/database/lib/src/schema/migration.dart
Normal file
|
@ -0,0 +1,88 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_database/src/persistent_store/persistent_store.dart';
|
||||
import 'package:protevus_database/src/schema/schema.dart';
|
||||
|
||||
/// Thrown when [Migration] encounters an error.
|
||||
class MigrationException implements Exception {
|
||||
MigrationException(this.message);
|
||||
String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// The base class for migration instructions.
|
||||
///
|
||||
/// For each set of changes to a database, a subclass of [Migration] is created.
|
||||
/// Subclasses will override [upgrade] to make changes to the [Schema] which
|
||||
/// are translated into database operations to update a database's schema.
|
||||
abstract class Migration {
|
||||
/// The current state of the [Schema].
|
||||
///
|
||||
/// During migration, this value will be modified as [SchemaBuilder] operations
|
||||
/// are executed. See [SchemaBuilder].
|
||||
Schema get currentSchema => database.schema;
|
||||
|
||||
/// The [PersistentStore] that represents the database being migrated.
|
||||
PersistentStore? get store => database.store;
|
||||
|
||||
// This value is provided by the 'upgrade' tool and is derived from the filename.
|
||||
int? version;
|
||||
|
||||
/// Receiver for database altering operations.
|
||||
///
|
||||
/// Methods invoked on this instance - such as [SchemaBuilder.createTable] - will be validated
|
||||
/// and generate the appropriate SQL commands to apply to a database to alter its schema.
|
||||
late SchemaBuilder database;
|
||||
|
||||
/// Method invoked to upgrade a database to this migration version.
|
||||
///
|
||||
/// Subclasses will override this method and invoke methods on [database] to upgrade
|
||||
/// the database represented by [store].
|
||||
Future upgrade();
|
||||
|
||||
/// Method invoked to downgrade a database from this migration version.
|
||||
///
|
||||
/// Subclasses will override this method and invoke methods on [database] to downgrade
|
||||
/// the database represented by [store].
|
||||
Future downgrade();
|
||||
|
||||
/// Method invoked to seed a database's data after this migration version is upgraded to.
|
||||
///
|
||||
/// Subclasses will override this method and invoke query methods on [store] to add data
|
||||
/// to a database after this migration version is executed.
|
||||
Future seed();
|
||||
|
||||
static String sourceForSchemaUpgrade(
|
||||
Schema existingSchema,
|
||||
Schema newSchema,
|
||||
int? version, {
|
||||
List<String>? changeList,
|
||||
}) {
|
||||
final diff = existingSchema.differenceFrom(newSchema);
|
||||
final source =
|
||||
SchemaBuilder.fromDifference(null, diff, changeList: changeList)
|
||||
.commands
|
||||
.map((line) => "\t\t$line")
|
||||
.join("\n");
|
||||
|
||||
return """
|
||||
import 'dart:async';
|
||||
import 'package:protevus_database/prots_database.dart
|
||||
|
||||
class Migration$version extends Migration {
|
||||
@override
|
||||
Future upgrade() async {
|
||||
$source
|
||||
}
|
||||
|
||||
@override
|
||||
Future downgrade() async {}
|
||||
|
||||
@override
|
||||
Future seed() async {}
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
231
packages/database/lib/src/schema/schema.dart
Normal file
231
packages/database/lib/src/schema/schema.dart
Normal file
|
@ -0,0 +1,231 @@
|
|||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/schema/schema_table.dart';
|
||||
|
||||
export 'migration.dart';
|
||||
export 'schema_builder.dart';
|
||||
export 'schema_column.dart';
|
||||
export 'schema_table.dart';
|
||||
|
||||
/// A portable representation of a database schema.
|
||||
///
|
||||
/// Instances of this type contain the database-only details of a [ManagedDataModel] and are typically
|
||||
/// instantiated from [ManagedDataModel]s. The purpose of this type is to have a portable, differentiable representation
|
||||
/// of an application's data model for use in external tooling.
|
||||
class Schema {
|
||||
/// Creates an instance of this type with a specific set of [tables].
|
||||
///
|
||||
/// Prefer to use [Schema.fromDataModel].
|
||||
Schema(List<SchemaTable> tables) : _tableStorage = tables;
|
||||
|
||||
/// Creates an instance of this type from [dataModel].
|
||||
///
|
||||
/// This is preferred method of creating an instance of this type. Each [ManagedEntity]
|
||||
/// in [dataModel] will correspond to a [SchemaTable] in [tables].
|
||||
Schema.fromDataModel(ManagedDataModel dataModel) {
|
||||
_tables = dataModel.entities.map((e) => SchemaTable.fromEntity(e)).toList();
|
||||
}
|
||||
|
||||
/// Creates a deep copy of [otherSchema].
|
||||
Schema.from(Schema otherSchema) {
|
||||
_tables =
|
||||
otherSchema.tables.map((table) => SchemaTable.from(table)).toList();
|
||||
}
|
||||
|
||||
/// Creates a instance of this type from [map].
|
||||
///
|
||||
/// [map] is typically created from [asMap].
|
||||
Schema.fromMap(Map<String, dynamic> map) {
|
||||
_tables = (map["tables"] as List<Map<String, dynamic>>)
|
||||
.map((t) => SchemaTable.fromMap(t))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Creates an empty schema.
|
||||
Schema.empty() {
|
||||
_tables = [];
|
||||
}
|
||||
|
||||
/// The tables in this database.
|
||||
///
|
||||
/// Returns an immutable list of tables in this schema.
|
||||
List<SchemaTable> get tables => List.unmodifiable(_tableStorage);
|
||||
|
||||
// Do not set this directly. Use _tables= instead.
|
||||
late List<SchemaTable> _tableStorage;
|
||||
|
||||
set _tables(List<SchemaTable> tables) {
|
||||
_tableStorage = tables;
|
||||
for (final t in _tableStorage) {
|
||||
t.schema = this;
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a table from [tables] by that table's name.
|
||||
///
|
||||
/// See [tableForName] for details.
|
||||
SchemaTable? operator [](String tableName) => tableForName(tableName);
|
||||
|
||||
/// The differences between two schemas.
|
||||
///
|
||||
/// In the return value, the receiver is the [SchemaDifference.expectedSchema]
|
||||
/// and [otherSchema] is [SchemaDifference.actualSchema].
|
||||
SchemaDifference differenceFrom(Schema otherSchema) {
|
||||
return SchemaDifference(this, otherSchema);
|
||||
}
|
||||
|
||||
/// Adds a table to this instance.
|
||||
///
|
||||
/// Sets [table]'s [SchemaTable.schema] to this instance.
|
||||
void addTable(SchemaTable table) {
|
||||
if (this[table.name!] != null) {
|
||||
throw SchemaException(
|
||||
"Table ${table.name} already exists and cannot be added.",
|
||||
);
|
||||
}
|
||||
|
||||
_tableStorage.add(table);
|
||||
table.schema = this;
|
||||
}
|
||||
|
||||
void replaceTable(SchemaTable existingTable, SchemaTable newTable) {
|
||||
if (!_tableStorage.contains(existingTable)) {
|
||||
throw SchemaException(
|
||||
"Table ${existingTable.name} does not exist and cannot be replaced.",
|
||||
);
|
||||
}
|
||||
|
||||
final index = _tableStorage.indexOf(existingTable);
|
||||
_tableStorage[index] = newTable;
|
||||
newTable.schema = this;
|
||||
existingTable.schema = null;
|
||||
}
|
||||
|
||||
void renameTable(SchemaTable table, String newName) {
|
||||
throw SchemaException("Renaming a table not yet implemented!");
|
||||
//
|
||||
// if (tableForName(newName) != null) {
|
||||
// throw new SchemaException("Table ${newName} already exist.");
|
||||
// }
|
||||
//
|
||||
// if (!tables.contains(table)) {
|
||||
// throw new SchemaException("Table ${table.name} does not exist in schema.");
|
||||
// }
|
||||
//
|
||||
// // Rename indices and constraints
|
||||
// table.name = newName;
|
||||
}
|
||||
|
||||
/// Removes a table from this instance.
|
||||
///
|
||||
/// [table] must be an instance in [tables] or an exception is thrown.
|
||||
/// Sets [table]'s [SchemaTable.schema] to null.
|
||||
void removeTable(SchemaTable table) {
|
||||
if (!tables.contains(table)) {
|
||||
throw SchemaException("Table ${table.name} does not exist in schema.");
|
||||
}
|
||||
table.schema = null;
|
||||
_tableStorage.remove(table);
|
||||
}
|
||||
|
||||
/// Returns a [SchemaTable] for [name].
|
||||
///
|
||||
/// [name] is case-insensitively compared to every [SchemaTable.name]
|
||||
/// in [tables]. If no table with this name exists, null is returned.
|
||||
///
|
||||
/// Note: tables are typically prefixed with an underscore when using
|
||||
/// Conduit naming conventions for [ManagedObject].
|
||||
SchemaTable? tableForName(String name) {
|
||||
final lowercaseName = name.toLowerCase();
|
||||
|
||||
return tables
|
||||
.firstWhereOrNull((t) => t.name!.toLowerCase() == lowercaseName);
|
||||
}
|
||||
|
||||
/// Emits this instance as a transportable [Map].
|
||||
Map<String, dynamic> asMap() {
|
||||
return {"tables": tables.map((t) => t.asMap()).toList()};
|
||||
}
|
||||
}
|
||||
|
||||
/// The difference between two compared [Schema]s.
|
||||
///
|
||||
/// This class is used for comparing schemas for validation and migration.
|
||||
class SchemaDifference {
|
||||
/// Creates a new instance that represents the difference between [expectedSchema] and [actualSchema].
|
||||
///
|
||||
SchemaDifference(this.expectedSchema, this.actualSchema) {
|
||||
for (final expectedTable in expectedSchema.tables) {
|
||||
final actualTable = actualSchema[expectedTable.name!];
|
||||
if (actualTable == null) {
|
||||
_differingTables.add(SchemaTableDifference(expectedTable, null));
|
||||
} else {
|
||||
final diff = expectedTable.differenceFrom(actualTable);
|
||||
if (diff.hasDifferences) {
|
||||
_differingTables.add(diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_differingTables.addAll(
|
||||
actualSchema.tables
|
||||
.where((t) => expectedSchema[t.name!] == null)
|
||||
.map((unexpectedTable) {
|
||||
return SchemaTableDifference(null, unexpectedTable);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// The 'expected' schema.
|
||||
final Schema expectedSchema;
|
||||
|
||||
/// The 'actual' schema.
|
||||
final Schema actualSchema;
|
||||
|
||||
/// Whether or not [expectedSchema] and [actualSchema] have differences.
|
||||
///
|
||||
/// If false, both [expectedSchema] and [actualSchema], their tables, and those tables' columns are identical.
|
||||
bool get hasDifferences => _differingTables.isNotEmpty;
|
||||
|
||||
/// Human-readable messages to describe differences between [expectedSchema] and [actualSchema].
|
||||
///
|
||||
/// Empty is [hasDifferences] is false.
|
||||
List<String> get errorMessages =>
|
||||
_differingTables.expand((diff) => diff.errorMessages).toList();
|
||||
|
||||
/// The differences, if any, between tables in [expectedSchema] and [actualSchema].
|
||||
List<SchemaTableDifference> get tableDifferences => _differingTables;
|
||||
|
||||
List<SchemaTable?> get tablesToAdd {
|
||||
return _differingTables
|
||||
.where((diff) => diff.expectedTable == null && diff.actualTable != null)
|
||||
.map((d) => d.actualTable)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<SchemaTable?> get tablesToDelete {
|
||||
return _differingTables
|
||||
.where((diff) => diff.expectedTable != null && diff.actualTable == null)
|
||||
.map((diff) => diff.expectedTable)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<SchemaTableDifference> get tablesToModify {
|
||||
return _differingTables
|
||||
.where((diff) => diff.expectedTable != null && diff.actualTable != null)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final List<SchemaTableDifference> _differingTables = [];
|
||||
}
|
||||
|
||||
/// Thrown when a [Schema] encounters an error.
|
||||
class SchemaException implements Exception {
|
||||
SchemaException(this.message);
|
||||
|
||||
String message;
|
||||
|
||||
@override
|
||||
String toString() => "Invalid schema. $message";
|
||||
}
|
540
packages/database/lib/src/schema/schema_builder.dart
Normal file
540
packages/database/lib/src/schema/schema_builder.dart
Normal file
|
@ -0,0 +1,540 @@
|
|||
import 'package:protevus_database/src/persistent_store/persistent_store.dart';
|
||||
import 'package:protevus_database/src/schema/schema.dart';
|
||||
|
||||
/*
|
||||
Tests for this class are spread out some. The testing concept used starts by understanding that
|
||||
that each method invoked on the builder (e.g. createTable, addColumn) adds a statement to [commands].
|
||||
A statement is either:
|
||||
|
||||
a) A Dart statement that replicate the command to build migration code
|
||||
b) A SQL command when running a migration
|
||||
|
||||
In effect, the generated Dart statement is the source code for the invoked method. Each method invoked on a
|
||||
builder is tested so that the generated Dart code is equivalent
|
||||
to the invocation. These tests are in generate_code_test.dart.
|
||||
|
||||
The code to ensure the generated SQL is accurate is in db/postgresql/schema_generator_sql_mapping_test.dart.
|
||||
|
||||
The logic that goes into testing that the commands generated to build a valid schema in an actual postgresql are in db/postgresql/migration_test.dart.
|
||||
*/
|
||||
|
||||
/// Generates SQL or Dart code that modifies a database schema.
|
||||
class SchemaBuilder {
|
||||
/// Creates a builder starting from an existing schema.
|
||||
///
|
||||
/// If [store] is null, this builder will emit [commands] that are Dart statements that replicate the methods invoked on this object.
|
||||
/// Otherwise, [commands] are SQL commands (for the database represented by [store]) that are equivalent to the method invoked on this object.
|
||||
SchemaBuilder(this.store, this.inputSchema, {this.isTemporary = false}) {
|
||||
schema = Schema.from(inputSchema);
|
||||
}
|
||||
|
||||
/// Creates a builder starting from the empty schema.
|
||||
///
|
||||
/// If [store] is null, this builder will emit [commands] that are Dart statements that replicate the methods invoked on this object.
|
||||
/// Otherwise, [commands] are SQL commands (for the database represented by [store]) that are equivalent to the method invoked on this object.
|
||||
SchemaBuilder.toSchema(
|
||||
PersistentStore? store,
|
||||
Schema targetSchema, {
|
||||
bool isTemporary = false,
|
||||
List<String>? changeList,
|
||||
}) : this.fromDifference(
|
||||
store,
|
||||
SchemaDifference(Schema.empty(), targetSchema),
|
||||
isTemporary: isTemporary,
|
||||
changeList: changeList,
|
||||
);
|
||||
|
||||
// Creates a builder
|
||||
SchemaBuilder.fromDifference(
|
||||
this.store,
|
||||
SchemaDifference difference, {
|
||||
this.isTemporary = false,
|
||||
List<String>? changeList,
|
||||
}) {
|
||||
schema = difference.expectedSchema;
|
||||
_generateSchemaCommands(
|
||||
difference,
|
||||
changeList: changeList,
|
||||
temporary: isTemporary,
|
||||
);
|
||||
}
|
||||
|
||||
/// The starting schema of this builder.
|
||||
late Schema inputSchema;
|
||||
|
||||
/// The resulting schema of this builder as operations are applied to it.
|
||||
late Schema schema;
|
||||
|
||||
/// The persistent store to validate and construct operations.
|
||||
///
|
||||
/// If this value is not-null, [commands] is a list of SQL commands for the underlying database that change the schema in response to
|
||||
/// methods invoked on this object. If this value is null, [commands] is a list Dart statements that replicate the methods invoked on this object.
|
||||
PersistentStore? store;
|
||||
|
||||
/// Whether or not this builder should create temporary tables.
|
||||
bool isTemporary;
|
||||
|
||||
/// A list of commands generated by operations performed on this builder.
|
||||
///
|
||||
/// If [store] is non-null, these commands will be SQL commands that upgrade [inputSchema] to [schema] as determined by [store].
|
||||
/// If [store] is null, these commands are ;-terminated Dart expressions that replicate the methods to call on this object to upgrade [inputSchema] to [schema].
|
||||
List<String> commands = [];
|
||||
|
||||
/// Validates and adds a table to [schema].
|
||||
void createTable(SchemaTable table) {
|
||||
schema.addTable(table);
|
||||
|
||||
if (store != null) {
|
||||
commands.addAll(store!.createTable(table, isTemporary: isTemporary));
|
||||
} else {
|
||||
commands.add(_getNewTableExpression(table));
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and renames a table in [schema].
|
||||
void renameTable(String currentTableName, String newName) {
|
||||
final table = schema.tableForName(currentTableName);
|
||||
if (table == null) {
|
||||
throw SchemaException("Table $currentTableName does not exist.");
|
||||
}
|
||||
|
||||
schema.renameTable(table, newName);
|
||||
if (store != null) {
|
||||
commands.addAll(store!.renameTable(table, newName));
|
||||
} else {
|
||||
commands.add("database.renameTable('$currentTableName', '$newName');");
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and deletes a table in [schema].
|
||||
void deleteTable(String tableName) {
|
||||
final table = schema.tableForName(tableName);
|
||||
if (table == null) {
|
||||
throw SchemaException("Table $tableName does not exist.");
|
||||
}
|
||||
|
||||
schema.removeTable(table);
|
||||
|
||||
if (store != null) {
|
||||
commands.addAll(store!.deleteTable(table));
|
||||
} else {
|
||||
commands.add('database.deleteTable("$tableName");');
|
||||
}
|
||||
}
|
||||
|
||||
/// Alters a table in [schema].
|
||||
void alterTable(
|
||||
String tableName,
|
||||
void Function(SchemaTable targetTable) modify,
|
||||
) {
|
||||
final existingTable = schema.tableForName(tableName);
|
||||
if (existingTable == null) {
|
||||
throw SchemaException("Table $tableName does not exist.");
|
||||
}
|
||||
|
||||
final newTable = SchemaTable.from(existingTable);
|
||||
modify(newTable);
|
||||
schema.replaceTable(existingTable, newTable);
|
||||
|
||||
final shouldAddUnique = existingTable.uniqueColumnSet == null &&
|
||||
newTable.uniqueColumnSet != null;
|
||||
final shouldRemoveUnique = existingTable.uniqueColumnSet != null &&
|
||||
newTable.uniqueColumnSet == null;
|
||||
|
||||
final innerCommands = <String>[];
|
||||
if (shouldAddUnique) {
|
||||
if (store != null) {
|
||||
commands.addAll(store!.addTableUniqueColumnSet(newTable));
|
||||
} else {
|
||||
innerCommands.add(
|
||||
"t.uniqueColumnSet = [${newTable.uniqueColumnSet!.map((s) => '"$s"').join(',')}]",
|
||||
);
|
||||
}
|
||||
} else if (shouldRemoveUnique) {
|
||||
if (store != null) {
|
||||
commands.addAll(store!.deleteTableUniqueColumnSet(newTable));
|
||||
} else {
|
||||
innerCommands.add("t.uniqueColumnSet = null");
|
||||
}
|
||||
} else {
|
||||
final haveSameLength = existingTable.uniqueColumnSet!.length ==
|
||||
newTable.uniqueColumnSet!.length;
|
||||
final haveSameKeys = existingTable.uniqueColumnSet!
|
||||
.every((s) => newTable.uniqueColumnSet!.contains(s));
|
||||
|
||||
if (!haveSameKeys || !haveSameLength) {
|
||||
if (store != null) {
|
||||
commands.addAll(store!.deleteTableUniqueColumnSet(newTable));
|
||||
commands.addAll(store!.addTableUniqueColumnSet(newTable));
|
||||
} else {
|
||||
innerCommands.add(
|
||||
"t.uniqueColumnSet = [${newTable.uniqueColumnSet!.map((s) => '"$s"').join(',')}]",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (store == null && innerCommands.isNotEmpty) {
|
||||
commands.add(
|
||||
'database.alterTable("$tableName", (t) {${innerCommands.join(";")};});',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and adds a column to a table in [schema].
|
||||
void addColumn(
|
||||
String tableName,
|
||||
SchemaColumn column, {
|
||||
String? unencodedInitialValue,
|
||||
}) {
|
||||
final table = schema.tableForName(tableName);
|
||||
if (table == null) {
|
||||
throw SchemaException("Table $tableName does not exist.");
|
||||
}
|
||||
|
||||
table.addColumn(column);
|
||||
if (store != null) {
|
||||
commands.addAll(
|
||||
store!.addColumn(
|
||||
table,
|
||||
column,
|
||||
unencodedInitialValue: unencodedInitialValue,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
commands.add(
|
||||
'database.addColumn("${column.table!.name}", ${_getNewColumnExpression(column)});',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and deletes a column in a table in [schema].
|
||||
void deleteColumn(String tableName, String columnName) {
|
||||
final table = schema.tableForName(tableName);
|
||||
if (table == null) {
|
||||
throw SchemaException("Table $tableName does not exist.");
|
||||
}
|
||||
|
||||
final column = table.columnForName(columnName);
|
||||
if (column == null) {
|
||||
throw SchemaException("Column $columnName does not exists.");
|
||||
}
|
||||
|
||||
table.removeColumn(column);
|
||||
|
||||
if (store != null) {
|
||||
commands.addAll(store!.deleteColumn(table, column));
|
||||
} else {
|
||||
commands.add('database.deleteColumn("$tableName", "$columnName");');
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and renames a column in a table in [schema].
|
||||
void renameColumn(String tableName, String columnName, String newName) {
|
||||
final table = schema.tableForName(tableName);
|
||||
if (table == null) {
|
||||
throw SchemaException("Table $tableName does not exist.");
|
||||
}
|
||||
|
||||
final column = table.columnForName(columnName);
|
||||
if (column == null) {
|
||||
throw SchemaException("Column $columnName does not exists.");
|
||||
}
|
||||
|
||||
table.renameColumn(column, newName);
|
||||
|
||||
if (store != null) {
|
||||
commands.addAll(store!.renameColumn(table, column, newName));
|
||||
} else {
|
||||
commands.add(
|
||||
"database.renameColumn('$tableName', '$columnName', '$newName');",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and alters a column in a table in [schema].
|
||||
///
|
||||
/// Alterations are made by setting properties of the column passed to [modify]. If the column's nullability
|
||||
/// changes from nullable to not nullable, all previously null values for that column
|
||||
/// are set to the value of [unencodedInitialValue].
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// database.alterColumn("table", "column", (c) {
|
||||
/// c.isIndexed = true;
|
||||
/// c.isNullable = false;
|
||||
/// }), unencodedInitialValue: "0");
|
||||
void alterColumn(
|
||||
String tableName,
|
||||
String columnName,
|
||||
void Function(SchemaColumn targetColumn) modify, {
|
||||
String? unencodedInitialValue,
|
||||
}) {
|
||||
final table = schema.tableForName(tableName);
|
||||
if (table == null) {
|
||||
throw SchemaException("Table $tableName does not exist.");
|
||||
}
|
||||
|
||||
final existingColumn = table[columnName];
|
||||
if (existingColumn == null) {
|
||||
throw SchemaException("Column $columnName does not exist.");
|
||||
}
|
||||
|
||||
final newColumn = SchemaColumn.from(existingColumn);
|
||||
modify(newColumn);
|
||||
|
||||
if (existingColumn.type != newColumn.type) {
|
||||
throw SchemaException(
|
||||
"May not change column type for '${existingColumn.name}' in '$tableName' (${existingColumn.typeString} -> ${newColumn.typeString})",
|
||||
);
|
||||
}
|
||||
|
||||
if (existingColumn.autoincrement != newColumn.autoincrement) {
|
||||
throw SchemaException(
|
||||
"May not change column autoincrementing behavior for '${existingColumn.name}' in '$tableName'",
|
||||
);
|
||||
}
|
||||
|
||||
if (existingColumn.isPrimaryKey != newColumn.isPrimaryKey) {
|
||||
throw SchemaException(
|
||||
"May not change column primary key status for '${existingColumn.name}' in '$tableName'",
|
||||
);
|
||||
}
|
||||
|
||||
if (existingColumn.relatedTableName != newColumn.relatedTableName) {
|
||||
throw SchemaException(
|
||||
"May not change reference table for foreign key column '${existingColumn.name}' in '$tableName' (${existingColumn.relatedTableName} -> ${newColumn.relatedTableName})",
|
||||
);
|
||||
}
|
||||
|
||||
if (existingColumn.relatedColumnName != newColumn.relatedColumnName) {
|
||||
throw SchemaException(
|
||||
"May not change reference column for foreign key column '${existingColumn.name}' in '$tableName' (${existingColumn.relatedColumnName} -> ${newColumn.relatedColumnName})",
|
||||
);
|
||||
}
|
||||
|
||||
if (existingColumn.name != newColumn.name) {
|
||||
renameColumn(tableName, existingColumn.name, newColumn.name);
|
||||
}
|
||||
|
||||
table.replaceColumn(existingColumn, newColumn);
|
||||
|
||||
final innerCommands = <String>[];
|
||||
if (existingColumn.isIndexed != newColumn.isIndexed) {
|
||||
if (store != null) {
|
||||
if (newColumn.isIndexed!) {
|
||||
commands.addAll(store!.addIndexToColumn(table, newColumn));
|
||||
} else {
|
||||
commands.addAll(store!.deleteIndexFromColumn(table, newColumn));
|
||||
}
|
||||
} else {
|
||||
innerCommands.add("c.isIndexed = ${newColumn.isIndexed}");
|
||||
}
|
||||
}
|
||||
|
||||
if (existingColumn.isUnique != newColumn.isUnique) {
|
||||
if (store != null) {
|
||||
commands.addAll(store!.alterColumnUniqueness(table, newColumn));
|
||||
} else {
|
||||
innerCommands.add('c.isUnique = ${newColumn.isUnique}');
|
||||
}
|
||||
}
|
||||
|
||||
if (existingColumn.defaultValue != newColumn.defaultValue) {
|
||||
if (store != null) {
|
||||
commands.addAll(store!.alterColumnDefaultValue(table, newColumn));
|
||||
} else {
|
||||
final value = newColumn.defaultValue == null
|
||||
? 'null'
|
||||
: '"${newColumn.defaultValue}"';
|
||||
innerCommands.add('c.defaultValue = $value');
|
||||
}
|
||||
}
|
||||
|
||||
if (existingColumn.isNullable != newColumn.isNullable) {
|
||||
if (store != null) {
|
||||
commands.addAll(
|
||||
store!
|
||||
.alterColumnNullability(table, newColumn, unencodedInitialValue),
|
||||
);
|
||||
} else {
|
||||
innerCommands.add('c.isNullable = ${newColumn.isNullable}');
|
||||
}
|
||||
}
|
||||
|
||||
if (existingColumn.deleteRule != newColumn.deleteRule) {
|
||||
if (store != null) {
|
||||
commands.addAll(store!.alterColumnDeleteRule(table, newColumn));
|
||||
} else {
|
||||
innerCommands.add('c.deleteRule = ${newColumn.deleteRule}');
|
||||
}
|
||||
}
|
||||
|
||||
if (store == null && innerCommands.isNotEmpty) {
|
||||
commands.add(
|
||||
'database.alterColumn("$tableName", "$columnName", (c) {${innerCommands.join(";")};});',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _generateSchemaCommands(
|
||||
SchemaDifference difference, {
|
||||
List<String>? changeList,
|
||||
bool temporary = false,
|
||||
}) {
|
||||
// We need to remove foreign keys from the initial table add and defer
|
||||
// them until after all tables in the schema have been created.
|
||||
// These can occur in both columns and multi column unique.
|
||||
// We'll split the creation of those tables into two different sets
|
||||
// of commands and run the difference afterwards
|
||||
final fkDifferences = <SchemaTableDifference>[];
|
||||
|
||||
for (final t in difference.tablesToAdd) {
|
||||
final copy = SchemaTable.from(t!);
|
||||
if (copy.hasForeignKeyInUniqueSet) {
|
||||
copy.uniqueColumnSet = null;
|
||||
}
|
||||
copy.columns.where((c) => c.isForeignKey).forEach(copy.removeColumn);
|
||||
|
||||
changeList?.add("Adding table '${copy.name}'");
|
||||
createTable(copy);
|
||||
|
||||
fkDifferences.add(SchemaTableDifference(copy, t));
|
||||
}
|
||||
|
||||
for (final td in fkDifferences) {
|
||||
_generateTableCommands(td, changeList: changeList);
|
||||
}
|
||||
|
||||
for (final t in difference.tablesToDelete) {
|
||||
changeList?.add("Deleting table '${t!.name}'");
|
||||
deleteTable(t!.name!);
|
||||
}
|
||||
|
||||
for (final t in difference.tablesToModify) {
|
||||
_generateTableCommands(t, changeList: changeList);
|
||||
}
|
||||
}
|
||||
|
||||
void _generateTableCommands(
|
||||
SchemaTableDifference difference, {
|
||||
List<String>? changeList,
|
||||
}) {
|
||||
for (final c in difference.columnsToAdd) {
|
||||
changeList?.add(
|
||||
"Adding column '${c!.name}' to table '${difference.actualTable!.name}'",
|
||||
);
|
||||
addColumn(difference.actualTable!.name!, c!);
|
||||
|
||||
if (!c.isNullable! && c.defaultValue == null) {
|
||||
changeList?.add(
|
||||
"WARNING: This migration may fail if table '${difference.actualTable!.name}' already has rows. "
|
||||
"Add an 'unencodedInitialValue' to the statement 'database.addColumn(\"${difference.actualTable!.name}\", "
|
||||
"SchemaColumn(\"${c.name}\", ...)'.");
|
||||
}
|
||||
}
|
||||
|
||||
for (final c in difference.columnsToRemove) {
|
||||
changeList?.add(
|
||||
"Deleting column '${c!.name}' from table '${difference.actualTable!.name}'",
|
||||
);
|
||||
deleteColumn(difference.actualTable!.name!, c!.name);
|
||||
}
|
||||
|
||||
for (final columnDiff in difference.columnsToModify) {
|
||||
changeList?.add(
|
||||
"Modifying column '${columnDiff.actualColumn!.name}' in '${difference.actualTable!.name}'",
|
||||
);
|
||||
alterColumn(difference.actualTable!.name!, columnDiff.actualColumn!.name,
|
||||
(c) {
|
||||
c.isIndexed = columnDiff.actualColumn!.isIndexed;
|
||||
c.defaultValue = columnDiff.actualColumn!.defaultValue;
|
||||
c.isUnique = columnDiff.actualColumn!.isUnique;
|
||||
c.isNullable = columnDiff.actualColumn!.isNullable;
|
||||
c.deleteRule = columnDiff.actualColumn!.deleteRule;
|
||||
});
|
||||
|
||||
if (columnDiff.expectedColumn!.isNullable! &&
|
||||
!columnDiff.actualColumn!.isNullable! &&
|
||||
columnDiff.actualColumn!.defaultValue == null) {
|
||||
changeList?.add(
|
||||
"WARNING: This migration may fail if table '${difference.actualTable!.name}' already has rows. "
|
||||
"Add an 'unencodedInitialValue' to the statement 'database.addColumn(\"${difference.actualTable!.name}\", "
|
||||
"SchemaColumn(\"${columnDiff.actualColumn!.name}\", ...)'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (difference.uniqueSetDifference?.hasDifferences ?? false) {
|
||||
changeList?.add(
|
||||
"Setting unique column constraint of '${difference.actualTable!.name}' to ${difference.uniqueSetDifference!.actualColumnNames}.",
|
||||
);
|
||||
alterTable(difference.actualTable!.name!, (t) {
|
||||
if (difference.uniqueSetDifference!.actualColumnNames.isEmpty) {
|
||||
t.uniqueColumnSet = null;
|
||||
} else {
|
||||
t.uniqueColumnSet = difference.uniqueSetDifference!.actualColumnNames;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static String _getNewTableExpression(SchemaTable table) {
|
||||
final builder = StringBuffer();
|
||||
builder.write('database.createTable(SchemaTable("${table.name}", [');
|
||||
builder.write(table.columns.map(_getNewColumnExpression).join(","));
|
||||
builder.write("]");
|
||||
|
||||
if (table.uniqueColumnSet != null) {
|
||||
final set = table.uniqueColumnSet!.map((p) => '"$p"').join(",");
|
||||
builder.write(", uniqueColumnSetNames: [$set]");
|
||||
}
|
||||
|
||||
builder.write('));');
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
static String _getNewColumnExpression(SchemaColumn column) {
|
||||
final builder = StringBuffer();
|
||||
if (column.relatedTableName != null) {
|
||||
builder
|
||||
.write('SchemaColumn.relationship("${column.name}", ${column.type}');
|
||||
builder.write(', relatedTableName: "${column.relatedTableName}"');
|
||||
builder.write(', relatedColumnName: "${column.relatedColumnName}"');
|
||||
builder.write(", rule: ${column.deleteRule}");
|
||||
} else {
|
||||
builder.write('SchemaColumn("${column.name}", ${column.type}');
|
||||
if (column.isPrimaryKey!) {
|
||||
builder.write(", isPrimaryKey: true");
|
||||
} else {
|
||||
builder.write(", isPrimaryKey: false");
|
||||
}
|
||||
if (column.autoincrement!) {
|
||||
builder.write(", autoincrement: true");
|
||||
} else {
|
||||
builder.write(", autoincrement: false");
|
||||
}
|
||||
if (column.defaultValue != null) {
|
||||
builder.write(', defaultValue: "${column.defaultValue}"');
|
||||
}
|
||||
if (column.isIndexed!) {
|
||||
builder.write(", isIndexed: true");
|
||||
} else {
|
||||
builder.write(", isIndexed: false");
|
||||
}
|
||||
}
|
||||
|
||||
if (column.isNullable!) {
|
||||
builder.write(", isNullable: true");
|
||||
} else {
|
||||
builder.write(", isNullable: false");
|
||||
}
|
||||
if (column.isUnique!) {
|
||||
builder.write(", isUnique: true");
|
||||
} else {
|
||||
builder.write(", isUnique: false");
|
||||
}
|
||||
|
||||
builder.write(")");
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
423
packages/database/lib/src/schema/schema_column.dart
Normal file
423
packages/database/lib/src/schema/schema_column.dart
Normal file
|
@ -0,0 +1,423 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/schema/schema.dart';
|
||||
|
||||
/// A portable representation of a database column.
|
||||
///
|
||||
/// Instances of this type contain the database-only details of a [ManagedPropertyDescription].
|
||||
class SchemaColumn {
|
||||
/// Creates an instance of this type from [name], [type] and other properties.
|
||||
SchemaColumn(
|
||||
this.name,
|
||||
ManagedPropertyType type, {
|
||||
this.isIndexed = false,
|
||||
this.isNullable = false,
|
||||
this.autoincrement = false,
|
||||
this.isUnique = false,
|
||||
this.defaultValue,
|
||||
this.isPrimaryKey = false,
|
||||
}) {
|
||||
_type = typeStringForType(type);
|
||||
}
|
||||
|
||||
/// A convenience constructor for properties that represent foreign key relationships.
|
||||
SchemaColumn.relationship(
|
||||
this.name,
|
||||
ManagedPropertyType type, {
|
||||
this.isNullable = true,
|
||||
this.isUnique = false,
|
||||
this.relatedTableName,
|
||||
this.relatedColumnName,
|
||||
DeleteRule rule = DeleteRule.nullify,
|
||||
}) {
|
||||
isIndexed = true;
|
||||
_type = typeStringForType(type);
|
||||
_deleteRule = deleteRuleStringForDeleteRule(rule);
|
||||
}
|
||||
|
||||
/// Creates an instance of this type to mirror [desc].
|
||||
SchemaColumn.fromProperty(ManagedPropertyDescription desc) {
|
||||
name = desc.name;
|
||||
|
||||
if (desc is ManagedRelationshipDescription) {
|
||||
isPrimaryKey = false;
|
||||
relatedTableName = desc.destinationEntity.tableName;
|
||||
relatedColumnName = desc.destinationEntity.primaryKey;
|
||||
if (desc.deleteRule != null) {
|
||||
_deleteRule = deleteRuleStringForDeleteRule(desc.deleteRule!);
|
||||
}
|
||||
} else if (desc is ManagedAttributeDescription) {
|
||||
defaultValue = desc.defaultValue;
|
||||
isPrimaryKey = desc.isPrimaryKey;
|
||||
}
|
||||
|
||||
_type = typeStringForType(desc.type!.kind);
|
||||
isNullable = desc.isNullable;
|
||||
autoincrement = desc.autoincrement;
|
||||
isUnique = desc.isUnique;
|
||||
isIndexed = desc.isIndexed;
|
||||
}
|
||||
|
||||
/// Creates a copy of [otherColumn].
|
||||
SchemaColumn.from(SchemaColumn otherColumn) {
|
||||
name = otherColumn.name;
|
||||
_type = otherColumn._type;
|
||||
isIndexed = otherColumn.isIndexed;
|
||||
isNullable = otherColumn.isNullable;
|
||||
autoincrement = otherColumn.autoincrement;
|
||||
isUnique = otherColumn.isUnique;
|
||||
defaultValue = otherColumn.defaultValue;
|
||||
isPrimaryKey = otherColumn.isPrimaryKey;
|
||||
relatedTableName = otherColumn.relatedTableName;
|
||||
relatedColumnName = otherColumn.relatedColumnName;
|
||||
_deleteRule = otherColumn._deleteRule;
|
||||
}
|
||||
|
||||
/// Creates an instance of this type from [map].
|
||||
///
|
||||
/// Where [map] is typically created by [asMap].
|
||||
SchemaColumn.fromMap(Map<String, dynamic> map) {
|
||||
name = map["name"] as String;
|
||||
_type = map["type"] as String?;
|
||||
isIndexed = map["indexed"] as bool?;
|
||||
isNullable = map["nullable"] as bool?;
|
||||
autoincrement = map["autoincrement"] as bool?;
|
||||
isUnique = map["unique"] as bool?;
|
||||
defaultValue = map["defaultValue"] as String?;
|
||||
isPrimaryKey = map["primaryKey"] as bool?;
|
||||
relatedTableName = map["relatedTableName"] as String?;
|
||||
relatedColumnName = map["relatedColumnName"] as String?;
|
||||
_deleteRule = map["deleteRule"] as String?;
|
||||
}
|
||||
|
||||
/// Creates an empty instance of this type.
|
||||
SchemaColumn.empty();
|
||||
|
||||
/// The name of this column.
|
||||
late String name;
|
||||
|
||||
/// The [SchemaTable] this column belongs to.
|
||||
///
|
||||
/// May be null if not assigned to a table.
|
||||
SchemaTable? table;
|
||||
|
||||
/// The [String] representation of this column's type.
|
||||
String? get typeString => _type;
|
||||
|
||||
/// The type of this column in a [ManagedDataModel].
|
||||
ManagedPropertyType? get type => typeFromTypeString(_type);
|
||||
|
||||
set type(ManagedPropertyType? t) {
|
||||
_type = typeStringForType(t);
|
||||
}
|
||||
|
||||
/// Whether or not this column is indexed.
|
||||
bool? isIndexed = false;
|
||||
|
||||
/// Whether or not this column is nullable.
|
||||
bool? isNullable = false;
|
||||
|
||||
/// Whether or not this column is autoincremented.
|
||||
bool? autoincrement = false;
|
||||
|
||||
/// Whether or not this column is unique.
|
||||
bool? isUnique = false;
|
||||
|
||||
/// The default value for this column when inserted into a database.
|
||||
String? defaultValue;
|
||||
|
||||
/// Whether or not this column is the primary key of its [table].
|
||||
bool? isPrimaryKey = false;
|
||||
|
||||
/// The related table name if this column is a foreign key column.
|
||||
///
|
||||
/// If this column has a foreign key constraint, this property is the name
|
||||
/// of the referenced table.
|
||||
///
|
||||
/// Null if this column is not a foreign key reference.
|
||||
String? relatedTableName;
|
||||
|
||||
/// The related column if this column is a foreign key column.
|
||||
///
|
||||
/// If this column has a foreign key constraint, this property is the name
|
||||
/// of the reference column in [relatedTableName].
|
||||
String? relatedColumnName;
|
||||
|
||||
/// The delete rule for this column if it is a foreign key column.
|
||||
///
|
||||
/// Undefined if not a foreign key column.
|
||||
DeleteRule? get deleteRule =>
|
||||
_deleteRule == null ? null : deleteRuleForDeleteRuleString(_deleteRule);
|
||||
|
||||
set deleteRule(DeleteRule? t) {
|
||||
if (t == null) {
|
||||
_deleteRule = null;
|
||||
} else {
|
||||
_deleteRule = deleteRuleStringForDeleteRule(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not this column is a foreign key column.
|
||||
bool get isForeignKey {
|
||||
return relatedTableName != null && relatedColumnName != null;
|
||||
}
|
||||
|
||||
String? _type;
|
||||
String? _deleteRule;
|
||||
|
||||
/// The differences between two columns.
|
||||
SchemaColumnDifference differenceFrom(SchemaColumn column) {
|
||||
return SchemaColumnDifference(this, column);
|
||||
}
|
||||
|
||||
/// Returns string representation of [ManagedPropertyType].
|
||||
static String? typeStringForType(ManagedPropertyType? type) {
|
||||
switch (type) {
|
||||
case ManagedPropertyType.integer:
|
||||
return "integer";
|
||||
case ManagedPropertyType.doublePrecision:
|
||||
return "double";
|
||||
case ManagedPropertyType.bigInteger:
|
||||
return "bigInteger";
|
||||
case ManagedPropertyType.boolean:
|
||||
return "boolean";
|
||||
case ManagedPropertyType.datetime:
|
||||
return "datetime";
|
||||
case ManagedPropertyType.string:
|
||||
return "string";
|
||||
case ManagedPropertyType.list:
|
||||
return null;
|
||||
case ManagedPropertyType.map:
|
||||
return null;
|
||||
case ManagedPropertyType.document:
|
||||
return "document";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns inverse of [typeStringForType].
|
||||
static ManagedPropertyType? typeFromTypeString(String? type) {
|
||||
switch (type) {
|
||||
case "integer":
|
||||
return ManagedPropertyType.integer;
|
||||
case "double":
|
||||
return ManagedPropertyType.doublePrecision;
|
||||
case "bigInteger":
|
||||
return ManagedPropertyType.bigInteger;
|
||||
case "boolean":
|
||||
return ManagedPropertyType.boolean;
|
||||
case "datetime":
|
||||
return ManagedPropertyType.datetime;
|
||||
case "string":
|
||||
return ManagedPropertyType.string;
|
||||
case "document":
|
||||
return ManagedPropertyType.document;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns string representation of [DeleteRule].
|
||||
static String? deleteRuleStringForDeleteRule(DeleteRule rule) {
|
||||
switch (rule) {
|
||||
case DeleteRule.cascade:
|
||||
return "cascade";
|
||||
case DeleteRule.nullify:
|
||||
return "nullify";
|
||||
case DeleteRule.restrict:
|
||||
return "restrict";
|
||||
case DeleteRule.setDefault:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns inverse of [deleteRuleStringForDeleteRule].
|
||||
static DeleteRule? deleteRuleForDeleteRuleString(String? rule) {
|
||||
switch (rule) {
|
||||
case "cascade":
|
||||
return DeleteRule.cascade;
|
||||
case "nullify":
|
||||
return DeleteRule.nullify;
|
||||
case "restrict":
|
||||
return DeleteRule.restrict;
|
||||
case "default":
|
||||
return DeleteRule.setDefault;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns portable representation of this instance.
|
||||
Map<String, dynamic> asMap() {
|
||||
return {
|
||||
"name": name,
|
||||
"type": _type,
|
||||
"nullable": isNullable,
|
||||
"autoincrement": autoincrement,
|
||||
"unique": isUnique,
|
||||
"defaultValue": defaultValue,
|
||||
"primaryKey": isPrimaryKey,
|
||||
"relatedTableName": relatedTableName,
|
||||
"relatedColumnName": relatedColumnName,
|
||||
"deleteRule": _deleteRule,
|
||||
"indexed": isIndexed
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => "$name (-> $relatedTableName.$relatedColumnName)";
|
||||
}
|
||||
|
||||
/// The difference between two compared [SchemaColumn]s.
|
||||
///
|
||||
/// This class is used for comparing database columns for validation and migration.
|
||||
class SchemaColumnDifference {
|
||||
/// Creates a new instance that represents the difference between [expectedColumn] and [actualColumn].
|
||||
SchemaColumnDifference(this.expectedColumn, this.actualColumn) {
|
||||
if (actualColumn != null && expectedColumn != null) {
|
||||
if (actualColumn!.isPrimaryKey != expectedColumn!.isPrimaryKey) {
|
||||
throw SchemaException(
|
||||
"Cannot change primary key of '${expectedColumn!.table!.name}'",
|
||||
);
|
||||
}
|
||||
|
||||
if (actualColumn!.relatedColumnName !=
|
||||
expectedColumn!.relatedColumnName) {
|
||||
throw SchemaException(
|
||||
"Cannot change an existing column '${expectedColumn!.table!.name}.${expectedColumn!.name}' to an inverse Relationship",
|
||||
);
|
||||
}
|
||||
|
||||
if (actualColumn!.relatedTableName != expectedColumn!.relatedTableName) {
|
||||
throw SchemaException(
|
||||
"Cannot change type of '${expectedColumn!.table!.name}.${expectedColumn!.name}'",
|
||||
);
|
||||
}
|
||||
|
||||
if (actualColumn!.type != expectedColumn!.type) {
|
||||
throw SchemaException(
|
||||
"Cannot change type of '${expectedColumn!.table!.name}.${expectedColumn!.name}'",
|
||||
);
|
||||
}
|
||||
|
||||
if (actualColumn!.autoincrement != expectedColumn!.autoincrement) {
|
||||
throw SchemaException(
|
||||
"Cannot change autoincrement behavior of '${expectedColumn!.table!.name}.${expectedColumn!.name}'",
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedColumn!.name.toLowerCase() !=
|
||||
actualColumn!.name.toLowerCase()) {
|
||||
_differingProperties.add(
|
||||
_PropertyDifference(
|
||||
"name",
|
||||
expectedColumn!.name,
|
||||
actualColumn!.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedColumn!.isIndexed != actualColumn!.isIndexed) {
|
||||
_differingProperties.add(
|
||||
_PropertyDifference(
|
||||
"isIndexed",
|
||||
expectedColumn!.isIndexed,
|
||||
actualColumn!.isIndexed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedColumn!.isUnique != actualColumn!.isUnique) {
|
||||
_differingProperties.add(
|
||||
_PropertyDifference(
|
||||
"isUnique",
|
||||
expectedColumn!.isUnique,
|
||||
actualColumn!.isUnique,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedColumn!.isNullable != actualColumn!.isNullable) {
|
||||
_differingProperties.add(
|
||||
_PropertyDifference(
|
||||
"isNullable",
|
||||
expectedColumn!.isNullable,
|
||||
actualColumn!.isNullable,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedColumn!.defaultValue != actualColumn!.defaultValue) {
|
||||
_differingProperties.add(
|
||||
_PropertyDifference(
|
||||
"defaultValue",
|
||||
expectedColumn!.defaultValue,
|
||||
actualColumn!.defaultValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedColumn!.deleteRule != actualColumn!.deleteRule) {
|
||||
_differingProperties.add(
|
||||
_PropertyDifference(
|
||||
"deleteRule",
|
||||
expectedColumn!.deleteRule,
|
||||
actualColumn!.deleteRule,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The expected column.
|
||||
///
|
||||
/// May be null if there is no column expected.
|
||||
final SchemaColumn? expectedColumn;
|
||||
|
||||
/// The actual column.
|
||||
///
|
||||
/// May be null if there is no actual column.
|
||||
final SchemaColumn? actualColumn;
|
||||
|
||||
/// Whether or not [expectedColumn] and [actualColumn] are different.
|
||||
bool get hasDifferences =>
|
||||
_differingProperties.isNotEmpty ||
|
||||
(expectedColumn == null && actualColumn != null) ||
|
||||
(actualColumn == null && expectedColumn != null);
|
||||
|
||||
/// Human-readable list of differences between [expectedColumn] and [actualColumn].
|
||||
///
|
||||
/// Empty is there are no differences.
|
||||
List<String> get errorMessages {
|
||||
if (expectedColumn == null && actualColumn != null) {
|
||||
return [
|
||||
"Column '${actualColumn!.name}' in table '${actualColumn!.table!.name}' should NOT exist, but is created by migration files"
|
||||
];
|
||||
} else if (expectedColumn != null && actualColumn == null) {
|
||||
return [
|
||||
"Column '${expectedColumn!.name}' in table '${expectedColumn!.table!.name}' should exist, but is NOT created by migration files"
|
||||
];
|
||||
}
|
||||
|
||||
return _differingProperties.map((property) {
|
||||
return property.getErrorMessage(
|
||||
expectedColumn!.table!.name,
|
||||
expectedColumn!.name,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
final List<_PropertyDifference> _differingProperties = [];
|
||||
}
|
||||
|
||||
class _PropertyDifference {
|
||||
_PropertyDifference(this.name, this.expectedValue, this.actualValue);
|
||||
|
||||
final String name;
|
||||
final dynamic expectedValue;
|
||||
final dynamic actualValue;
|
||||
|
||||
String getErrorMessage(String? actualTableName, String? expectedColumnName) {
|
||||
return "Column '$expectedColumnName' in table '$actualTableName' expected "
|
||||
"'$expectedValue' for '$name', but migration files yield '$actualValue'";
|
||||
}
|
||||
}
|
351
packages/database/lib/src/schema/schema_table.dart
Normal file
351
packages/database/lib/src/schema/schema_table.dart
Normal file
|
@ -0,0 +1,351 @@
|
|||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/managed/relationship_type.dart';
|
||||
import 'package:protevus_database/src/schema/schema.dart';
|
||||
|
||||
/// A portable representation of a database table.
|
||||
///
|
||||
/// Instances of this type contain the database-only details of a [ManagedEntity]. See also [Schema].
|
||||
class SchemaTable {
|
||||
/// Creates an instance of this type with a [name], [columns] and [uniqueColumnSetNames].
|
||||
SchemaTable(
|
||||
this.name,
|
||||
List<SchemaColumn> columns, {
|
||||
List<String>? uniqueColumnSetNames,
|
||||
}) {
|
||||
uniqueColumnSet = uniqueColumnSetNames;
|
||||
_columns = columns;
|
||||
}
|
||||
|
||||
/// Creates an instance of this type to mirror [entity].
|
||||
SchemaTable.fromEntity(ManagedEntity entity) {
|
||||
name = entity.tableName;
|
||||
|
||||
final validProperties = entity.properties.values
|
||||
.where(
|
||||
(p) =>
|
||||
(p is ManagedAttributeDescription && !p.isTransient) ||
|
||||
(p is ManagedRelationshipDescription &&
|
||||
p.relationshipType == ManagedRelationshipType.belongsTo),
|
||||
)
|
||||
.toList();
|
||||
|
||||
_columns =
|
||||
validProperties.map((p) => SchemaColumn.fromProperty(p!)).toList();
|
||||
|
||||
uniqueColumnSet = entity.uniquePropertySet?.map((p) => p.name).toList();
|
||||
}
|
||||
|
||||
/// Creates a deep copy of [otherTable].
|
||||
SchemaTable.from(SchemaTable otherTable) {
|
||||
name = otherTable.name;
|
||||
_columns = otherTable.columns.map((col) => SchemaColumn.from(col)).toList();
|
||||
_uniqueColumnSet = otherTable._uniqueColumnSet;
|
||||
}
|
||||
|
||||
/// Creates an empty table.
|
||||
SchemaTable.empty();
|
||||
|
||||
/// Creates an instance of this type from [map].
|
||||
///
|
||||
/// This [map] is typically generated from [asMap];
|
||||
SchemaTable.fromMap(Map<String, dynamic> map) {
|
||||
name = map["name"] as String?;
|
||||
_columns = (map["columns"] as List<Map<String, dynamic>>)
|
||||
.map((c) => SchemaColumn.fromMap(c))
|
||||
.toList();
|
||||
uniqueColumnSet = (map["unique"] as List?)?.cast();
|
||||
}
|
||||
|
||||
/// The [Schema] this table belongs to.
|
||||
///
|
||||
/// May be null if not assigned to a [Schema].
|
||||
Schema? schema;
|
||||
|
||||
/// The name of the database table.
|
||||
String? name;
|
||||
|
||||
/// The names of a set of columns that must be unique for each row in this table.
|
||||
///
|
||||
/// Are sorted alphabetically. Not modifiable.
|
||||
List<String>? get uniqueColumnSet =>
|
||||
_uniqueColumnSet != null ? List.unmodifiable(_uniqueColumnSet!) : null;
|
||||
|
||||
set uniqueColumnSet(List<String>? columnNames) {
|
||||
if (columnNames != null) {
|
||||
_uniqueColumnSet = List.from(columnNames);
|
||||
_uniqueColumnSet?.sort((String a, String b) => a.compareTo(b));
|
||||
} else {
|
||||
_uniqueColumnSet = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// An unmodifiable list of [SchemaColumn]s in this table.
|
||||
List<SchemaColumn> get columns => List.unmodifiable(_columnStorage ?? []);
|
||||
|
||||
bool get hasForeignKeyInUniqueSet => columns
|
||||
.where((c) => c.isForeignKey)
|
||||
.any((c) => uniqueColumnSet?.contains(c.name) ?? false);
|
||||
|
||||
List<SchemaColumn>? _columnStorage;
|
||||
List<String>? _uniqueColumnSet;
|
||||
|
||||
set _columns(List<SchemaColumn> columns) {
|
||||
_columnStorage = columns;
|
||||
for (final c in _columnStorage!) {
|
||||
c.table = this;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a [SchemaColumn] in this instance by its name.
|
||||
///
|
||||
/// See [columnForName] for more details.
|
||||
SchemaColumn? operator [](String columnName) => columnForName(columnName);
|
||||
|
||||
/// The differences between two tables.
|
||||
SchemaTableDifference differenceFrom(SchemaTable table) {
|
||||
return SchemaTableDifference(this, table);
|
||||
}
|
||||
|
||||
/// Adds [column] to this table.
|
||||
///
|
||||
/// Sets [column]'s [SchemaColumn.table] to this instance.
|
||||
void addColumn(SchemaColumn column) {
|
||||
if (this[column.name] != null) {
|
||||
throw SchemaException("Column ${column.name} already exists.");
|
||||
}
|
||||
|
||||
_columnStorage!.add(column);
|
||||
column.table = this;
|
||||
}
|
||||
|
||||
void renameColumn(SchemaColumn column, String? newName) {
|
||||
throw SchemaException("Renaming a column not yet implemented!");
|
||||
|
||||
// if (!columns.contains(column)) {
|
||||
// throw new SchemaException("Column ${column.name} does not exist on ${name}.");
|
||||
// }
|
||||
//
|
||||
// if (columnForName(newName) != null) {
|
||||
// throw new SchemaException("Column ${newName} already exists.");
|
||||
// }
|
||||
//
|
||||
// if (column.isPrimaryKey) {
|
||||
// throw new SchemaException("May not rename primary key column (${column.name} -> ${newName})");
|
||||
// }
|
||||
//
|
||||
// // We also must rename indices
|
||||
// column.name = newName;
|
||||
}
|
||||
|
||||
/// Removes [column] from this table.
|
||||
///
|
||||
/// Exact [column] must be in this table, else an exception is thrown.
|
||||
/// Sets [column]'s [SchemaColumn.table] to null.
|
||||
void removeColumn(SchemaColumn column) {
|
||||
if (!columns.contains(column)) {
|
||||
throw SchemaException("Column ${column.name} does not exist on $name.");
|
||||
}
|
||||
|
||||
_columnStorage!.remove(column);
|
||||
column.table = null;
|
||||
}
|
||||
|
||||
/// Replaces [existingColumn] with [newColumn] in this table.
|
||||
void replaceColumn(SchemaColumn existingColumn, SchemaColumn newColumn) {
|
||||
if (!columns.contains(existingColumn)) {
|
||||
throw SchemaException(
|
||||
"Column ${existingColumn.name} does not exist on $name.",
|
||||
);
|
||||
}
|
||||
|
||||
final index = _columnStorage!.indexOf(existingColumn);
|
||||
_columnStorage![index] = newColumn;
|
||||
newColumn.table = this;
|
||||
existingColumn.table = null;
|
||||
}
|
||||
|
||||
/// Returns a [SchemaColumn] with [name].
|
||||
///
|
||||
/// Case-insensitively compares names of [columns] with [name]. Returns null if no column exists
|
||||
/// with [name].
|
||||
SchemaColumn? columnForName(String name) {
|
||||
final lowercaseName = name.toLowerCase();
|
||||
return columns
|
||||
.firstWhereOrNull((col) => col.name.toLowerCase() == lowercaseName);
|
||||
}
|
||||
|
||||
/// Returns portable representation of this table.
|
||||
Map<String, dynamic> asMap() {
|
||||
return {
|
||||
"name": name,
|
||||
"columns": columns.map((c) => c.asMap()).toList(),
|
||||
"unique": uniqueColumnSet
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => name!;
|
||||
}
|
||||
|
||||
/// The difference between two [SchemaTable]s.
|
||||
///
|
||||
/// This class is used for comparing schemas for validation and migration.
|
||||
class SchemaTableDifference {
|
||||
/// Creates a new instance that represents the difference between [expectedTable] and [actualTable].
|
||||
SchemaTableDifference(this.expectedTable, this.actualTable) {
|
||||
if (expectedTable != null && actualTable != null) {
|
||||
for (final expectedColumn in expectedTable!.columns) {
|
||||
final actualColumn =
|
||||
actualTable != null ? actualTable![expectedColumn.name] : null;
|
||||
if (actualColumn == null) {
|
||||
_differingColumns.add(SchemaColumnDifference(expectedColumn, null));
|
||||
} else {
|
||||
final diff = expectedColumn.differenceFrom(actualColumn);
|
||||
if (diff.hasDifferences) {
|
||||
_differingColumns.add(diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_differingColumns.addAll(
|
||||
actualTable!.columns
|
||||
.where((t) => expectedTable![t.name] == null)
|
||||
.map((unexpectedColumn) {
|
||||
return SchemaColumnDifference(null, unexpectedColumn);
|
||||
}),
|
||||
);
|
||||
|
||||
uniqueSetDifference =
|
||||
SchemaTableUniqueSetDifference(expectedTable!, actualTable!);
|
||||
}
|
||||
}
|
||||
|
||||
/// The expected table.
|
||||
///
|
||||
/// May be null if no table is expected.
|
||||
final SchemaTable? expectedTable;
|
||||
|
||||
/// The actual table.
|
||||
///
|
||||
/// May be null if there is no table.
|
||||
final SchemaTable? actualTable;
|
||||
|
||||
/// The difference between [SchemaTable.uniqueColumnSet]s.
|
||||
///
|
||||
/// Null if either [expectedTable] or [actualTable] are null.
|
||||
SchemaTableUniqueSetDifference? uniqueSetDifference;
|
||||
|
||||
/// Whether or not [expectedTable] and [actualTable] are the same.
|
||||
bool get hasDifferences =>
|
||||
_differingColumns.isNotEmpty ||
|
||||
expectedTable?.name?.toLowerCase() != actualTable?.name?.toLowerCase() ||
|
||||
(expectedTable == null && actualTable != null) ||
|
||||
(actualTable == null && expectedTable != null) ||
|
||||
(uniqueSetDifference?.hasDifferences ?? false);
|
||||
|
||||
/// Human-readable list of differences between [expectedTable] and [actualTable].
|
||||
List<String> get errorMessages {
|
||||
if (expectedTable == null && actualTable != null) {
|
||||
return [
|
||||
"Table '$actualTable' should NOT exist, but is created by migration files."
|
||||
];
|
||||
} else if (expectedTable != null && actualTable == null) {
|
||||
return [
|
||||
"Table '$expectedTable' should exist, but it is NOT created by migration files."
|
||||
];
|
||||
}
|
||||
|
||||
final diffs =
|
||||
_differingColumns.expand((diff) => diff.errorMessages).toList();
|
||||
diffs.addAll(uniqueSetDifference?.errorMessages ?? []);
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
List<SchemaColumnDifference> get columnDifferences => _differingColumns;
|
||||
|
||||
List<SchemaColumn?> get columnsToAdd {
|
||||
return _differingColumns
|
||||
.where(
|
||||
(diff) => diff.expectedColumn == null && diff.actualColumn != null,
|
||||
)
|
||||
.map((diff) => diff.actualColumn)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<SchemaColumn?> get columnsToRemove {
|
||||
return _differingColumns
|
||||
.where(
|
||||
(diff) => diff.expectedColumn != null && diff.actualColumn == null,
|
||||
)
|
||||
.map((diff) => diff.expectedColumn)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<SchemaColumnDifference> get columnsToModify {
|
||||
return _differingColumns
|
||||
.where(
|
||||
(columnDiff) =>
|
||||
columnDiff.expectedColumn != null &&
|
||||
columnDiff.actualColumn != null,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final List<SchemaColumnDifference> _differingColumns = [];
|
||||
}
|
||||
|
||||
/// Difference between two [SchemaTable.uniqueColumnSet]s.
|
||||
class SchemaTableUniqueSetDifference {
|
||||
SchemaTableUniqueSetDifference(
|
||||
SchemaTable expectedTable,
|
||||
SchemaTable actualTable,
|
||||
) : expectedColumnNames = expectedTable.uniqueColumnSet ?? [],
|
||||
actualColumnNames = actualTable.uniqueColumnSet ?? [],
|
||||
_tableName = actualTable.name;
|
||||
|
||||
/// The expected set of unique column names.
|
||||
final List<String> expectedColumnNames;
|
||||
|
||||
/// The actual set of unique column names.
|
||||
final List<String> actualColumnNames;
|
||||
|
||||
final String? _tableName;
|
||||
|
||||
/// Whether or not [expectedColumnNames] and [actualColumnNames] are equivalent.
|
||||
bool get hasDifferences {
|
||||
if (expectedColumnNames.length != actualColumnNames.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !expectedColumnNames.every(actualColumnNames.contains);
|
||||
}
|
||||
|
||||
/// Human-readable list of differences between [expectedColumnNames] and [actualColumnNames].
|
||||
List<String> get errorMessages {
|
||||
if (expectedColumnNames.isEmpty && actualColumnNames.isNotEmpty) {
|
||||
return [
|
||||
"Multi-column unique constraint on table '$_tableName' "
|
||||
"should NOT exist, but is created by migration files."
|
||||
];
|
||||
} else if (expectedColumnNames.isNotEmpty && actualColumnNames.isEmpty) {
|
||||
return [
|
||||
"Multi-column unique constraint on table '$_tableName' "
|
||||
"should exist, but it is NOT created by migration files."
|
||||
];
|
||||
}
|
||||
|
||||
if (hasDifferences) {
|
||||
final expectedColumns = expectedColumnNames.map((c) => "'$c'").join(", ");
|
||||
final actualColumns = actualColumnNames.map((c) => "'$c'").join(", ");
|
||||
|
||||
return [
|
||||
"Multi-column unique constraint on table '$_tableName' "
|
||||
"is expected to be for properties $expectedColumns, but is actually $actualColumns"
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
|
@ -10,6 +10,11 @@ environment:
|
|||
|
||||
# Add regular dependencies here.
|
||||
dependencies:
|
||||
protevus_http: ^0.0.1
|
||||
protevus_openapi: ^0.0.1
|
||||
protevus_runtime: ^0.0.1
|
||||
collection: ^1.18.0
|
||||
meta: ^1.12.0
|
||||
# path: ^1.8.0
|
||||
|
||||
dev_dependencies:
|
||||
|
|
Loading…
Reference in a new issue