using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using Microsoft.Dynamics.AX.Metadata.MetaModel;
using Microsoft.Dynamics.AX.Metadata.Providers;
using Microsoft.Dynamics.AX.Metadata.Storage;
namespace D365MetadataBridge.Services
{
///
/// Creates and modifies D365FO metadata objects using the official IMetadataProvider API.
/// Uses interface casts (IMetaClassProvider, IMetaTableProvider, etc.) because DiskProvider
/// implements Create/Update as explicit interface members (dynamic dispatch fails).
///
public class MetadataWriteService
{
private IMetadataProvider _provider;
private readonly string _packagesPath;
// Cache resolved ModelSaveInfo per model name
private readonly Dictionary _modelCache = new Dictionary(StringComparer.OrdinalIgnoreCase);
// Cache the publisher verdict per model name — see IsMicrosoftModel. Cleared with
// _modelCache in UpdateProvider for the same reason: it is read off the provider.
private readonly Dictionary _microsoftModelCache = new Dictionary(StringComparer.OrdinalIgnoreCase);
// Cache the package (top-level PackagesLocalDirectory folder) each model lives in —
// see ResolvePackageName. Cleared in UpdateProvider: a model deployed or created
// since the last refresh may be the one that finally resolves.
private readonly Dictionary _packageNameCache = new Dictionary(StringComparer.OrdinalIgnoreCase);
public MetadataWriteService(IMetadataProvider provider, string packagesPath)
{
_provider = provider;
_packagesPath = packagesPath;
}
///
/// Called by MetadataReadService.RefreshProvider() to keep the write service in sync.
///
/// The model cache is derived from the provider (ResolveModelSaveInfo asks
/// _provider.ModelManifest first), so it cannot outlive it. A model the OLD manifest
/// did not enumerate — the usual case for a model that was just deployed, or created
/// and not yet built — falls back to the descriptor scan, whose ModelSaveInfo carries
/// the SequenceId as Id and SequenceId=0; that is exactly the shape that makes
/// IMetadataProvider.Create() throw NullReferenceException. Cached, it survived every
/// later refresh, so the refresh that finally made the manifest able to answer
/// correctly changed nothing and every subsequent create into that model kept NREing.
///
///
/// (collection type, object name) -> the model that owns it.
///
/// GetModelSaveInfoForObject below is called by every write operation, and
/// its third strategy walks every package x 21 AOT folders on disk. The
/// answer is a property of the provider generation, so it is cached for
/// exactly as long as the other two caches: cleared in UpdateProvider when
/// the provider is replaced.
///
/// MEASURED, so the comment does not overclaim: on a model the runtime
/// manifest already knows (an ordinary create-then-modify against fm-mcp),
/// this cache changes nothing — strategy 1 answers immediately and the
/// expensive fallbacks never run. A/B over create+3 operations and three
/// modifies was inside the noise. It pays only where strategy 1 and 2 miss
/// and the disk walk is reached: a model absent from the manifest, i.e. one
/// not yet built or registered — which is exactly the sandbox case, and the
/// case a write is most likely to be repeated in.
///
private readonly Dictionary _objectModelCache = new(StringComparer.OrdinalIgnoreCase);
public void UpdateProvider(IMetadataProvider newProvider)
{
_provider = newProvider;
_modelCache.Clear();
_microsoftModelCache.Clear();
_objectModelCache.Clear();
_packageNameCache.Clear();
}
// ========================
// MODEL RESOLUTION
// ========================
///
/// Resolves a model name to ModelSaveInfo.
///
/// PREFERRED: the provider's model manifest, which gives the model's real runtime
/// identity — the small ordinal Id AND the SequenceId. This matters because the
/// model descriptor's <Id> element is actually the SequenceId (a large number);
/// parsing it into ModelSaveInfo.Id while leaving SequenceId=0 makes
/// IMetadataProvider.Create() throw NullReferenceException. (Verified: a custom model's
/// manifest Id=116 SequenceId=896000930 creates fine; descriptor-derived Id=896000930
/// SequenceId=0 NREs.) The descriptor scan stays as a fallback for models the
/// manifest does not enumerate (e.g. not yet built/registered).
/// Caches results for repeated calls.
///
public ModelSaveInfo? ResolveModelSaveInfo(string modelName)
{
if (_modelCache.TryGetValue(modelName, out var cached))
return cached;
// Preferred: authoritative identity from the runtime model manifest.
var fromManifest = ResolveModelSaveInfoFromManifest(modelName);
if (fromManifest != null) { _modelCache[modelName] = fromManifest; return fromManifest; }
// Fallback: descriptor scan. Note the Id/SequenceId caveat above — this path
// can yield a ModelSaveInfo the create API rejects, but it is the best we can do
// when the model is absent from the manifest.
// Scan {packagesPath}/{*}/Descriptor/{modelName}.xml
// First try the direct path (most models: package name = model name)
var directPath = Path.Combine(_packagesPath, modelName, "Descriptor", modelName + ".xml");
if (File.Exists(directPath))
{
var msi = ParseModelDescriptor(directPath, modelName);
if (msi != null) { _modelCache[modelName] = msi; return msi; }
}
// Fallback: scan all Descriptor folders
try
{
foreach (var packageDir in Directory.GetDirectories(_packagesPath))
{
var descDir = Path.Combine(packageDir, "Descriptor");
if (!Directory.Exists(descDir)) continue;
foreach (var xmlFile in Directory.GetFiles(descDir, "*.xml"))
{
var msi = ParseModelDescriptor(xmlFile, modelName);
if (msi != null) { _modelCache[modelName] = msi; return msi; }
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WriteService] Error scanning model descriptors: {ex.Message}");
}
return null;
}
///
/// Resolves ModelSaveInfo from the provider's model manifest by model name, capturing
/// the real runtime Id + SequenceId (see ResolveModelSaveInfo remarks). Returns null
/// when the model is not enumerated by the manifest.
///
private ModelSaveInfo? ResolveModelSaveInfoFromManifest(string modelName)
{
try
{
var manifest = _provider.ModelManifest;
if (manifest == null) return null;
foreach (var mi in manifest.ListModelInfos())
{
if (string.Equals(mi.Name, modelName, StringComparison.OrdinalIgnoreCase))
return new ModelSaveInfo { Id = mi.Id, Layer = mi.Layer, Name = mi.Name, SequenceId = mi.SequenceId };
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WriteService] Manifest model lookup failed for '{modelName}': {ex.Message}");
}
return null;
}
private ModelSaveInfo? ParseModelDescriptor(string xmlPath, string targetModelName)
{
try
{
var doc = XDocument.Load(xmlPath);
var root = doc.Root;
if (root == null) return null;
// Handle namespace — descriptor files use xmlns
var ns = root.GetDefaultNamespace();
var nameEl = root.Element(ns + "Name") ?? root.Element("Name");
if (nameEl == null || !string.Equals(nameEl.Value, targetModelName, StringComparison.OrdinalIgnoreCase))
return null;
var idEl = root.Element(ns + "Id") ?? root.Element("Id");
var layerEl = root.Element(ns + "Layer") ?? root.Element("Layer");
if (idEl == null || layerEl == null) return null;
if (!int.TryParse(idEl.Value, out int id) || !int.TryParse(layerEl.Value, out int layer))
{
Console.Error.WriteLine($"[WriteService] Invalid numeric Id or Layer in descriptor {xmlPath}: Id='{idEl.Value}' Layer='{layerEl.Value}'");
return null;
}
// Name is REQUIRED for Create: the SDK routes a brand-new object to its
// model folder via ModelSaveInfo.Name. Leaving it null is why createObject
// threw NullReferenceException while modify (Update) — which already knows
// the existing object's location — worked fine.
return new ModelSaveInfo
{
Id = id,
Layer = layer,
Name = targetModelName
};
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WriteService] Error parsing descriptor {xmlPath}: {ex.Message}");
return null;
}
}
// ========================
// MODEL OWNERSHIP
// ========================
///
/// Is this model shipped by Microsoft?
///
/// Answered from the model manifest's Publisher, which is the model's own
/// declaration of who owns it — not a name list that goes stale with every
/// release, and not the layer (a partner solution can sit in a low layer).
///
/// Default-allow on purpose: an unreadable manifest, or a model the manifest
/// does not enumerate, answers false. A guard that cannot see the evidence must
/// not start refusing writes that work today; the cost of the rare miss is the
/// behaviour we already had, while a false positive would block a customer's own
/// model with no way around it from inside the tool.
///
private bool IsMicrosoftModel(string? modelName)
{
if (string.IsNullOrWhiteSpace(modelName)) return false;
if (_microsoftModelCache.TryGetValue(modelName!, out var cached)) return cached;
var isMicrosoft = false;
try
{
var manifest = _provider.ModelManifest;
if (manifest != null)
{
foreach (var mi in manifest.ListModelInfos())
{
if (!string.Equals(mi.Name, modelName, StringComparison.OrdinalIgnoreCase)) continue;
isMicrosoft = (mi.Publisher ?? "").IndexOf("Microsoft", StringComparison.OrdinalIgnoreCase) >= 0;
break;
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WriteService] Publisher lookup failed for model '{modelName}': {ex.Message}");
}
_microsoftModelCache[modelName!] = isMicrosoft;
return isMicrosoft;
}
///
/// Refuses a write into a Microsoft-shipped model.
///
/// The bridge resolves its target by NAME and writes wherever that name lives, so
/// nothing about a request distinguishes "modify my object" from "modify the base
/// application". The TS modify path checks ownership from the resolved FILE path,
/// but that is a different resolution than the one the bridge performs and it is
/// not on every route into these methods (batchModify, a direct RPC). This is the
/// check at the point of the actual write.
///
private void AssertModelWritable(ModelSaveInfo? msi, string operation, string objectName, string alternative)
{
if (msi == null || !IsMicrosoftModel(msi.Name)) return;
throw new InvalidOperationException(
$"Refusing {operation} on '{objectName}': it belongs to Microsoft-shipped model '{msi.Name}'. " +
$"Writing into the base application is not repeatable — the next platform update overwrites it. {alternative}");
}
// ========================
// CREATE OPERATIONS
// ========================
///
/// Creates a new AxClass via IMetaClassProvider.Create().
///
public object CreateClass(string name, string modelName, string? declaration,
List? methods, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axClass = new AxClass { Name = name };
// Set declaration (class header + member variables)
if (!string.IsNullOrEmpty(declaration))
axClass.Declaration = declaration;
else
axClass.Declaration = $"public class {name}\n{{\n}}";
// Apply properties
if (properties != null)
{
foreach (var kv in properties)
SetAxClassProperty(axClass, kv.Key, kv.Value);
}
// Add methods
if (methods != null)
{
foreach (var m in methods)
{
var axMethod = new AxMethod { Name = m.Name, Source = m.Source ?? "" };
axClass.AddMethod(axMethod);
}
}
// Write to disk via provider API
var classProvider = _provider.Classes as IMetaClassProvider
?? throw new InvalidOperationException("DiskProvider.Classes does not implement IMetaClassProvider");
classProvider.Create(axClass, msi);
var filePath = GetExpectedPath("AxClass", name, modelName);
return new { success = true, objectType = "class", objectName = name, modelName, filePath, api = "IMetaClassProvider.Create" };
}
///
/// Creates a new AxTable via IMetaTableProvider.Create().
///
public object CreateTable(string name, string modelName,
List? fields, List? fieldGroups,
List? indexes, List? relations,
List? methods, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axTable = new AxTable { Name = name };
// Visual Studio puts SubscriberAccessLevel Read=Allow on a new table (issue
// #1073). Set before the caller's properties so an explicit value wins.
axTable.SubscriberAccessLevel = AccessGrantText.VisualStudioDefault();
// Apply table-level properties (Label, TableGroup, CacheLookup, etc.).
// Finding #35: an unknown key was dropped in silence — report it instead.
var unsupportedProperties = new List();
if (properties != null)
{
foreach (var kv in properties)
if (!SetAxTableProperty(axTable, kv.Key, kv.Value))
unsupportedProperties.Add(kv.Key);
}
// ...except on TempDB/InMemory tables, which VS creates without one.
if ((axTable.TableType == Microsoft.Dynamics.AX.Metadata.Core.MetaModel.TableType.TempDB
|| axTable.TableType == Microsoft.Dynamics.AX.Metadata.Core.MetaModel.TableType.InMemory)
&& !(properties?.Keys.Any(k => k.Equals("subscriberAccessLevel", StringComparison.OrdinalIgnoreCase)) ?? false))
axTable.SubscriberAccessLevel = new Microsoft.Dynamics.AX.Metadata.Core.MetaModel.AccessGrant();
// Add fields
if (fields != null)
{
foreach (var f in fields)
{
var axField = CreateTableField(f);
axTable.AddField(axField);
}
}
// Add field groups
if (fieldGroups != null)
{
foreach (var fg in fieldGroups)
{
var axFg = new AxTableFieldGroup { Name = fg.Name, Label = fg.Label };
if (fg.Fields != null)
{
foreach (var fieldRef in fg.Fields)
{
var fgField = new AxTableFieldGroupField { DataField = fieldRef };
axFg.AddField(fgField);
}
}
axTable.AddFieldGroup(axFg);
}
}
// Add indexes
if (indexes != null)
{
foreach (var ix in indexes)
{
var axIdx = new AxTableIndex { Name = ix.Name };
axIdx.AllowDuplicates = ix.AllowDuplicates ? Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.Yes : Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.No;
if (ix.AlternateKey)
axIdx.AlternateKey = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.Yes;
foreach (var ixf in RequireIndexFields(name, ix.Name, ix.Fields))
{
var axIxField = new AxTableIndexField { DataField = ixf };
axIdx.AddField(axIxField);
}
axTable.AddIndex(axIdx);
}
}
// Add relations
if (relations != null)
{
foreach (var rel in relations)
{
var axRel = new AxTableRelation { Name = rel.Name, RelatedTable = rel.RelatedTable ?? "" };
foreach (var c in RequireRelationConstraints(name, rel.Name, rel.Constraints))
axRel.AddConstraint(NewRelationConstraint(rel.Name, c));
axTable.AddRelation(axRel);
}
}
// Add methods
if (methods != null)
{
foreach (var m in methods)
{
var axMethod = new AxMethod { Name = m.Name, Source = m.Source ?? "" };
axTable.AddMethod(axMethod);
}
}
var tableProvider = _provider.Tables as IMetaTableProvider
?? throw new InvalidOperationException("DiskProvider.Tables does not implement IMetaTableProvider");
tableProvider.Create(axTable, msi);
var filePath = GetExpectedPath("AxTable", name, modelName);
return new
{
success = true, objectType = "table", objectName = name, modelName, filePath,
api = "IMetaTableProvider.Create",
appliedProperties = (properties?.Keys ?? Enumerable.Empty())
.Where(k => !unsupportedProperties.Contains(k)).ToList(),
unsupportedProperties,
};
}
///
/// Creates a new AxTable with BP-smart defaults auto-derived from table group and type.
/// Auto-generates: CacheLookup, SaveDataPerCompany, TitleField1/2, PrimaryIndex,
/// ClusteredIndex, ReplacementKey, 5 standard FieldGroups (AutoReport, AutoLookup,
/// AutoIdentification, AutoSummary, AutoBrowse), and DeleteActions (Restricted).
/// This is the primary creation path for generate_smart_table — all BP logic lives here.
///
public object CreateSmartTable(string name, string modelName,
string? tableGroup, string? tableType, string? label,
List? fields, List? extraFieldGroups,
List? indexes, List? relations,
List? methods, Dictionary? extraProperties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axTable = new AxTable { Name = name };
// ── Validate TableGroup (TempDB/InMemory are TableType, NOT TableGroup) ──
if (tableGroup == "TempDB" || tableGroup == "InMemory")
throw new ArgumentException(
$"Invalid TableGroup '{tableGroup}'. 'TempDB' and 'InMemory' are TableType values, " +
"not TableGroup values. Pass them via the tableType parameter instead.");
var normalizedTableType = string.IsNullOrEmpty(tableType)
|| tableType!.Equals("RegularTable", StringComparison.OrdinalIgnoreCase)
? "" : tableType;
var isTempTable = normalizedTableType == "TempDB" || normalizedTableType == "InMemory";
var effectiveTableGroup = string.IsNullOrEmpty(tableGroup) ? "Main" : tableGroup;
// ── Declaration with doc comment ──
axTable.Declaration = $"/// \n/// The {name} table.\n/// \npublic class {name} extends common\n{{\n}}";
// ── Label ──
if (!string.IsNullOrEmpty(label))
axTable.Label = label;
// ── TableGroup ──
if (Enum.TryParse(effectiveTableGroup, true, out var tg))
axTable.TableGroup = tg;
// ── TableType (only for TempDB / InMemory — RegularTable is the default, omitted) ──
if (!string.IsNullOrEmpty(normalizedTableType))
{
if (Enum.TryParse(normalizedTableType, true, out var tt))
axTable.TableType = tt;
}
// ── BP: CacheLookup — set based on TableGroup to avoid BP warning ──
if (isTempTable)
{
axTable.CacheLookup = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel.None;
}
else
{
var cacheLookupMap = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["Parameter"] = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel.Found,
["Group"] = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel.Found,
["Main"] = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel.Found,
["Transaction"] = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel.None,
["WorksheetHeader"] = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel.None,
["WorksheetLine"] = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel.None,
["Miscellaneous"] = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel.NotInTTS,
["Framework"] = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel.Found,
["Reference"] = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel.Found,
};
axTable.CacheLookup = cacheLookupMap.TryGetValue(effectiveTableGroup!, out var cl)
? cl
: Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel.Found;
}
// ── BP: SaveDataPerCompany — TempDB/InMemory are session-scoped, not company-scoped ──
axTable.SaveDataPerCompany = isTempTable
? Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.No
: Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.Yes;
// ── SubscriberAccessLevel — Read=Allow, as Visual Studio writes on a new
// regular table (issue #1073); none on TempDB/InMemory. extraProperties
// below can still set another value. ──
if (!isTempTable)
axTable.SubscriberAccessLevel = AccessGrantText.VisualStudioDefault();
// ── Add fields ──
var fieldNames = new List();
if (fields != null)
{
foreach (var f in fields)
{
var axField = CreateTableField(f);
axTable.AddField(axField);
fieldNames.Add(f.Name);
}
}
// ── BP: TitleField1/TitleField2 — first two non-RecId fields ──
var titleCandidates = fieldNames
.Where(n => !n.Equals("RecId", StringComparison.OrdinalIgnoreCase))
.ToList();
if (titleCandidates.Count > 0) axTable.TitleField1 = titleCandidates[0];
if (titleCandidates.Count > 1) axTable.TitleField2 = titleCandidates[1];
// ── Add indexes + track unique/clustered for PrimaryIndex/ClusteredIndex ──
string? uniqueIndexName = null;
string? clusteredIndexName = null;
if (indexes != null)
{
foreach (var ix in indexes)
{
var axIdx = new AxTableIndex { Name = ix.Name };
axIdx.AllowDuplicates = ix.AllowDuplicates
? Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.Yes
: Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.No;
if (ix.AlternateKey || !ix.AllowDuplicates)
{
axIdx.AlternateKey = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.Yes;
axIdx.AllowDuplicates = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.No;
if (uniqueIndexName == null) uniqueIndexName = ix.Name;
}
foreach (var ixf in RequireIndexFields(name, ix.Name, ix.Fields))
axIdx.AddField(new AxTableIndexField { DataField = ixf });
axTable.AddIndex(axIdx);
}
}
// ── BP: PrimaryIndex / ReplacementKey / ClusteredIndex ──
if (uniqueIndexName != null)
{
axTable.PrimaryIndex = uniqueIndexName;
axTable.ReplacementKey = uniqueIndexName;
axTable.ClusteredIndex = clusteredIndexName ?? uniqueIndexName;
}
// ── BP: DeleteActions — Restricted for each relation target table ──
if (relations != null)
{
foreach (var rel in relations)
{
var relTable = rel.RelatedTable ?? "";
if (!string.IsNullOrEmpty(relTable))
{
try
{
axTable.DeleteActions.Add(new AxTableDeleteAction
{
Name = relTable,
Table = relTable,
DeleteAction = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.DeleteAction.Restricted,
});
}
catch (Exception ex)
{
Console.Error.WriteLine($"[WriteService] DeleteAction for '{relTable}' skipped: {ex.Message}");
}
}
}
}
// ── BP: 5 standard FieldGroups ──
var nonRecIdFields = fieldNames
.Where(n => !n.Equals("RecId", StringComparison.OrdinalIgnoreCase))
.ToList();
// AutoReport — first 5 fields (BP requires at least one field)
var autoReport = new AxTableFieldGroup { Name = "AutoReport" };
foreach (var f in nonRecIdFields.Take(5))
autoReport.AddField(new AxTableFieldGroupField { DataField = f });
axTable.AddFieldGroup(autoReport);
// AutoLookup — first 3 fields
var autoLookup = new AxTableFieldGroup { Name = "AutoLookup" };
foreach (var f in nonRecIdFields.Take(3))
autoLookup.AddField(new AxTableFieldGroupField { DataField = f });
axTable.AddFieldGroup(autoLookup);
// AutoIdentification — empty with AutoPopulate=Yes
var autoIdent = new AxTableFieldGroup { Name = "AutoIdentification" };
autoIdent.AutoPopulate = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.Yes;
axTable.AddFieldGroup(autoIdent);
// AutoSummary
axTable.AddFieldGroup(new AxTableFieldGroup { Name = "AutoSummary" });
// AutoBrowse
axTable.AddFieldGroup(new AxTableFieldGroup { Name = "AutoBrowse" });
// Extra field groups from caller (beyond the standard 5)
if (extraFieldGroups != null)
{
foreach (var fg in extraFieldGroups)
{
var axFg = new AxTableFieldGroup { Name = fg.Name, Label = fg.Label };
if (fg.Fields != null)
{
foreach (var fieldRef in fg.Fields)
axFg.AddField(new AxTableFieldGroupField { DataField = fieldRef });
}
axTable.AddFieldGroup(axFg);
}
}
// ── Add relations ──
if (relations != null)
{
foreach (var rel in relations)
{
var axRel = new AxTableRelation { Name = rel.Name, RelatedTable = rel.RelatedTable ?? "" };
foreach (var c in RequireRelationConstraints(name, rel.Name, rel.Constraints))
axRel.AddConstraint(NewRelationConstraint(rel.Name, c));
axTable.AddRelation(axRel);
}
}
// ── Add methods ──
if (methods != null)
{
foreach (var m in methods)
axTable.AddMethod(new AxMethod { Name = m.Name, Source = m.Source ?? "" });
}
// ── Apply any extra properties (overrides auto-set values if needed) ──
// Finding #35: the return value used to be thrown away, so an unknown key
// (configurationKey, …) produced a stderr line nobody reads and a ✅ to the
// caller. Collect what did NOT apply and report it in the response.
var unsupportedProperties = new List();
if (extraProperties != null)
{
foreach (var kv in extraProperties)
if (!SetAxTableProperty(axTable, kv.Key, kv.Value))
unsupportedProperties.Add(kv.Key);
}
// ── Write to disk via IMetadataProvider ──
var tableProvider = _provider.Tables as IMetaTableProvider
?? throw new InvalidOperationException("DiskProvider.Tables does not implement IMetaTableProvider");
tableProvider.Create(axTable, msi);
var filePath = GetExpectedPath("AxTable", name, modelName);
return new
{
success = true,
objectType = "table",
objectName = name,
modelName,
filePath,
api = "IMetaTableProvider.Create (Smart)",
appliedProperties = (extraProperties?.Keys ?? Enumerable.Empty())
.Where(k => !unsupportedProperties.Contains(k)).ToList(),
unsupportedProperties,
bpDefaults = new
{
cacheLookup = axTable.CacheLookup.ToString(),
saveDataPerCompany = axTable.SaveDataPerCompany.ToString(),
titleField1 = axTable.TitleField1,
titleField2 = axTable.TitleField2,
primaryIndex = axTable.PrimaryIndex,
clusteredIndex = axTable.ClusteredIndex,
fieldGroupCount = 5 + (extraFieldGroups?.Count ?? 0),
deleteActionCount = relations?.Count ?? 0,
},
};
}
///
/// Creates a new AxEnum via IMetaEnumProvider.Create().
///
public object CreateEnum(string name, string modelName,
List? values, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axEnum = new AxEnum { Name = name };
// Properties
if (properties != null)
{
foreach (var kv in properties)
SetAxEnumProperty(axEnum, kv.Key, kv.Value);
}
// Values
if (values != null)
{
foreach (var v in values)
{
var axVal = new AxEnumValue { Name = v.Name, Value = v.Value };
if (!string.IsNullOrEmpty(v.Label)) axVal.Label = v.Label;
if (!string.IsNullOrEmpty(v.CountryRegionCodes)) axVal.CountryRegionCodes = v.CountryRegionCodes;
axEnum.AddEnumValue(axVal);
}
}
var enumProvider = _provider.Enums as IMetaEnumProvider
?? throw new InvalidOperationException("DiskProvider.Enums does not implement IMetaEnumProvider");
enumProvider.Create(axEnum, msi);
var filePath = GetExpectedPath("AxEnum", name, modelName);
return new { success = true, objectType = "enum", objectName = name, modelName, filePath, api = "IMetaEnumProvider.Create" };
}
///
/// Creates a new AxEdt via IMetaEdtProvider.Create().
///
public object CreateEdt(string name, string modelName, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
// AxEdt is abstract — determine the concrete subtype from properties
var baseType = properties != null && properties.TryGetValue("BaseType", out var bt) ? bt : null;
var extends_ = properties != null && properties.TryGetValue("Extends", out var ext) ? ext : null;
AxEdt axEdt;
switch ((baseType ?? "string").ToLowerInvariant())
{
case "int": case "integer": axEdt = new AxEdtInt { Name = name }; break;
case "real": axEdt = new AxEdtReal { Name = name }; break;
case "date": axEdt = new AxEdtDate { Name = name }; break;
case "utcdatetime": case "datetime": axEdt = new AxEdtUtcDateTime { Name = name }; break;
case "int64": axEdt = new AxEdtInt64 { Name = name }; break;
case "enum": axEdt = new AxEdtEnum { Name = name }; break;
case "guid": axEdt = new AxEdtGuid { Name = name }; break;
case "container": axEdt = new AxEdtContainer { Name = name }; break;
default: axEdt = new AxEdtString { Name = name }; break;
}
// stringSize on a non-string base type is the common miss here (see
// SetAxEdtProperty): the EDT is created either way, so report the drop rather
// than fail the create — but do not let it pass as applied.
var unsupportedProperties = new List();
if (properties != null)
{
foreach (var kv in properties)
if (!SetAxEdtProperty(axEdt, kv.Key, kv.Value))
unsupportedProperties.Add(kv.Key);
}
var edtProvider = _provider.Edts as IMetaEdtProvider
?? throw new InvalidOperationException("DiskProvider.Edts does not implement IMetaEdtProvider");
edtProvider.Create(axEdt, msi);
var filePath = GetExpectedPath("AxEdt", name, modelName);
return new { success = true, objectType = "edt", objectName = name, modelName, filePath, unsupportedProperties, api = "IMetaEdtProvider.Create" };
}
///
/// Creates a new Query object via IMetaQueryProvider.
/// AxQuery is abstract — use AxQuerySimple (concrete subclass) for creation.
///
public object CreateQuery(string name, string modelName, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
// AxQuery is abstract. Use reflection to try AxQuerySimple first.
// If that fails, fall back by creating a dynamic instance.
AxQuery axQuery;
try
{
var queryType = typeof(AxQuery).Assembly.GetType("Microsoft.Dynamics.AX.Metadata.MetaModel.AxQuerySimple");
if (queryType != null)
{
axQuery = (AxQuery)Activator.CreateInstance(queryType)!;
}
else
{
throw new InvalidOperationException("AxQuerySimple type not found in metadata assembly");
}
}
catch
{
throw new InvalidOperationException("Cannot create AxQuery instance — AxQuery is abstract and AxQuerySimple was not found. Use XML fallback.");
}
axQuery.Name = name;
// The concrete subclass decides which of these exist at all (SetAxQueryProperty),
// so what could not be written is part of the answer.
var unsupportedProperties = new List();
if (properties != null)
{
foreach (var kv in properties)
if (!SetAxQueryProperty(axQuery, kv.Key, kv.Value))
unsupportedProperties.Add(kv.Key);
}
var queryProvider = _provider.Queries as IMetaQueryProvider
?? throw new InvalidOperationException("DiskProvider.Queries does not implement IMetaQueryProvider");
queryProvider.Create(axQuery, msi);
var filePath = GetExpectedPath("AxQuery", name, modelName);
return new { success = true, objectType = "query", objectName = name, modelName, filePath, unsupportedProperties, api = "IMetaQueryProvider.Create" };
}
///
/// Creates a new View object via IMetaViewProvider.
/// Note: View fields are NOT added during creation because AxViewField is abstract.
/// Use modify_d365fo_file to add fields after creation, or pass xmlContent for full XML.
///
public object CreateView(string name, string modelName,
List? fields,
Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axView = new AxView { Name = name };
if (properties != null)
{
foreach (var kv in properties)
SetAxViewProperty(axView, kv.Key, kv.Value);
}
// Note: AxViewField is abstract — field creation is skipped during initial Create.
// Fields should be added via modify_d365fo_file or by passing full xmlContent.
if (fields != null && fields.Count > 0)
{
Console.Error.WriteLine($"[WriteService] CreateView: {fields.Count} fields requested but AxViewField is abstract — fields skipped. Use XML fallback for views with fields.");
}
var viewProvider = _provider.Views as IMetaViewProvider
?? throw new InvalidOperationException("DiskProvider.Views does not implement IMetaViewProvider");
viewProvider.Create(axView, msi);
var filePath = GetExpectedPath("AxView", name, modelName);
return new { success = true, objectType = "view", objectName = name, modelName, filePath, api = "IMetaViewProvider.Create" };
}
///
/// Creates a new MenuItemAction object via IMetaMenuItemActionProvider.
///
public object CreateMenuItemAction(string name, string modelName, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axMI = new AxMenuItemAction { Name = name };
var unsupportedProperties = ApplyMenuItemProperties(axMI, properties);
var provider = _provider.MenuItemActions as IMetaMenuItemActionProvider
?? throw new InvalidOperationException("DiskProvider.MenuItemActions does not implement IMetaMenuItemActionProvider");
provider.Create(axMI, msi);
var filePath = GetExpectedPath("AxMenuItemAction", name, modelName);
return new { success = true, objectType = "menu-item-action", objectName = name, modelName, filePath, unsupportedProperties, api = "IMetaMenuItemActionProvider.Create" };
}
///
/// Creates a new MenuItemDisplay object via IMetaMenuItemDisplayProvider.
///
public object CreateMenuItemDisplay(string name, string modelName, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axMI = new AxMenuItemDisplay { Name = name };
var unsupportedProperties = ApplyMenuItemProperties(axMI, properties);
var provider = _provider.MenuItemDisplays as IMetaMenuItemDisplayProvider
?? throw new InvalidOperationException("DiskProvider.MenuItemDisplays does not implement IMetaMenuItemDisplayProvider");
provider.Create(axMI, msi);
var filePath = GetExpectedPath("AxMenuItemDisplay", name, modelName);
return new { success = true, objectType = "menu-item-display", objectName = name, modelName, filePath, unsupportedProperties, api = "IMetaMenuItemDisplayProvider.Create" };
}
///
/// Creates a new MenuItemOutput object via IMetaMenuItemOutputProvider.
///
public object CreateMenuItemOutput(string name, string modelName, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axMI = new AxMenuItemOutput { Name = name };
var unsupportedProperties = ApplyMenuItemProperties(axMI, properties);
var provider = _provider.MenuItemOutputs as IMetaMenuItemOutputProvider
?? throw new InvalidOperationException("DiskProvider.MenuItemOutputs does not implement IMetaMenuItemOutputProvider");
provider.Create(axMI, msi);
var filePath = GetExpectedPath("AxMenuItemOutput", name, modelName);
return new { success = true, objectType = "menu-item-output", objectName = name, modelName, filePath, unsupportedProperties, api = "IMetaMenuItemOutputProvider.Create" };
}
///
/// Applies a property bag to any of the three menu item types and returns the keys
/// that did NOT apply. Shared because Action/Display/Output differ only in their
/// provider — the property surface is AxMenuItem's for all three.
///
private List ApplyMenuItemProperties(dynamic axMI, Dictionary? properties)
{
var unsupported = new List();
if (properties == null) return unsupported;
foreach (var kv in properties)
if (!SetAxMenuItemProperty(axMI, kv.Key, kv.Value))
unsupported.Add(kv.Key);
return unsupported;
}
///
/// Creates a new SecurityPrivilege object via IMetaSecurityPrivilegeProvider.
///
public object CreateSecurityPrivilege(string name, string modelName, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axObj = new AxSecurityPrivilege { Name = name };
if (properties != null)
{
foreach (var kv in properties)
SetAxSecurityPrivilegeProperty(axObj, kv.Key, kv.Value);
}
var provider = _provider.SecurityPrivileges as IMetaSecurityPrivilegeProvider
?? throw new InvalidOperationException("DiskProvider.SecurityPrivileges does not implement IMetaSecurityPrivilegeProvider");
provider.Create(axObj, msi);
var filePath = GetExpectedPath("AxSecurityPrivilege", name, modelName);
return new { success = true, objectType = "security-privilege", objectName = name, modelName, filePath, api = "IMetaSecurityPrivilegeProvider.Create" };
}
///
/// Creates a new SecurityDuty object via IMetaSecurityDutyProvider.
///
public object CreateSecurityDuty(string name, string modelName, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axObj = new AxSecurityDuty { Name = name };
if (properties != null)
{
foreach (var kv in properties)
SetAxSecurityDutyProperty(axObj, kv.Key, kv.Value);
}
var provider = _provider.SecurityDuties as IMetaSecurityDutyProvider
?? throw new InvalidOperationException("DiskProvider.SecurityDuties does not implement IMetaSecurityDutyProvider");
provider.Create(axObj, msi);
var filePath = GetExpectedPath("AxSecurityDuty", name, modelName);
return new { success = true, objectType = "security-duty", objectName = name, modelName, filePath, api = "IMetaSecurityDutyProvider.Create" };
}
///
/// Creates a new SecurityRole object via IMetaSecurityRoleProvider.
///
public object CreateSecurityRole(string name, string modelName, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axObj = new AxSecurityRole { Name = name };
if (properties != null)
{
foreach (var kv in properties)
SetAxSecurityRoleProperty(axObj, kv.Key, kv.Value);
}
var provider = _provider.SecurityRoles as IMetaSecurityRoleProvider
?? throw new InvalidOperationException("DiskProvider.SecurityRoles does not implement IMetaSecurityRoleProvider");
provider.Create(axObj, msi);
var filePath = GetExpectedPath("AxSecurityRole", name, modelName);
return new { success = true, objectType = "security-role", objectName = name, modelName, filePath, api = "IMetaSecurityRoleProvider.Create" };
}
// ========================
// CREATE EXTENSION OBJECTS
// ========================
///
/// Creates a new AxTableExtension via DiskProvider.
/// Extension name format: "BaseTable.ModelExtension"
///
public object CreateTableExtension(string name, string modelName,
List? fields, List? fieldGroups,
List? indexes, List? relations,
List? methods, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axExt = new AxTableExtension { Name = name };
// Add fields
if (fields != null)
{
foreach (var f in fields)
{
var axField = CreateTableField(f);
axExt.Fields.Add(axField);
}
}
// Add field groups
if (fieldGroups != null)
{
foreach (var fg in fieldGroups)
{
var axFg = new AxTableFieldGroup { Name = fg.Name, Label = fg.Label };
if (fg.Fields != null)
{
foreach (var fieldRef in fg.Fields)
axFg.AddField(new AxTableFieldGroupField { DataField = fieldRef });
}
axExt.FieldGroups.Add(axFg);
}
}
// Add indexes
if (indexes != null)
{
foreach (var ix in indexes)
{
var axIdx = new AxTableIndex { Name = ix.Name };
axIdx.AllowDuplicates = ix.AllowDuplicates
? Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.Yes
: Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.No;
if (ix.AlternateKey)
axIdx.AlternateKey = Microsoft.Dynamics.AX.Metadata.Core.MetaModel.NoYes.Yes;
foreach (var ixf in RequireIndexFields(name, ix.Name, ix.Fields))
axIdx.AddField(new AxTableIndexField { DataField = ixf });
axExt.Indexes.Add(axIdx);
}
}
// Add relations
if (relations != null)
{
foreach (var rel in relations)
{
var axRel = new AxTableRelation { Name = rel.Name, RelatedTable = rel.RelatedTable ?? "" };
foreach (var c in RequireRelationConstraints(name, rel.Name, rel.Constraints))
axRel.AddConstraint(NewRelationConstraint(rel.Name, c));
axExt.Relations.Add(axRel);
}
}
// Add methods (AxTableExtension doesn't expose Methods statically — use dynamic)
if (methods != null)
{
foreach (var m in methods)
((dynamic)axExt).Methods.Add(new AxMethod { Name = m.Name, Source = m.Source ?? "" });
}
var provider = _provider.TableExtensions as IMetaTableExtensionProvider
?? throw new InvalidOperationException("DiskProvider.TableExtensions does not implement IMetaTableExtensionProvider");
provider.Create(axExt, msi);
var filePath = GetExpectedPath("AxTableExtension", name, modelName);
return new { success = true, objectType = "table-extension", objectName = name, modelName, filePath, api = "IMetaTableExtensionProvider.Create" };
}
///
/// Creates a new AxFormExtension via DiskProvider.
/// Extension name format: "BaseForm.ModelExtension"
///
public object CreateFormExtension(string name, string modelName,
List? methods, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axExt = new AxFormExtension { Name = name };
if (methods != null)
{
foreach (var m in methods)
((dynamic)axExt).Methods.Add(new AxMethod { Name = m.Name, Source = m.Source ?? "" });
}
var provider = _provider.FormExtensions as IMetaFormExtensionProvider
?? throw new InvalidOperationException("DiskProvider.FormExtensions does not implement IMetaFormExtensionProvider");
provider.Create(axExt, msi);
var filePath = GetExpectedPath("AxFormExtension", name, modelName);
return new { success = true, objectType = "form-extension", objectName = name, modelName, filePath, api = "IMetaFormExtensionProvider.Create" };
}
///
/// Creates a new AxEnumExtension via DiskProvider.
/// Extension name format: "BaseEnum.ModelExtension"
///
public object CreateEnumExtension(string name, string modelName,
List? values, Dictionary? properties)
{
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axExt = new AxEnumExtension { Name = name };
if (values != null)
{
foreach (var v in values)
{
var axVal = new AxEnumValue { Name = v.Name, Value = v.Value };
if (!string.IsNullOrEmpty(v.Label)) axVal.Label = v.Label;
if (!string.IsNullOrEmpty(v.CountryRegionCodes)) axVal.CountryRegionCodes = v.CountryRegionCodes;
axExt.EnumValues.Add(axVal);
}
}
var provider = _provider.EnumExtensions as IMetaEnumExtensionProvider
?? throw new InvalidOperationException("DiskProvider.EnumExtensions does not implement IMetaEnumExtensionProvider");
provider.Create(axExt, msi);
var filePath = GetExpectedPath("AxEnumExtension", name, modelName);
return new { success = true, objectType = "enum-extension", objectName = name, modelName, filePath, api = "IMetaEnumExtensionProvider.Create" };
}
///
/// Creates a new AxForm via IMetaFormProvider.Create().
/// Note: Only basic structure (name, data sources, methods). Complex design trees should
/// use xmlContent fallback. Controls are not added during creation.
///
public object CreateForm(string name, string modelName,
List? methods, Dictionary? properties)
{
// TODO [Phase 2]: CreateSmartForm — port FormPatternTemplates (SimpleList, DetailsMaster,
// DetailsTransaction, Workspace, etc.) from TypeScript to C# so that generate_smart_form
// can use the bridge like generate_smart_table does with CreateSmartTable.
var msi = ResolveModelSaveInfo(modelName)
?? throw new ArgumentException($"Model '{modelName}' not found in {_packagesPath}");
var axForm = new AxForm { Name = name };
var unknownProperties = new List();
if (properties != null)
{
foreach (var kv in properties)
{
switch (kv.Key.ToLowerInvariant())
{
case "label": axForm.Design.Caption = kv.Value; break;
case "caption": axForm.Design.Caption = kv.Value; break;
// properties.dataSource used to be accepted and silently dropped, so a
// form created with it came out with an empty and the
// caller only found out at compile/runtime. Honour it: the value is the
// TABLE name, and the data source takes the same name (D365FO convention).
case "datasource":
case "table":
if (!string.IsNullOrWhiteSpace(kv.Value))
axForm.AddDataSource(CreateFormDataSourceRoot(kv.Value.Trim(), kv.Value.Trim(), null));
break;
default:
unknownProperties.Add(kv.Key);
break;
}
}
}
var hasClassDeclaration = false;
if (methods != null)
{
foreach (var m in methods)
{
if (FormAuthoringDefaults.IsClassDeclarationMethod(m.Name)) hasClassDeclaration = true;
axForm.AddMethod(new AxMethod { Name = m.Name, Source = m.Source ?? "" });
}
}
// Without a classDeclaration xppc rejects the form outright
// ("The 'classDeclaration' is missing from element '