diff --git a/.editorconfig b/.editorconfig
index 34af5a8..48b9a2c 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -651,4 +651,8 @@ dotnet_diagnostic.CS8981.severity = none
[src/Dataverse/**]
dotnet_diagnostic.CA1510.severity = none
dotnet_diagnostic.CA1307.severity = none
-dotnet_diagnostic.MA0009.severity = none # With data that comes from Dataverse we will not worry about Denial of service attacks
\ No newline at end of file
+dotnet_diagnostic.MA0009.severity = none # With data that comes from Dataverse we will not worry about Denial of service attacks
+
+[src/Dataverse/AdhocJobs/Jobs/**]
+dotnet_diagnostic.MA0048.severity = none # Job files use top-level statements which generate a Program class, not matching file name
+dotnet_diagnostic.CA1812.severity = none # Same reason - generated Program class appears never instantiated
\ No newline at end of file
diff --git a/XrmBedrock.slnx b/XrmBedrock.slnx
index 307215d..be82227 100644
--- a/XrmBedrock.slnx
+++ b/XrmBedrock.slnx
@@ -10,7 +10,7 @@
-
+
diff --git a/src/Dataverse/AdhocJobs/AdhocJobs.csproj b/src/Dataverse/AdhocJobs/AdhocJobs.csproj
new file mode 100644
index 0000000..22bd39f
--- /dev/null
+++ b/src/Dataverse/AdhocJobs/AdhocJobs.csproj
@@ -0,0 +1,14 @@
+
+
+ Exe
+ FileBasedProgram
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Dataverse/AdhocJobs/JobContext.cs b/src/Dataverse/AdhocJobs/JobContext.cs
new file mode 100644
index 0000000..6c26228
--- /dev/null
+++ b/src/Dataverse/AdhocJobs/JobContext.cs
@@ -0,0 +1,10 @@
+using Microsoft.Xrm.Sdk;
+using SharedContext.Dao;
+
+namespace AdhocJobs;
+
+public record JobContext(
+ IOrganizationService OrgService,
+ DataverseAccessObject Dao,
+ Uri DataverseUri,
+ IServiceProvider Services);
diff --git a/src/Dataverse/AdhocJobs/JobSetup.cs b/src/Dataverse/AdhocJobs/JobSetup.cs
new file mode 100644
index 0000000..edac973
--- /dev/null
+++ b/src/Dataverse/AdhocJobs/JobSetup.cs
@@ -0,0 +1,48 @@
+using Azure.Identity;
+using DataverseConnection;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Xrm.Sdk;
+using SharedContext.Dao;
+
+namespace AdhocJobs;
+
+public static class JobSetup
+{
+#pragma warning disable S1075 // URIs should not be hardcoded
+ private const string DefaultUri = "https://YOUR-ENV.crm4.dynamics.com";
+#pragma warning restore S1075 // URIs should not be hardcoded
+
+ public static JobContext Initialize(string[] args)
+ {
+ return Initialize(args, null);
+ }
+
+ public static JobContext Initialize(string[] args, Action? configureServices)
+ {
+ ArgumentNullException.ThrowIfNull(args);
+ var dataverseUri = args.Length > 0 ? new Uri(args[0]) : new Uri(DefaultUri);
+
+ var services = new ServiceCollection();
+ services.AddLogging(builder => builder.AddConsole());
+ services.AddDataverseWithOrganizationServices(options =>
+ {
+ // Swap with whatever fits you. This uses the token from az login
+ options.TokenCredential = new AzureCliCredential();
+ options.DataverseUrl = dataverseUri.ToString();
+ });
+
+ configureServices?.Invoke(services);
+
+ var sp = services.BuildServiceProvider();
+ var orgService = sp.GetRequiredService();
+ var logger = sp.GetRequiredService().CreateLogger("BatchJob");
+ var dao = new DataverseAccessObject(orgService, logger);
+
+ var ctx = new JobContext(orgService, dao, dataverseUri, sp);
+
+ logger.LogInformation($"Connected to {ctx.DataverseUri}");
+
+ return ctx;
+ }
+}
diff --git a/src/Dataverse/AdhocJobs/Jobs/ExampleJob.cs b/src/Dataverse/AdhocJobs/Jobs/ExampleJob.cs
new file mode 100644
index 0000000..9d6844b
--- /dev/null
+++ b/src/Dataverse/AdhocJobs/Jobs/ExampleJob.cs
@@ -0,0 +1,14 @@
+#:package DataverseConnection@1.1.1
+#:package Azure.Identity@1.13.2
+#:project ../AdhocJobs.csproj
+#:property PublishAot=false
+
+using AdhocJobs;
+
+var ctx = JobSetup.Initialize(args);
+
+var accounts = ctx.Dao.RetrieveList(xrm => xrm.AccountSet)
+ .Select(a => new { a.AccountId, a.Name })
+ .ToList();
+
+Console.WriteLine($"Found {accounts.Count} accounts");
diff --git a/src/Dataverse/ConsoleJobs/App.config b/src/Dataverse/ConsoleJobs/App.config
deleted file mode 100644
index 4a99e16..0000000
--- a/src/Dataverse/ConsoleJobs/App.config
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Dataverse/ConsoleJobs/ConsoleJobs.csproj b/src/Dataverse/ConsoleJobs/ConsoleJobs.csproj
deleted file mode 100644
index 65e54cb..0000000
--- a/src/Dataverse/ConsoleJobs/ConsoleJobs.csproj
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
- net462
- enable
- enable
- Exe
- AnyCPU
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Dataverse/ConsoleJobs/Helpers/CsvHelper.cs b/src/Dataverse/ConsoleJobs/Helpers/CsvHelper.cs
deleted file mode 100644
index 1486394..0000000
--- a/src/Dataverse/ConsoleJobs/Helpers/CsvHelper.cs
+++ /dev/null
@@ -1,147 +0,0 @@
-using System.ComponentModel;
-using System.Configuration;
-using System.Globalization;
-using System.Text;
-
-namespace ConsoleJobs.Helpers;
-
-internal static class CsvHelper
-{
- public static void WriteToCsv(string folderPath, string fileName, IEnumerable items, bool appendToFileIfExists = false)
- where T : class, new()
- {
- if (string.IsNullOrWhiteSpace(folderPath))
- throw new ArgumentException("File path is empty", nameof(folderPath));
-
- if (string.IsNullOrWhiteSpace(fileName))
- throw new ArgumentException("File name is empty", nameof(fileName));
-
- var filePath = Path.Combine(folderPath, fileName);
-
- FileInfo fi = new FileInfo(filePath);
- Directory.CreateDirectory(fi.DirectoryName);
-
- using StreamWriter writer = new StreamWriter(filePath, appendToFileIfExists, Encoding.UTF8);
-
- if (!appendToFileIfExists)
- {
- writer.WriteLine(GetCsvHeader());
- }
-
- foreach (var item in items)
- {
- writer.WriteLine(RowToCsv(item));
- }
- }
-
- public static List ReadFromCsv(string filePath, bool hasHeader = true)
- where T : class, new()
- {
- var returnList = new List();
-
- using (StreamReader reader = new StreamReader(filePath, Encoding.UTF8))
- {
- var res = reader.ReadLine();
- if (hasHeader && res != null)
- {
- res = reader.ReadLine();
- }
-
- while (res != null)
- {
- returnList.Add(RowFromCsv(res));
- res = reader.ReadLine();
- }
- }
-
- return returnList;
- }
-
- public static string GetCsvHeader()
- where T : class, new()
- {
- var properties = typeof(T).GetProperties();
- var strings = new string[properties.Length];
- for (int i = 0; i < properties.Length; ++i)
- {
- if (properties[i].GetCustomAttributes(typeof(HeaderAttribute), true).FirstOrDefault() is HeaderAttribute headerNameAttribute)
- {
- strings[i] = headerNameAttribute.Name;
- }
- else
- {
- strings[i] = properties[i].Name;
- }
- }
-
- return string.Join(ConfigurationManager.AppSettings["CsvSeparator"], strings);
- }
-
- public static string RowToCsv(T item)
- where T : class, new()
- {
- var properties = typeof(T).GetProperties();
- var strings = new string[properties.Length];
- for (int i = 0; i < properties.Length; ++i)
- {
- strings[i] = properties[i].GetValue(item)?.ToString() ?? string.Empty;
- }
-
- return string.Join(ConfigurationManager.AppSettings["CsvSeparator"], strings);
- }
-
- public static T RowFromCsv(string item)
- where T : class, new()
- {
- var attributeStrings = item.Split(ConfigurationManager.AppSettings["CsvSeparator"].ToArray());
- var properties = typeof(T).GetProperties();
-
- if (attributeStrings.Length != properties.Length)
- throw new InvalidDataException("Number of columns in csv does not match number of properties in class");
-
- T returnval = new T();
- for (int i = 0; i < attributeStrings.Length; ++i)
- {
- var property = properties[i];
- object value = FromString(property.PropertyType, attributeStrings[i]);
- property.SetValue(returnval, value);
- }
-
- return returnval;
- }
-
- private static object FromString(Type type, string s)
- {
- switch (Type.GetTypeCode(type))
- {
- case TypeCode.Int32:
- if (type.IsEnum)
- return Enum.Parse(type, s);
-
- return int.Parse(s, CultureInfo.InvariantCulture);
- case TypeCode.Int64:
- return long.Parse(s, CultureInfo.InvariantCulture);
- case TypeCode.String:
- return s;
- case TypeCode.Boolean:
- return bool.Parse(s);
- case TypeCode.DateTime:
- return DateTime.Parse(s, CultureInfo.InvariantCulture);
- case TypeCode.Double:
- return double.Parse(s, CultureInfo.InvariantCulture);
- case TypeCode.Decimal:
- return decimal.Parse(s, CultureInfo.InvariantCulture);
- case TypeCode.Object:
- if (Nullable.GetUnderlyingType(type) != null && !string.IsNullOrEmpty(s))
- return FromString(Nullable.GetUnderlyingType(type), s);
-
- if (Guid.TryParse(s, out Guid res))
- return res;
-
- return TypeDescriptor.GetConverter(type).ConvertFromString(null, CultureInfo.InvariantCulture, s);
-
- default:
- return TypeDescriptor.GetConverter(type).ConvertFromString(null, CultureInfo.InvariantCulture, s);
- }
- }
-}
diff --git a/src/Dataverse/ConsoleJobs/Helpers/HeaderAttribute.cs b/src/Dataverse/ConsoleJobs/Helpers/HeaderAttribute.cs
deleted file mode 100644
index 008c8d3..0000000
--- a/src/Dataverse/ConsoleJobs/Helpers/HeaderAttribute.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace ConsoleJobs.Helpers;
-
-[AttributeUsage(AttributeTargets.Property)]
-internal sealed class HeaderAttribute : Attribute
-{
- public string Name { get; internal set; }
-
- public HeaderAttribute(string name)
- {
- Name = name;
- }
-}
diff --git a/src/Dataverse/ConsoleJobs/Jobs/ExampleJob.cs b/src/Dataverse/ConsoleJobs/Jobs/ExampleJob.cs
deleted file mode 100644
index 63bd459..0000000
--- a/src/Dataverse/ConsoleJobs/Jobs/ExampleJob.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using ConsoleJobs.Helpers;
-using ConsoleJobs.Setup;
-using Microsoft.Extensions.Logging;
-
-namespace ConsoleJobs.Jobs;
-
-#pragma warning disable CA1518 // It's an example, it's OK that its not referenced
-#pragma warning disable CA1812 // It's an example, it's OK that its not referenced
-
-internal sealed class ExampleJob : IJob
-#pragma warning restore CA1812
-#pragma warning restore CA1518
-{
- private sealed class PrintableAccount
- {
- public Guid? Id { get; set; }
-
- public string? Name { get; set; }
- }
-
- public void Run(EnvironmentConfig env)
- {
- var accounts = env.Dao.RetrieveList(xrm => xrm.AccountSet).Select(x => new PrintableAccount { Id = x.AccountId, Name = x.Name });
-
- env.Tracing.LogInformation("Account count: {Count}", accounts.Count());
-
- CsvHelper.WriteToCsv(env.CsvFolderPath, "ListOfAccounts.csv", accounts);
- }
-}
diff --git a/src/Dataverse/ConsoleJobs/Jobs/IJob.cs b/src/Dataverse/ConsoleJobs/Jobs/IJob.cs
deleted file mode 100644
index 1ecc681..0000000
--- a/src/Dataverse/ConsoleJobs/Jobs/IJob.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using ConsoleJobs.Setup;
-
-namespace ConsoleJobs.Jobs;
-
-internal interface IJob
-{
- void Run(EnvironmentConfig env);
-}
diff --git a/src/Dataverse/ConsoleJobs/Program.cs b/src/Dataverse/ConsoleJobs/Program.cs
deleted file mode 100644
index 306ea16..0000000
--- a/src/Dataverse/ConsoleJobs/Program.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-using ConsoleJobs.Jobs;
-using ConsoleJobs.Setup;
-using System.Configuration;
-using Environment = ConsoleJobs.Setup.Environment;
-
-namespace ConsoleJobs;
-
-internal static class Program
-{
- private static void Main()
- {
- var env = GetEnviromentFromConfig();
- IJob job = GetJobFromConfiguration(); // Used to get the job from the app config file
- ExecuteJobOnEnvironment(env, job);
- }
-
- ///
- /// Gets the enviroment from app config.
- ///
- /// The Dataverse environment to execute the job on
- private static Environment GetEnviromentFromConfig()
- {
- Environment result;
- var envStr = ConfigurationManager.AppSettings["Environment"];
- if (string.IsNullOrWhiteSpace(envStr) || !Enum.TryParse(envStr, out result))
- throw new ConfigurationErrorsException("Environment not specified in App.config or could not be parsed as EnvironmentEnum");
-
- return result;
- }
-
- ///
- /// Creates an instance of the IJob given the classname.
- ///
- /// The job to execute
- private static IJob GetJobFromConfiguration()
- {
- var envStr = ConfigurationManager.AppSettings["JobClassName"];
- if (string.IsNullOrWhiteSpace(envStr))
- throw new ConfigurationErrorsException("JobClassName not specified in App.config");
-
- Type t = Type.GetType(envStr);
- return (IJob)Activator.CreateInstance(t);
- }
-
- ///
- /// Creates a connection to CRM by getting the clientid and secret from the app config.
- ///
- /// Which Dataverse environment to execute on
- /// Returns the config with relevant properties for the job, including the Dao
- private static EnvironmentConfig GetEnv(Environment env)
- {
- var newEnv = EnvironmentConfig.Create(env);
- return newEnv;
- }
-
- ///
- /// Executes the job on the specified environment
- ///
- /// The environment the job should be executed on
- /// The job to execute
- private static void ExecuteJobOnEnvironment(Environment env, IJob job)
- {
- Console.WriteLine($"You are attempting to run {job.GetType().Name} on {env}.\nPress 'Y' to continue...");
- var keyPressed = Console.ReadKey();
- if (keyPressed.Key != ConsoleKey.Y)
- {
-#pragma warning disable CA1303 // Do not pass literals as localized parameters
- Console.WriteLine("Aborted by user.\nExiting...");
-#pragma warning restore CA1303 // Do not pass literals as localized parameters
- return;
- }
-
- var environment = GetEnv(env);
-#pragma warning disable CA1031 // Do not catch general exception types
- try
- {
- job.Run(environment);
- }
- catch (Exception e)
- {
- Console.WriteLine(e.Message);
- }
-#pragma warning restore CA1031 // Do not catch general exception types
-
-#pragma warning disable CA1303 // Do not pass literals as localized parameters
- Console.WriteLine("Program finished.\nPress any key to continue...");
-#pragma warning restore CA1303 // Do not pass literals as localized parameters
- Console.ReadKey();
- }
-}
diff --git a/src/Dataverse/ConsoleJobs/README.md b/src/Dataverse/ConsoleJobs/README.md
deleted file mode 100644
index 8ce0066..0000000
--- a/src/Dataverse/ConsoleJobs/README.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# ConsoleJobs
-ConsoleJobs is a framework that streamlines the development of jobs/scripts that run against D365 using self-contained, single-configured jobs with CSV read/write capability.
-
-## Features
-- **Self-contained jobs.** You write the entirety of your ConsoleJob in one file.
-- **Centralized configuration.** All necessary configuration is in App.config.
-- **Methods for reading and writing to CSV files.** Generic read and write methods are provided with minimal per-job setup.
-
-## Configuration
-In App.config, define the following parameters:
-
-`Environment` The environment to run your ConsoleJob against.
-
-`JobClassName` The full name of the ConsoleJob to run.
-
-`DevEnv`, `TestEnv`, `UatEnv`, `ProdEnv`: URLs of your Dev, Test, UAT and Prod environments.
-
-`CsvSeparator` Separator to used for CSV files. Configured here for convenience, due to Excel's regional differences.
-
-`AuthType` The authentication type for connecting to Dynamics 365 (set to "OAuth").
-
-`Username` The email address of a user that has access to the CRM-environment.
-
-`LoginPrompt` When to display the login prompt (set to "Always").
-
-`AppId` The application ID for Azure AD authentication. [Supplied by Microsoft.](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/xrm-tooling/use-connection-strings-xrm-tooling-connect#connection-string-parameters) No need to change this.
-
-`RedirectUri` The redirect URI for OAuth authentication flow. [Supplied by Microsoft.](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/xrm-tooling/use-connection-strings-xrm-tooling-connect#connection-string-parameters) No need to change this.
-
-## Usage
-1. Create your job as a class implementing the `IJob` interface in the /Jobs directory.
-2. Set the `ConsoleJobs` project as your startup project in Visual Studio.
-3. In the App.config, make sure to configure `JobClassName`, `Environment`, `DevEnv`, `TestEnv`, `UatEnv`, `ProdEnv` and `Username`.
-4. Build and run the `ConsoleJobs` project to run your job.
-
-### Adding Additional Environments
-To add additional environments, create an entry in App.config for it in the same fashion as the other environments, expand the `EnvironmentsEnum` and `GetUrlFromEnvironment()` in `EnvironmentConfig.cs` to include your new environment.
\ No newline at end of file
diff --git a/src/Dataverse/ConsoleJobs/Setup/ConsoleLogger.cs b/src/Dataverse/ConsoleJobs/Setup/ConsoleLogger.cs
deleted file mode 100644
index 828850b..0000000
--- a/src/Dataverse/ConsoleJobs/Setup/ConsoleLogger.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using Microsoft.Extensions.Logging;
-
-namespace ConsoleJobs.Setup;
-
-internal sealed class ConsoleLogger : ILogger
-{
- public ConsoleLogger()
- {
- }
-
- public IDisposable? BeginScope(TState state)
- where TState : notnull => default!;
-
- public bool IsEnabled(LogLevel logLevel) => true;
-
- public void Log(
- LogLevel logLevel,
- EventId eventId,
- TState state,
- Exception? exception,
- Func formatter)
- {
- if (!IsEnabled(logLevel))
- return;
-
- if (formatter is null)
- throw new ArgumentNullException(nameof(formatter));
-
- Console.WriteLine($"[{eventId.Id,2}: {logLevel,-12}]");
- Console.WriteLine($"{formatter(state, exception)}");
- }
-}
diff --git a/src/Dataverse/ConsoleJobs/Setup/Environment.cs b/src/Dataverse/ConsoleJobs/Setup/Environment.cs
deleted file mode 100644
index a89f0ab..0000000
--- a/src/Dataverse/ConsoleJobs/Setup/Environment.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace ConsoleJobs.Setup;
-
-internal enum Environment
-{
- Dev,
- Test,
- Uat,
- Prod,
-}
diff --git a/src/Dataverse/ConsoleJobs/Setup/EnvironmentConfig.cs b/src/Dataverse/ConsoleJobs/Setup/EnvironmentConfig.cs
deleted file mode 100644
index 0b37e26..0000000
--- a/src/Dataverse/ConsoleJobs/Setup/EnvironmentConfig.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-using Microsoft.Extensions.Logging;
-using Microsoft.Xrm.Tooling.Connector;
-using SharedContext.Dao;
-using System.Configuration;
-
-namespace ConsoleJobs.Setup;
-
-internal sealed class EnvironmentConfig
-{
- public Environment CurrentEnvironment { get; private set; }
-
- public DataverseAccessObject Dao { get; private set; } = null!;
-
- public ILogger Tracing { get; private set; }
-
- ///
- /// By default set to the desktop folder.
- ///
- public string CsvFolderPath { get; private set; } = string.Empty;
-
- private EnvironmentConfig()
- {
- Tracing = new ConsoleLogger();
- }
-
- public static EnvironmentConfig Create(Environment env)
- {
- return new EnvironmentConfig
- {
- CurrentEnvironment = env,
- Dao = GetDao(env),
- CsvFolderPath = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop),
- };
- }
-
- private static DataverseAccessObject GetDao(Environment env)
- {
- var connString = GetDataverseConnectionstring(env);
-#pragma warning disable CA2000 // Dispose objects before losing scope
- var orgService = GetOrgService(connString);
-#pragma warning restore CA2000 // Dispose objects before losing scope
- var logger = new ConsoleLogger();
- return new DataverseAccessObject(orgService, logger);
- }
-
- private static string GetDataverseConnectionstring(Environment env)
- {
- var auth = ConfigurationManager.AppSettings["AuthType"];
- var url = GetUrlFromEnvironment(env);
- var username = ConfigurationManager.AppSettings["Username"];
- var loginprompt = ConfigurationManager.AppSettings["LoginPrompt"];
- var appId = ConfigurationManager.AppSettings["AppId"];
- var redirectUri = ConfigurationManager.AppSettings["RedirectUri"];
-
- return $"AuthType={auth};url={url};Username={username};LoginPrompt={loginprompt};AppId={appId};RedirectUri={redirectUri};";
- }
-
- private static string GetUrlFromEnvironment(Environment env)
- {
- return env switch
- {
- Environment.Dev => ConfigurationManager.AppSettings["DevEnv"],
- Environment.Test => ConfigurationManager.AppSettings["TestEnv"],
- Environment.Uat => ConfigurationManager.AppSettings["UatEnv"],
- Environment.Prod => ConfigurationManager.AppSettings["ProdEnv"],
- _ => throw new ArgumentException("Environment not supported", nameof(env)),
- };
- }
-
- private static CrmServiceClient GetOrgService(string dataverseConnectionstring)
- {
- var client = new CrmServiceClient(dataverseConnectionstring);
- if (client.LastCrmException != null)
-#pragma warning disable CA2201 // Do not raise reserved exception types
-#pragma warning disable S112 // General or reserved exceptions should never be thrown
- throw new Exception($"Connection to D365 fails with exception {client.LastCrmException.Message} Error: {client.LastCrmError}", client.LastCrmException);
-#pragma warning restore S112 // General or reserved exceptions should never be thrown
-#pragma warning restore CA2201 // Do not raise reserved exception types
- return client;
- }
-}