Prechádzať zdrojové kódy

Merge commit '2482d98788311d40dff39de7fd1640e4517ed1b0' into frank

Frank van den Bos 10 mesiacov pred
rodič
commit
c2ad43accd

+ 9 - 1
InABox.Core/Aggregate.cs

@@ -140,6 +140,8 @@ namespace InABox.Core
     {
         Type TAggregate { get; }
 
+        Type TResult { get; }
+
         IComplexFormulaNode GetExpression();
 
         AggregateCalculation GetCalculation();
@@ -161,6 +163,8 @@ namespace InABox.Core
 
         Type IComplexFormulaAggregateNode.TAggregate => typeof(TAggregate);
 
+        Type IComplexFormulaAggregateNode.TResult => typeof(TResult);
+
         public ComplexFormulaAggregateNode(IComplexFormulaNode<TAggregate, TResult> expression, AggregateCalculation calculation, Filter<TAggregate>? filter, Dictionary<Expression<Func<TAggregate, object?>>, Expression<Func<TType, object?>>> links)
         {
             Expression = expression;
@@ -260,6 +264,8 @@ namespace InABox.Core
 
     public interface IComplexFormulaFormulaNode : IComplexFormulaNode
     {
+        Type TResult { get; }
+
         IEnumerable<IComplexFormulaNode> GetOperands();
 
         FormulaOperator GetOperator();
@@ -270,6 +276,8 @@ namespace InABox.Core
 
         public FormulaOperator Operator { get; set; }
 
+        Type IComplexFormulaFormulaNode.TResult => typeof(TResult);
+
         public ComplexFormulaFormulaNode(IComplexFormulaNode<TType, TResult>[] operands, FormulaOperator op)
         {
             Operands = operands;
@@ -430,7 +438,7 @@ namespace InABox.Core
 
     public interface IComplexFormulaGenerator<TType, TResult>
     {
-        IComplexFormulaNode<TType, TResult> Property(Expression<Func<TType, TResult>> epxression);
+        IComplexFormulaNode<TType, TResult> Property(Expression<Func<TType, TResult>> expression);
 
         IComplexFormulaNode<TType, TResult> Formula(FormulaOperator op, params IComplexFormulaNode<TType, TResult>[] operands);
 

+ 6 - 0
InABox.Core/CoreTable/CoreRow.cs

@@ -189,6 +189,12 @@ namespace InABox.Core
             //String colname = CoreUtils.GetFullPropertyName(expression, ".");
             Set(colname, value);
         }
+        public void Update<TSource, TType>(Expression<Func<TSource, TType>> expression, Func<TType, TType> update)
+        {
+            var colname = GetColName(expression);
+            //String colname = CoreUtils.GetFullPropertyName(expression, ".");
+            Set(colname, update(Get<TType>(colname)));
+        }
 
         public void Set<T>(int col, T value)
         {

+ 12 - 0
InABox.Core/CoreTreeNodes.cs

@@ -62,6 +62,11 @@ namespace InABox.Core
         public object? this[string column]
         {
             get => Row[column];
+            set
+            {
+                Row[column] = value;
+                _owner.DoColumnChanged(this, column);
+            }
         }
 
         public event PropertyChangedEventHandler? PropertyChanged;
@@ -184,5 +189,12 @@ namespace InABox.Core
             }
         }
         
+        public delegate void ColumnChangedEventHandler(CoreTreeNode node, string column);
+        public event ColumnChangedEventHandler? ColumnChanged;
+
+        internal void DoColumnChanged(CoreTreeNode node, string column)
+        {
+            ColumnChanged?.Invoke(node, column);
+        }
     }
 }

+ 10 - 0
InABox.Core/DatabaseSchema/DatabaseSchema.cs

@@ -338,6 +338,16 @@ namespace InABox.Core
         public static IEnumerable<IProperty> RootProperties(Type type)
             => PropertiesInternal(type).Where(x => x.Parent is null);
 
+        /// <summary>
+        /// Return all properties that are defined locally on <paramref name="type"/>, following sub-objects but not entity links; does not retrieve calculated fields. (On entity links, the ID property is retrieved.)
+        /// </summary>
+        /// <param name="type"></param>
+        /// <returns></returns>
+        public static IEnumerable<IProperty> LocalProperties(Type type)
+            => PropertiesInternal(type).Where(
+                x => (!x.HasParentEntityLink() || (x.Parent?.HasParentEntityLink() != true && x.Name.EndsWith(".ID")))
+                    && !x.IsCalculated);
+
         /// <summary>
         /// Return the standard property list for <paramref name="type"/>; this includes nested properties.
         /// </summary>

+ 6 - 0
InABox.Core/Entity.cs

@@ -51,6 +51,12 @@ namespace InABox.Core
     {
     }
 
+    /// <summary>
+    ///     Indicate that an <see cref="Entity"/> is able to be merged together.
+    /// </summary>
+    /// <remarks>
+    ///     It is recommended that an <see cref="Entity"/> that implements this should provide a <see cref="object.ToString"/> implementation.
+    /// </remarks>
     public interface IMergeable
     {
     }

+ 4 - 2
InABox.Core/Query/Column.cs

@@ -22,6 +22,8 @@ namespace InABox.Core
         /// Every column needs a name to distinguish it from other columns, for example in query result tables, or in SQL queries.
         /// </remarks>
         string Name { get; }
+
+        Type Type { get; }
     }
 
     public interface IBaseColumns
@@ -38,6 +40,8 @@ namespace InABox.Core
     {
         public string Name { get; }
 
+        public Type Type => typeof(TResult);
+
         public IComplexFormulaNode<T, TResult> Formula { get; }
 
         public ComplexColumn(string name, IComplexFormulaNode<T, TResult> formula)
@@ -57,8 +61,6 @@ namespace InABox.Core
     {
         string Property { get; }
 
-        Type Type { get; }
-
         string IBaseColumn.Name => Property;
     }
 

+ 16 - 6
InABox.Database/DbFactory.cs

@@ -66,16 +66,26 @@ public static class DbFactory
 
     public static IProvider NewProvider(Logger logger) => ProviderFactory.NewProvider(logger);
 
-    public static void Start()
+    public static void Start(Type[]? types = null)
     {
         CoreUtils.CheckLicensing();
+
+        if(types is not null)
+        {
+            ProviderFactory.Types = types.Concat(CoreUtils.IterateTypes(typeof(CoreUtils).Assembly).Where(x => !x.IsAbstract))
+                .Where(x => x.IsClass && !x.IsGenericType && x.IsSubclassOf(typeof(Entity)))
+                .ToArray();
+        }
+        else
+        {
+            ProviderFactory.Types = Entities.Where(x =>
+                x.IsClass
+                && !x.IsGenericType
+                && x.IsSubclassOf(typeof(Entity))
+            ).ToArray();
+        }
         
         // Start the provider
-        ProviderFactory.Types = Entities.Where(x =>
-            x.IsClass
-            && !x.IsGenericType
-            && x.IsSubclassOf(typeof(Entity))
-        ).ToArray();
 
         ProviderFactory.Start();
 

+ 2 - 0
InABox.Database/Stores/IStore.cs

@@ -27,6 +27,8 @@ namespace InABox.Database
         void Save(IEnumerable<Entity> entities, string auditnote);
         void Delete(Entity entity, string auditnote);
         void Delete(IEnumerable<Entity> entities, string auditnote);
+
+        public IStore<TEntity> FindSubStore<TEntity>() where TEntity : Entity, new();
     }
 
     public interface IStore<T> : IStore where T : Entity, new()

+ 31 - 45
InABox.Database/Stores/Store.cs

@@ -420,10 +420,26 @@ namespace InABox.Database
             }
         }
 
+        protected virtual void BeforeSave(IEnumerable<T> entities)
+        {
+            foreach(var entity in entities)
+            {
+                BeforeSave(entity);
+            }
+        }
+
         protected virtual void AfterSave(T entity)
         {
         }
 
+        protected virtual void AfterSave(IEnumerable<T> entities)
+        {
+            foreach(var entity in entities)
+            {
+                AfterSave(entity);
+            }
+        }
+
         protected virtual void OnSave(T entity, ref string auditnote)
         {
             CheckAutoIncrement(entity);
@@ -498,56 +514,38 @@ namespace InABox.Database
 
         public void Save(IEnumerable<T> entities, string auditnote)
         {
-            DoSave(entities, auditnote);
+            DoSave(entities.AsArray(), auditnote);
         }
 
         public void Save(IEnumerable<Entity> entities, string auditnote)
         {
-            var updates = new List<T>();
-            foreach (var entity in entities)
-                updates.Add((T)entity);
-            DoSave(updates, auditnote);
+            DoSave(entities.Select(x => (T)x).ToArray(), auditnote);
         }
 
-        protected virtual void OnSave(IEnumerable<T> entities, ref string auditnote)
+        protected virtual void OnSave(T[] entities, ref string auditnote)
         {
-            CheckAutoIncrement(entities.ToArray());
+            CheckAutoIncrement(entities);
             Provider.Save(entities);
         }
 
-        private void DoSave(IEnumerable<T> entities, string auditnote)
+        private void DoSave(T[] entities, string auditnote)
         {
             UpdateUserTracking(UserTrackingAction.Write);
 
-            entities = RunScript(ScriptType.BeforeSave, entities.ToList());
-
-            //OpenSession("Save", true);
+            entities = RunScript(ScriptType.BeforeSave, entities).AsArray();
 
-            //try
-            //{
             // Process any AutoIncrement Fields before we apply the Unique Code test
             // Thus, if we have a unique autoincrement, it will be populated prior to validation
-            CheckAutoIncrement(entities.ToArray());
+            CheckAutoIncrement(entities);
 
             var changes = new Dictionary<T, string>();
             foreach (var entity in entities)
             {
                 changes[entity] = entity.ChangedValues();
-                //UpdateInternalLinks(entity);
-                BeforeSave(entity);
             }
+            BeforeSave(entities);
 
-            try
-            {
-                //OpenSession("Save", true);
-                OnSave(entities, ref auditnote);
-                //CloseSession("Save", true);
-            }
-            catch (Exception e)
-            {
-                //CloseSession("Save", true);
-                throw e;
-            }
+            OnSave(entities, ref auditnote);
 
             if (DbFactory.IsSupported<AuditTrail>())
             {
@@ -559,10 +557,10 @@ namespace InABox.Database
                     if (!string.IsNullOrEmpty(auditnote))
                         notes.Add(auditnote);
 
-                    if (changes.ContainsKey(entity) && !string.IsNullOrEmpty(changes[entity]))
-                        notes.Add(changes[entity]);
+                    if (changes.TryGetValue(entity, out string? value) && !string.IsNullOrEmpty(value))
+                        notes.Add(value);
 
-                    if (notes.Any())
+                    if (notes.Count != 0)
                     {
                         var audit = new AuditTrail
                         {
@@ -572,30 +570,18 @@ namespace InABox.Database
                             Note = string.Join(": ", notes)
                         };
                         audittrails.Add(audit);
-                        //Provider.Save<AuditTrail>(audit);
                     }
                 }
 
-                if (audittrails.Any())
+                if (audittrails.Count != 0)
                     Provider.Save(audittrails);
             }
 
-            foreach (var entity in entities)
-            {
-                AfterSave(entity);
-                //UpdateExternalLinks(entity, false);
-                //entity.CommitChanges();
-            }
+            AfterSave(entities);
 
-            entities = RunScript(ScriptType.AfterSave, entities);
+            entities = RunScript(ScriptType.AfterSave, entities).AsArray();
             
             NotifyListeners(entities);
-
-            //}
-            //catch (Exception e)
-            //{
-            //    throw e;
-            //}
         }
 
         private static List<Tuple<Type, Action<Guid[]>>> _listeners = new List<Tuple<Type, Action<Guid[]>>>();

+ 204 - 25
inabox.database.sqlite/SQLiteProvider.cs

@@ -1,5 +1,6 @@
 using System.Collections;
 using System.Data;
+using System.Data.Common;
 using System.Data.SQLite;
 using System.Diagnostics.CodeAnalysis;
 using System.Linq.Expressions;
@@ -41,12 +42,163 @@ internal abstract class SQLiteAccessor : IDisposable
 
         Connection = new SQLiteConnection(conn);
         Connection.BusyTimeout = Convert.ToInt32(TimeSpan.FromMinutes(2).TotalMilliseconds);
+
         Connection.Open();
+
         Connection.SetLimitOption(SQLiteLimitOpsEnum.SQLITE_LIMIT_VARIABLE_NUMBER, 10000);
         ++nConnections;
     }
 }
 
+#region Custom Decimal Functions
+
+[SQLiteFunction(Name = "DECIMAL_SUM", Arguments = 1, FuncType = FunctionType.Aggregate)]
+public class SQLiteDecimalSum : SQLiteFunction
+{
+    public override void Step(object[] args, int stepNumber, ref object contextData)
+    {
+        if (args.Length < 1 || args[0] == DBNull.Value)
+            return;
+        decimal d = Convert.ToDecimal(args[0]);
+        if (contextData != null) d += (decimal)contextData;
+        contextData = d;
+    }
+
+    public override object Final(object contextData)
+    {
+        return contextData;
+    }
+}
+
+[SQLiteFunction(Name = "DECIMAL_ADD", Arguments = -1, FuncType = FunctionType.Scalar)]
+public class SQLiteDecimalAdd : SQLiteFunction
+{
+    public override object? Invoke(object[] args)
+    {
+        var result = 0.0M;
+        for(int i = 0; i < args.Length; ++i)
+        {
+            var arg = args[i];
+            if(arg == DBNull.Value)
+            {
+                return null;
+            }
+            else
+            {
+                result += Convert.ToDecimal(arg);
+            }
+        }
+        return result;
+    }
+}
+[SQLiteFunction(Name = "DECIMAL_SUB", Arguments = -1, FuncType = FunctionType.Scalar)]
+public class SQLiteDecimalSub : SQLiteFunction
+{
+    public override object? Invoke(object[] args)
+    {
+        if(args.Length == 0)
+        {
+            return 0.0M;
+        }
+        else if(args.Length == 1)
+        {
+            if (args[0] == DBNull.Value)
+            {
+                return null;
+            }
+            else
+            {
+                return -Convert.ToDecimal(args[0]);
+            }
+        }
+        else
+        {
+            if (args[0] == DBNull.Value)
+            {
+                return null;
+            }
+            var result = Convert.ToDecimal(args[0]);
+            foreach(var arg in args.Skip(1))
+            {
+                if(arg == DBNull.Value)
+                {
+                    return null;
+                }
+                result -= Convert.ToDecimal(arg);
+            }
+            return result;
+        }
+    }
+}
+[SQLiteFunction(Name = "DECIMAL_MUL", Arguments = -1, FuncType = FunctionType.Scalar)]
+public class SQLiteDecimalMult : SQLiteFunction
+{
+    public override object? Invoke(object[] args)
+    {
+        var result = 1.0M;
+        foreach(var arg in args)
+        {
+            if(arg == DBNull.Value)
+            {
+                return null;
+            }
+            result *= Convert.ToDecimal(arg);
+        }
+        return result;
+    }
+}
+[SQLiteFunction(Name = "DECIMAL_DIV", Arguments = -1, FuncType = FunctionType.Scalar)]
+public class SQLiteDecimalDiv : SQLiteFunction
+{
+    public override object? Invoke(object[] args)
+    {
+        if(args.Length == 0)
+        {
+            return 1.0M;
+        }
+        else if(args.Length == 1)
+        {
+            if (args[0] == DBNull.Value)
+            {
+                return null;
+            }
+            else
+            {
+                var denom = Convert.ToDecimal(args[0]);
+                if(denom == 0M)
+                {
+                    return new Exception("Attempt to divide by zero.");
+                }
+                return 1.0M / denom;
+            }
+        }
+        else
+        {
+            if (args[0] == DBNull.Value)
+            {
+                return null;
+            }
+            var result = Convert.ToDecimal(args[0]);
+            foreach(var arg in args.Skip(1))
+            {
+                if(arg == DBNull.Value)
+                {
+                    return null;
+                }
+                var denom = Convert.ToDecimal(arg);
+                if(denom == 0M)
+                {
+                    return new Exception("Attempt to divide by zero.");
+                }
+                result /= denom;
+            }
+            return result;
+        }
+    }
+}
+
+#endregion
+
 internal class SQLiteReadAccessor : SQLiteAccessor
 {
     public SQLiteReadAccessor(string url)
@@ -478,7 +630,7 @@ public class SQLiteProviderFactory : IProviderFactory
         if (type == typeof(byte[]))
             return "BLOB";
 
-        if (type.IsFloatingPoint())
+        if (type.IsFloatingPoint() || type == typeof(decimal))
             return "NUM";
 
         if (type.GetInterfaces().Contains(typeof(IPackable)))
@@ -1720,11 +1872,11 @@ public class SQLiteProvider : IProvider
         Dictionary<string, string> fieldmap, List<string> columns, bool useparams)
         => GetSortClauseNonGeneric(typeof(T), command, sort, prefix, tables, fieldmap, columns, useparams);
 
-    private static string GetCalculation(AggregateCalculation calculation, string columnname)
+    private static string GetCalculation(AggregateCalculation calculation, string columnname, Type TResult)
     {
         return calculation switch
         {
-            AggregateCalculation.Sum => "SUM",
+            AggregateCalculation.Sum => TResult == typeof(decimal) ? "DECIMAL_SUM" : "SUM",
             AggregateCalculation.Count => "COUNT",
             AggregateCalculation.Maximum => "MAX",
             AggregateCalculation.Minimum => "MIN",
@@ -1866,7 +2018,7 @@ public class SQLiteProvider : IProvider
 
                 var aggregates = new Dictionary<string, string>
                 {
-                    { aggCol, GetCalculation(agg.GetCalculation(), aggCol) }
+                    { aggCol, GetCalculation(agg.GetCalculation(), aggCol, agg.TResult) }
                 };
 
                 var subquery = string.Format("({0})",
@@ -1947,48 +2099,75 @@ public class SQLiteProvider : IProvider
                 switch (op)
                 {
                     case FormulaOperator.Add:
-                        if(operands.Count == 0)
+                        if(formula.TResult == typeof(decimal))
                         {
-                            return "0.00";
+                            return $"DECIMAL_ADD({string.Join(',', operands)})";
                         }
                         else
                         {
-                            return $"({string.Join('+', operands)})";
+                            if(operands.Count == 0)
+                            {
+                                return "0.00";
+                            }
+                            {
+                                return $"({string.Join('+', operands)})";
+                            }
                         }
                     case FormulaOperator.Subtract:
-                        if(operands.Count == 0)
-                        {
-                            return "0.00";
-                        }
-                        else if(operands.Count == 1)
+                        if (formula.TResult == typeof(decimal))
                         {
-                            return $"(-{operands[0]})";
+                            return $"DECIMAL_SUB({string.Join(',', operands)})";
                         }
                         else
                         {
-                            return $"({string.Join('-', operands)})";
+                            if (operands.Count == 0)
+                            {
+                                return "0.00";
+                            }
+                            else if (operands.Count == 1)
+                            {
+                                return $"(-{operands[0]})";
+                            }
+                            else
+                            {
+                                return $"({string.Join('-', operands)})";
+                            }
                         }
                     case FormulaOperator.Multiply:
-                        if(operands.Count == 0)
+                        if (formula.TResult == typeof(decimal))
                         {
-                            return "1.00";
+                            return $"DECIMAL_MUL({string.Join(',', operands)})";
                         }
                         else
                         {
-                            return $"({string.Join('*', operands)})";
+                            if (operands.Count == 0)
+                            {
+                                return "1.00";
+                            }
+                            else
+                            {
+                                return $"({string.Join('*', operands)})";
+                            }
                         }
                     case FormulaOperator.Divide:
-                        if(operands.Count == 0)
-                        {
-                            return "1.00";
-                        }
-                        else if(operands.Count == 1)
+                        if (formula.TResult == typeof(decimal))
                         {
-                            return $"(1.00 / {operands[0]})";
+                            return $"DECIMAL_DIV({string.Join(',', operands)})";
                         }
                         else
                         {
-                            return $"({string.Join('/', operands)})";
+                            if (operands.Count == 0)
+                            {
+                                return "1.00";
+                            }
+                            else if (operands.Count == 1)
+                            {
+                                return $"(1.00 / {operands[0]})";
+                            }
+                            else
+                            {
+                                return $"({string.Join('/', operands)})";
+                            }
                         }
                     case FormulaOperator.Maximum:
                         return $"MAX({string.Join(',', operands)})";
@@ -2056,7 +2235,7 @@ public class SQLiteProvider : IProvider
 
                             if (!internalaggregate)
                             {
-                                var scols = new Dictionary<string, string> { { agg.Aggregate, GetCalculation(agg.Calculation, baseCol.Name) } };
+                                var scols = new Dictionary<string, string> { { agg.Aggregate, GetCalculation(agg.Calculation, baseCol.Name, baseCol.Type) } };
 
                                 var linkedtype = agg.Source;
                                 /*var siblings = columns.Where(x => !x.Equals(baseCol.Name) && x.Split('.').First().Equals(bits.First()))

+ 23 - 26
inabox.wpf/DynamicGrid/DynamicDataGrid.cs

@@ -12,6 +12,7 @@ using System.Windows.Controls;
 using InABox.Clients;
 using InABox.Configuration;
 using InABox.Core;
+using InABox.Wpf;
 using InABox.WPF;
 using Expression = System.Linq.Expressions.Expression;
 
@@ -601,24 +602,21 @@ public class DynamicDataGrid<TEntity> : DynamicGrid<TEntity>, IDynamicDataGrid w
         var otherids = rows.Select(r => r.Get<TEntity, Guid>(x => x.ID)).Where(x => x != targetid).ToArray();
         string[] others = rows.Where(r => otherids.Contains(r.Get<Guid>("ID"))).Select(x => x.ToObject<TEntity>().ToString()!).ToArray();
         var nRows = rows.Length;
-        if (MessageBox.Show(
-                string.Format(
-                    "This will merge the following items:\n\n- {0}\n\n into:\n\n- {1}\n\nAfter this, the items will be permanently removed.\nAre you sure you wish to do this?",
-                    string.Join("\n- ", others),
-                    target
-                ),
-                "Merge Items Warning",
-                MessageBoxButton.YesNo,
-                MessageBoxImage.Stop) != MessageBoxResult.Yes
-           )
+        if (!MessageWindow.ShowYesNo(
+                $"This will merge the following items:\n\n" +
+                $"- {string.Join("\n- ", others)}\n\n" +
+                $" into:\n\n" +
+                $"- {target}\n\n" +
+                $"After this, the items will be permanently removed.\n" +
+                $"Are you sure you wish to do this?",
+                "Merge Items Warning"))
             return false;
 
         using (new WaitCursor())
         {
             var types = CoreUtils.Entities.Where(
                 x =>
-                    x.IsClass
-                    && !x.IsGenericType
+                    !x.IsGenericType
                     && x.IsSubclassOf(typeof(Entity))
                     && !x.Equals(typeof(AuditTrail))
                     && !x.Equals(typeof(TEntity))
@@ -635,23 +633,22 @@ public class DynamicDataGrid<TEntity> : DynamicGrid<TEntity>, IDynamicDataGrid w
                         x.PropertyType.GetInterfaces().Contains(typeof(IEntityLink))
                         && x.PropertyType.GetInheritedGenericTypeArguments().Contains(typeof(TEntity))
                 );
-                foreach (var prop in props)
+                foreach (var prop in DatabaseSchema.LocalProperties(type))
                 {
-                    var propname = string.Format(prop.Name + ".ID");
-                    var filter = Core.Filter.Create(type);
-                    filter.Expression = CoreUtils.CreateMemberExpression(type, propname);
-                    filter.Operator = Operator.InList;
-                    filter.Value = otherids;
-                    var columns = Columns.None(type)
-                        .Add("ID")
-                        .Add(propname);
-                    var updates = ClientFactory.CreateClient(type).Query(filter, columns).Rows.Select(r => r.ToObject(type)).ToArray();
-                    if (updates.Any())
+                    if(prop.Parent is null
+                        || prop.Parent.PropertyType.GetInterfaceDefinition(typeof(IEntityLink<>)) is not Type intDef
+                        || intDef.GenericTypeArguments[0] != typeof(TEntity))
+                    {
+                        continue;
+                    }
+                    var filter = Filter.Create(type, prop.Name).InList(otherids);
+                    var columns = Columns.None(type).Add("ID").Add(prop.Name);
+                    var updates = ClientFactory.CreateClient(type).Query(filter, columns).Rows.ToArray(r => r.ToObject(type));
+                    if (updates.Length != 0)
                     {
                         foreach (var update in updates)
-                            CoreUtils.SetPropertyValue(update, propname, targetid);
-                        ClientFactory.CreateClient(type).Save(updates,
-                            string.Format("Merged {0} Records", typeof(TEntity).EntityName().Split('.').Last()));
+                            prop.Setter()(update, targetid);
+                        ClientFactory.CreateClient(type).Save(updates, $"Merged {typeof(TEntity).Name} Records");
                     }
                 }
             }

+ 5 - 2
inabox.wpf/DynamicGrid/DynamicGrid.cs

@@ -441,7 +441,7 @@ public abstract class DynamicGrid<T> : DynamicGrid, IDynamicGridUIComponentParen
     void IDynamicGridUIComponentParent<T>.DoubleClickCell(CoreRow? row, DynamicColumnBase? column)
     {
         var args = new DynamicGridCellClickEventArgs(row, column);
-        if (OnCellDoubleClick is not null && column is DynamicGridColumn col)
+        if (OnCellDoubleClick is not null)
         {
             
             OnCellDoubleClick?.Invoke(this, args);
@@ -713,7 +713,10 @@ public abstract class DynamicGrid<T> : DynamicGrid, IDynamicGridUIComponentParen
         if (DuplicateBtn != null)
             DuplicateBtn.Visibility = Visibility.Collapsed;
 
-        reloadColumns = reloadColumns || UIComponent.OptionsChanged();
+        if (UIComponent.OptionsChanged())
+        {
+            reloadColumns = true;
+        }
 
         if(reloadColumns && IsReady)
         {

+ 4 - 4
inabox.wpf/DynamicGrid/UIComponent/DynamicGridGridUIComponent.cs

@@ -1133,8 +1133,6 @@ public class DynamicGridGridUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
 
                 var newColumn = newcol.CreateGridColumn();
 
-                newColumn.AllowEditing = newcol.Editable && Parent.IsDirectEditMode();
-
                 var summary = newcol.Summary();
                 if (summary != null)
                     Summaries.Add(summary);
@@ -1633,8 +1631,10 @@ public class DynamicGridGridUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
     {
         if (e.Key == Key.OemPeriod)
         {
-            var editor = e.OriginalSource as TimeSpanEdit;
-            if (editor != null && editor.SelectionStart < 2) editor.SelectionStart = 3;
+            if (e.OriginalSource is TimeSpanEdit editor && editor.SelectionStart < 2)
+            {
+                editor.SelectionStart = 3;
+            }
         }
         else if (e.Key == Key.Tab)
         {

+ 266 - 5
inabox.wpf/DynamicGrid/UIComponent/DynamicGridTreeUIComponent.cs

@@ -241,6 +241,12 @@ public class DynamicGridTreeUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
         _tree.FilterLevel = FilterLevel.Extended;
         _tree.SelectionForeground = DynamicGridUtils.SelectionForeground;
         _tree.SelectionBackground = DynamicGridUtils.SelectionBackground;
+
+        _tree.EditTrigger = EditTrigger.OnTap;
+        _tree.CurrentCellBeginEdit += _tree_CurrentCellBeginEdit;
+        _tree.CurrentCellEndEdit += _tree_CurrentCellEndEdit;
+        _tree.CurrentCellDropDownSelectionChanged += _tree_CurrentCellDropDownSelectionChanged;
+        _tree.PreviewKeyUp += _tree_PreviewKeyUp;
         
         _tree.ColumnSizer = TreeColumnSizer.None;
         _tree.RowHeight = 30D;
@@ -276,12 +282,17 @@ public class DynamicGridTreeUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
 
     #region Input
 
-    private CoreRow? GetRowFromIndex(int rowIndex)
+    private CoreTreeNode? GetNodeFromIndex(int rowIndex)
     {
         // Syncfusion has given us the row index, so it also will give us the correct row, after sorting.
         // Hence, here we use the syncfusion DataGrid.GetRecordAtRowIndex, which *should* always return a DataRowView.
         var row = _tree.GetNodeAtRowIndex(rowIndex);
-        return MapRow((row.Item as CoreTreeNode)?.Row);
+        return row.Item as CoreTreeNode;
+    }
+
+    private CoreRow? GetRowFromIndex(int rowIndex)
+    {
+        return MapRow(GetNodeFromIndex(rowIndex)?.Row);
     }
 
     private void _tree_CellDoubleTapped(object? sender, TreeGridCellDoubleTappedEventArgs e)
@@ -493,6 +504,16 @@ public class DynamicGridTreeUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
     {
         ColumnsMenu.Visibility = Parent.Options.SelectColumns ? Visibility.Visible : Visibility.Hidden;
 
+        var allowEditing = Parent.IsDirectEditMode();
+        var reloadColumns = false;
+
+        if (_tree.AllowEditing != allowEditing)
+        {
+            _tree.NavigationMode = allowEditing ? NavigationMode.Cell : NavigationMode.Row;
+            _tree.AllowEditing = allowEditing;
+            reloadColumns = true;
+        }
+
         _tree.AllowFiltering = Parent.Options.FilterRows;
 
         if (Parent.Options.DragSource)
@@ -515,7 +536,7 @@ public class DynamicGridTreeUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
         _tree.AllowDrop = Parent.Options.DragTarget;
         _tree.SelectionMode = Parent.Options.MultiSelect ? GridSelectionMode.Extended : GridSelectionMode.Single;
 
-        return false;
+        return reloadColumns;
     }
 
     private void _tree_CellToolTipOpening(object? sender, TreeGridCellToolTipOpeningEventArgs e)
@@ -1040,6 +1061,7 @@ public class DynamicGridTreeUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
     public CoreTreeNodes Nodes { get; set; }
 
     private CoreTable? _innerTable;
+    private bool _invalidating = false;
 
     public void BeforeRefresh()
     {
@@ -1055,6 +1077,7 @@ public class DynamicGridTreeUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
         _innerTable.LoadColumns(data.Columns);
 
         for (var i = 0; i < ActionColumns.Count; i++)
+        {
             _innerTable.Columns.Add(
                 new CoreColumn
                 {
@@ -1063,6 +1086,7 @@ public class DynamicGridTreeUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
                         ? typeof(BitmapImage)
                         : typeof(String)
                 });
+        }
 
         foreach (var row in data.Rows)
         {
@@ -1074,6 +1098,7 @@ public class DynamicGridTreeUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
             var _parent = row.Get<Guid>(ParentColumn.Property);
             nodes.Add(_id, _parent, newRow);
         }
+        nodes.ColumnChanged += Nodes_ColumnChanged;
         Nodes = nodes;
         _tree.ItemsSource = nodes.Nodes;
 
@@ -1086,6 +1111,7 @@ public class DynamicGridTreeUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
     public void AddPage(IEnumerable<CoreRow> page)
     {
         if (_innerTable is null) return;
+        _invalidating = true;
 
         foreach(var row in page)
         {
@@ -1100,6 +1126,8 @@ public class DynamicGridTreeUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
 
         CalculateRowHeight();
         UpdateRecordCount();
+
+        _invalidating = false;
     }
 
     private void ProcessRow(CoreRow innerRow, CoreRow row)
@@ -1180,12 +1208,15 @@ public class DynamicGridTreeUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
     public void InvalidateRow(CoreRow row)
     {
         if (_innerTable is null || row.Index < 0 || row.Index >= _innerTable.Rows.Count) return;
+        _invalidating = true;
 
         var _innerRow = _innerTable.Rows[row.Index];
         ProcessRow(_innerRow, row);
 
         var coreTreeNode = Nodes.Find(_innerRow);
         coreTreeNode?.InvalidateData();
+
+        _invalidating = false;
     }
 
 
@@ -1194,16 +1225,246 @@ public class DynamicGridTreeUIComponent<T> : IDynamicGridUIComponent<T>, IDynami
         _tree.ScrollInView(new RowColumnIndex(row.Index + 1, 0));
     }
 
+    private CoreTreeNode? GetNode(CoreRow row)
+    {
+        if (_innerTable is null || row.Index < 0 || row.Index >= _innerTable.Rows.Count) return null;
+
+        var _innerRow = _innerTable.Rows[row.Index];
+        var node = Nodes.Find(_innerRow);
+        return node;
+    }
+
     public void UpdateCell(CoreRow row, string column, object? value)
     {
-        throw new NotImplementedException();
+        var node = GetNode(row);
+        if(node is not null)
+        {
+            node[column] = value;
+            node.InvalidateData();
+        }
     }
 
     public void UpdateRow(CoreRow row)
     {
-        throw new NotImplementedException();
+        var dataRow = GetNode(row);
+        if(dataRow is not null)
+        {
+            foreach(var (key, value) in row)
+            {
+                dataRow[key] = value;
+            }
+            for (var i = 0; i < ActionColumns.Count; i++)
+                dataRow[$"_ActionColumn{i}"] = ActionColumns[i].Data(row);
+            dataRow.InvalidateData();
+        }
     }
 
+    #region Direct Edit
+
+    private void _tree_PreviewKeyUp(object sender, KeyEventArgs e)
+    {
+        if (e.Key == Key.OemPeriod)
+        {
+            if (e.OriginalSource is Syncfusion.Windows.Shared.TimeSpanEdit editor && editor.SelectionStart < 2)
+            {
+                editor.SelectionStart = 3;
+            }
+        }
+        else if (e.Key == Key.Tab)
+        {
+            if (Parent.IsDirectEditMode())
+            {
+                _tree.SelectionController.CurrentCellManager.EndEdit();
+                _tree.MoveFocus(new TraversalRequest(FocusNavigationDirection.Right));
+                _tree.SelectionController.CurrentCellManager.BeginEdit();
+                e.Handled = true;
+            }
+        }
+    }
+
+    private bool bChanged;
+
+    private class DirectEditingObject
+    {
+        public T Object { get; set; }
+
+        public CoreRow Row { get; set; }
+
+        public CoreTreeNode? Node { get; set; }
+
+        public DirectEditingObject(T obj, CoreRow row, CoreTreeNode? node)
+        {
+            Object = obj;
+            Row = row;
+            Node = node;
+        }
+    }
+
+    private DirectEditingObject? _editingObject;
+
+    private DirectEditingObject EnsureEditingObject(CoreRow row)
+    {
+        _editingObject ??= new(Parent.LoadItem(row), row, GetNode(row));
+        return _editingObject;
+    }
+
+    private void UpdateData(string column, Dictionary<CoreColumn, object?> updates)
+    {
+        if (_editingObject is null)
+            return;
+
+        var coreRow = _editingObject.Row;
+
+        try
+        {
+            Parent.UpdateData(_editingObject.Object, coreRow, column, updates);
+        }
+        catch(Exception e)
+        {
+            MessageWindow.ShowError($"Error saving {typeof(T)}", e);
+        }
+    }
+    private void UpdateData(CoreTreeNode node, int columnIndex)
+    {
+        if (GetColumn(columnIndex) is DynamicGridColumn gridcol)
+        {
+            var datacol = Parent.Data.Columns.FirstOrDefault(x => x.ColumnName.Equals(gridcol.ColumnName));
+            if (datacol != null)
+            {
+                var value = node?[datacol.ColumnName];
+                if (value is null)
+                    value = CoreUtils.GetDefault(datacol.DataType);
+                else
+                    value = CoreUtils.ChangeType(value, datacol.DataType);
+
+                UpdateData(datacol.ColumnName, new Dictionary<CoreColumn, object?>() { { datacol, value } });
+            }
+        }
+    }
+
+    private Dictionary<string, CoreTable> _lookups = new();
+    private void _tree_CurrentCellBeginEdit(object? sender, TreeGridCurrentCellBeginEditEventArgs e)
+    {
+        var row = GetRowFromIndex(e.RowColumnIndex.RowIndex);
+        if (row is null)
+            return;
+
+        EnsureEditingObject(row);
+
+        if (_tree.Columns[e.RowColumnIndex.ColumnIndex] is TreeGridComboBoxColumn column && column.ItemsSource == null)
+        {
+            var gridColumn = GetColumn(e.RowColumnIndex.ColumnIndex);
+            if(gridColumn is DynamicGridColumn col)
+            {
+                var property = col.ColumnName;
+                var prop = CoreUtils.GetProperty(typeof(T), property);
+                var editor = prop.GetEditor();
+                if (editor is ILookupEditor lookupEditor)
+                {
+                    if (!_lookups.ContainsKey(property))
+                        _lookups[property] = lookupEditor.Values(typeof(T), property);
+                    var combo = column;
+                    combo.ItemsSource = _lookups[property].ToDictionary(_lookups[property].Columns[0].ColumnName, "Display");
+                    combo.SelectedValuePath = "Key";
+                    combo.DisplayMemberPath = "Value";
+                }
+            }
+
+        }
+
+        bChanged = false;
+    }
+
+    private void Nodes_ColumnChanged(CoreTreeNode node, string column)
+    {
+        if (_invalidating) return;
+
+        var row = GetRow(node);
+        if (row is null)
+            return;
+
+        var data = Parent.Data;
+
+        var dataCol = Parent.Data.Columns.FirstOrDefault(x => x.ColumnName.Equals(column));
+        var col = ColumnList.OfType<DynamicGridColumn>()
+            .FirstOrDefault(x => x.ColumnName.Equals(column));
+        
+        if (col is null || dataCol is null)
+            return;
+
+        if (col is DynamicGridCheckBoxColumn<T>)
+        {
+            EnsureEditingObject(row);
+            if(_editingObject is not null)
+            {
+                var value = node[column];
+
+                _invalidating = true;
+                UpdateData(column, new Dictionary<CoreColumn, object?>() { { dataCol, value } });
+                _invalidating = false;
+            }
+
+            _editingObject = null;
+        }
+        if (_editingObject is not null)
+            bChanged = true;
+    }
+
+    private void _tree_CurrentCellDropDownSelectionChanged(object? sender, CurrentCellDropDownSelectionChangedEventArgs e)
+    {
+        var row = GetRowFromIndex(e.RowColumnIndex.RowIndex);
+        if (row is null)
+            return;
+        EnsureEditingObject(row);
+        if ((_editingObject is not null) && (e.SelectedItem is Tuple<object?, string> tuple))
+        {
+            var gridColumn = GetColumn(e.RowColumnIndex.ColumnIndex);
+            if (gridColumn is DynamicGridColumn col)
+            {
+                var corecol = col.ColumnName;
+
+                var updates = new Dictionary<CoreColumn, object?>();
+
+                var prefix = string.Join(".", corecol.Split(".").Reverse().Skip(1).Reverse());
+                var field = corecol.Split(".").Last();
+
+                var prop = CoreUtils.GetProperty(typeof(T), corecol);
+                if (prop.GetEditor() is ILookupEditor editor)
+                {
+                    var data = editor.Values(typeof(T), corecol);
+                    var lookuprow = data.Rows.FirstOrDefault(r => Equals(r[field], tuple.Item1))
+                        ?? data.NewRow(true);
+
+                    foreach (CoreColumn lookupcol in data.Columns)
+                    {
+                        var columnname = String.IsNullOrWhiteSpace(prefix)
+                            ? lookupcol.ColumnName
+                            : String.Join(".", prefix, lookupcol.ColumnName);
+                        var updatecol = Parent.Data.Columns.FirstOrDefault(x => String.Equals(x.ColumnName, columnname));
+                        if (updatecol != null)
+                            updates[updatecol] = lookuprow[lookupcol.ColumnName];
+                    }
+                    UpdateData(corecol, updates);
+                    bChanged = true;
+                }
+            }
+        }
+    }
+
+    private void _tree_CurrentCellEndEdit(object? sender, CurrentCellEndEditEventArgs e)
+    {
+        if (_editingObject is not null && bChanged)
+        {
+            UpdateData(_editingObject.Node, e.RowColumnIndex.ColumnIndex);
+        }
+        if (bChanged)
+            Parent.DoChanged();
+        bChanged = false;
+        _editingObject = null;
+    }
+
+    #endregion
+
     #region Drag + Drop
 
     private void _tree_DragOver(object sender, DragEventArgs e)