Home » Design Patterns » Repository pattern
Category Archives: Repository pattern
Write Sitecore Experience Forms Log Entries to a Custom SQL Server Database Table
Not long after I wrote the code for my last post, I continued exploring ways of changing service classes in Sitecore Experience Forms.
One thing that popped out when continuing on this quest was Sitecore.ExperienceForms.Diagnostics.ILogger. I immediately thought “I just wrote code for retrieving Forms configuration settings from a SQL Server database table, why not create a new ILogger service class for storing log entries in a custom SQL table?”
Well, that’s what I did, and the code in this post captures how I went about doing that.
You might be asking “Mike, you know you can just use a SQL appender in log4net, right?”
Well, I certainly could have but what fun would that have been?
Anyways, let’s get started.
We first need a class that represents a log entry. I created the following POCO class to serve that purpose:
using System; namespace Sandbox.Foundation.Forms.Models.Logging { public class LogEntry { public Exception Exception { get; set; } public string LogEntryType { get; set; } public string LogMessage { get; set; } public string Message { get; set; } public object Owner { get; set; } public DateTime CreatedDate { get; set; } } }
Since I hate calling the “new” keyword when creating new instances of classes, I chose to create a factory class. The following interface will be for instances of classes that create LogEntry instances:
using System; using Sandbox.Foundation.Forms.Models.Logging; namespace Sandbox.Foundation.Forms.Services.Factories.Diagnostics { public interface ILogEntryFactory { LogEntry CreateLogEntry(string logEntryType, string message, Exception exception, Type ownerType, DateTime createdDate); LogEntry CreateLogEntry(string logEntryType, string message, Exception exception, object owner, DateTime createdDate); LogEntry CreateLogEntry(string logEntryType, string message, Type ownerType, DateTime createdDate); LogEntry CreateLogEntry(string logEntryType, string message, object owner, DateTime createdDate); } }
Well, we can’t do much with just an interface. The following class implements the interface above. It creates an instance of LogEntry with the passed parameters to all methods (assuming the required parameters are passed with the proper values on them):
using System; using Sandbox.Foundation.Forms.Models.Logging; namespace Sandbox.Foundation.Forms.Services.Factories.Diagnostics { public class LogEntryFactory : ILogEntryFactory { public LogEntry CreateLogEntry(string logEntryType, string message, Exception exception, Type ownerType, DateTime createdDate) { return CreateLogEntry(logEntryType, message, exception, ownerType, createdDate); } public LogEntry CreateLogEntry(string logEntryType, string message, Exception exception, object owner, DateTime createdDate) { if (!CanCreateLogEntry(logEntryType, message, owner, createdDate)) { return null; } return new LogEntry { LogEntryType = logEntryType, Message = message, Exception = exception, Owner = owner, CreatedDate = createdDate }; } public LogEntry CreateLogEntry(string logEntryType, string message, Type ownerType, DateTime createdDate) { return CreateLogEntry(logEntryType, message, ownerType, createdDate); } public LogEntry CreateLogEntry(string logEntryType, string message, object owner, DateTime createdDate) { if(!CanCreateLogEntry(logEntryType, message, owner, createdDate)) { return null; } return new LogEntry { LogEntryType = logEntryType, Message = message, Owner = owner, CreatedDate = createdDate }; } protected virtual bool CanCreateLogEntry(string logEntryType, string message, object owner, DateTime createdDate) { return !string.IsNullOrWhiteSpace(logEntryType) && !string.IsNullOrWhiteSpace(message) && owner != null && createdDate != DateTime.MinValue && createdDate != DateTime.MaxValue; } } }
I didn’t want to send LogEntry instances directly to a repository class instance directly, so I created the following class to represent the entities which will ultimately be stored in the database:
using System; namespace Sandbox.Foundation.Forms.Models.Logging { public class RepositoryLogEntry { public string LogEntryType { get; set; } public string LogMessage { get; set; } public DateTime Created { get; set; } } }
As I had done with LogEntry, I created a factory class for it. The difference here is we will be passing an instance of LogEntry to this new factory so we can create a RepositoryLogEntry instance from it.
The following interface is for factories of RepositoryLogEntry:
using System; using Sandbox.Foundation.Forms.Models.Logging; namespace Sandbox.Foundation.Forms.Services.Factories.Diagnostics { public interface IRepositoryLogEntryFactory { RepositoryLogEntry CreateRepositoryLogEntry(LogEntry entry); RepositoryLogEntry CreateRepositoryLogEntry(string logEntryType, string logMessage, DateTime created); } }
Now that we have the interface ready to go, we need an implementation class for it. The following class does the job:
using System; using Sandbox.Foundation.Forms.Models.Logging; namespace Sandbox.Foundation.Forms.Services.Factories.Diagnostics { public class RepositoryLogEntryFactory : IRepositoryLogEntryFactory { public RepositoryLogEntry CreateRepositoryLogEntry(LogEntry entry) { return CreateRepositoryLogEntry(entry.LogEntryType, entry.LogMessage, entry.CreatedDate); } public RepositoryLogEntry CreateRepositoryLogEntry(string logEntryType, string logMessage, DateTime created) { if (!CanCreateRepositoryLogEntry(logEntryType, logMessage, created)) { return null; } return new RepositoryLogEntry { LogEntryType = logEntryType, LogMessage = logMessage, Created = created }; } protected virtual bool CanCreateRepositoryLogEntry(string logEntryType, string logMessage, DateTime created) { return !string.IsNullOrWhiteSpace(logEntryType) && !string.IsNullOrWhiteSpace(logMessage) && created != DateTime.MinValue && created != DateTime.MaxValue; } } }
I’m following a similiar structure here as I had done in the LogEntryFactory class above. The CanCreateRepositoryLogEntry() method ensures required parameters are passed to methods on the class. If they are not, then a null reference is returned to the caller.
Since I hate hardcoding things, I decided to create a service class that gets the newline character. The following interface is for classes that do that:
namespace Sandbox.Foundation.Forms.Services.Environment { public interface IEnvironmentService { string GetNewLine(); } }
This next class implements the interface above:
namespace Sandbox.Foundation.Forms.Services.Environment { public class EnvironmentService : IEnvironmentService { public string GetNewLine() { return System.Environment.NewLine; } } }
In the class above, I’m taking advantage of stuff build into the .NET library for getting the newline character.
I love when I discover things like this, albeit wish I had found something like this when trying to find an html break string for something I was working on the other day, but I digress (if you know of a way, please let me know in a comment below 😉 ).
The above interface and class might seem out of place in this post but I am using them when formatting messages for the LogEntry instances further down in another service class. Just keep an eye out for it.
Since I loathe hardcoding strings with a passion, I like to hide these away in Sitecore configuration patch files and hydrate a POCO class instance with the values from the aforementioned configuration. The following class is such a POCO settings object for a service class I will discuss further down in the post:
namespace Sandbox.Foundation.Forms.Models.Logging { public class LogEntryServiceSettings { public string DebugLogEntryType { get; set; } public string ErrorLogEntryType { get; set; } public string FatalLogEntryType { get; set; } public string InfoLogEntryType { get; set; } public string WarnLogEntryType { get; set; } public string ExceptionPrefix { get; set; } public string MessagePrefix { get; set; } public string SourcePrefix { get; set; } public string NestedExceptionPrefix { get; set; } public string LogEntryTimeFormat { get; set; } } }
Okay, so need we need to know what “type” of LogEntry we are dealing with — is it an error or a warning or what? — before sending to a repository to save in the database. I created the following interface for service classes that return back strings for the different LogEntry types, and also generate a log message from the data on properties on the LogEntry instance — this is the message that will end up in the database for the LogEntry:
using Sandbox.Foundation.Forms.Models.Logging; namespace Sandbox.Foundation.Forms.Services.Diagnostics { public interface ILogEntryService { string GetDebugLogEntryType(); string GetErrorLogEntryType(); string GetFatalLogEntryType(); string GetInfoLogEntryType(); string GetWarnLogEntryType(); string GenerateLogMessage(LogEntry entry); } }
And here is its implementation class:
using System; using System.Text; using Sandbox.Foundation.Forms.Models.Logging; using Sandbox.Foundation.Forms.Services.Environment; namespace Sandbox.Foundation.Forms.Services.Diagnostics { public class LogEntryService : ILogEntryService { private readonly string _newLine; private readonly LogEntryServiceSettings _logEntryServiceSettings; public LogEntryService(IEnvironmentService environmentService, LogEntryServiceSettings logEntryServiceSettings) { _newLine = GetNewLine(environmentService); _logEntryServiceSettings = logEntryServiceSettings; } protected virtual string GetNewLine(IEnvironmentService environmentService) { return environmentService.GetNewLine(); } public string GetDebugLogEntryType() { return _logEntryServiceSettings.DebugLogEntryType; } public string GetErrorLogEntryType() { return _logEntryServiceSettings.ErrorLogEntryType; } public string GetFatalLogEntryType() { return _logEntryServiceSettings.FatalLogEntryType; } public string GetInfoLogEntryType() { return _logEntryServiceSettings.InfoLogEntryType; } public string GetWarnLogEntryType() { return _logEntryServiceSettings.WarnLogEntryType; } public string GenerateLogMessage(LogEntry entry) { if(!CanGenerateLogMessage(entry)) { return string.Empty; } string exceptionMessage = GenerateExceptionMessage(entry.Exception); if(string.IsNullOrWhiteSpace(exceptionMessage)) { return $"{entry.Message}"; } return $"{entry.Message} {exceptionMessage}"; } protected virtual bool CanGenerateLogMessage(LogEntry entry) { return entry != null && !string.IsNullOrWhiteSpace(entry.Message) && entry.Owner != null; } protected virtual string GenerateExceptionMessage(Exception exception) { if(exception == null) { return string.Empty; } StringBuilder messageBuilder = new StringBuilder(); messageBuilder.Append(_logEntryServiceSettings.ExceptionPrefix).Append(exception.GetType().FullName); ; AppendNewLine(messageBuilder); messageBuilder.Append(_logEntryServiceSettings.MessagePrefix).Append(exception.Message); AppendNewLine(messageBuilder); if (!string.IsNullOrWhiteSpace(exception.Source)) { messageBuilder.Append(_logEntryServiceSettings.SourcePrefix).Append(exception.Source); AppendNewLine(messageBuilder); } if(!string.IsNullOrWhiteSpace(exception.StackTrace)) { messageBuilder.Append(exception.StackTrace); AppendNewLine(messageBuilder); } if (exception.InnerException != null) { AppendNewLine(messageBuilder); messageBuilder.Append(_logEntryServiceSettings.NestedExceptionPrefix); AppendNewLine(messageBuilder, 3); messageBuilder.Append(GenerateExceptionMessage(exception.InnerException)); AppendNewLine(messageBuilder); } return messageBuilder.ToString(); } protected virtual void AppendNewLine(StringBuilder builder, int repeatCount = 1) { AppendRepeat(builder, _newLine, repeatCount); } protected virtual void AppendRepeat(StringBuilder builder, string stringToAppend, int repeatCount) { if (builder == null || string.IsNullOrWhiteSpace(stringToAppend) || repeatCount < 1) { return; } for(int i = 0; i < repeatCount; i++) { builder.Append(stringToAppend); } } } }
I’m not going to discuss all the code in the above class as it should be self-explanatory.
I do want to point out GenerateLogMessage() will generate one of two strings, depending on whether an Exception was set on the LogEntry instance.
If an Exception was set, we append the Exception details — the GenerateExceptionMessage() method generates a string from the Exception — onto the end of the LogEntry message
If it was not set, we just return the LogEntry message to the caller.
Well, now we need a place to store the log entries. I used the following SQL script to create a new table for storing these:
USE [ExperienceFormsSettings] GO CREATE TABLE [dbo].[ExperienceFormsLog]( [ID] [uniqueidentifier] NOT NULL, [LogEntryType] [nvarchar](max) NOT NULL, [LogMessage] [nvarchar](max) NOT NULL, [Created] [datetime] NOT NULL, CONSTRAINT [PK_ExperienceFormsLog] PRIMARY KEY CLUSTERED ( [ID] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY] GO ALTER TABLE [dbo].[ExperienceFormsLog] ADD DEFAULT (newsequentialid()) FOR [ID] GO
I also sprinkled some magical database dust onto the table:
😉
Wonderful, we now can move on to the fun bit — actually writing some code to store these entries into the database table created from the SQL script above.
I wrote the following POCO class to represent a SQL command — either a query or statement (it really doesn’t matter as it will support both):
namespace Sandbox.Foundation.Forms.Models.Logging { public class SqlCommand { public string Sql { get; set; } public object[] Parameters { get; set; } } }
I’m sure I could have found something in Sitecore.Kernel.dll that does exactly what the class above does but I couldn’t find such a thing (if you know of such a class, please share in a comment below).
Now we need a settings class for the SQL Logger I am writing further down in this post. As I had done for the LogEntryService class above, this data will be coming from Sitecore configuration:
namespace Sandbox.Foundation.Forms.Models.Logging { public class SqlLoggerSettings { public string LogPrefix { get; set; } public string LogDatabaseConnectionStringName { get; set; } public string InsertLogEntrySqlFormat { get; set; } public string ConnectionStringNameColumnName { get; set; } public string FieldsPrefixColumnName { get; set; } public string FieldsIndexNameColumnName { get; set; } public int NotFoundOrdinal { get; set; } public string LogEntryTypeParameterName { get; set; } public string LogMessageParameterName { get; set; } public string CreatedParameterName { get; set; } } }
Now the fun part — creating an implementation of Sitecore.ExperienceForms.Diagnostics.ILogger:
using System; using Sitecore.Abstractions; using Sitecore.Data.DataProviders.Sql; using Sitecore.ExperienceForms.Diagnostics; using Sandbox.Foundation.Forms.Services.Factories; using Sandbox.Foundation.Forms.Models.Logging; using Sandbox.Foundation.Forms.Services.Factories.Diagnostics; namespace Sandbox.Foundation.Forms.Services.Diagnostics { public class SqlLogger : ILogger { private readonly SqlLoggerSettings _sqlLoggerSettings; private readonly BaseSettings _settings; private readonly BaseFactory _factory; private readonly SqlDataApi _sqlDataApi; private readonly ILogEntryFactory _logEntryFactory; private readonly ILogEntryService _logEntryService; private readonly IRepositoryLogEntryFactory _repositoryLogEntryFactory; public SqlLogger(SqlLoggerSettings sqlLoggerSettings, BaseSettings settings, BaseFactory factory, ISqlDataApiFactory sqlDataApiFactory, ILogEntryFactory logEntryFactory, IRepositoryLogEntryFactory repositoryLogEntryFactory, ILogEntryService logEntryService) { _sqlLoggerSettings = sqlLoggerSettings; _settings = settings; _factory = factory; _sqlDataApi = CreateSqlDataApi(sqlDataApiFactory); _logEntryFactory = logEntryFactory; _logEntryService = logEntryService; _repositoryLogEntryFactory = repositoryLogEntryFactory; } protected virtual SqlDataApi CreateSqlDataApi(ISqlDataApiFactory sqlDataApiFactory) { return sqlDataApiFactory.CreateSqlDataApi(GetLogDatabaseConnectionString()); } protected virtual string GetLogDatabaseConnectionString() { return _settings.GetConnectionString(GetLogDatabaseConnectionStringName()); } protected virtual string GetLogDatabaseConnectionStringName() { return _sqlLoggerSettings.LogDatabaseConnectionStringName; } public void Debug(string message) { Debug(message, GetDefaultOwner()); } public void Debug(string message, object owner) { SaveLogEntry(CreateLogEntry(GetDebugLogEntryType(), message, owner, GetLogEntryDateTime())); } protected virtual string GetDebugLogEntryType() { return _logEntryService.GetDebugLogEntryType(); } public void LogError(string message) { LogError(message, null, GetDefaultOwner()); } public void LogError(string message, object owner) { LogError(message, null, owner); } public void LogError(string message, Exception exception, Type ownerType) { LogError(message, exception, (object)ownerType); } public void LogError(string message, Exception exception, object owner) { SaveLogEntry(CreateLogEntry(GetErrorLogEntryType(), message, exception, owner, GetLogEntryDateTime())); } protected virtual string GetErrorLogEntryType() { return _logEntryService.GetErrorLogEntryType(); } public void Fatal(string message) { Fatal(message, null, GetDefaultOwner()); } public void Fatal(string message, object owner) { Fatal(message, null, owner); } public void Fatal(string message, Exception exception, Type ownerType) { Fatal(message, exception, (object)ownerType); } public void Fatal(string message, Exception exception, object owner) { SaveLogEntry(CreateLogEntry(GetFatalLogEntryType(), message, exception, owner, GetLogEntryDateTime())); } protected virtual string GetFatalLogEntryType() { return _logEntryService.GetFatalLogEntryType(); } public void Info(string message) { Info(message, GetDefaultOwner()); } public void Info(string message, object owner) { SaveLogEntry(CreateLogEntry(GetInfoLogEntryType(), message, owner, GetLogEntryDateTime())); } protected virtual string GetInfoLogEntryType() { return _logEntryService.GetInfoLogEntryType(); } public void Warn(string message) { Warn(message, GetDefaultOwner()); } public void Warn(string message, object owner) { SaveLogEntry(CreateLogEntry(GetWarnLogEntryType(), message, owner, GetLogEntryDateTime())); } protected virtual string AddPrefixToMessage(string message) { return string.Concat(_sqlLoggerSettings.LogPrefix, message); } protected virtual object GetDefaultOwner() { return this; } protected virtual LogEntry CreateLogEntry(string logEntryType, string message, Exception exception, Type ownerType, DateTime createdDate) { return _logEntryFactory.CreateLogEntry(logEntryType, message, exception, ownerType, createdDate); } protected virtual LogEntry CreateLogEntry(string logEntryType, string message, Exception exception, object owner, DateTime createdDate) { return _logEntryFactory.CreateLogEntry(logEntryType, message, exception, owner, createdDate); } protected virtual LogEntry CreateLogEntry(string logEntryType, string message, Type ownerType, DateTime createdDate) { return _logEntryFactory.CreateLogEntry(logEntryType, message, ownerType, createdDate); } protected virtual LogEntry CreateLogEntry(string logEntryType, string message, object owner, DateTime createdDate) { return _logEntryFactory.CreateLogEntry(logEntryType, message, owner, createdDate); } protected virtual string GetWarnLogEntryType() { return _logEntryService.GetWarnLogEntryType(); } protected virtual DateTime GetLogEntryDateTime() { return DateTime.Now.ToUniversalTime(); } protected virtual void SaveLogEntry(LogEntry entry) { if (entry == null) { return; } entry.LogMessage = _logEntryService.GenerateLogMessage(entry); RepositoryLogEntry repositoryEntry = CreateRepositoryLogEntry(entry); if (repositoryEntry == null) { return; } SaveRepositoryLogEntry(repositoryEntry); } protected virtual string GenerateLogMessage(LogEntry entry) { return _logEntryService.GenerateLogMessage(entry); } protected virtual RepositoryLogEntry CreateRepositoryLogEntry(LogEntry entry) { return _repositoryLogEntryFactory.CreateRepositoryLogEntry(entry); } protected virtual void SaveRepositoryLogEntry(RepositoryLogEntry entry) { if(!CanLogEntry(entry)) { return; } SqlCommand insertCommand = GetinsertCommand(entry); if(insertCommand == null) { return; } ExecuteNoResult(insertCommand); } protected virtual bool CanLogEntry(RepositoryLogEntry entry) { return entry != null && !string.IsNullOrWhiteSpace(entry.LogEntryType) && !string.IsNullOrWhiteSpace(entry.LogMessage) && entry.Created > DateTime.MinValue && entry.Created < DateTime.MaxValue; } protected virtual SqlCommand GetinsertCommand(RepositoryLogEntry entry) { return new SqlCommand { Sql = GetInsertLogEntrySql(), Parameters = GetinsertCommandParameters(entry) }; } protected virtual object[] GetinsertCommandParameters(RepositoryLogEntry entry) { return new object[] { GetLogEntryTypeParameterName(), entry.LogEntryType, GetLogMessageParameterName(), entry.LogMessage, GetCreatedParameterName(), entry.Created }; } protected virtual string GetLogEntryTypeParameterName() { return _sqlLoggerSettings.LogEntryTypeParameterName; } protected virtual string GetLogMessageParameterName() { return _sqlLoggerSettings.LogMessageParameterName; } protected virtual string GetCreatedParameterName() { return _sqlLoggerSettings.CreatedParameterName; } protected virtual string GetInsertLogEntrySql() { return _sqlLoggerSettings.InsertLogEntrySqlFormat; } protected virtual void ExecuteNoResult(SqlCommand sqlCommand) { _factory.GetRetryer().ExecuteNoResult(() => { _sqlDataApi.Execute(sqlCommand.Sql, sqlCommand.Parameters); }); } } }
Since there is a lot of code in the class above, I’m not going to talk about all of it — it should be clear on what this class is doing for the most part.
I do want to highlight that the SaveRepositoryLogEntry() method takes in a RepositoryLogEntry instance; builds up a SqlCommand instance from it as well as the insert SQL statement and parameters from the SqlLoggerSettings instance (these are coming from Sitecore configuration, and there are hooks on this class to allow for overriding these if needed); and passes the SqlCommand instance to the ExecuteNoResult() method which uses the SqlDataApi instance for saving to the database. Plus, I’m leveraging an “out of the box” “retryer” from the Sitecore.Kernel.dll to ensure it makes its way into the database table.
Moreover, I’m reusing the ISqlDataApiFactory instance above from my previous post. Have a read of it so you can see what this factory class does.
Since Experience Forms was built perfectly — 😉 — I couldn’t see any LogEntry instances being saved to my database right away. So went ahead and created some <forms.renderField> pipeline processors to capture some.
The following interface is for a <forms.renderField> pipeline processor to just throw an exception by dividing by zero:
using Sitecore.ExperienceForms.Mvc.Pipelines.RenderField; namespace Sandbox.Foundation.Forms.Pipelines.RenderField { public interface IThrowExceptionProcessor { void Process(RenderFieldEventArgs args); } }
Here is its implementation class:
using System; using Sitecore.ExperienceForms.Diagnostics; using Sitecore.ExperienceForms.Mvc.Pipelines.RenderField; namespace Sandbox.Foundation.Forms.Pipelines.RenderField { public class ThrowExceptionProcessor : IThrowExceptionProcessor { private readonly ILogger _logger; public ThrowExceptionProcessor(ILogger logger) { _logger = logger; } public void Process(RenderFieldEventArgs args) { try { int i = 1 / GetZero(); } catch(Exception ex) { _logger.LogError(ToString(), ex, this); } } private int GetZero() { return 0; } } }
I’m sure you would never do such a thing, right? 😉
I then created the following interface for another <forms.renderField> pipeline processor to log some information on the RenderFieldEventArgs instance sent to the Process() method:
using Sitecore.ExperienceForms.Mvc.Pipelines.RenderField; namespace Sandbox.Foundation.Forms.Pipelines.RenderField { public interface ILogRenderedFieldInfo { void Process(RenderFieldEventArgs args); } }
Here is the implementation class for this:
using Sitecore.ExperienceForms.Diagnostics; using Sitecore.ExperienceForms.Mvc.Pipelines.RenderField; using Sitecore.Mvc.Pipelines; namespace Sandbox.Foundation.Forms.Pipelines.RenderField { public class LogRenderedFieldInfo : MvcPipelineProcessor<RenderFieldEventArgs>, ILogRenderedFieldInfo { private readonly ILogger _logger; public LogRenderedFieldInfo(ILogger logger) { _logger = logger; } public override void Process(RenderFieldEventArgs args) { LogInfo($"ViewModel Details:\n\nName: {args.ViewModel.Name}, ItemId: {args.ViewModel.ItemId}, TemplateId: {args.ViewModel.TemplateId}, FieldTypeItemId: {args.ViewModel.FieldTypeItemId}"); LogInfo($"RenderingSettings Details\n\nFieldTypeName: {args.RenderingSettings.FieldTypeName}, FieldTypeId: {args.RenderingSettings.FieldTypeId}, FieldTypeIcon: {args.RenderingSettings.FieldTypeIcon}, FieldTypeDisplayName: {args.RenderingSettings.FieldTypeDisplayName}, FieldTypeBackgroundColor: {args.RenderingSettings.FieldTypeBackgroundColor}"); LogInfo($"Item Details: {args.Item.ID}, Name: {args.Item.Name} FullPath: {args.Item.Paths.FullPath}, TemplateID: {args.Item.TemplateID}"); } protected virtual void LogInfo(string message) { if(string.IsNullOrWhiteSpace(message)) { return; } _logger.Info(message); } } }
I then registered everything in the Sitecore IoC container using the following configurator:
using System; using Microsoft.Extensions.DependencyInjection; using Sitecore.Abstractions; using Sitecore.DependencyInjection; using Sitecore.ExperienceForms.Diagnostics; using Sandbox.Foundation.Forms.Services.Factories.Diagnostics; using Sandbox.Foundation.Forms.Services.Factories; using Sandbox.Foundation.Forms.Models.Logging; using Sandbox.Foundation.Forms.Services.Environment; using Sandbox.Foundation.Forms.Services.Diagnostics; using Sandbox.Foundation.Forms.Pipelines.RenderField; namespace Sandbox.Foundation.Forms { public class SqlLoggerConfigurator : IServicesConfigurator { public void Configure(IServiceCollection serviceCollection) { ConfigureConfigObjects(serviceCollection); ConfigureFactories(serviceCollection); ConfigureServices(serviceCollection); ConfigurePipelineProcessors(serviceCollection); } private void ConfigureConfigObjects(IServiceCollection serviceCollection) { serviceCollection.AddSingleton(provider => GetLogEntryServiceSettings(provider)); serviceCollection.AddSingleton(provider => GetSqlLoggerSettings(provider)); } private LogEntryServiceSettings GetLogEntryServiceSettings(IServiceProvider provider) { return CreateConfigObject<LogEntryServiceSettings>(provider, "moduleSettings/foundation/forms/logEntryServiceSettings"); } private SqlLoggerSettings GetSqlLoggerSettings(IServiceProvider provider) { return CreateConfigObject<SqlLoggerSettings>(provider, "moduleSettings/foundation/forms/sqlLoggerSettings"); } private TConfigObject CreateConfigObject<TConfigObject>(IServiceProvider provider, string path) where TConfigObject : class { BaseFactory factory = GetService<BaseFactory>(provider); return factory.CreateObject(path, true) as TConfigObject; } private TService GetService<TService>(IServiceProvider provider) { return provider.GetService<TService>(); } private void ConfigureFactories(IServiceCollection serviceCollection) { serviceCollection.AddSingleton<ILogEntryFactory, LogEntryFactory>(); serviceCollection.AddSingleton<IRepositoryLogEntryFactory, RepositoryLogEntryFactory>(); serviceCollection.AddSingleton<ISqlDataApiFactory, SqlDataApiFactory>(); } private void ConfigureServices(IServiceCollection serviceCollection) { serviceCollection.AddSingleton<IEnvironmentService, EnvironmentService>(); serviceCollection.AddSingleton<ILogEntryService, LogEntryService>(); serviceCollection.AddSingleton<ILogger, SqlLogger>(); } private void ConfigurePipelineProcessors(IServiceCollection serviceCollection) { serviceCollection.AddSingleton<ILogRenderedFieldInfo, LogRenderedFieldInfo>(); serviceCollection.AddSingleton<IThrowExceptionProcessor, ThrowExceptionProcessor>(); } } }
Note: the GetLogEntryServiceSettings() and the GetSqlLoggerSettings() methods both create settings objects by using the Sitecore Configuration Factory. Ultimately, these settings objects are thrown into the container so they can be injected into the service classes that need them.
I then strung everything together using the following the Sitecore patch configuration file.
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <pipelines> <forms.renderField> <processor type="Sandbox.Foundation.Forms.Pipelines.RenderField.LogRenderedFieldInfo, Sandbox.Foundation.Forms" resolve="true"/> <processor type="Sandbox.Foundation.Forms.Pipelines.RenderField.ThrowExceptionProcessor, Sandbox.Foundation.Forms" resolve="true"/> </forms.renderField> </pipelines> <services> <configurator type="Sandbox.Foundation.Forms.SqlLoggerConfigurator, Sandbox.Foundation.Forms" /> <register serviceType="Sitecore.ExperienceForms.Diagnostics.ILogger, Sitecore.ExperienceForms"> <patch:delete /> </register> </services> <moduleSettings> <foundation> <forms> <logEntryServiceSettings type="Sandbox.Foundation.Forms.Models.Logging.LogEntryServiceSettings, Sandbox.Foundation.Forms" singleInstance="true"> <DebugLogEntryType>DEBUG</DebugLogEntryType> <ErrorLogEntryType>ERROR</ErrorLogEntryType> <FatalLogEntryType>FATAL</FatalLogEntryType> <InfoLogEntryType>INFO</InfoLogEntryType> <WarnLogEntryType>WARN</WarnLogEntryType> <ExceptionPrefix>Exception: </ExceptionPrefix> <MessagePrefix>Message: </MessagePrefix> <SourcePrefix>Source: </SourcePrefix> <NestedExceptionPrefix>Nested Exception</NestedExceptionPrefix> <LogEntryTimeFormat>HH:mm:ss.ff</LogEntryTimeFormat> </logEntryServiceSettings> <sqlLoggerSettings type="Sandbox.Foundation.Forms.Models.Logging.SqlLoggerSettings, Sandbox.Foundation.Forms" singleInstance="true"> <LogPrefix>[Experience Forms]:</LogPrefix> <LogDatabaseConnectionStringName>ExperienceFormsSettings</LogDatabaseConnectionStringName> <InsertLogEntrySqlFormat>INSERT INTO {0}ExperienceFormsLog{1}({0}LogEntryType{1},{0}LogMessage{1},{0}Created{1})VALUES({2}logEntryType{3},{2}logMessage{3},{2}created{3});</InsertLogEntrySqlFormat> <LogEntryTypeParameterName>logEntryType</LogEntryTypeParameterName> <LogMessageParameterName>logMessage</LogMessageParameterName> <CreatedParameterName>created</CreatedParameterName> </sqlLoggerSettings> </forms> </foundation> </moduleSettings> </sitecore> </configuration>
Ok, let’s take this for a spin.
After building and deploying everything above, I spun up my Sitecore instance:
I then navigated to a form I had created in a previous post:
After the page with my form was done loading, I ran a query on my custom log table and saw this:
As you can see, it worked.
If you have any questions or comments, don’t hesitate to drop these in a comment below.
Until next time, have yourself a Sitecoretastic day!
Grab Sitecore Experience Forms Configuration Settings from a Custom SQL Server Database Table
Just the other day, I poking around Sitecore Experience Forms to see what’s customisable and have pretty much concluded virtually everything is.
How?
Just have a look at http://[instance]/sitecore/admin/showservicesconfig.aspx of your Sitecore instance and scan for “ExperienceForms”. You will also discover lots of its service class are registered in Sitecore’s IoC container — such makes it easy to swap things out with your own service implementation classes as long as they implement those service types defined in the container.
Since I love to tinkering with all things in Sitecore — most especially when it comes to customising bits of it — I decided to have a crack at replacing IFormsConfigurationSettings — more specifically Sitecore.ExperienceForms.Configuration.IFormsConfigurationSettings in Sitecore.ExperienceForms.dll which represents Sitecore configuration settings for Experience Forms — as it appeared to be something simple enough to do.
The IFormsConfigurationSettings interface represents the following configuration settings:
So, what did I do to customise it?
I wrote a bunch of code — it’s all in this post — which pulls these settings from a custom SQL Server database table.
Why did I do that? Well, I did it because I could. 😉
Years ago, long before my Sitecore days, I worked on ASP.NET Web Applications which had their configuration settings stored in SQL Server databases. Whether this was, or still is, a good idea is a discussion for another time though you are welcome to drop a comment below with your thoughts.
However, for the meantime, just roll with it as the point of this post is to show that you can customise virtually everything in Experience Forms, and I’m just showing one example.
I first created the following class which implements IFormsConfigurationSettings:
using Sitecore.ExperienceForms.Configuration; namespace Sandbox.Foundation.Forms.Models.Configuration { public class SandboxFormsConfigurationSettings : IFormsConfigurationSettings { public string ConnectionStringName { get; set; } public string FieldsPrefix { get; set; } public string FieldsIndexName { get; set; } } }
You might be asking “Mike, why did you create an implementation class when Experience Forms already provides one ‘out of the box’?”
Well, all the properties defined in Sitecore.ExperienceForms.Configuration.IFormsConfigurationSettings — these are the same properties that you see in the implementation class above — lack mutators on them in the interface. My implementation class of IFormsConfigurationSettings adds them in — I hate baking method calls in property accessors as it doesn’t seem clean to me. ¯\_(ツ)_/¯
When I had a look at the “out of the box” implementation class of IFormsConfigurationSettings, I discovered direct calls to the GetSetting() method on the Sitecore.Configuration.Settings static class — this lives in Sitecore.Kernel.dll — but that doesn’t help me with setting those properties, hence the custom IFormsConfigurationSettings class above.
Next, I used the following SQL script to create my custom settings database table:
USE [ExperienceFormsSettings] GO CREATE TABLE [dbo].[FormsConfigurationSettings]( [FieldsPrefix] [nvarchar](max) NULL, [FieldsIndexName] [nvarchar](max) NULL, [ConnectionStringName] [nvarchar](max) NULL ) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY] GO
I then inserted the settings from the configuration file snapshot above into my new table (I’ve omitted the SQL insert statement for this):
Now we need a way to retrieve these settings from the database. The following interface will be for factory classes which create instances of Sitecore.Data.DataProviders.Sql.SqlDataApi — this lives in Sitecore.Kernel.dll — with the passed connection strings:
using Sitecore.Data.DataProviders.Sql; namespace Sandbox.Foundation.Forms.Services.Factories { public interface ISqlDataApiFactory { SqlDataApi CreateSqlDataApi(string connectionString); } }
Well, we can’t do much with an interface without having some class implement it. The following class implements the interface above:
using Sitecore.Data.DataProviders.Sql; using Sitecore.Data.SqlServer; namespace Sandbox.Foundation.Forms.Services.Factories { public class SqlDataApiFactory : ISqlDataApiFactory { public SqlDataApi CreateSqlDataApi(string connectionString) { if(string.IsNullOrWhiteSpace(connectionString)) { return null; } return new SqlServerDataApi(connectionString); } } }
It’s just creating an instance of the SqlServerDataApi class. Nothing special about it at all.
Ironically, I do have to save my own configuration settings in Sitecore Configuration — this would include the the connection string name to the database that contains my new table as well as a few other things.
An instance of the following class will contain these settings — have a look at /sitecore/moduleSettings/foundation/forms/repositorySettings in the Sitecore patch file near the bottom of this post but be sure to come back up here when you are finished 😉 — and this instance will be put into the Sitecore IoC container so it can be injected in an instance of a class I’ll talk about further down in this post:
namespace Sandbox.Foundation.Forms.Models.Configuration { public class RepositorySettings { public string ConnectionStringName { get; set; } public string GetSettingsSql { get; set; } public string ConnectionStringNameColumnName { get; set; } public string FieldsPrefixColumnName { get; set; } public string FieldsIndexNameColumnName { get; set; } public int NotFoundOrdinal { get; set; } } }
I then defined the following interface for repository classes which retrieve IFormsConfigurationSettings instances:
using Sitecore.ExperienceForms.Configuration; namespace Sandbox.Foundation.Forms.Repositories.Configuration { public interface ISettingsRepository { IFormsConfigurationSettings GetFormsConfigurationSettings(); } }
Here’s the implementation class for the interface above:
using System; using System.Data; using System.Linq; using Sitecore.Abstractions; using Sitecore.Data.DataProviders.Sql; using Sitecore.ExperienceForms.Configuration; using Sitecore.ExperienceForms.Diagnostics; using Sandbox.Foundation.Forms.Models.Configuration; using Sandbox.Foundation.Forms.Services.Factories; namespace Sandbox.Foundation.Forms.Repositories.Configuration { public class SettingsRepository : ISettingsRepository { private readonly RepositorySettings _repositorySettings; private readonly BaseSettings _settings; private readonly int _notFoundOrdinal; private readonly ILogger _logger; private readonly SqlDataApi _sqlDataApi; public SettingsRepository(RepositorySettings repositorySettings, BaseSettings settings, ILogger logger, ISqlDataApiFactory sqlDataApiFactory) { _repositorySettings = repositorySettings; _settings = settings; _notFoundOrdinal = GetNotFoundOrdinal(); _logger = logger; _sqlDataApi = GetSqlDataApi(sqlDataApiFactory); } protected virtual SqlDataApi GetSqlDataApi(ISqlDataApiFactory sqlDataApiFactory) { return sqlDataApiFactory.CreateSqlDataApi(GetConnectionString()); } protected virtual string GetConnectionString() { return _settings.GetConnectionString(GetConnectionStringName()); } protected virtual string GetConnectionStringName() { return _repositorySettings.ConnectionStringName; } public IFormsConfigurationSettings GetFormsConfigurationSettings() { try { return _sqlDataApi.CreateObjectReader(GetSqlQuery(), GetParameters(), GetMaterializer()).FirstOrDefault(); } catch (Exception ex) { LogError(ex); } return CreateFormsConfigurationSettingsNullObject(); } protected virtual string GetSqlQuery() { return _repositorySettings.GetSettingsSql; } protected virtual object[] GetParameters() { return Enumerable.Empty<object>().ToArray(); } protected virtual Func<IDataReader, IFormsConfigurationSettings> GetMaterializer() { return new Func<IDataReader, IFormsConfigurationSettings>(ParseFormsConfigurationSettings); } protected virtual IFormsConfigurationSettings ParseFormsConfigurationSettings(IDataReader dataReader) { return new SandboxFormsConfigurationSettings { ConnectionStringName = GetString(dataReader, GetConnectionStringNameColumnName()), FieldsPrefix = GetString(dataReader, GetFieldsPrefixColumnName()), FieldsIndexName = GetString(dataReader, GetFieldsIndexNameColumnName()) }; } protected virtual string GetString(IDataReader dataReader, string columnName) { if(dataReader == null || string.IsNullOrWhiteSpace(columnName)) { return string.Empty; } int ordinal = GetOrdinal(dataReader, columnName); if(ordinal == _notFoundOrdinal) { return string.Empty; } return dataReader.GetString(ordinal); } protected virtual int GetOrdinal(IDataReader dataReader, string columnName) { if(dataReader == null || string.IsNullOrWhiteSpace(columnName)) { return _notFoundOrdinal; } try { return dataReader.GetOrdinal(columnName); } catch(IndexOutOfRangeException) { return _notFoundOrdinal; } } protected virtual int GetNotFoundOrdinal() { return _repositorySettings.NotFoundOrdinal; } protected virtual void LogError(Exception exception) { _logger.LogError(ToString(), exception, this); } protected virtual string GetConnectionStringNameColumnName() { return _repositorySettings.ConnectionStringNameColumnName; } protected virtual string GetFieldsPrefixColumnName() { return _repositorySettings.FieldsPrefixColumnName; } protected virtual string GetFieldsIndexNameColumnName() { return _repositorySettings.FieldsIndexNameColumnName; } protected virtual IFormsConfigurationSettings CreateFormsConfigurationSettingsNullObject() { return new SandboxFormsConfigurationSettings { ConnectionStringName = string.Empty, FieldsIndexName = string.Empty, FieldsPrefix = string.Empty }; } } }
I’m not going to go into details of all the code above but will talk about some important pieces.
The GetFormsConfigurationSettings() method above creates an instance of the IFormsConfigurationSettings instance using the SqlDataApi instance created from the injected factory service — this was defined above — with the SQL query provided from configuration along with the GetMaterializer() method which just uses the ParseFormsConfigurationSettings() method to create an instance of the IFormsConfigurationSettings by grabbing data from the IDataReader instance.
Phew, I’m out of breath as that was a mouthful. 😉
I then registered all of my service classes above in the Sitecore IoC container using the following the configurator — aka a class that implements the IServicesConfigurator interface:
using System; using Microsoft.Extensions.DependencyInjection; using Sitecore.Abstractions; using Sitecore.DependencyInjection; using Sitecore.ExperienceForms.Configuration; using Sandbox.Foundation.Forms.Repositories.Configuration; using Sandbox.Foundation.Forms.Models.Configuration; using Sandbox.Foundation.Forms.Services.Factories; namespace Sandbox.Foundation.Forms { public class SettingsConfigurator : IServicesConfigurator { public void Configure(IServiceCollection serviceCollection) { serviceCollection.AddSingleton(provider => GetRepositorySettings(provider)); serviceCollection.AddSingleton<ISqlDataApiFactory, SqlDataApiFactory>(); serviceCollection.AddSingleton<ISettingsRepository, SettingsRepository>(); serviceCollection.AddSingleton(provider => GetFormsConfigurationSettings(provider)); } private RepositorySettings GetRepositorySettings(IServiceProvider provider) { return CreateConfigObject<RepositorySettings>(provider, "moduleSettings/foundation/forms/repositorySettings"); } private TConfigObject CreateConfigObject<TConfigObject>(IServiceProvider provider, string path) where TConfigObject : class { BaseFactory factory = GetService<BaseFactory>(provider); return factory.CreateObject(path, true) as TConfigObject; } private IFormsConfigurationSettings GetFormsConfigurationSettings(IServiceProvider provider) { ISettingsRepository repository = GetService<ISettingsRepository>(provider); return repository.GetFormsConfigurationSettings(); } private TService GetService<TService>(IServiceProvider provider) { return provider.GetService<TService>(); } } }
One thing to note is the GetRepositorySettings() method above uses the Configuration Factory — this is represented by the BaseFactory abstract class which lives in the Sitecore IoC container “out of the box” — to create an instance of the RepositorySettings class, defined further up in this post, using the settings in the following Sitecore patch file:
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <services> <configurator type="Sandbox.Foundation.Forms.SettingsConfigurator, Sandbox.Foundation.Forms" /> <register serviceType="Sitecore.ExperienceForms.Configuration.IFormsConfigurationSettings, Sitecore.ExperienceForms"> <patch:delete /> </register> </services> <moduleSettings> <foundation> <forms> <repositorySettings type="Sandbox.Foundation.Forms.Models.Configuration.RepositorySettings, Sandbox.Foundation.Forms" singleInstance="true"> <ConnectionStringName>ExperienceFormsSettings</ConnectionStringName> <GetSettingsSql>SELECT TOP (1) {0}ConnectionStringName{1},{0}FieldsPrefix{1},{0}FieldsIndexName{1} FROM {0}FormsConfigurationSettings{1}</GetSettingsSql> <ConnectionStringNameColumnName>ConnectionStringName</ConnectionStringNameColumnName> <FieldsPrefixColumnName>FieldsPrefix</FieldsPrefixColumnName> <FieldsIndexNameColumnName>FieldsIndexName</FieldsIndexNameColumnName> <NotFoundOrdinal>-1</NotFoundOrdinal> </repositorySettings> </forms> </foundation> </moduleSettings> </sitecore> </configuration>
I want to point out that I’m deleting the Sitecore.ExperienceForms.Configuration.IFormsConfigurationSettings service from Sitecore’s configuration as I am adding it back in to the Sitecore IoC container with my own via the configurator above.
After deploying everything, I waited for my Sitecore instance to reload.
Once Sitecore was responsive again, I navigated to “Forms” on the Sitecore Launch Pad and made sure everything had still worked as before.
It did.
Trust me, it did. 😉
If you have any questions/comments on any of the above, or would just like to drop a line to say “hello”, then please share in a comment below.
Otherwise, until next time, keep on Sitecoring.