From 1e1d0ad6a34fc6d52b579fc457865ad679bdcc45 Mon Sep 17 00:00:00 2001 From: Patrick Stewart Date: Tue, 3 Sep 2024 13:16:23 -0700 Subject: [PATCH] add(conduit): refactoring conduit core --- packages/database/lib/db.dart | 4 + .../database/lib/src/managed/attributes.dart | 310 +++++++++ .../database/lib/src/managed/backing.dart | 192 ++++++ .../database/lib/src/managed/context.dart | 184 +++++ .../database/lib/src/managed/data_model.dart | 121 ++++ .../lib/src/managed/data_model_manager.dart | 37 + .../database/lib/src/managed/document.dart | 57 ++ packages/database/lib/src/managed/entity.dart | 382 ++++++++++ .../database/lib/src/managed/exception.dart | 8 + .../database/lib/src/managed/key_path.dart | 27 + .../database/lib/src/managed/managed.dart | 13 + packages/database/lib/src/managed/object.dart | 304 ++++++++ .../lib/src/managed/property_description.dart | 582 ++++++++++++++++ .../lib/src/managed/relationship_type.dart | 2 + packages/database/lib/src/managed/set.dart | 71 ++ packages/database/lib/src/managed/type.dart | 128 ++++ .../lib/src/managed/validation/impl.dart | 65 ++ .../lib/src/managed/validation/managed.dart | 105 +++ .../lib/src/managed/validation/metadata.dart | 650 ++++++++++++++++++ .../persistent_store/persistent_store.dart | 100 +++ packages/database/lib/src/query/error.dart | 91 +++ .../lib/src/query/matcher_expression.dart | 431 ++++++++++++ packages/database/lib/src/query/mixin.dart | 191 +++++ packages/database/lib/src/query/page.dart | 40 ++ .../database/lib/src/query/predicate.dart | 203 ++++++ packages/database/lib/src/query/query.dart | 425 ++++++++++++ packages/database/lib/src/query/reduce.dart | 62 ++ .../lib/src/query/sort_descriptor.dart | 16 + .../lib/src/query/sort_predicate.dart | 17 + .../database/lib/src/schema/migration.dart | 88 +++ packages/database/lib/src/schema/schema.dart | 231 +++++++ .../lib/src/schema/schema_builder.dart | 540 +++++++++++++++ .../lib/src/schema/schema_column.dart | 423 ++++++++++++ .../database/lib/src/schema/schema_table.dart | 351 ++++++++++ packages/database/pubspec.yaml | 5 + 35 files changed, 6456 insertions(+) create mode 100644 packages/database/lib/db.dart create mode 100644 packages/database/lib/src/managed/attributes.dart create mode 100644 packages/database/lib/src/managed/backing.dart create mode 100644 packages/database/lib/src/managed/context.dart create mode 100644 packages/database/lib/src/managed/data_model.dart create mode 100644 packages/database/lib/src/managed/data_model_manager.dart create mode 100644 packages/database/lib/src/managed/document.dart create mode 100644 packages/database/lib/src/managed/entity.dart create mode 100644 packages/database/lib/src/managed/exception.dart create mode 100644 packages/database/lib/src/managed/key_path.dart create mode 100644 packages/database/lib/src/managed/managed.dart create mode 100644 packages/database/lib/src/managed/object.dart create mode 100644 packages/database/lib/src/managed/property_description.dart create mode 100644 packages/database/lib/src/managed/relationship_type.dart create mode 100644 packages/database/lib/src/managed/set.dart create mode 100644 packages/database/lib/src/managed/type.dart create mode 100644 packages/database/lib/src/managed/validation/impl.dart create mode 100644 packages/database/lib/src/managed/validation/managed.dart create mode 100644 packages/database/lib/src/managed/validation/metadata.dart create mode 100644 packages/database/lib/src/persistent_store/persistent_store.dart create mode 100644 packages/database/lib/src/query/error.dart create mode 100644 packages/database/lib/src/query/matcher_expression.dart create mode 100644 packages/database/lib/src/query/mixin.dart create mode 100644 packages/database/lib/src/query/page.dart create mode 100644 packages/database/lib/src/query/predicate.dart create mode 100644 packages/database/lib/src/query/query.dart create mode 100644 packages/database/lib/src/query/reduce.dart create mode 100644 packages/database/lib/src/query/sort_descriptor.dart create mode 100644 packages/database/lib/src/query/sort_predicate.dart create mode 100644 packages/database/lib/src/schema/migration.dart create mode 100644 packages/database/lib/src/schema/schema.dart create mode 100644 packages/database/lib/src/schema/schema_builder.dart create mode 100644 packages/database/lib/src/schema/schema_column.dart create mode 100644 packages/database/lib/src/schema/schema_table.dart diff --git a/packages/database/lib/db.dart b/packages/database/lib/db.dart new file mode 100644 index 0000000..6caa3b7 --- /dev/null +++ b/packages/database/lib/db.dart @@ -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'; diff --git a/packages/database/lib/src/managed/attributes.dart b/packages/database/lib/src/managed/attributes.dart new file mode 100644 index 0000000..710f442 --- /dev/null +++ b/packages/database/lib/src/managed/attributes.dart @@ -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`) 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 properties) + : this(uniquePropertySet: properties); + + /// Each instance of the associated table definition is unique for these properties. + /// + /// null if not set. + final List? 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 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()], +); diff --git a/packages/database/lib/src/managed/backing.dart b/packages/database/lib/src/managed/backing.dart new file mode 100644 index 0000000..5080c64 --- /dev/null +++ b/packages/database/lib/src/managed/backing.dart @@ -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 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 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 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 keyPaths = []; + KeyPath? workingKeyPath; + + @override + Map 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; + } +} diff --git a/packages/database/lib/src/managed/context.dart b/packages/database/lib/src/managed/context.dart new file mode 100644 index 0000000..9f3c9ea --- /dev/null +++ b/packages/database/lib/src/managed/context.dart @@ -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 _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 . 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 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(transaction) + /// ..values = someObject; + /// await q.insert(); + /// ... + /// }); + Future transaction( + Future 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 insertObject(T object) { + final query = Query(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> insertObjects( + List objects, + ) async { + return Query(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 fetchObjectWithID( + 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(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; +} diff --git a/packages/database/lib/src/managed/data_model.dart b/packages/database/lib/src/managed/data_model.dart new file mode 100644 index 0000000..a8e02cf --- /dev/null +++ b/packages/database/lib/src/managed/data_model.dart @@ -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 instanceTypes) { + final runtimes = RuntimeContext.current.runtimes.iterable + .whereType() + .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(); + + 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 get entities => _entities.values; + final Map _entities = {}; + final Map _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"; + } +} diff --git a/packages/database/lib/src/managed/data_model_manager.dart b/packages/database/lib/src/managed/data_model_manager.dart new file mode 100644 index 0000000..8c4b786 --- /dev/null +++ b/packages/database/lib/src/managed/data_model_manager.dart @@ -0,0 +1,37 @@ +import 'package:protevus_database/src/managed/data_model.dart'; +import 'package:protevus_database/src/managed/entity.dart'; + +Map _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); + } + } +} diff --git a/packages/database/lib/src/managed/document.dart b/packages/database/lib/src/managed/document.dart new file mode 100644 index 0000000..554d4af --- /dev/null +++ b/packages/database/lib/src/managed/document.dart @@ -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; + } +} diff --git a/packages/database/lib/src/managed/entity.dart b/packages/database/lib/src/managed/entity.dart new file mode 100644 index 0000000..9164b65 --- /dev/null +++ b/packages/database/lib/src/managed/entity.dart @@ -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 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 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 get properties { + final all = Map.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? uniquePropertySet; + + /// List of [ManagedValidator]s for attributes of this entity. + /// + /// All validators for all [attributes] in one, flat list. Order is undefined. + late List 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? get defaultProperties { + if (_defaultProperties == null) { + final elements = []; + 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 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? _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({ManagedBacking? backing}) { + if (backing != null) { + return (runtime.instanceOfImplementation(backing: backing)..entity = this) + as T; + } + return (runtime.instanceOfImplementation()..entity = this) as T; + } + + ManagedSet? setOf(Iterable objects) { + return runtime.setOfImplementation(objects) as ManagedSet?; + } + + /// 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 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 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 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 identifyProperties( + T Function(U x) propertiesIdentifier, + ) { + final tracker = ManagedAccessTrackingBacking(); + final obj = instanceOf(backing: tracker); + propertiesIdentifier(obj); + + return tracker.keyPaths; + } + + APISchemaObject document(APIDocumentContext context) { + final schemaProperties = {}; + 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 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, + ); +} diff --git a/packages/database/lib/src/managed/exception.dart b/packages/database/lib/src/managed/exception.dart new file mode 100644 index 0000000..f251ce7 --- /dev/null +++ b/packages/database/lib/src/managed/exception.dart @@ -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); +} diff --git a/packages/database/lib/src/managed/key_path.dart b/packages/database/lib/src/managed/key_path.dart new file mode 100644 index 0000000..d7bb55d --- /dev/null +++ b/packages/database/lib/src/managed/key_path.dart @@ -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 path; + List? 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); + } +} diff --git a/packages/database/lib/src/managed/managed.dart b/packages/database/lib/src/managed/managed.dart new file mode 100644 index 0000000..34a4026 --- /dev/null +++ b/packages/database/lib/src/managed/managed.dart @@ -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'; diff --git a/packages/database/lib/src/managed/object.dart b/packages/database/lib/src/managed/object.dart new file mode 100644 index 0000000..c04b82a --- /dev/null +++ b/packages/database/lib/src/managed/object.dart @@ -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 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 extends Serializable { + /// IMPROVEMENT: Cache of entity.properties to reduce property loading time + late Map 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 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 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 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 asMap() { + final outputMap = {}; + + 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; +} diff --git a/packages/database/lib/src/managed/property_description.dart b/packages/database/lib/src/managed/property_description.dart new file mode 100644 index 0000000..0c12665 --- /dev/null +++ b/packages/database/lib/src/managed/property_description.dart @@ -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 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 get validators => _validators; + + final List _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( + 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 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 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( + 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 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 { + 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) { + 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) { + 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"; + } +} diff --git a/packages/database/lib/src/managed/relationship_type.dart b/packages/database/lib/src/managed/relationship_type.dart new file mode 100644 index 0000000..1166e7b --- /dev/null +++ b/packages/database/lib/src/managed/relationship_type.dart @@ -0,0 +1,2 @@ +/// The possible database relationships. +enum ManagedRelationshipType { hasOne, hasMany, belongsTo } diff --git a/packages/database/lib/src/managed/set.dart b/packages/database/lib/src/managed/set.dart new file mode 100644 index 0000000..2d0e1e5 --- /dev/null +++ b/packages/database/lib/src/managed/set.dart @@ -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 posts; +/// } +/// +/// class Post extends ManagedObject<_Post> implements _Post {} +/// class _Post { +/// ... +/// @Relate(#posts) +/// User user; +/// } +class ManagedSet extends Object + with ListMixin { + /// Creates an empty [ManagedSet]. + ManagedSet() { + _innerValues = []; + } + + /// Creates a [ManagedSet] from an [Iterable] of [InstanceType]s. + ManagedSet.from(Iterable items) { + _innerValues = items.toList(); + } + + /// Creates a [ManagedSet] from an [Iterable] of [dynamic]s. + ManagedSet.fromDynamic(Iterable items) { + _innerValues = List.from(items); + } + + late final List _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 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; + } +} diff --git a/packages/database/lib/src/managed/type.dart b/packages/database/lib/src/managed/type.dart new file mode 100644 index 0000000..933b8cc --- /dev/null +++ b/packages/database/lib/src/managed/type.dart @@ -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( + ManagedPropertyType kind, + ManagedType? elements, + Map 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 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; + case ManagedPropertyType.list: + return dartValue is List; + 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 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; +} diff --git a/packages/database/lib/src/managed/validation/impl.dart b/packages/database/lib/src/managed/validation/impl.dart new file mode 100644 index 0000000..f9e25f3 --- /dev/null +++ b/packages/database/lib/src/managed/validation/impl.dart @@ -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; + } + } +} diff --git a/packages/database/lib/src/managed/validation/managed.dart b/packages/database/lib/src/managed/validation/managed.dart new file mode 100644 index 0000000..8987fe2 --- /dev/null +++ b/packages/database/lib/src/managed/validation/managed.dart @@ -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"; + } + } +} diff --git a/packages/database/lib/src/managed/validation/metadata.dart b/packages/database/lib/src/managed/validation/metadata.dart new file mode 100644 index 0000000..dd2e7dd --- /dev/null +++ b/packages/database/lib/src/managed/validation/metadata.dart @@ -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 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 { + /// CustomValidate({bool onUpdate: true, bool onInsert: true}) + /// : super(onUpdate: onUpdate, onInsert: onInsert); + /// + /// bool validate( + /// ValidateOperation operation, + /// ManagedAttributeDescription property, + /// String value, + /// List 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 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; + 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; + 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; + 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?; + } + break; + } + } + + dynamic _oneOfCompiler( + ManagedType typeBeingValidated, { + Type? relationshipInverseType, + }) { + if (_value is! List) { + throw ValidateCompilationError( + "Validate.oneOf value must be a List, 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, 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 get _expressions { + final comparisons = []; + 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; + } +} diff --git a/packages/database/lib/src/persistent_store/persistent_store.dart b/packages/database/lib/src/persistent_store/persistent_store.dart new file mode 100644 index 0000000..77cc212 --- /dev/null +++ b/packages/database/lib/src/persistent_store/persistent_store.dart @@ -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 newQuery( + ManagedContext context, + ManagedEntity entity, { + T? values, + }); + + /// Executes an arbitrary command. + Future execute(String sql, {Map? substitutionValues}); + + Future executeQuery( + String formatString, + Map values, + int timeoutInSeconds, { + PersistentStoreQueryReturnType? returnType, + }); + + Future transaction( + ManagedContext transactionContext, + Future Function(ManagedContext transaction) transactionBlock, + ); + + /// Closes the underlying database connection. + Future close(); + + // -- Schema Ops -- + + List createTable(SchemaTable table, {bool isTemporary = false}); + + List renameTable(SchemaTable table, String name); + + List deleteTable(SchemaTable table); + + List addTableUniqueColumnSet(SchemaTable table); + + List deleteTableUniqueColumnSet(SchemaTable table); + + List addColumn( + SchemaTable table, + SchemaColumn column, { + String? unencodedInitialValue, + }); + + List deleteColumn(SchemaTable table, SchemaColumn column); + + List renameColumn( + SchemaTable table, + SchemaColumn column, + String name, + ); + + List alterColumnNullability( + SchemaTable table, + SchemaColumn column, + String? unencodedInitialValue, + ); + + List alterColumnUniqueness(SchemaTable table, SchemaColumn column); + + List alterColumnDefaultValue(SchemaTable table, SchemaColumn column); + + List alterColumnDeleteRule(SchemaTable table, SchemaColumn column); + + List addIndexToColumn(SchemaTable table, SchemaColumn column); + + List renameIndex( + SchemaTable table, + SchemaColumn column, + String newIndexName, + ); + + List deleteIndexFromColumn(SchemaTable table, SchemaColumn column); + + Future get schemaVersion; + + Future upgrade( + Schema fromSchema, + List withMigrations, { + bool temporary = false, + }); +} diff --git a/packages/database/lib/src/query/error.dart b/packages/database/lib/src/query/error.dart new file mode 100644 index 0000000..08ba948 --- /dev/null +++ b/packages/database/lib/src/query/error.dart @@ -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 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? offendingItems; + + @override + Response get response { + return Response(_getStatus(event), null, _getBody(message, offendingItems)); + } + + static Map _getBody( + String? message, + List? 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, +} diff --git a/packages/database/lib/src/query/matcher_expression.dart b/packages/database/lib/src/query/matcher_expression.dart new file mode 100644 index 0000000..2650912 --- /dev/null +++ b/packages/database/lib/src/query/matcher_expression.dart @@ -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 { + QueryExpressionJunction._(this.lhs); + + final QueryExpression 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() +/// ..where((e) => e.name).equalTo("Bob"); +/// +class QueryExpression { + QueryExpression(this.keyPath); + + QueryExpression.byAddingKey( + QueryExpression 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 _createJunction() => + QueryExpressionJunction._(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() + /// ..where((e) => e.name).not.equalTo("Bob"); + QueryExpression 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() + /// ..where((u) => u.id ).equalTo(1); + /// + QueryExpressionJunction 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() + /// ..where((e) => e.id).notEqualTo(60000); + /// + QueryExpressionJunction 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() + /// ..where((u) => u.name ).like("bob"); + /// + QueryExpressionJunction 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() + /// ..where((e) => e.id).notEqualTo(60000); + /// + QueryExpressionJunction 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() + /// ..where((e) => e.salary).greaterThan(60000); + QueryExpressionJunction 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() + /// ..where((e) => e.salary).greaterThanEqualTo(60000); + QueryExpressionJunction 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() + /// ..where((e) => e.salary).lessThan(60000); + QueryExpressionJunction 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() + /// ..where((e) => e.salary).lessThanEqualTo(60000); + QueryExpressionJunction 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() + /// ..where((s) => s.title).contains("Director"); + /// + QueryExpressionJunction 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() + /// ..where((s) => s.name).beginsWith("B"); + QueryExpressionJunction 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() + /// ..where((e) => e.name).endsWith("son"); + QueryExpressionJunction 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() + /// ..where((e) => e.department).oneOf(["Engineering", "HR"]); + QueryExpressionJunction oneOf(Iterable 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() + /// ..where((e) => e.salary).between(80000, 100000); + QueryExpressionJunction 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() + /// ..where((e) => e.salary).outsideOf(80000, 100000); + QueryExpressionJunction 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() + /// ..where((e) => e.manager).identifiedBy(5); + QueryExpressionJunction 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() + /// ..where((e) => e.manager).isNull(); + QueryExpressionJunction 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() + /// ..where((e) => e.manager).isNotNull(); + QueryExpressionJunction isNotNull() { + expression = const NullCheckExpression(shouldBeNull: false); + + return _createJunction(); + } +} diff --git a/packages/database/lib/src/query/mixin.dart b/packages/database/lib/src/query/mixin.dart new file mode 100644 index 0000000..6450adb --- /dev/null +++ b/packages/database/lib/src/query/mixin.dart @@ -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 + implements Query { + @override + int offset = 0; + + @override + int fetchLimit = 0; + + @override + int timeoutInSeconds = 30; + + @override + bool canModifyAllInstances = false; + + @override + Map? valueMap; + + @override + QueryPredicate? predicate; + + @override + QuerySortPredicate? sortPredicate; + + QueryPage? pageDescriptor; + final List sortDescriptors = []; + final Map subQueries = {}; + + QueryMixin? _parentQuery; + List> expressions = []; + InstanceType? _valueObject; + + List? _propertiesToFetch; + + List 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 where( + 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(properties.first); + expressions.add(expr); + return expr; + } + + @override + Query join({ + T? Function(InstanceType x)? object, + ManagedSet? Function(InstanceType x)? set, + }) { + final relationship = object ?? set!; + final desc = entity.identifyRelationship(relationship); + + return _createSubquery(desc); + } + + @override + void pageBy( + 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 Function(InstanceType x) propertyIdentifier, + QuerySortOrder order, + ) { + final attribute = entity.identifyAttribute(propertyIdentifier); + + sortDescriptors.add(QuerySortDescriptor(attribute.name, order)); + } + + @override + void returningProperties( + List 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 _createSubquery( + 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(context); + (subquery as QueryMixin)._parentQuery = this; + subQueries[fromRelationship] = subquery; + + return subquery; + } +} diff --git a/packages/database/lib/src/query/page.dart b/packages/database/lib/src/query/page.dart new file mode 100644 index 0000000..16cba8c --- /dev/null +++ b/packages/database/lib/src/query/page.dart @@ -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; +} diff --git a/packages/database/lib/src/query/predicate.dart b/packages/database/lib/src/query/predicate.dart new file mode 100644 index 0000000..6d0a82a --- /dev/null +++ b/packages/database/lib/src/query/predicate.dart @@ -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 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 = {}; + 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 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 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 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, + ); + } +} diff --git a/packages/database/lib/src/query/query.dart b/packages/database/lib/src/query/query.dart new file mode 100644 index 0000000..c3491cf --- /dev/null +++ b/packages/database/lib/src/query/query.dart @@ -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() +/// ..where((e) => e.salary).greaterThan(50000); +/// final employees = await query.fetch(); +abstract class Query { + /// 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( + 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(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 insertObject( + 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> insertObjects( + ManagedContext context, + List 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 notes; + /// } + /// + /// To fetch an object and one of its has-one properties, use the [object] closure: + /// + /// var query = Query() + /// ..join(object: (u) => u.profile); + /// + /// To fetch an object and its has-many properties, use the [set] closure: + /// + /// var query = Query() + /// ..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() + /// ..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(); + /// 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 join({ + T? Function(InstanceType x)? object, + ManagedSet? 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() + /// ..pageBy((e) => e.hireDate, QuerySortOrder.descending); + /// var recentHires = await recentHireQuery.fetch(); + /// + /// var nextRecentHireQuery = Query() + /// ..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 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() + /// ..sortBy((e) => e.name, QuerySortOrder.ascending); + void sortBy( + 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(); + /// 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 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() + /// ..where((e) => e.name).equalTo("Bob"); + /// + /// You may select properties of relationships using this method. + /// + /// final query = Query() + /// ..where((e) => e.manager.name).equalTo("Sally"); + /// + QueryExpression where( + 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? 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() + /// ..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() + /// ..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() + /// ..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() + /// ..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 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() + /// ..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 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(); + /// 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> insertMany(List 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() + /// ..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> 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 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(); + /// var allUsers = q.fetch(); + /// + Future> fetch(); + + /// Fetches a single [InstanceType] from the database. + /// + /// This method behaves the same as [fetch], but limits the results to a single object. + Future 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() + /// ..where.id = whereEqualTo(1); + /// var deletedCount = await q.delete(); + Future 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 +} diff --git a/packages/database/lib/src/query/reduce.dart b/packages/database/lib/src/query/reduce.dart new file mode 100644 index 0000000..1078af5 --- /dev/null +++ b/packages/database/lib/src/query/reduce.dart @@ -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 { + /// Computes the average of some [ManagedObject] property. + /// + /// [selector] identifies the property being averaged, e.g. + /// + /// var query = Query(); + /// 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 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(); + /// var totalUsers = await query.reduce.count(); + /// + Future count(); + + /// Finds the maximum of some [ManagedObject] property. + /// + /// [selector] identifies the property being evaluated, e.g. + /// + /// var query = Query(); + /// 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 maximum(U? Function(T object) selector); + + /// Finds the minimum of some [ManagedObject] property. + /// + /// [selector] identifies the property being evaluated, e.g. + /// + /// var query = new Query(); + /// 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 minimum(U? Function(T object) selector); + + /// Finds the sum of some [ManagedObject] property. + /// + /// [selector] identifies the property being evaluated, e.g. + /// + /// var query = new Query(); + /// 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 sum(U? Function(T object) selector); +} diff --git a/packages/database/lib/src/query/sort_descriptor.dart b/packages/database/lib/src/query/sort_descriptor.dart new file mode 100644 index 0000000..7549498 --- /dev/null +++ b/packages/database/lib/src/query/sort_descriptor.dart @@ -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; +} diff --git a/packages/database/lib/src/query/sort_predicate.dart b/packages/database/lib/src/query/sort_predicate.dart new file mode 100644 index 0000000..ef40891 --- /dev/null +++ b/packages/database/lib/src/query/sort_predicate.dart @@ -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; +} diff --git a/packages/database/lib/src/schema/migration.dart b/packages/database/lib/src/schema/migration.dart new file mode 100644 index 0000000..32c1d5c --- /dev/null +++ b/packages/database/lib/src/schema/migration.dart @@ -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? 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 {} +} + """; + } +} diff --git a/packages/database/lib/src/schema/schema.dart b/packages/database/lib/src/schema/schema.dart new file mode 100644 index 0000000..c10df7c --- /dev/null +++ b/packages/database/lib/src/schema/schema.dart @@ -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 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 map) { + _tables = (map["tables"] as List>) + .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 get tables => List.unmodifiable(_tableStorage); + + // Do not set this directly. Use _tables= instead. + late List _tableStorage; + + set _tables(List 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 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 get errorMessages => + _differingTables.expand((diff) => diff.errorMessages).toList(); + + /// The differences, if any, between tables in [expectedSchema] and [actualSchema]. + List get tableDifferences => _differingTables; + + List get tablesToAdd { + return _differingTables + .where((diff) => diff.expectedTable == null && diff.actualTable != null) + .map((d) => d.actualTable) + .toList(); + } + + List get tablesToDelete { + return _differingTables + .where((diff) => diff.expectedTable != null && diff.actualTable == null) + .map((diff) => diff.expectedTable) + .toList(); + } + + List get tablesToModify { + return _differingTables + .where((diff) => diff.expectedTable != null && diff.actualTable != null) + .toList(); + } + + final List _differingTables = []; +} + +/// Thrown when a [Schema] encounters an error. +class SchemaException implements Exception { + SchemaException(this.message); + + String message; + + @override + String toString() => "Invalid schema. $message"; +} diff --git a/packages/database/lib/src/schema/schema_builder.dart b/packages/database/lib/src/schema/schema_builder.dart new file mode 100644 index 0000000..fb9f915 --- /dev/null +++ b/packages/database/lib/src/schema/schema_builder.dart @@ -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? 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? 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 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 = []; + 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 = []; + 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? 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 = []; + + 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? 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(); + } +} diff --git a/packages/database/lib/src/schema/schema_column.dart b/packages/database/lib/src/schema/schema_column.dart new file mode 100644 index 0000000..61fd015 --- /dev/null +++ b/packages/database/lib/src/schema/schema_column.dart @@ -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 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 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 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'"; + } +} diff --git a/packages/database/lib/src/schema/schema_table.dart b/packages/database/lib/src/schema/schema_table.dart new file mode 100644 index 0000000..8ce2ebd --- /dev/null +++ b/packages/database/lib/src/schema/schema_table.dart @@ -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 columns, { + List? 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 map) { + name = map["name"] as String?; + _columns = (map["columns"] as List>) + .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? get uniqueColumnSet => + _uniqueColumnSet != null ? List.unmodifiable(_uniqueColumnSet!) : null; + + set uniqueColumnSet(List? 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 get columns => List.unmodifiable(_columnStorage ?? []); + + bool get hasForeignKeyInUniqueSet => columns + .where((c) => c.isForeignKey) + .any((c) => uniqueColumnSet?.contains(c.name) ?? false); + + List? _columnStorage; + List? _uniqueColumnSet; + + set _columns(List 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 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 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 get columnDifferences => _differingColumns; + + List get columnsToAdd { + return _differingColumns + .where( + (diff) => diff.expectedColumn == null && diff.actualColumn != null, + ) + .map((diff) => diff.actualColumn) + .toList(); + } + + List get columnsToRemove { + return _differingColumns + .where( + (diff) => diff.expectedColumn != null && diff.actualColumn == null, + ) + .map((diff) => diff.expectedColumn) + .toList(); + } + + List get columnsToModify { + return _differingColumns + .where( + (columnDiff) => + columnDiff.expectedColumn != null && + columnDiff.actualColumn != null, + ) + .toList(); + } + + final List _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 expectedColumnNames; + + /// The actual set of unique column names. + final List 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 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 []; + } +} diff --git a/packages/database/pubspec.yaml b/packages/database/pubspec.yaml index 09ef7cf..8c8d8f5 100644 --- a/packages/database/pubspec.yaml +++ b/packages/database/pubspec.yaml @@ -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: