Skip to content

Commit

Permalink
Support UPDATE & DELETE of activitypointer records, and principalobje…
Browse files Browse the repository at this point in the history
…ctaccess records that reference an activitypointer

Fixes #540
  • Loading branch information
MarkMpn committed Sep 21, 2024
1 parent 2317098 commit 98d757e
Show file tree
Hide file tree
Showing 6 changed files with 301 additions and 250 deletions.
2 changes: 1 addition & 1 deletion MarkMpn.Sql4Cds.Engine.Tests/ExecutionPlanTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3371,7 +3371,7 @@ public void SimpleDelete()

var delete = AssertNode<DeleteNode>(plans[0]);
Assert.AreEqual("account", delete.LogicalName);
Assert.AreEqual("account.accountid", delete.PrimaryIdSource);
Assert.AreEqual("account.accountid", delete.ColumnMappings["accountid"]);
var fetch = AssertNode<FetchXmlScan>(delete.Source);
AssertFetchXml(fetch, @"
<fetch>
Expand Down
9 changes: 1 addition & 8 deletions MarkMpn.Sql4Cds.Engine/ExecutionPlan/BaseDmlNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -405,9 +405,6 @@ protected Dictionary<string, Func<Entity, object>> CompileColumnMappings(DataSou
if (!attributes.TryGetValue(destAttributeName, out var attr) || attr.AttributeOf != null)
continue;

if (metadata.LogicalName == "principalobjectaccess" && (attr.LogicalName == "objecttypecode" || attr.LogicalName == "principaltypecode"))
continue;

var sourceSqlType = schema.Schema[sourceColumnName].Type;
var destType = attr.GetAttributeType();
var destSqlType = attr.IsPrimaryId == true ? DataTypeHelpers.UniqueIdentifier : attr.GetAttributeSqlType(dataSource, true);
Expand Down Expand Up @@ -435,8 +432,7 @@ protected Dictionary<string, Func<Entity, object>> CompileColumnMappings(DataSou
Expression convertedExpr;
var lookupAttr = attr as LookupAttributeMetadata;

if (lookupAttr != null && lookupAttr.AttributeType != AttributeTypeCode.PartyList && metadata.IsIntersect != true ||
metadata.LogicalName == "principalobjectaccess" && (attr.LogicalName == "objectid" || attr.LogicalName == "principalid"))
if (lookupAttr != null && lookupAttr.AttributeType != AttributeTypeCode.PartyList && metadata.IsIntersect != true)
{
if (sourceSqlType.IsSameAs(DataTypeHelpers.EntityReference))
{
Expand All @@ -461,9 +457,6 @@ protected Dictionary<string, Func<Entity, object>> CompileColumnMappings(DataSou
{
var typeColName = destAttributeName + "type";

if (metadata.LogicalName == "principalobjectaccess" && (attr.LogicalName == "objectid" || attr.LogicalName == "principalid"))
typeColName = destAttributeName.Replace("id", "typecode");

var sourceTargetColumnName = mappings[typeColName];
var sourceTargetType = schema.Schema[sourceTargetColumnName].Type;

Expand Down
185 changes: 68 additions & 117 deletions MarkMpn.Sql4Cds.Engine/ExecutionPlan/DeleteNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,28 +35,12 @@ class DeleteNode : BaseDmlNode
public string LogicalName { get; set; }

/// <summary>
/// The column that contains the primary ID of the records to delete
/// The columns that are used for the delete requests and their corresponding source columns
/// </summary>
[Category("Delete")]
[Description("The column that contains the primary ID of the records to delete")]
[DisplayName("PrimaryId Source")]
public string PrimaryIdSource { get; set; }

/// <summary>
/// The column that contains the secondary ID of the records to delete (used for many-to-many intersect and elastic tables)
/// </summary>
[Category("Delete")]
[Description("The column that contains the secondary ID of the records to delete (used for many-to-many intersect and elastic tables)")]
[DisplayName("SecondaryId Source")]
public string SecondaryIdSource { get; set; }

/// <summary>
/// The column that contains the type code of the records to delete (used for activity records)
/// </summary>
[Category("Delete")]
[Description("The column that contains the type code of the records to delete (used for activity records)")]
[DisplayName("ActivityTypeCode Source")]
public string ActivityTypeCodeSource { get; set; }
[Description("The columns that are used for the delete requests and their corresponding source columns")]
[DisplayName("Column Mappings")]
public Dictionary<string, string> ColumnMappings { get; set; }

[Category("Delete")]
public override int MaxDOP { get; set; }
Expand All @@ -74,14 +58,11 @@ class DeleteNode : BaseDmlNode

public override void AddRequiredColumns(NodeCompilationContext context, IList<string> requiredColumns)
{
if (!requiredColumns.Contains(PrimaryIdSource))
requiredColumns.Add(PrimaryIdSource);

if (SecondaryIdSource != null && !requiredColumns.Contains(SecondaryIdSource))
requiredColumns.Add(SecondaryIdSource);

if (ActivityTypeCodeSource != null && !requiredColumns.Contains(ActivityTypeCodeSource))
requiredColumns.Add(ActivityTypeCodeSource);
foreach (var col in ColumnMappings.Values)
{
if (!requiredColumns.Contains(col))
requiredColumns.Add(col);
}

Source.AddRequiredColumns(context, requiredColumns);
}
Expand All @@ -100,26 +81,31 @@ public override IRootExecutionPlanNodeInternal[] FoldQuery(NodeCompilationContex
if ((context.Options.UseBulkDelete || LogicalName == "audit") &&
Source is FetchXmlScan fetch &&
LogicalName == fetch.Entity.name &&
PrimaryIdSource.Equals($"{fetch.Alias}.{dataSource.Metadata[LogicalName].PrimaryIdAttribute}") &&
String.IsNullOrEmpty(SecondaryIdSource) &&
String.IsNullOrEmpty(ActivityTypeCodeSource))
ColumnMappings.Count == 1)
{
return new[] { new BulkDeleteJobNode { DataSource = DataSource, Source = fetch } };
var metadata = dataSource.Metadata[LogicalName];

if (ColumnMappings.TryGetValue(metadata.PrimaryIdAttribute, out var primaryIdSource) &&
primaryIdSource == $"{fetch.Alias}.{dataSource.Metadata[LogicalName].PrimaryIdAttribute}")
{
return new[] { new BulkDeleteJobNode { DataSource = DataSource, Source = fetch } };
}
}

return new[] { this };
}

protected override void RenameSourceColumns(IDictionary<string, string> columnRenamings)
{
if (columnRenamings.TryGetValue(PrimaryIdSource, out var primaryIdSourceRenamed))
PrimaryIdSource = primaryIdSourceRenamed;
var renamedMappings = new Dictionary<string, string>();

if (SecondaryIdSource != null && columnRenamings.TryGetValue(SecondaryIdSource, out var secondaryIdSourceRenamed))
SecondaryIdSource = secondaryIdSourceRenamed;

if (ActivityTypeCodeSource != null && columnRenamings.TryGetValue(ActivityTypeCodeSource, out var activityTypeCodeSourceRenamed))
ActivityTypeCodeSource = activityTypeCodeSourceRenamed;
foreach (var mapping in ColumnMappings)
{
if (columnRenamings.TryGetValue(mapping.Value, out var renamed))
renamedMappings[mapping.Key] = renamed;
else
renamedMappings[mapping.Key] = mapping.Value;
}
}

public override void Execute(NodeExecutionContext context, out int recordsAffected, out string message)
Expand All @@ -133,60 +119,33 @@ public override void Execute(NodeExecutionContext context, out int recordsAffect

List<Entity> entities;
EntityMetadata meta;
Func<Entity, object> primaryIdAccessor;
Func<Entity, object> secondaryIdAccessor = null;

Dictionary<string, Func<Entity, object>> attributeAccessors;

using (_timer.Run())
{
entities = GetDmlSourceEntities(context, out var schema);

// Precompile mappings with type conversions
meta = dataSource.Metadata[LogicalName];
var dateTimeKind = context.Options.UseLocalTimeZone ? DateTimeKind.Local : DateTimeKind.Utc;
var primaryKey = meta.PrimaryIdAttribute;
string secondaryKey = null;

// Special cases for the keys used for intersect entities
if (meta.LogicalName == "listmember")
{
primaryKey = "listid";
secondaryKey = "entityid";
}
else if (meta.IsIntersect == true)
{
var relationship = meta.ManyToManyRelationships.Single();
primaryKey = relationship.Entity1IntersectAttribute;
secondaryKey = relationship.Entity2IntersectAttribute;
}
else if (meta.LogicalName == "principalobjectaccess")
{
primaryKey = "objectid";
secondaryKey = "principalid";
}
else if (meta.DataProviderId == DataProviders.ElasticDataProvider)
{
secondaryKey = "partitionid";
}
var mappingsToCompile = ColumnMappings;

var fullMappings = new Dictionary<string, string>
// Entity type codes will be presented as a string but the mapping compilation will try to convert
// them to an int, so remove it and we can access the string directly
if (LogicalName == "principalobjectaccess")
{
[primaryKey] = PrimaryIdSource
};

if (secondaryKey != null)
fullMappings[secondaryKey] = SecondaryIdSource;

if (meta.LogicalName == "principalobjectaccess")
mappingsToCompile = new Dictionary<string, string>(ColumnMappings);
mappingsToCompile.Remove("objecttypecode");
mappingsToCompile.Remove("principaltypecode");
}
else if (LogicalName == "activitypointer")
{
fullMappings["objecttypecode"] = PrimaryIdSource.Replace("id", "typecode");
fullMappings["principaltypecode"] = SecondaryIdSource.Replace("id", "typecode");
mappingsToCompile = new Dictionary<string, string>(ColumnMappings);
mappingsToCompile.Remove("activitytypecode");
}

var attributeAccessors = CompileColumnMappings(dataSource, LogicalName, fullMappings, schema, dateTimeKind, entities);
primaryIdAccessor = attributeAccessors[primaryKey];

if (SecondaryIdSource != null)
secondaryIdAccessor = attributeAccessors[secondaryKey];
attributeAccessors = CompileColumnMappings(dataSource, LogicalName, mappingsToCompile, schema, dateTimeKind, entities);
}

// Check again that the update is allowed. Don't count any UI interaction in the execution time
Expand All @@ -204,7 +163,7 @@ public override void Execute(NodeExecutionContext context, out int recordsAffect
context.Options,
entities,
meta,
entity => CreateDeleteRequest(meta, entity, primaryIdAccessor, secondaryIdAccessor),
entity => CreateDeleteRequest(meta, entity, attributeAccessors),
new OperationNames
{
InProgressUppercase = "Deleting",
Expand All @@ -229,50 +188,47 @@ public override void Execute(NodeExecutionContext context, out int recordsAffect
}
}

private OrganizationRequest CreateDeleteRequest(EntityMetadata meta, Entity entity, Func<Entity,object> primaryIdAccessor, Func<Entity,object> secondaryIdAccessor)
private OrganizationRequest CreateDeleteRequest(EntityMetadata meta, Entity entity, Dictionary<string,Func<Entity,object>> attributeAccessors)
{
if (meta.LogicalName == "principalobjectaccess")
{
var revoke = new RevokeAccessRequest
var objectId = (Guid)attributeAccessors["objectid"](entity);
var objectTypeCode = entity.GetAttributeValue<SqlString>(ColumnMappings["objecttypecode"]).Value;
var principalId = (Guid)attributeAccessors["principalid"](entity);
var principalTypeCode = entity.GetAttributeValue<SqlString>(ColumnMappings["principaltypecode"]).Value;

return new RevokeAccessRequest
{
Target = (EntityReference)primaryIdAccessor(entity),
Revokee = (EntityReference)secondaryIdAccessor(entity)
Target = new EntityReference(objectTypeCode, objectId),
Revokee = new EntityReference(principalTypeCode, principalId)
};

// Special case for activitypointer - need to set the specific activity type code
var activityTypeCode = entity.GetAttributeValue<SqlString>(ActivityTypeCodeSource);
if (!activityTypeCode.IsNull)
revoke.Target.LogicalName = activityTypeCode.Value;

return revoke;
}

var id = (Guid)primaryIdAccessor(entity);

// Special case messages for intersect entities
if (meta.IsIntersect == true)
if (meta.LogicalName == "listmember")
{
var secondaryId = (Guid)secondaryIdAccessor(entity);

if (meta.LogicalName == "listmember")
return new RemoveMemberListRequest
{
return new RemoveMemberListRequest
{
ListId = id,
EntityId = secondaryId
};
}

ListId = (Guid)attributeAccessors["listid"](entity),
EntityId = (Guid)attributeAccessors["entityid"](entity)
};
}
else if (meta.IsIntersect == true)
{
var relationship = meta.ManyToManyRelationships.Single();

var targetId = (Guid)attributeAccessors[relationship.Entity1IntersectAttribute](entity);
var relatedId = (Guid)attributeAccessors[relationship.Entity2IntersectAttribute](entity);

return new DisassociateRequest
{
Target = new EntityReference(relationship.Entity1LogicalName, id),
RelatedEntities = new EntityReferenceCollection { new EntityReference(relationship.Entity2LogicalName, secondaryId) },
Target = new EntityReference(relationship.Entity1LogicalName, targetId),
RelatedEntities = new EntityReferenceCollection { new EntityReference(relationship.Entity2LogicalName, relatedId) },
Relationship = new Relationship(relationship.SchemaName) { PrimaryEntityRole = EntityRole.Referencing }
};
}

var id = (Guid)attributeAccessors[meta.PrimaryIdAttribute](entity);
var req = new DeleteRequest
{
Target = new EntityReference(LogicalName, id)
Expand All @@ -286,18 +242,14 @@ private OrganizationRequest CreateDeleteRequest(EntityMetadata meta, Entity enti
KeyAttributes =
{
[meta.PrimaryIdAttribute] = id,
["partitionid"] = secondaryIdAccessor(entity)
["partitionid"] = attributeAccessors["partitionid"](entity)
}
};
}

// Special case for activitypointer - need to set the specific activity type code
if (ActivityTypeCodeSource != null)
{
var activityTypeCode = entity.GetAttributeValue<SqlString>(ActivityTypeCodeSource);
if (!activityTypeCode.IsNull)
req.Target.LogicalName = activityTypeCode.Value;
}
if (LogicalName == "activitypointer")
req.Target.LogicalName = entity.GetAttributeValue<SqlString>(ColumnMappings["activitytypecode"]).Value;

return req;
}
Expand All @@ -317,6 +269,7 @@ protected override ExecuteMultipleResponse ExecuteMultiple(DataSource dataSource
return base.ExecuteMultiple(dataSource, org, meta, req);

if (meta.DataProviderId == DataProviders.ElasticDataProvider
&& req.Requests.Cast<DeleteRequest>().GroupBy(r => r.Target.LogicalName).Count() == 1
// DeleteMultiple is only supported on elastic tables, even if other tables do define the message
/* || dataSource.MessageCache.IsMessageAvailable(meta.LogicalName, "DeleteMultiple")*/)
{
Expand Down Expand Up @@ -428,9 +381,7 @@ public override object Clone()
Length = Length,
LogicalName = LogicalName,
MaxDOP = MaxDOP,
PrimaryIdSource = PrimaryIdSource,
SecondaryIdSource = SecondaryIdSource,
ActivityTypeCodeSource = ActivityTypeCodeSource,
ColumnMappings = ColumnMappings,
Source = (IExecutionPlanNodeInternal)Source.Clone(),
Sql = Sql
};
Expand Down
Loading

0 comments on commit 98d757e

Please sign in to comment.