add(conduit): refactoring conduit core

This commit is contained in:
Patrick Stewart 2024-09-03 13:16:23 -07:00
parent 50322e71b2
commit 1e1d0ad6a3
35 changed files with 6456 additions and 0 deletions

View 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';

View 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()],
);

View 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;
}
}

View 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;
}

View 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";
}
}

View 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);
}
}
}

View 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;
}
}

View 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,
);
}

View 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);
}

View 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);
}
}

View 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';

View 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;
}

View 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";
}
}

View file

@ -0,0 +1,2 @@
/// The possible database relationships.
enum ManagedRelationshipType { hasOne, hasMany, belongsTo }

View 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;
}
}

View 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;
}

View 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;
}
}
}

View 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";
}
}
}

View 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;
}
}

View 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,
});
}

View 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,
}

View 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();
}
}

View 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;
}
}

View 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;
}

View 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,
);
}
}

View 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
}

View 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);
}

View 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;
}

View 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;
}

View 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 {}
}
""";
}
}

View 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";
}

View 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();
}
}

View 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'";
}
}

View 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 [];
}
}

View file

@ -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: