using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using System.Linq;
using D365MetadataBridge.Models;
namespace D365MetadataBridge.Services
{
///
/// Provides cross-reference queries using the DYNAMICSXREFDB SQL database.
/// This replaces the FTS5 text-search approach with real compiler-resolved references.
///
/// ## Query failures are errors, never empty results
///
/// Every method here answers one question — "what else touches this?" — and the answer
/// drives whether it is safe to change something. A failed query used to be returned as
/// a SUCCESSFUL response carrying count=0 plus an `error` field, which reads to any
/// caller that does not inspect the extra field as the strongest possible clearance:
/// "nothing references this, go ahead". A down or unreachable xref database therefore
/// produced false negatives on exactly the question the service exists to answer, and
/// the more broken the database, the safer everything looked.
///
/// So failures throw. RequestDispatcher.HandleXref turns them into a JSON-RPC error, and
/// the TS adapters already treat a rejected call as "no answer from the bridge" and fall
/// back to their own scan instead of trusting a zero.
///
public class CrossReferenceService
{
///
/// Rethrows a failed xref query as an error the dispatcher will surface, with the
/// query's subject in the message so the caller can see what was NOT answered.
///
private static Exception QueryFailed(string operation, string subject, Exception cause)
{
Console.Error.WriteLine($"[CrossRefService] {operation}({subject}) failed: {cause.Message}");
return new InvalidOperationException(
$"Cross-reference query {operation}('{subject}') failed: {cause.Message}. " +
"This is NOT an empty result — the cross-reference database did not answer, so nothing " +
"can be concluded about references to this object.", cause);
}
private readonly string _connectionString;
public CrossReferenceService(string server, string database)
{
_connectionString = $"Server={server};Database={database};Integrated Security=True;TrustServerCertificate=True;";
// Test the connection
using (var conn = new SqlConnection(_connectionString))
{
conn.Open();
Console.Error.WriteLine($"[CrossRefService] Connected to {server}\\{database}");
}
}
// ============================================================
// Path parsing helpers
// ============================================================
///
/// Parse a DYNAMICSXREFDB path like "/Classes/SalesFormLetter/Methods/run"
/// into (objectType, objectName, segment, segmentName).
///
private static (string objectType, string objectName, string? segment, string? segmentName) ParsePath(string path)
{
// Paths: /Classes/X, /Classes/X/Methods/Y, /Tables/X, /Tables/X/Fields/Y
var parts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
string objectType = parts.Length >= 1 ? parts[0] : "";
string objectName = parts.Length >= 2 ? parts[1] : "";
string? segment = parts.Length >= 3 ? parts[2] : null;
string? segmentName = parts.Length >= 4 ? parts[3] : null;
return (objectType, objectName, segment, segmentName);
}
// DYNAMICSXREFDB [References].Kind values.
//
// These were DECODED EMPIRICALLY (2026-09-09) by sampling source/target path pairs per
// value across three live xref databases spanning two platform versions (10.0.2645.99,
// 10.0.2645.111, 10.0.2428.205). The mapping was identical in all three.
//
// The previous comment here claimed "1=Read/Reference, 2=DerivedFrom/Extends". Both
// halves were wrong, and the second one was costly: Kind 2 is the GENERIC type
// reference — 18.3M of the 28M rows — so every `EcoResDescription desc;` declaration and
// every `NoYes::Yes` read was being reported as "extends". A where-used on /Enums/NoYes
// came back 500-for-500 "extends", which nothing can extend. Extends is Kind 4.
private const byte KindCall = 1; // method call: Foo/Methods/a -> Bar/Methods/b
private const byte KindTypeRef = 2; // names a type: ... -> /Edts/Counter
private const byte KindImplements = 3; // class -> interface: AbsNettingMarkTransMgr -> INettingMarkTrans
private const byte KindExtends = 4; // class -> base: _Performance -> RunBaseBatch
private const byte KindDelegate = 6; // ... -> /Property/IsDelegate
private const byte KindAttribute = 7; // ... -> /Classes/HookableAttribute
private const byte KindTag = 9; // ... -> /Tags/
private const byte KindOverride = 10; // super(): Foo/Methods/pack -> Base/Methods/pack
///
/// Escape the LIKE metacharacters in a literal that is being CONCATENATED into a LIKE
/// pattern. Parameterising the pattern stops SQL injection but not this: inside a LIKE,
/// "_" still means "any single character".
///
/// That matters here because the D365 CoC naming convention is "<Base>_Extension",
/// so essentially every class name this service handles contains an underscore, and an
/// unescaped "/Classes/Foo_Extension/Methods/%" also matches "/Classes/FooXExtension/…".
/// A census of this xref DB found no name pair that actually collides that way, so this
/// is closing a latent hole rather than a live miscount — but the patterns are built from
/// class names and element paths, and nothing stops the next model from shipping the pair.
///
/// "[" must be replaced FIRST, or it would go on to escape the brackets introduced by the
/// other two replacements.
///
private static string EscapeLike(string literal) =>
literal.Replace("[", "[[]").Replace("%", "[%]").Replace("_", "[_]");
///
/// Last slash-separated segment of an xref path — the member name for a
/// "/Container/Owner/Methods/name" path. Null when the path has no segments.
///
private static string? LeafSegment(string path)
{
var parts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
return parts.Length > 0 ? parts[parts.Length - 1] : null;
}
///
/// Categorize a reference from its xref Kind, refining the generic type-reference
/// kind by what the target actually is. See the Kind constants above for how the
/// values were established.
///
private static string CategorizeReference(byte? kind, string sourcePath, string targetPath)
{
switch (kind)
{
case KindCall: return "call";
case KindImplements: return "implements";
case KindExtends: return "extends";
case KindDelegate: return "delegate-declaration";
case KindAttribute: return "attribute";
case KindTag: return "tag";
case KindOverride: return "override";
}
// Kind 2 (and anything unrecognised): the source line names the target. Say what
// kind of naming it is from the target's shape.
if (targetPath.Contains("/Fields/")) return "field-access";
var (_, _, targetSeg, _) = ParsePath(targetPath);
if (targetSeg == "Methods") return "method-reference";
if (string.IsNullOrEmpty(targetSeg)) return "type-reference";
return "reference";
}
// ============================================================
// P1: FindReferences — enriched with referenceType + caller parsing
// ============================================================
///
/// Find all references to a given object path.
/// Enriched: returns referenceType (call/extends/field-access/type-reference)
/// and callerClass/callerMethod parsed from the source path.
///
public object FindReferences(string objectPath)
{
var references = new List();
// Container segments that can own methods/fields, used to expand a
// member-qualified ("Owner.member") input across object types.
string[] memberContainers = { "Tables", "Classes", "Forms", "Views", "DataEntityViews", "Queries", "Maps" };
var pathVariants = new List();
bool memberQualified = false;
if (objectPath.Contains("/"))
{
// Explicit path — as given: an AOT path ("/Tables/SalesTable/Methods/initFromX")
// or a metadata one ("Table/SalesTable"). An X++ one gets its metadata twin below.
pathVariants.Add(objectPath);
memberQualified = objectPath.Contains("/Methods/") || objectPath.Contains("/Fields/") ||
objectPath.Contains("/Method/") || objectPath.Contains("/TableField") ||
objectPath.Contains("/EnumValues/") || objectPath.Contains("/EnumValue/") ||
objectPath.Contains("/TableIndexs/") || objectPath.Contains("/TableIndex/") ||
objectPath.Contains("/TableFieldGroups/") || objectPath.Contains("/TableFieldGroup/");
}
else if (objectPath.Contains("."))
{
// Member-qualified bare input ("Owner.member"): scope to a single
// method/field on its declaring type. We don't know whether Owner is
// a Table/Class/… so expand method+field variants across containers;
// only the path that actually exists will match.
memberQualified = true;
var dot = objectPath.LastIndexOf('.');
var owner = objectPath.Substring(0, dot);
var member = objectPath.Substring(dot + 1);
foreach (var c in memberContainers)
{
pathVariants.Add($"/{c}/{owner}/Methods/{member}");
pathVariants.Add($"/{c}/{owner}/Fields/{member}");
}
pathVariants.Add($"/Enums/{owner}/EnumValues/{member}");
// "Form.DataSource.method": a form data source's method.
var ownerDot = owner.IndexOf('.');
if (ownerDot > 0 && owner.IndexOf('.', ownerDot + 1) < 0)
pathVariants.Add($"/Forms/{owner.Substring(0, ownerDot)}/DataSources/{owner.Substring(ownerDot + 1)}/Methods/{member}");
}
else
{
// Bare name — we do not know which AOT type it is, so try every container
// that can be the TARGET of a reference. A target is stored in one of TWO
// shapes, depending on which provider wrote the reference:
//
// X++ code (provider Xppc.exe): plural + leading slash — "/Tables/",
// "/Edts/", children "/Tables//Fields/", ".../Methods/".
// Declarative metadata (provider Metadata): singular, NO leading slash, and
// for an EDT its concrete subtype — "Table/", "Form/",
// "EdtString/", children "Table//TableFieldString/",
// "Table//Method/". Source paths of these rows carry the referencing
// property ("Form/F/FormDataSourceRoot/?Table").
//
// Only the first shape used to be queried, on the belief — stated here — that
// targets were always plural + slash. On a live DYNAMICSXREFDB (platform
// 7.0.7858, 19.5M references) the second shape holds 2.3M of them, every one
// Kind 2: a table's form data sources, relations, entity mappings and field
// bindings, and the table fields typed with an EDT. A where-used on a custom
// table returned 334 of its 785 references; on a custom EDT, 12 of 108, and
// none of the fields using it.
//
// Edts/Maps/Reports/MenuItem* were missing here, so a where-used on any of
// them returned zero rows and the TS caller silently degraded to its
// name-based index scan. For an EDT that is close to useless, because an
// EDT's real usages are table fields and form control properties — metadata,
// not X++ text.
foreach (var c in new[]
{
"Tables", "Classes", "Enums", "Views", "DataEntityViews", "Queries", "Forms",
"Edts", "Maps", "Reports",
"MenuItemDisplays", "MenuItemActions", "MenuItemOutputs",
})
{
pathVariants.Add($"/{c}/{objectPath}");
}
}
// Every X++-shaped target also exists in the metadata shape; query both,
// whichever way the target was given. A table field or enum value may also come
// from an extension, which is matched by pattern (see AddExtensionMembers).
var extensionPatterns = new List();
foreach (var p in pathVariants.ToList())
{
AddMetadataTwins(p, pathVariants);
AddExtensionMembers(p, extensionPatterns);
}
pathVariants = pathVariants.Distinct(StringComparer.Ordinal).ToList();
// Also add sub-paths (methods, fields) so we catch method-level references.
// A member-qualified target already points at an exact leaf path, so adding
// "/%" children would wrongly pull in unrelated nested references — skip it.
var extraPaths = new List();
if (!memberQualified)
{
foreach (var p in pathVariants)
{
// LIKE pattern for children. Only this derived copy is escaped — the
// variants themselves go into an IN (...) equality list, where "_" is
// an ordinary character and escaping it would stop them matching.
extraPaths.Add(EscapeLike(p) + "/%");
}
}
// Build parameterized query with IN + LIKE
var paramNames = new List();
var allParams = new List<(string name, string value)>();
for (int i = 0; i < pathVariants.Count; i++)
{
paramNames.Add($"@P{i}");
allParams.Add(($"@P{i}", pathVariants[i]));
}
var likeConditions = new List();
var likePatterns = extraPaths.Concat(extensionPatterns).ToList();
for (int i = 0; i < likePatterns.Count; i++)
{
var pname = $"@L{i}";
likeConditions.Add($"tgt.Path LIKE {pname}");
allParams.Add((pname, likePatterns[i]));
}
var whereClause = $"tgt.Path IN ({string.Join(",", paramNames)})";
if (likeConditions.Count > 0)
whereClause += $" OR {string.Join(" OR ", likeConditions)}";
// Rows are capped PER SHAPE and the totals counted separately. With one cap for
// both, rows sorted by source path put every "/Classes/…" code row before any
// "Form/…" metadata row, so a table with 500 code references (CustTable)
// returned no metadata at all — the very rows this lookup exists to add. The
// candidate names are resolved once: the LIKEs against Names are the costly
// part, the joins on [References].TargetId are indexed. The totals join the
// source name exactly as the rows do: [References] keeps rows whose SourceId is
// no longer in Names (77 of the 93 references to one table field on a live
// database), and counting those made the rows look like a sample of a total
// nobody could list.
string ShapeRows(string shape) => $@"
SELECT * FROM (
SELECT TOP ({RowsPerShape})
src.Path AS SourcePath, t.Path AS TargetPath, sm.Module AS SourceModule,
r.Kind, r.Line, r.[Column]
FROM #xrefTargets t
INNER JOIN [References] r ON r.TargetId = t.Id
INNER JOIN dbo.Names src ON src.Id = r.SourceId
LEFT JOIN dbo.Modules sm ON sm.Id = src.ModuleId
WHERE {shape}
ORDER BY src.Path, r.Line) AS shapeRows";
var query = $@"
SELECT tgt.Id, tgt.Path INTO #xrefTargets FROM dbo.Names tgt WHERE ({whereClause});
SELECT SourcePath, TargetPath, SourceModule, Kind, Line, [Column] FROM (
{ShapeRows("t.Path LIKE N'/%'")}
UNION ALL
{ShapeRows("t.Path NOT LIKE N'/%'")}
) AS rows
ORDER BY CASE WHEN TargetPath LIKE N'/%' THEN 0 ELSE 1 END, SourcePath, Line;
SELECT COUNT(*), SUM(CASE WHEN t.Path LIKE N'/%' THEN 0 ELSE 1 END)
FROM #xrefTargets t INNER JOIN [References] r ON r.TargetId = t.Id
INNER JOIN dbo.Names src ON src.Id = r.SourceId;
DROP TABLE #xrefTargets;";
int total = 0, metadataTotal = 0;
try
{
using (var conn = new SqlConnection(_connectionString))
{
conn.Open();
using (var cmd = new SqlCommand(query, conn))
{
foreach (var (name, value) in allParams)
cmd.Parameters.AddWithValue(name, value);
cmd.CommandTimeout = 30;
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
var sourcePath = reader.GetString(0);
var targetPath = reader.GetString(1);
byte? kindByte = reader.IsDBNull(3) ? (byte?)null : reader.GetByte(3);
var (srcType, srcObj, srcSeg, srcSegName) = ParsePath(sourcePath);
references.Add(new ReferenceInfoModel
{
SourcePath = sourcePath,
SourceModule = reader.IsDBNull(2) ? null : reader.GetString(2),
Kind = kindByte?.ToString(),
Line = reader.IsDBNull(4) ? 0 : (int)reader.GetInt16(4),
Column = reader.IsDBNull(5) ? 0 : (int)reader.GetInt16(5),
ReferenceType = CategorizeReference(kindByte, sourcePath, targetPath),
CallerClass = srcObj,
CallerMethod = srcSeg == "Methods" ? srcSegName : null,
});
}
if (reader.NextResult() && reader.Read())
{
total = reader.IsDBNull(0) ? 0 : reader.GetInt32(0);
metadataTotal = reader.IsDBNull(1) ? 0 : reader.GetInt32(1);
}
}
}
}
}
catch (SqlException ex)
{
throw QueryFailed("findReferences", objectPath, ex);
}
// count: rows returned; total / metadataTotal: every matching reference.
return new { objectPath, count = references.Count, total, metadataTotal, references };
}
/// Rows returned per target shape (X++ code, declarative metadata).
private const int RowsPerShape = 500;
///
/// The singular metadata type names a plural X++ container is stored under, with
/// every EDT subtype found as a target in a live DYNAMICSXREFDB.
///
private static readonly Dictionary MetadataTypes = new Dictionary(StringComparer.Ordinal)
{
["Tables"] = new[] { "Table" },
["Classes"] = new[] { "Class" },
["Enums"] = new[] { "Enum" },
["Views"] = new[] { "View" },
["DataEntityViews"] = new[] { "DataEntityView" },
["Queries"] = new[] { "QuerySimple", "QueryComposite" },
["Forms"] = new[] { "Form" },
["Maps"] = new[] { "Map" },
["Reports"] = new[] { "Report" },
["MenuItemDisplays"] = new[] { "MenuItemDisplay" },
["MenuItemActions"] = new[] { "MenuItemAction" },
["MenuItemOutputs"] = new[] { "MenuItemOutput" },
["Edts"] = new[]
{
"EdtString", "EdtReal", "EdtEnum", "EdtInt64", "EdtDate", "EdtInt", "EdtUtcDateTime",
"EdtContainer", "EdtGuid", "EdtTime",
},
};
///
/// The segment a field is stored under in the metadata shape, per container — it
/// names the field's type, so there is one per type. Every segment found as a
/// target in a live DYNAMICSXREFDB. Listed rather than matched with
/// "TableField%": in LIKE "%" also crosses "/", so "Table/T/TableField%/Name"
/// matched the field group "Table/T/TableFieldGroup/Name" as well — and a table
/// with a field and a field group of the same name is common
/// (AccountingDistributionTemplate.Name).
///
private static readonly Dictionary MetadataFieldSegments = new Dictionary(StringComparer.Ordinal)
{
["Tables"] = new[]
{
"TableFieldString", "TableFieldEnum", "TableFieldReal", "TableFieldInt64", "TableFieldInt",
"TableFieldDate", "TableFieldUtcDateTime", "TableFieldGuid", "TableFieldContainer", "TableFieldTime",
},
["Maps"] = new[]
{
"MapFieldString", "MapFieldEnum", "MapFieldReal", "MapFieldInt64", "MapFieldInt",
"MapFieldDate", "MapFieldUtcDateTime", "MapFieldGuid", "MapFieldContainer", "MapFieldTime",
},
["Views"] = new[]
{
"ViewFieldBound", "ViewFieldComputedString", "ViewFieldComputedEnum", "ViewFieldComputedReal",
"ViewFieldComputedInt64", "ViewFieldComputedInt", "ViewFieldComputedDate", "ViewFieldComputedUtcDateTime",
},
["DataEntityViews"] = new[]
{
"DataEntityViewMappedField", "DataEntityViewUnmappedFieldString", "DataEntityViewUnmappedFieldEnum",
"DataEntityViewUnmappedFieldReal", "DataEntityViewUnmappedFieldInt64", "DataEntityViewUnmappedFieldInt",
"DataEntityViewUnmappedFieldDate", "DataEntityViewUnmappedFieldUtcDateTime",
"DataEntityViewUnmappedFieldGuid", "DataEntityViewUnmappedFieldContainer",
},
};
///
/// Add the metadata twin of an X++-shaped target path: "/Tables/T" → "Table/T",
/// "/Edts/E" → "EdtString/E", … ; for a member, "/Views/V/Methods/m" →
/// "View/V/Method/m" (tables, classes, forms, views, data entities, maps) and
/// "/Tables/T/Fields/f" → "Table/T/TableFieldString/f", "Table/T/TableFieldEnum/f", …
/// (tables, maps, views, data entities); "/Tables/T/TableIndexs/i" → "Table/T/TableIndex/i",
/// "/Tables/T/TableFieldGroups/g" → "Table/T/TableFieldGroup/g"; a form data source's
/// method "/Forms/F/DataSources/DS/Methods/m" → "Form/F/FormDataSourceRoot/DS/Method/m".
/// Metadata-shaped and unknown paths add nothing.
///
private static void AddMetadataTwins(string xppPath, List exact)
{
if (!xppPath.StartsWith("/")) return;
var parts = xppPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2 || !MetadataTypes.TryGetValue(parts[0], out var types)) return;
if (parts.Length == 2)
{
foreach (var t in types) exact.Add($"{t}/{parts[1]}");
}
else if (parts.Length == 4)
{
if (parts[2] == "Methods")
{
foreach (var t in types) exact.Add($"{t}/{parts[1]}/Method/{parts[3]}");
}
else if (parts[2] == "Fields" && MetadataFieldSegments.TryGetValue(parts[0], out var segments))
{
foreach (var t in types)
foreach (var s in segments) exact.Add($"{t}/{parts[1]}/{s}/{parts[3]}");
}
else if (parts[2] == "EnumValues" && parts[0] == "Enums")
{
exact.Add($"Enum/{parts[1]}/EnumValue/{parts[3]}");
}
// X++ spells these "TableIndexs" / "TableFieldGroups"; metadata singular.
else if (parts[2] == "TableIndexs")
{
foreach (var t in types) exact.Add($"{t}/{parts[1]}/TableIndex/{parts[3]}");
}
else if (parts[2] == "TableFieldGroups")
{
foreach (var t in types) exact.Add($"{t}/{parts[1]}/TableFieldGroup/{parts[3]}");
}
}
else if (parts.Length == 6 && parts[0] == "Forms" && parts[2] == "DataSources" && parts[4] == "Methods")
{
// A form data source's method: "/Forms/F/DataSources/DS/Methods/m" and
// "Form/F/FormDataSourceRoot/DS/Method/m". Every data source is stored at the
// root, joined ones included, under its own name (not its table's).
exact.Add($"Form/{parts[1]}/FormDataSourceRoot/{parts[3]}/Method/{parts[5]}");
}
}
///
/// A field a table extension adds, and a value an enum extension adds, are stored
/// under the extension only, in both shapes: "/TableExtensions/T.Ext/Fields/f" and
/// "TableExtension/T.Ext/TableFieldString/f", "/EnumExtensions/E.Ext/EnumValues/v"
/// and "EnumExtension/E.Ext/EnumValue/v". On a live DYNAMICSXREFDB that is 17,788 and
/// 8,769 references, and a where-used on "PurchLine.<added field>" found none of them:
/// "/Tables/PurchLine/Fields/<f>" has no references at all. An extension is always
/// named "<base>.<suffix>" (every one of the 1,104 on that database), so the
/// suffix is the only wildcard; the member segment after it is exact.
///
private static void AddExtensionMembers(string xppPath, List patterns)
{
var parts = xppPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
if (!xppPath.StartsWith("/") || parts.Length != 4) return;
var baseName = EscapeLike(parts[1]);
var member = EscapeLike(parts[3]);
if (parts[0] == "Tables" && parts[2] == "Fields")
{
patterns.Add($"/TableExtensions/{baseName}.%/Fields/{member}");
foreach (var s in MetadataFieldSegments["Tables"])
patterns.Add($"TableExtension/{baseName}.%/{s}/{member}");
}
else if (parts[0] == "Enums" && parts[2] == "EnumValues")
{
patterns.Add($"/EnumExtensions/{baseName}.%/EnumValues/{member}");
patterns.Add($"EnumExtension/{baseName}.%/EnumValue/{member}");
}
}
// ============================================================
// P3: FindExtensionClasses — enriched with method-level CoC detail
// ============================================================
///
/// The attribute a Chain of Command class carries. Stored under this exact path — it is
/// "ExtensionOf", NOT "ExtensionOfAttribute" like most other attribute names in Names.
///
private const string ExtensionOfAttributePath = "/Classes/ExtensionOf";
///
/// Containers a CoC class extension can be based on. [ExtensionOf] accepts
/// classStr/tableStr/formStr/viewStr/mapStr/dataEntityViewStr/queryStr, so the base is
/// by no means always a class — the caller passes a bare name and we resolve which.
///
private static readonly string[] CocBaseContainers =
{ "Classes", "Tables", "Forms", "Views", "Maps", "DataEntityViews", "Queries" };
///
/// The extended artifact from one [ExtensionOf] declaration: the terminal element of the
/// prefix chain the intrinsic's arguments produce.
///
/// Two details that are load-bearing:
/// * Comparison is case-INSENSITIVE. The xref stores the same element under inconsistent
/// casing — "/Forms/PurchTable/DataSources/purchLine" alongside
/// "/Forms/PurchTable/DataSources/PurchLine/DataFields/PriceUnit" — so an ordinal
/// StartsWith fails to see the chain and returns the ANCESTOR. That was 3 wrong answers
/// in 4,696 before this was fixed. The database collation is CI_AS, so SQL already
/// matches this way and the two layers must agree.
/// * Targets are bounded to the ExtensionOf attribute's own argument window. 52 declarations
/// share their line with a second attribute, and that attribute's arguments are references
/// on the same line. On the corpus measured, none of them changed an answer (the
/// co-attribute either takes no metadata argument or names the same element), so this is
/// insurance rather than a live fix — but it is what makes those 52 safe by design.
///
/// Returns null when the declaration produced no usable target.
///
private static string? ResolveExtendedElement(List<(string target, int kind, int col)> refs, int extCol)
{
// The next attribute to the right closes ExtensionOf's argument window.
var limit = int.MaxValue;
foreach (var (target, kind, col) in refs)
if (kind == KindAttribute && target != ExtensionOfAttributePath && col > extCol && col < limit)
limit = col;
var scoped = new List();
foreach (var (target, kind, col) in refs)
if (kind == KindTypeRef && col > extCol && col < limit && !scoped.Contains(target))
scoped.Add(target);
if (scoped.Count == 0) return null;
// Maximum of the prefix order: the one nothing else extends.
foreach (var candidate in scoped)
{
var isPrefixOfAnother = false;
foreach (var other in scoped)
{
if (ReferenceEquals(other, candidate)) continue;
if (other.StartsWith(candidate + "/", StringComparison.OrdinalIgnoreCase)) { isPrefixOfAnother = true; break; }
}
if (!isPrefixOfAnother) return candidate;
}
// Not a chain — shouldn't happen (zero cases in 4,696), so prefer the deepest rather
// than silently returning an ancestor.
scoped.Sort((a, b) => b.Length.CompareTo(a.Length));
return scoped[0];
}
///
/// True when IS the requested object, or is nested inside it
/// (a form's data source, control or data field). Nested hits are kept deliberately: an
/// agent about to wrap SalesLine.active on a form needs to see it is already wrapped. The
/// caller groups them by element so they are never pooled with the parent's own count.
///
private static bool IsRequestedOrNested(string element, string baseName)
{
foreach (var container in CocBaseContainers)
{
var basePath = $"/{container}/{baseName}";
if (string.Equals(element, basePath, StringComparison.OrdinalIgnoreCase)) return true;
if (element.StartsWith(basePath + "/", StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
///
/// Find the Chain of Command extension classes of a given base object, together with
/// the base methods each one wraps. The base may be a class, table, form, view, map,
/// data entity or query — [ExtensionOf] accepts all of them.
///
public object FindExtensionClasses(string baseClassName)
{
// className → (module, the element the class actually extends)
var extensionClassNames = new Dictionary();
try
{
using (var conn = new SqlConnection(_connectionString))
{
conn.Open();
// Step 1 — identify genuine [ExtensionOf] classes and READ what each extends.
//
// History: this accepted `r.Kind = 2 OR src.Path LIKE '%_Extension%'`, believing
// Kind 2 meant DerivedFrom. It does not (see the Kind constants above) — it is
// the generic type reference, so every class that merely MENTIONED the base was
// reported as extending it. On SalesFormLetter: 259 reported against 9 real.
//
// How the extended element is recovered. `ExtensionOf` takes ONE string
// (`public void new(str name)`); the multiplicity comes from the INTRINSIC that
// produces it. While resolving e.g. formDataFieldStr(Form, DataSource, Field)
// the compiler emits one Kind 2 reference per metadata level it names, and those
// levels are nested by construction:
// /Forms/VendOpenTrans
// /Forms/VendOpenTrans/DataSources/TaxWithholdTrans
// /Forms/VendOpenTrans/DataSources/TaxWithholdTrans/DataFields/TaxReimbursement_IT
// So the targets form a PREFIX CHAIN and the extended artifact is its terminal
// element — the one that is not a proper prefix of any other. That is a maximum
// of a partial order, not a "longest string" guess, and it was checked rather
// than assumed: across all 4,696 ExtensionOf classes in a live xref DB the set
// is a single chain every time (zero ambiguous). Measured element shapes:
// /Classes/* 2633, /Tables/* 828, /Forms/* 696, /Forms/*/DataSources/* 241,
// /DataEntityViews/* 170, /Forms/*/Controls/* 77,
// /Forms/*/DataSources/*/DataFields/* 41, /Maps/* 6, /Views/* 4.
//
// Reading the element (rather than matching any declaration-level reference)
// is what keeps a table CoC apart from a form CoC of the same name, and it drops
// classes that name the base for some OTHER reason — e.g.
// SalesCopyingTAMDeduction_Extension extends /Classes/SalesCopying but also
// references /Tables/SalesTable from a different attribute on another line.
//
// Candidate classes are those naming the requested object anywhere in their
// ExtensionOf declaration; the element test below then decides. `LIKE base + '/%'`
// admits nested elements (a form's data sources, controls and data fields), which
// the caller groups and labels separately.
var whereTargets = new List();
var sqlParams = new List<(string name, string value)>();
for (int i = 0; i < CocBaseContainers.Length; i++)
{
whereTargets.Add($"cand.Path = @T{i} OR cand.Path LIKE @P{i}");
// @T is an equality test and takes the name as-is; only @P is a LIKE pattern.
sqlParams.Add(($"@T{i}", $"/{CocBaseContainers[i]}/{baseClassName}"));
sqlParams.Add(($"@P{i}", $"/{CocBaseContainers[i]}/{EscapeLike(baseClassName)}/%"));
}
// Returns every Kind 2 target on each candidate's ExtensionOf line, plus the
// columns needed to bound them to that attribute's own arguments. The chain is
// resolved in C# because SQL cannot express "maximum of the prefix order"
// cheaply, and the set per class is tiny (1-3 rows).
var sql = $@"
WITH extLine AS (
SELECT DISTINCT ra.SourceId, ra.Line, ra.[Column] AS ExtCol
FROM [References] ra
JOIN [Names] att ON att.Id = ra.TargetId
WHERE ra.Kind = {KindAttribute} AND att.Path = @ExtensionOfAttr
),
candidates AS (
SELECT DISTINCT e.SourceId, e.Line, e.ExtCol
FROM extLine e
JOIN [References] rc ON rc.SourceId = e.SourceId AND rc.Line = e.Line AND rc.Kind = {KindTypeRef}
JOIN [Names] cand ON cand.Id = rc.TargetId
WHERE {string.Join(" OR ", whereTargets.Select(w => $"({w})"))}
)
SELECT src.Path, m.Module, tgt.Path, r.Kind, r.[Column]
FROM candidates c
JOIN [Names] src ON src.Id = c.SourceId
LEFT JOIN [Modules] m ON m.Id = src.ModuleId
JOIN [References] r ON r.SourceId = c.SourceId AND r.Line = c.Line
AND r.Kind IN ({KindTypeRef}, {KindAttribute})
JOIN [Names] tgt ON tgt.Id = r.TargetId
WHERE c.ExtCol = (SELECT MIN(c2.ExtCol) FROM candidates c2 WHERE c2.SourceId = c.SourceId)
ORDER BY src.Path";
// class → (module, extCol, [(target, kind, column)])
var raw = new Dictionary refs)>();
using (var cmd = new SqlCommand(sql, conn))
{
foreach (var (name, value) in sqlParams)
cmd.Parameters.AddWithValue(name, value);
cmd.Parameters.AddWithValue("@ExtensionOfAttr", ExtensionOfAttributePath);
cmd.CommandTimeout = 60;
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
var path = reader.GetString(0);
var parts = path.Split('/');
var className = parts.Length >= 3 ? parts[2] : path;
var module = reader.IsDBNull(1) ? null : reader.GetString(1);
var target = reader.GetString(2);
var kind = reader.IsDBNull(3) ? 0 : reader.GetByte(3);
var col = reader.IsDBNull(4) ? 0 : (int)reader.GetInt16(4);
if (!raw.TryGetValue(className, out var entry))
{
entry = (module, int.MaxValue, new List<(string, int, int)>());
raw[className] = entry;
}
entry.refs.Add((target, kind, col));
// The ExtensionOf attribute's own column anchors the argument window.
if (kind == KindAttribute && target == ExtensionOfAttributePath && col < entry.extCol)
entry = (entry.module, col, entry.refs);
raw[className] = entry;
}
}
}
foreach (var kv in raw)
{
var element = ResolveExtendedElement(kv.Value.refs, kv.Value.extCol);
if (element == null) continue;
// Keep only classes whose ELEMENT is the requested object or nested inside
// it. This is what discards a class that named the base incidentally.
if (!IsRequestedOrNested(element, baseClassName)) continue;
extensionClassNames[kv.Key] = (kv.Value.module, element);
}
// Step 2 — which base methods each extension actually WRAPS.
//
// A CoC wrap shows up as a Kind 1 (call) from the extension's method to the
// base method OF THE SAME NAME — that call is the `next`. The previous query
// filtered on neither the kind nor the name, so it collected every base
// method the class happened to call: that is why non-extensions came back
// claiming to wrap "construct, update", which were merely the calls they made.
var results = new List();
foreach (var kvp in extensionClassNames)
{
var extClassName = kvp.Key;
var (module, element) = kvp.Value;
var methodSql = $@"
SELECT DISTINCT src.Path, tgt.Path
FROM [References] r
JOIN [Names] src ON r.SourceId = src.Id
JOIN [Names] tgt ON r.TargetId = tgt.Id
WHERE src.Path LIKE @ExtClassMethods
AND tgt.Path LIKE @BaseClassMethods
AND r.Kind = {KindCall}";
var wrappedMethods = new List();
using (var cmd2 = new SqlCommand(methodSql, conn))
{
// Both patterns are escaped: "_Extension" is the CoC naming convention, so
// extClassName almost always carries an underscore, and an element path
// can too ("/Forms/…/DataFields/TaxReimbursement_IT").
cmd2.Parameters.AddWithValue("@ExtClassMethods", $"/Classes/{EscapeLike(extClassName)}/Methods/%");
// Anchored to the EXTENDED ELEMENT, not to the requested object. That is
// the whole payoff of reading the element: a form CoC wraps a method on
// one specific data source ("/Forms/SalesTable/DataSources/SalesLine/
// Methods/active"), and the SalesTable form has NINE data sources with an
// `active` method. Scoping to the base and matching leaf names alone could
// not tell them apart; scoping to the element makes the question exact.
cmd2.Parameters.AddWithValue("@BaseClassMethods", $"{EscapeLike(element)}/Methods/%");
cmd2.CommandTimeout = 60;
using (var reader2 = cmd2.ExecuteReader())
{
while (reader2.Read())
{
// Same leaf name on both sides = the `next` call.
var srcLeaf = LeafSegment(reader2.GetString(0));
var tgtLeaf = LeafSegment(reader2.GetString(1));
if (srcLeaf == null || tgtLeaf == null) continue;
if (!string.Equals(srcLeaf, tgtLeaf, StringComparison.OrdinalIgnoreCase)) continue;
if (!wrappedMethods.Contains(tgtLeaf))
wrappedMethods.Add(tgtLeaf);
}
}
}
results.Add(new ExtensionClassDetailModel
{
ClassName = extClassName,
Module = module,
ExtendedElement = element,
WrappedMethods = wrappedMethods,
});
}
// Ordered so the caller's grouping is stable and the requested object's own
// extensions lead, with nested elements (data sources, controls, data fields)
// following in path order.
results.Sort((a, b) =>
{
var byElement = string.Compare(a.ExtendedElement, b.ExtendedElement, StringComparison.OrdinalIgnoreCase);
return byElement != 0 ? byElement : string.Compare(a.ClassName, b.ClassName, StringComparison.OrdinalIgnoreCase);
});
return new
{
baseClassName,
count = results.Count,
extensions = results,
_source = "C# bridge (DYNAMICSXREFDB)"
};
}
}
catch (Exception ex)
{
throw QueryFailed("findExtensionClasses", baseClassName, ex);
}
}
// ============================================================
// P4: FindEventSubscribers — enriched with event type filtering
// ============================================================
///
/// Find event handler / subscriber classes for a given target object.
/// Enriched: supports optional eventName and handlerType filtering.
/// Returns individual method entries with eventName and handlerType classification.
///
public object FindEventSubscribers(string targetName, string? eventNameFilter = null, string? handlerTypeFilter = null)
{
var results = new List();
try
{
using (var conn = new SqlConnection(_connectionString))
{
conn.Open();
// Query: find all references TO the target from classes with event-related patterns
// Broader than before: include ALL referencing classes, not just *EventHandler named ones
var sql = @"
SELECT DISTINCT src.Path, m.Module
FROM [References] r
JOIN [Names] src ON r.SourceId = src.Id
JOIN [Names] tgt ON r.TargetId = tgt.Id
LEFT JOIN [Modules] m ON src.ModuleId = m.Id
WHERE (
tgt.Path LIKE @TargetTable
OR tgt.Path LIKE @TargetTablePath
OR tgt.Path LIKE @TargetClass
OR tgt.Path LIKE @TargetClassPath
)
AND src.Path LIKE '/Classes/%'
AND (
src.Path LIKE '%EventHandler%'
OR src.Path LIKE '%_Handler%'
OR src.Path LIKE '%Events%'
OR src.Path LIKE '%_Extension%'
)
ORDER BY src.Path";
using (var cmd = new SqlCommand(sql, conn))
{
// All four are compared with LIKE (see the WHERE above), so all four
// are escaped — including the two with no trailing wildcard, where an
// unescaped "_" would still match any single character.
var escTarget = EscapeLike(targetName);
cmd.Parameters.AddWithValue("@TargetTable", $"/Tables/{escTarget}");
cmd.Parameters.AddWithValue("@TargetTablePath", $"/Tables/{escTarget}/%");
cmd.Parameters.AddWithValue("@TargetClass", $"/Classes/{escTarget}");
cmd.Parameters.AddWithValue("@TargetClassPath", $"/Classes/{escTarget}/%");
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
var path = reader.GetString(0);
var parts = path.Split('/');
var className = parts.Length >= 3 ? parts[2] : path;
var methodName = parts.Length >= 5 ? parts[4] : null;
var module = reader.IsDBNull(1) ? null : reader.GetString(1);
// Classify handler type based on naming conventions
var handlerType = ClassifyHandlerType(className, methodName, path);
var eventName = ExtractEventName(methodName, className, targetName);
// Apply filters
if (eventNameFilter != null && eventName != null
&& !string.Equals(eventName, eventNameFilter, StringComparison.OrdinalIgnoreCase))
continue;
if (handlerTypeFilter != null && handlerTypeFilter != "all"
&& !string.Equals(handlerType, handlerTypeFilter, StringComparison.OrdinalIgnoreCase))
continue;
results.Add(new EventSubscriberDetailModel
{
ClassName = className,
Module = module,
MethodName = methodName,
EventName = eventName,
HandlerType = handlerType,
});
}
}
}
}
// Deduplicate by class+method
var distinct = results
.GroupBy(r => $"{r.ClassName}.{r.MethodName}")
.Select(g => g.First())
.ToList();
return new
{
targetName,
count = distinct.Count,
handlers = distinct,
_source = "C# bridge (DYNAMICSXREFDB)"
};
}
catch (Exception ex)
{
throw QueryFailed("findEventSubscribers", targetName, ex);
}
}
/// Classify handler type based on naming conventions.
private static string ClassifyHandlerType(string className, string? methodName, string path)
{
var combined = $"{className}.{methodName}".ToLowerInvariant();
if (combined.Contains("dataevent") || combined.Contains("oninserted") ||
combined.Contains("onupdated") || combined.Contains("ondeleted") ||
combined.Contains("onvalidated") || combined.Contains("oninit"))
return "dataEvent";
if (combined.Contains("pre_") || combined.Contains("_pre"))
return "pre";
if (combined.Contains("post_") || combined.Contains("_post"))
return "post";
if (combined.Contains("delegate"))
return "delegate";
return "static"; // default for SubscribesTo handlers
}
/// Extract event name from method/class naming patterns.
private static string? ExtractEventName(string? methodName, string className, string targetName)
{
if (methodName == null) return null;
// Common patterns: onInserted_handler, onValidatedWrite_handler, etc.
var lower = methodName.ToLowerInvariant();
var standardEvents = new[] { "oninserted", "onupdated", "ondeleted",
"onvalidatedwrite", "onvalidatedinsert", "onvalidateddelete",
"oninitialized", "oninitvalue" };
foreach (var ev in standardEvents)
{
if (lower.Contains(ev))
return ev.Substring(0, 1).ToUpper() + ev.Substring(1); // Capitalize
}
return methodName;
}
// ============================================================
// P5: FindApiUsageCallers — callers of an API via References
// ============================================================
///
/// Find all callers of a given API class/method via cross-reference database.
/// Groups results by caller class for pattern analysis.
///
public object FindApiUsageCallers(string apiName, int limit = 200)
{
var callers = new List();
var pathVariants = new List();
if (apiName.StartsWith("/"))
{
pathVariants.Add(apiName);
}
else
{
// The "/%" children are LIKE patterns and get escaped; the bare paths are
// dispatched to "=" below, where "_" is an ordinary character.
pathVariants.Add($"/Classes/{apiName}");
pathVariants.Add($"/Classes/{EscapeLike(apiName)}/%");
pathVariants.Add($"/Tables/{apiName}");
pathVariants.Add($"/Tables/{EscapeLike(apiName)}/%");
}
// Build WHERE clause
var conditions = new List();
var allParams = new List<(string name, string value)>();
for (int i = 0; i < pathVariants.Count; i++)
{
var pname = $"@T{i}";
if (pathVariants[i].Contains("%"))
conditions.Add($"tgt.Path LIKE {pname}");
else
conditions.Add($"tgt.Path = {pname}");
allParams.Add((pname, pathVariants[i]));
}
var query = $@"
SELECT TOP {limit}
src.Path AS SourcePath,
sm.Module AS SourceModule,
r.Kind,
r.Line
FROM [References] r
INNER JOIN dbo.Names tgt ON tgt.Id = r.TargetId
INNER JOIN dbo.Names src ON src.Id = r.SourceId
LEFT JOIN dbo.Modules sm ON sm.Id = src.ModuleId
WHERE ({string.Join(" OR ", conditions)})
AND src.Path LIKE '/Classes/%'
ORDER BY sm.Module, src.Path, r.Line";
try
{
using (var conn = new SqlConnection(_connectionString))
{
conn.Open();
using (var cmd = new SqlCommand(query, conn))
{
foreach (var (name, value) in allParams)
cmd.Parameters.AddWithValue(name, value);
cmd.CommandTimeout = 30;
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
var sourcePath = reader.GetString(0);
var (_, callerClass, seg, segName) = ParsePath(sourcePath);
callers.Add(new ApiUsageCallerModel
{
CallerClass = callerClass,
CallerMethod = seg == "Methods" ? segName : null,
Module = reader.IsDBNull(1) ? null : reader.GetString(1),
Kind = reader.IsDBNull(2) ? null : reader.GetByte(2).ToString(),
Line = reader.IsDBNull(3) ? 0 : (int)reader.GetInt16(3),
});
}
}
}
}
}
catch (SqlException ex)
{
throw QueryFailed("findApiUsageCallers", apiName, ex);
}
// Group by caller class for pattern summary
var byClass = callers
.GroupBy(c => c.CallerClass)
.Select(g => new
{
callerClass = g.Key,
module = g.First().Module,
methods = g.Where(c => c.CallerMethod != null)
.Select(c => c.CallerMethod!)
.Distinct()
.ToList(),
callCount = g.Count()
})
.OrderByDescending(x => x.callCount)
.ToList();
return new
{
apiName,
totalCallers = callers.Count,
uniqueClasses = byClass.Count,
callersByClass = byClass,
callers,
_source = "C# bridge (DYNAMICSXREFDB)"
};
}
// ============================================================
// Schema / Debug helpers
// ============================================================
///
/// Discover the actual schema of the DYNAMICSXREFDB for debugging.
///
public object GetSchemaInfo()
{
var tables = new List