From 1017e766db556c89a1999c6e9173b7a30afd567c Mon Sep 17 00:00:00 2001 From: "J. Ritchie Carroll" Date: Thu, 25 Jun 2026 16:03:45 -0500 Subject: [PATCH] Initial refactor of TableOperations that separates value expression attribute application. --- .../Model/ExpressionTableOperations.cs | 146 +++++++++++++++ .../Model/SecureTableOperations.cs | 2 +- src/Gemstone.Data/Model/TableOperations.cs | 173 ++++++++++++------ src/UnitTests/TableOperationsDefaultsTest.cs | 113 ++++++++++++ 4 files changed, 380 insertions(+), 54 deletions(-) create mode 100644 src/Gemstone.Data/Model/ExpressionTableOperations.cs create mode 100644 src/UnitTests/TableOperationsDefaultsTest.cs diff --git a/src/Gemstone.Data/Model/ExpressionTableOperations.cs b/src/Gemstone.Data/Model/ExpressionTableOperations.cs new file mode 100644 index 000000000..cc07c2f49 --- /dev/null +++ b/src/Gemstone.Data/Model/ExpressionTableOperations.cs @@ -0,0 +1,146 @@ +//****************************************************************************************************** +// ExpressionTableOperations.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this +// file except in compliance with the License. You may obtain a copy of the License at: +// +// http://opensource.org/licenses/MIT +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/25/2026 - J. Ritchie Carroll +// Generated original version of source code. +// +//****************************************************************************************************** +// ReSharper disable StaticMemberInGenericType + +using System; +using System.Collections.Generic; +using Gemstone.Expressions.Evaluator; +using Gemstone.Expressions.Model; + +namespace Gemstone.Data.Model; + +/// +/// Defines database operations for a modeled table that additionally evaluates attribute-based value +/// expressions, i.e., and +/// , when creating, defaulting and updating records. +/// +/// Modeled table. +/// +/// +/// The base is a pure POCO operator: it honors only the standard +/// and carries no dependency on the +/// . This keeps the simple case working without +/// any external setup, e.g., new TableOperations<MyModel>(connection).NewRecord() will never +/// fail because of an unconfigured value-expression dependency such as Settings. +/// +/// +/// Use to opt in to the richer value-expression behavior. When +/// using this type, the caller is responsible for ensuring any dependencies referenced by the model's +/// value expressions (e.g., Settings, UserInfo, custom symbols registered via the +/// ) are properly initialized. +/// +/// +public class ExpressionTableOperations : TableOperations where T : class, new() +{ + // Nested Types + private class CurrentScope : ValueExpressionScopeBase + { + // Define instance variables exposed to ValueExpressionAttributeBase expressions + #pragma warning disable 169, 414, 649, CS8618 + public TableOperations TableOperations; + public AdoDataConnection Connection; + #pragma warning restore 169, 414, 649, CS8618 + } + + /// + /// Creates a new instance. + /// + /// instance to use for database operations. + /// Custom run-time tokens to apply to any modeled values. + public ExpressionTableOperations(AdoDataConnection connection, IEnumerable>? customTokens = null) + : base(connection, customTokens) + { + } + + /// + /// Creates a new instance. + /// + /// instance to use for database operations. + /// Delegate to handle table operation exceptions. + /// Custom run-time tokens to apply to any modeled values. + /// + /// When exception handler is provided, table operations will not throw exceptions for database calls, any + /// encountered exceptions will be passed to handler for processing. Otherwise, exceptions will be thrown + /// on the call stack. + /// + public ExpressionTableOperations(AdoDataConnection connection, Action exceptionHandler, IEnumerable>? customTokens = null) + : base(connection, exceptionHandler, customTokens) + { + } + + /// + protected override T CreateRecordInstance() + { + return s_createRecordInstance(new CurrentScope { TableOperations = this, Connection = Connection }); + } + + /// + protected override void ApplyRecordDefaultValues(T record) + { + s_applyRecordDefaults(new CurrentScope { Instance = record, TableOperations = this, Connection = Connection }); + } + + /// + protected override void ApplyRecordUpdateValues(T record) + { + s_updateRecordInstance(new CurrentScope { Instance = record, TableOperations = this, Connection = Connection }); + } + + // Static Fields + private static readonly Func s_createRecordInstance; + private static readonly Action s_updateRecordInstance; + private static readonly Action s_applyRecordDefaults; + private static TypeRegistry? s_typeRegistry; + + // Static Constructor + static ExpressionTableOperations() + { + // Create an instance of modeled table to allow any static functionality to be initialized, + // such as registering any custom types or symbols that may be useful for value expressions + ValueExpressionParser.InitializeType(); + + // Generate compiled "create new", "apply defaults" and "update" record functions for modeled table, + // honoring DefaultValueAttribute, DefaultValueExpressionAttribute and UpdateValueExpressionAttribute. + // Note: RecordProperties is sourced from the base table operations for the same modeled type T. + s_createRecordInstance = ValueExpressionParser.CreateInstance(RecordProperties, s_typeRegistry); + s_updateRecordInstance = ValueExpressionParser.UpdateInstance(RecordProperties, s_typeRegistry); + s_applyRecordDefaults = ValueExpressionParser.ApplyDefaults(RecordProperties, s_typeRegistry); + } + + // Static Properties + + /// + /// Gets or sets instance used for evaluating encountered instances + /// of the on modeled table properties. + /// + /// + /// Accessing this property will create a unique type registry for the current type which + /// will initially contain the values found in the + /// and can be augmented with custom types. Set to null to restore use of the default type registry. + /// + public static TypeRegistry TypeRegistry + { + get => s_typeRegistry ??= ValueExpressionParser.DefaultTypeRegistry.Clone(); + set => s_typeRegistry = value; + } +} diff --git a/src/Gemstone.Data/Model/SecureTableOperations.cs b/src/Gemstone.Data/Model/SecureTableOperations.cs index 091bda3aa..b3ee1a5de 100644 --- a/src/Gemstone.Data/Model/SecureTableOperations.cs +++ b/src/Gemstone.Data/Model/SecureTableOperations.cs @@ -57,7 +57,7 @@ public SecureTableOperations(TableOperations operations) /// db to create secure operations to. public SecureTableOperations(AdoDataConnection connection) { - BaseOperations = new(connection); + BaseOperations = new ExpressionTableOperations(connection); } #region [ Properties ] diff --git a/src/Gemstone.Data/Model/TableOperations.cs b/src/Gemstone.Data/Model/TableOperations.cs index 9390ce344..97c6b62f8 100644 --- a/src/Gemstone.Data/Model/TableOperations.cs +++ b/src/Gemstone.Data/Model/TableOperations.cs @@ -38,6 +38,7 @@ using System.Data.Common; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; @@ -48,11 +49,11 @@ using Gemstone.Collections.IAsyncEnumerableExtensions; using Gemstone.Data.DataExtensions; using Gemstone.Diagnostics; -using Gemstone.Expressions.Evaluator; using Gemstone.Expressions.Model; using Gemstone.Reflection.MemberInfoExtensions; using Gemstone.Security.Cryptography; using Gemstone.StringExtensions; +using LinqExpression = System.Linq.Expressions.Expression; namespace Gemstone.Data.Model; @@ -65,15 +66,6 @@ namespace Gemstone.Data.Model; #region [ Members ] // Nested Types - private class CurrentScope : ValueExpressionScopeBase - { - // Define instance variables exposed to ValueExpressionAttributeBase expressions - #pragma warning disable 169, 414, 649, CS8618 - public TableOperations TableOperations; - public AdoDataConnection Connection; - #pragma warning restore 169, 414, 649, CS8618 - } - private class NullConnection : DbConnection { [AllowNull] @@ -407,17 +399,66 @@ public TableOperations(AdoDataConnection connection, Action exception #region [ Methods ] + /// + /// Creates a new modeled record instance and applies any modeled default values. + /// + /// New modeled record instance with any defined default values applied. + /// + /// The base implementation creates a new + /// instance and applies only the constant values specified by any + /// on the model properties. Derived types, such as + /// , may override this method to apply additional behavior, + /// e.g., evaluation of instances. + /// + protected virtual T CreateRecordInstance() + { + T record = new(); + s_applyDefaultValues(record); + return record; + } + + /// + /// Applies any modeled default values to the specified . + /// + /// Record to update. + /// + /// The base implementation applies only the constant values specified + /// by any on the model properties. Derived types may override this + /// method to apply additional behavior, e.g., evaluation of + /// instances. + /// + protected virtual void ApplyRecordDefaultValues(T record) + { + s_applyDefaultValues(record); + } + + /// + /// Applies any modeled update values to the specified . + /// + /// Record to update. + /// + /// The base implementation performs no action since update value + /// expressions are not part of standard POCO behavior. Derived types may override this method to apply + /// additional behavior, e.g., evaluation of instances. + /// + protected virtual void ApplyRecordUpdateValues(T record) + { + } + /// /// Creates a new modeled record instance, applying any modeled default values as specified by a - /// or on the - /// model properties. + /// on the model properties. /// /// New modeled record instance with any defined default values applied. + /// + /// To also apply values, use + /// . + /// public T? NewRecord() { try { - return s_createRecordInstance(new CurrentScope { TableOperations = this, Connection = Connection }); + return CreateRecordInstance(); } catch (Exception ex) { @@ -437,15 +478,18 @@ public TableOperations(AdoDataConnection connection, Action exception /// /// Applies the default values on the specified modeled table - /// where any of the properties are marked with either - /// or . + /// where any of the properties are marked with a . /// /// Record to update. + /// + /// To also apply values, use + /// . + /// public void ApplyRecordDefaults(T record) { try { - s_applyRecordDefaults(new CurrentScope { Instance = record, TableOperations = this, Connection = Connection }); + ApplyRecordDefaultValues(record); } catch (Exception ex) { @@ -465,15 +509,18 @@ void ITableOperations.ApplyRecordDefaults(object value) } /// - /// Applies the update values on the specified modeled table where - /// any of the properties are marked with . + /// Applies the update values on the specified modeled table . /// /// Record to update. + /// + /// The base implementation performs no action. To apply + /// values, use . + /// public void ApplyRecordUpdates(T record) { try { - s_updateRecordInstance(new CurrentScope { Instance = record, TableOperations = this, Connection = Connection }); + ApplyRecordUpdateValues(record); } catch (Exception ex) { @@ -2136,7 +2183,7 @@ private TReturn UpdateRecordOperation(T record, TReturn zeroReturn, Fun try { - s_updateRecordInstance(new CurrentScope { Instance = record, TableOperations = this, Connection = Connection }); + ApplyRecordUpdateValues(record); if (RootQueryRestriction is not null && (applyRootQueryRestriction ?? ApplyRootQueryRestrictionToUpdates)) restriction = (RootQueryRestriction + restriction)!; @@ -2986,16 +3033,13 @@ private bool FieldIsEncrypted(string fieldName) private static readonly string s_deleteWhereSql; private static readonly string s_primaryKeyFields; private static readonly bool s_hasPrimaryKeyIdentityField; - private static readonly Func s_createRecordInstance; - private static readonly Action s_updateRecordInstance; - private static readonly Action s_applyRecordDefaults; + private static readonly Action s_applyDefaultValues; private static readonly DataTable s_tableSchema; private static readonly HashSet s_validFieldNames; private static readonly HashSet s_searchableFields; private static readonly (Regex, Func)[] s_searchExtensions; private static readonly (Regex, Func)[] s_sortExtensions; private static readonly HashSet s_excludedFields; - private static TypeRegistry? s_typeRegistry; // Static Constructor static TableOperations() @@ -3168,14 +3212,11 @@ static TableOperations() s_updateProperties = updateProperties.ToArray(); s_primaryKeyProperties = primaryKeyProperties.ToArray(); - // Create an instance of modeled table to allow any static functionality to be initialized, - // such as registering any custom types or symbols that may be useful for value expressions - ValueExpressionParser.InitializeType(); - - // Generate compiled "create new" and "update" record functions for modeled table - s_createRecordInstance = ValueExpressionParser.CreateInstance(s_properties.Values, s_typeRegistry); - s_updateRecordInstance = ValueExpressionParser.UpdateInstance(s_properties.Values, s_typeRegistry); - s_applyRecordDefaults = ValueExpressionParser.ApplyDefaults(s_properties.Values, s_typeRegistry); + // Generate compiled function that applies any modeled "DefaultValueAttribute" constants to a record. + // Note: evaluation of attribute-based value expressions (DefaultValueExpressionAttribute / + // UpdateValueExpressionAttribute) is intentionally not handled here -- that behavior is opt-in via + // the derived "ExpressionTableOperations" so the base type carries no type-registry dependency. + s_applyDefaultValues = BuildDefaultValueApplier(); // Generate a data table to be used for schema operations s_tableSchema = new DataTable(s_tableName); @@ -3232,6 +3273,46 @@ static TableOperations() } } + // Builds a compiled delegate that applies any modeled "DefaultValueAttribute" constant values to a record. + // This mirrors the constant-assignment semantics used for default values but, unlike the expression-based + // path, has no dependency on the Gemstone.Expressions type registry. Any error is deferred to invocation + // so an unrelated bad default value does not fail static initialization of the whole modeled table. + private static Action BuildDefaultValueApplier() + { + try + { + ParameterExpression record = LinqExpression.Parameter(typeof(T), "record"); + List assignments = []; + + foreach (PropertyInfo property in s_properties.Values) + { + if (!property.TryGetAttribute(out DefaultValueAttribute? defaultValueAttribute)) + continue; + + LinqExpression value = LinqExpression.Constant(defaultValueAttribute.Value, property.PropertyType); + assignments.Add(LinqExpression.Call(record, property.SetMethod!, value)); + } + + return assignments.Count == 0 ? + static _ => { } : + LinqExpression.Lambda>(LinqExpression.Block(assignments), record).Compile(); + } + catch (Exception ex) + { + return _ => throw new ArgumentException($"Error evaluating \"{nameof(DefaultValueAttribute)}\" for a property of type \"{typeof(T).FullName}\": {ex.Message}", ex); + } + } + + /// + /// Gets the set of modeled record properties for type , i.e., the public + /// read/write properties that are not marked with . + /// + /// + /// Exposed for derived types, such as , that need to build + /// their own per-property behavior over the same property set used by the base table operations. + /// + protected static IEnumerable RecordProperties => s_properties.Values; + internal static bool IsSearchableField(string fieldName) { return s_searchableFields.Contains(fieldName); @@ -3280,23 +3361,6 @@ private static (Regex, Func)[] ResolveExtensionAttributes - /// Gets or sets instance used for evaluating encountered instances - /// of the on modeled table properties. - /// - /// - /// Accessing this property will create a unique type registry for the current type which - /// will initially contain the values found in the - /// and can be augmented with custom types. Set to null to restore use of the default type registry. - /// - public static TypeRegistry TypeRegistry - { - get => s_typeRegistry ??= ValueExpressionParser.DefaultTypeRegistry.Clone(); - set => s_typeRegistry = value; - } - // Static Methods /// @@ -3320,7 +3384,8 @@ public static TypeRegistry TypeRegistry /// /// This method is useful to create a new type instance when no data connection /// is available, applying any modeled default values as specified by a - /// or on the model properties. + /// on the model properties. To also apply values, use + /// . /// public static Func NewRecordFunction() { @@ -3335,7 +3400,8 @@ public static TypeRegistry TypeRegistry /// /// This method is useful to apply defaults values to an existing type instance when no data /// connection is available, applying any modeled default values as specified by a - /// or on the model properties. + /// on the model properties. To also apply values, use + /// . /// public static Action ApplyRecordDefaultsFunction() { @@ -3349,8 +3415,9 @@ public static Action ApplyRecordDefaultsFunction() /// Delegate for the method. /// /// This method is useful to apply update values to an existing type instance when no data - /// connection is available, applying any modeled update values as specified by instances of the - /// on the model properties. + /// connection is available. The base implementation performs no action; to apply + /// modeled update values as specified by instances of the on the + /// model properties, use . /// public static Action ApplyRecordUpdatesFunction() { diff --git a/src/UnitTests/TableOperationsDefaultsTest.cs b/src/UnitTests/TableOperationsDefaultsTest.cs new file mode 100644 index 000000000..f8ea05580 --- /dev/null +++ b/src/UnitTests/TableOperationsDefaultsTest.cs @@ -0,0 +1,113 @@ +//****************************************************************************************************** +// TableOperationsDefaultsTest.cs - Gbtc +// +// Copyright © 2026, Grid Protection Alliance. All Rights Reserved. +// +// Licensed to the Grid Protection Alliance (GPA) under one or more contributor license agreements. See +// the NOTICE file distributed with this work for additional information regarding copyright ownership. +// The GPA licenses this file to you under the MIT License (MIT), the "License"; you may not use this +// file except in compliance with the License. You may obtain a copy of the License at: +// +// http://opensource.org/licenses/MIT +// +// Unless agreed to in writing, the subject software distributed under the License is distributed on an +// "AS-IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. Refer to the +// License for the specific language governing permissions and limitations. +// +// Code Modification History: +// ---------------------------------------------------------------------------------------------------- +// 06/25/2026 - J. Ritchie Carroll +// Generated original version of source code. +// +//****************************************************************************************************** + +using System; +using System.ComponentModel; +using System.Data; +using System.Data.Common; +using Gemstone.Data.Model; +using Gemstone.Expressions.Model; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Gemstone.Data.UnitTests +{ + [TestClass] + public class TableOperationsDefaultsTest + { + // Sample model with a standard constant default value and a value-expression default value. + // The value expression uses only the always-registered "Guid" type, so it requires no external + // setup (e.g., Settings) -- this lets us verify the create path without a live database. + public class SampleModel + { + [PrimaryKey(true)] + public int ID { get; set; } + + [DefaultValue(42)] + public int Code { get; set; } + + [DefaultValueExpression("Guid.NewGuid()")] + public Guid Token { get; set; } + } + + private static AdoDataConnection CreateConnection() + { + // Connect using a no-op connection type so no live database is required. + return new AdoDataConnection(null!, typeof(TestConnection)); + } + + [TestMethod] + public void BaseTableOperations_NewRecord_AppliesOnlyDefaultValueAttribute() + { + using AdoDataConnection connection = CreateConnection(); + + TableOperations table = new(connection); + SampleModel record = table.NewRecord(); + + Assert.IsNotNull(record); + Assert.AreEqual(42, record.Code); // DefaultValueAttribute applied + Assert.AreEqual(Guid.Empty, record.Token); // DefaultValueExpressionAttribute NOT applied + } + + [TestMethod] + public void ExpressionTableOperations_NewRecord_AppliesValueExpressions() + { + using AdoDataConnection connection = CreateConnection(); + + ExpressionTableOperations table = new(connection); + SampleModel record = table.NewRecord(); + + Assert.IsNotNull(record); + Assert.AreEqual(42, record.Code); // DefaultValueAttribute applied + Assert.AreNotEqual(Guid.Empty, record.Token); // DefaultValueExpressionAttribute evaluated + } + + [TestMethod] + public void BaseTableOperations_ApplyRecordDefaults_AppliesOnlyDefaultValueAttribute() + { + using AdoDataConnection connection = CreateConnection(); + + TableOperations table = new(connection); + SampleModel record = new(); + table.ApplyRecordDefaults(record); + + Assert.AreEqual(42, record.Code); + Assert.AreEqual(Guid.Empty, record.Token); + } + + // No-op connection that allows constructing an AdoDataConnection without a live database. + public class TestConnection : DbConnection + { + public override string ConnectionString { get; set; } = string.Empty; + public override int ConnectionTimeout => 0; + public override string Database => string.Empty; + public override string DataSource => string.Empty; + public override string ServerVersion => string.Empty; + public override ConnectionState State => ConnectionState.Open; + public override void Open() { } + public override void Close() { } + protected override DbCommand CreateDbCommand() => null!; + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => null!; + public override void ChangeDatabase(string databaseName) { } + } + } +}