diff --git a/CHANGELOG.md b/CHANGELOG.md index 3198552..3295a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,24 @@ All notable changes to xpp-lens (called xpp-graft up to 1.2.0). Versions follow `` in `src/XppLens/XppLens.csproj`; release tags are `vX.Y.Z`. +## 1.3.2 — 2026-10-26 + +### Fixed +- The `model` filter of `xpp_find`, `xpp_refs`, `xpp_changed` and `property=` listings took one name only: a list + such as `model="Contoso;Fabrikam*"` matched nothing and the answer was empty. All of them now take names separated + by `;` with `*` and `?`, like `xpp_grep` already did. +- `xpp_find "Table.Extension.Field"` split the name at the first dot and found nothing; a field added in a named + extension (`CustTable.ContosoExtension.MyField`) is now found. +- `xpp_method` with only `lines` (no method) returned nothing; it now shows those lines of the object's file and the + methods they belong to — the way to read a spot named by a build error or a grep hit. + +### Changed +- `xpp_extensions` is several times faster for objects with many extension classes (measured warm: `SalesTable` + 9.2 s → 0.5 s, `VendTable` 5.1 s → 1.8 s): the Chain of Command details of each extension class were looked up + by a column without an index, which read the whole reference table once per extension class. +- `xpp_join` reads the table relations from a small index of its own instead of the whole member table (the first + call of a server and every CLI call: 1.3 s → 0.65 s); the index is built once, in a few seconds. + ## 1.3.1 — 2026-10-26 ### Fixed diff --git a/README.md b/README.md index caaab5f..0e96dae 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Useful parameters: ```powershell .\install.ps1 -PackagesDir K:\AosService\PackagesLocalDirectory # instead of auto-detection .\install.ps1 -Languages en-US,de -DisplayLanguage de # label languages -.\install.ps1 -FullModels XPL,XPLRetail # force models into the full index +.\install.ps1 -FullModels ContosoCore,ContosoRetail # force models into the full index .\install.ps1 -StandardModels HugeIsvModel # push a model down to the standard tier .\install.ps1 -NoStandard # custom models only (fast, small index) .\install.ps1 -First -NoBuild -NoRegister # non-interactive / CI @@ -73,7 +73,7 @@ New distribution package: `pack.ps1` (add `-FrameworkDependent` if you prefer 5 ```powershell xpplens config # show settings xpplens config --add-language de # add a label language -xpplens config --add-full-model XPL # model/package into the full index +xpplens config --add-full-model Contoso # model/package into the full index xpplens config --add-standard-model ContosoIsv # model into the standard tier xpplens config --add-standard-publisher "Contoso" xpplens config --packages-dir K:\AosService\PackagesLocalDirectory diff --git a/README.pl.md b/README.pl.md index d9bf7cc..92b6c55 100644 --- a/README.pl.md +++ b/README.pl.md @@ -59,7 +59,7 @@ Przydatne parametry: ```powershell .\install.ps1 -PackagesDir K:\AosService\PackagesLocalDirectory # zamiast wykrywania .\install.ps1 -Languages en-US,de -DisplayLanguage de # języki etykiet -.\install.ps1 -FullModels XPL,XPLRetail # wymuszenie pełnego indeksu +.\install.ps1 -FullModels ContosoCore,ContosoRetail # wymuszenie pełnego indeksu .\install.ps1 -StandardModels HugeIsvModel # zepchnięcie modelu do poziomu standardowego .\install.ps1 -NoStandard # tylko modele własne (szybko, mały indeks) .\install.ps1 -First -NoBuild -NoRegister # tryb nieinteraktywny / CI @@ -73,7 +73,7 @@ Nowy pakiet dystrybucyjny: `pack.ps1` (dodaj `-FrameworkDependent`, jeśli wolis ```powershell xpplens config # pokaż ustawienia xpplens config --add-language de # dołóż język etykiet -xpplens config --add-full-model XPL # model/pakiet do pełnego indeksu +xpplens config --add-full-model Contoso # model/pakiet do pełnego indeksu xpplens config --add-standard-model ContosoIsv # model do poziomu standardowego xpplens config --add-standard-publisher "Contoso" xpplens config --packages-dir K:\AosService\PackagesLocalDirectory diff --git a/install.ps1 b/install.ps1 index f4f9c61..b762dc1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -13,7 +13,7 @@ .EXAMPLE .\install.ps1 .EXAMPLE - .\install.ps1 -PackagesDir K:\AosService\PackagesLocalDirectory -Languages en-US,de -FullModels XPL,XPLCore + .\install.ps1 -PackagesDir K:\AosService\PackagesLocalDirectory -Languages en-US,de -FullModels ContosoCore,ContosoRetail .NOTES Running it again over an existing installation (an update) keeps its settings: languages and the @@ -209,6 +209,6 @@ if ($desktop.Count -gt 0) { } } Say "Claude Code in a terminal / VS Code: '/mcp' -> reconnect xpp-lens, or start a new session." -Say "Change settings later: `"$exe`" config --add-language de --add-full-model XPL" +Say "Change settings later: `"$exe`" config --add-language de --add-full-model Contoso" Say "Index status: `"$exe`" status" Say "Uninstall: $InstallDir\uninstall.ps1" diff --git a/release.ps1 b/release.ps1 index d8097fb..27b5464 100644 --- a/release.ps1 +++ b/release.ps1 @@ -107,7 +107,9 @@ if (-not $Pre) { $changelog = [IO.File]::ReadAllText((Resolve-Path 'CHANGELOG.md')) $sectionRx = "(?ms)^## $([regex]::Escape($Version))\b.*?(?=^## |\z)" $section = [regex]::Match($changelog, $sectionRx) -if (-not $section.Success) { Fail "CHANGELOG.md has no '## $Version' section" } +# A dry run also serves as the scan before an ordinary push to dev, so it goes on without the section. +if (-not $section.Success -and $DryRun) { Write-Host " WARNING: CHANGELOG.md has no '## $Version' section (needed for the release)" -ForegroundColor Yellow } +elseif (-not $section.Success) { Fail "CHANGELOG.md has no '## $Version' section" } Ok "CHANGELOG section found, tag is free, $work is up to date" # ---------------------------------------------------------------- 2. version diff --git a/src/XppLens/Program.cs b/src/XppLens/Program.cs index dd98e18..7d4fbb4 100644 --- a/src/XppLens/Program.cs +++ b/src/XppLens/Program.cs @@ -268,7 +268,7 @@ xpplens config show settings xpplens config --packages-dir | --index-path --add-language pl | --remove-language pl | --languages en-US,pl --display-language en-US - --add-full-model XPL | --remove-full-model XPL | --full-models A,B + --add-full-model Contoso | --remove-full-model Contoso | --full-models A,B --add-standard-model X | --remove-standard-model X --add-standard-publisher "Contoso" | --standard-publishers Microsoft --index-standard true|false | --standard-code true|false diff --git a/src/XppLens/Queries.Chains.cs b/src/XppLens/Queries.Chains.cs index 97f3ce7..36de6f5 100644 --- a/src/XppLens/Queries.Chains.cs +++ b/src/XppLens/Queries.Chains.cs @@ -65,7 +65,7 @@ List ChainedRefs(Store s, string member, string kinds, string? modelLi FROM refs r JOIN objects so ON so.id = r.object_id JOIN files f ON f.id = r.file_id JOIN models md ON md.id = f.model_id LEFT JOIN methods sm ON sm.id = r.method_id WHERE r.target IS NULL AND r.member = $m AND r.kind IN ({kinds}) AND r.via LIKE 'ret:%' - AND ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!') + AND xl_model($model, md.name, md.package) ORDER BY md.tier DESC, so.name, sm.name, r.line LIMIT 5000 """, ("$m", member), ("$model", modelLike)) .Select(r => (Kind: r.GetString(0), Via: r.GetString(1), Line: r.GetInt32(2), Type: r.GetString(3), Obj: r.GetString(4), diff --git a/src/XppLens/Queries.Tools.cs b/src/XppLens/Queries.Tools.cs index e114edc..8793ffa 100644 --- a/src/XppLens/Queries.Tools.cs +++ b/src/XppLens/Queries.Tools.cs @@ -324,10 +324,10 @@ public string Changed(string? since, string? model, string? type, int limit) => var from = ParseSince(since, DateTime.UtcNow); if (from == null) return $"cannot read since='{since}' — use e.g. 24h, 3d, 2026-10-01 or '2026-10-01 14:00'."; var tp = TypePattern(type); - var modelLike = PatOrNull(model); + var modelLike = ModelArg(model); var where = $""" f.mtime > $t AND md.tier >= 0 - AND ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!') {(tp != null ? "AND o.type LIKE $tp" : "")} + AND xl_model($model, md.name, md.package) {(tp != null ? "AND o.type LIKE $tp" : "")} """; var args = new (string, object?)[] { ("$t", from.Value.Ticks), ("$model", modelLike), ("$tp", tp), ("$limit", limit) }; const string from_ = "FROM files f JOIN objects o ON o.file_id = f.id JOIN models md ON md.id = f.model_id"; @@ -336,7 +336,7 @@ public string Changed(string? since, string? model, string? type, int limit) => .Select(r => $"{Tag(r.GetString(0), r.GetString(1), r.GetInt32(2))} {r.GetInt64(3)}").ToList(); var labels = s.Query(""" SELECT lf.label_file, lf.lang, lf.mtime, md.package, md.name, md.tier FROM label_files lf JOIN models md ON md.id = lf.model_id - WHERE lf.mtime > $t AND md.tier >= 0 AND ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!') + WHERE lf.mtime > $t AND md.tier >= 0 AND xl_model($model, md.name, md.package) ORDER BY lf.mtime DESC LIMIT 50 """, args).Select(r => $" {new DateTime(r.GetInt64(2), DateTimeKind.Utc).ToLocalTime():MM-dd HH:mm} labels {r.GetString(0)} ({r.GetString(1)}) {Tag(r.GetString(3), r.GetString(4), r.GetInt32(5))}").ToList(); diff --git a/src/XppLens/Queries.cs b/src/XppLens/Queries.cs index 9bb2eb9..0924198 100644 --- a/src/XppLens/Queries.cs +++ b/src/XppLens/Queries.cs @@ -64,6 +64,9 @@ static string Tag(string package, string model, int tier) static string Pat(string q) => Esc(q).Replace('*', '%').Replace('?', '_'); static string Like(string q) => HasWildcard(q) ? Pat(q) : $"%{Esc(q)}%"; static string? PatOrNull(string? q) => string.IsNullOrWhiteSpace(q) ? null : Pat(q.Trim()); + + /// The model filter as the SQL function xl_model takes it (see Store.ModelMatches): names separated by ';', * ? wildcards. + static string? ModelArg(string? model) => string.IsNullOrWhiteSpace(model) ? null : model.Trim(); static bool HasWildcard(string q) => q.Contains('*') || q.Contains('?'); /// @@ -262,7 +265,7 @@ string ListProperties(string query, string? type, string? model, string property return eq > 0 ? (Name: p[..eq].Trim(), Filter: NameDirectory.WholeName(p[(eq + 1)..].Trim())) : (Name: p.Trim(), Filter: (Regex?)null); }).ToList(); var tp = TypePattern(type); - var args = new List<(string, object?)> { ("$tp", tp), ("$model", PatOrNull(model)) }; + var args = new List<(string, object?)> { ("$tp", tp), ("$model", ModelArg(model)) }; var names = SplitList(query).Where(q => q.Trim('*').Length > 0).ToList(); if (names.Count == 0 && tp == null && string.IsNullOrWhiteSpace(model)) return "listing properties needs a scope: a name pattern, type (e.g. entity) or model."; @@ -272,7 +275,7 @@ string ListProperties(string query, string? type, string? model, string property var rows = s.Query($""" SELECT o.id, o.file_id, o.type, o.name, o.target, o.extends, o.header, o.props, f.path, md.package, md.name, md.tier FROM models md JOIN files f ON f.model_id = md.id JOIN objects o ON o.file_id = f.id - WHERE ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!') + WHERE xl_model($model, md.name, md.package) AND {nameSql} {(tp != null ? "AND o.type LIKE $tp" : "")} ORDER BY md.tier DESC, o.type, o.name """, [.. args]).Select(r => ReadObj(r)).ToList(); @@ -318,7 +321,17 @@ string FindOne(string query, string? kind, string? type, string? model, int limi string? objPart = query, memPart = null; int sep = query.IndexOf("::", StringComparison.Ordinal); int sepLen = 2; - if (sep < 0) { sep = query.IndexOf('.'); sepLen = 1; } + if (sep < 0) + { + sep = query.IndexOf('.'); + sepLen = 1; + // "Table.Extension.Field": the extension object is named "Table.Extension", so the member follows the + // last dot when the part before it is an object. + int last = query.LastIndexOf('.'); + if (last > sep && !HasWildcard(query[..last]) + && s.Scalar("SELECT 1 FROM objects WHERE name = $n LIMIT 1", ("$n", query[..last])) != null) + sep = last; + } if (sep >= 0) { objPart = query[..sep].Trim(); @@ -327,8 +340,8 @@ string FindOne(string query, string? kind, string? type, string? model, int limi if (memPart.Length == 0) memPart = null; } var tp = TypePattern(type); - var modelLike = PatOrNull(model); - const string modelFilter = "AND ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!')"; + var modelLike = ModelArg(model); + const string modelFilter = "AND xl_model($model, md.name, md.package)"; // Extension objects are named "Base.Model", so a dot is not always "Object.member". bool dotted = sep >= 0 && sepLen == 1; @@ -663,10 +676,12 @@ string ExtensionDetail(Store s, ObjRow e) { if (e.Type == "AxClass") { - var wraps = s.Query("SELECT DISTINCT member FROM refs WHERE object_id = $id AND kind = 'coc' AND via = 'next' AND member IS NOT NULL ORDER BY member", - ("$id", e.Id)).Select(r => r.GetString(0)).ToList(); - var of = s.Query("SELECT via, member FROM refs WHERE object_id = $id AND kind = 'coc' AND method_id = (SELECT MIN(id) FROM methods WHERE object_id = $id AND name = 'classDeclaration') LIMIT 1", - ("$id", e.Id)).Select(r => $"{r.GetString(0)}{(r.IsDBNull(1) ? "" : ":" + r.GetString(1))}").FirstOrDefault(); + // file_id first: refs has no index on object_id, but one on file_id (an object lives in one file) — + // without it each extension class meant a scan of the whole refs table. + var wraps = s.Query("SELECT DISTINCT member FROM refs WHERE file_id = $fid AND object_id = $id AND kind = 'coc' AND via = 'next' AND member IS NOT NULL ORDER BY member", + ("$fid", e.FileId), ("$id", e.Id)).Select(r => r.GetString(0)).ToList(); + var of = s.Query("SELECT via, member FROM refs WHERE file_id = $fid AND object_id = $id AND kind = 'coc' AND method_id = (SELECT MIN(id) FROM methods WHERE object_id = $id AND name = 'classDeclaration') LIMIT 1", + ("$fid", e.FileId), ("$id", e.Id)).Select(r => $"{r.GetString(0)}{(r.IsDBNull(1) ? "" : ":" + r.GetString(1))}").FirstOrDefault(); var parts = new List(); if (of != null && !of.StartsWith("classStr", StringComparison.OrdinalIgnoreCase) && !of.StartsWith("tableStr", StringComparison.OrdinalIgnoreCase)) parts.Add(of); if (wraps.Count > 0) parts.Add("wraps " + string.Join(", ", wraps)); @@ -684,10 +699,36 @@ string ExtensionDetail(Store s, ObjRow e) /// One or more methods. method may list several names ("a;b") of the same object; without method, /// objectName may list "Object.method" / "Object::method" items. match/lines return a numbered fragment. /// + /// Lines "from-to" (at most 300) of an object's XML file, numbered as in the file. + string FileLines(string objectName, string? type, string lines) => svc.Read(s => + { + var m = Regex.Match(lines.Trim(), @"^(\d+)\s*-\s*(\d+)$"); + if (!m.Success) return $"lines '{lines}': give a range like 120-180."; + int from = int.Parse(m.Groups[1].Value), to = int.Parse(m.Groups[2].Value); + if (to < from) (from, to) = (to, from); + to = Math.Min(to, from + 299); + var o = ObjectRows(s, objectName, type).FirstOrDefault(); + if (o == null) return NotFound(s, objectName, type); + if (o.Compiled || !File.Exists(o.Path)) return $"{o.Type} {o.Name} {o.Tag}: no source file (compiled package)."; + var sb = new StringBuilder($"{o.Type} {o.Name} {o.Tag}\n{o.Path}:{from}-{to}\n"); + var methodsHere = s.Query("SELECT owner, name, start_line, end_line FROM methods WHERE object_id = $id AND start_line <= $to AND end_line >= $from ORDER BY start_line", + ("$id", o.Id), ("$from", from), ("$to", to)) + .Select(r => $"{(r.IsDBNull(0) || r.GetString(0).Length == 0 ? "" : r.GetString(0) + "/")}{r.GetString(1)} L{r.GetInt32(2)}-{r.GetInt32(3)}").ToList(); + if (methodsHere.Count > 0) sb.AppendLine("in: " + string.Join(", ", methodsHere)); + foreach (var (no, text) in File.ReadLines(o.Path).Skip(from - 1).Take(to - from + 1).Select((t, i) => (from + i, t))) + sb.AppendLine($"{no,6}: {text}"); + return Finish(sb); + }); + public string Method(string objectName, string? method, string? type, string? match = null, string? lines = null, int context = 3) { var frag = FragmentSpec.Create(match, lines, context); var pairs = new List<(string Obj, string Method)>(); + if (method is "null" or "undefined") method = null; + // Only a line range of one object (e.g. from a build error or a grep hit): those lines of its file. + if (string.IsNullOrWhiteSpace(method) && !string.IsNullOrWhiteSpace(lines) && SplitList(objectName) is [var single] + && !single.Contains("::", StringComparison.Ordinal) && !single.Contains('.')) + return FileLines(single, type, lines); var methods = SplitList(method); if (methods.Count > 0) { @@ -1008,7 +1049,7 @@ string Chain(string via, string? member) public string Refs(string name, string? member, string? kind, string? model, int limit) => svc.Read(s => { limit = Math.Clamp(limit, 1, 1000); - var modelLike = PatOrNull(model); + var modelLike = ModelArg(model); name = name.Trim(); string? readAs = null; if (string.IsNullOrWhiteSpace(member) && SplitMemberName(s, name) is ({ } objectPart, { } memberPart)) @@ -1019,7 +1060,7 @@ public string Refs(string name, string? member, string? kind, string? model, int } var where = """ r.target = $n AND ($mem IS NULL OR r.member = $mem) AND ($kind IS NULL OR r.kind = $kind) - AND ($model IS NULL OR md.name LIKE $model ESCAPE '!' OR md.package LIKE $model ESCAPE '!') + AND xl_model($model, md.name, md.package) """; var args = new (string, object?)[] { ("$n", name), ("$mem", string.IsNullOrWhiteSpace(member) ? null : member), ("$kind", string.IsNullOrWhiteSpace(kind) ? null : kind), ("$model", modelLike), ("$limit", limit) }; diff --git a/src/XppLens/Store.cs b/src/XppLens/Store.cs index ec15d09..bb152fe 100644 --- a/src/XppLens/Store.cs +++ b/src/XppLens/Store.cs @@ -36,6 +36,7 @@ public Store(string path) : this(path, reader: false) }.ToString(); Conn = new SqliteConnection(cs); Conn.Open(); + Conn.CreateFunction("xl_model", ModelMatches, isDeterministic: true); if (reader) { Exec("PRAGMA busy_timeout=120000; PRAGMA temp_store=MEMORY; PRAGMA cache_size=-65536; PRAGMA mmap_size=2147483648;"); @@ -68,10 +69,32 @@ void EnsureExtras() { // Cache tables of a 1.2 pre-release that scanned standard XML on demand (replaced by standard call refs). Exec("DROP TABLE IF EXISTS std_refs; DROP TABLE IF EXISTS std_scan;"); + // 1.3.2: xpp_join reads every table relation; a covering partial index holds only those rows (~7% of members) + // instead of a scan of the whole members table. Built once, in a few seconds. + Exec("CREATE INDEX IF NOT EXISTS ix_members_relation ON members(object_id, name, info) WHERE kind = 'relation'"); } public void Dispose() => Conn.Dispose(); + static readonly System.Collections.Concurrent.ConcurrentDictionary ModelPatterns = new(); + + /// + /// SQL function xl_model($model, md.name, md.package): the model filter of the tools. $model is null (no filter) or + /// one or more names separated by ';' with * and ? as wildcards, matched case-insensitively against the model + /// name or the package name ("Contoso;Fabrikam*"). + /// + internal static bool ModelMatches(string? list, string? model, string? package) + { + if (string.IsNullOrWhiteSpace(list)) return true; + var patterns = ModelPatterns.GetOrAdd(list, l => l + .Split([';', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(p => new System.Text.RegularExpressions.Regex( + "^" + System.Text.RegularExpressions.Regex.Escape(p).Replace(@"\*", ".*").Replace(@"\?", ".") + "$", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant)) + .ToArray()); + return patterns.Any(r => (model != null && r.IsMatch(model)) || (package != null && r.IsMatch(package))); + } + public int Exec(string sql, params (string Name, object? Value)[] args) { using var cmd = Command(sql, args); diff --git a/src/XppLens/XppLens.csproj b/src/XppLens/XppLens.csproj index ee18ae1..d645bc3 100644 --- a/src/XppLens/XppLens.csproj +++ b/src/XppLens/XppLens.csproj @@ -20,7 +20,7 @@ xpp-lens — X++ code index for MCP X++ (Dynamics 365 F&O) code index served to AI assistants over MCP Copyright © 2026 WillowDev - 1.3.1 + 1.3.2 diff --git a/tests/XppLens.Tests/QueryTests.cs b/tests/XppLens.Tests/QueryTests.cs index 139d1a3..759ab02 100644 --- a/tests/XppLens.Tests/QueryTests.cs +++ b/tests/XppLens.Tests/QueryTests.cs @@ -135,6 +135,38 @@ public void Refs_take_table_dot_field_like_find() Assert.DoesNotContain("read as", Q.Refs("DemoCustomer.Contoso", null, null, null, 150)); } + [Fact] + public void Model_filters_take_lists() + { + Assert.True(Store.ModelMatches("Nope;Contoso*", "ContosoCore", "ContosoCore")); + Assert.True(Store.ModelMatches("stdbase", "StdBase", "StdBase")); + Assert.False(Store.ModelMatches("Nope;Other*", "ContosoCore", "ContosoCore")); + Assert.True(Store.ModelMatches(null, "x", "y")); + + var both = Q.Find("Demo*", "object", null, "Nope;StdBase", 40); + Assert.Contains("DemoCustomer", both); + var refs = Q.Refs("DemoCustomer", null, null, "Nope;Contoso*", 150); + Assert.Contains("ContosoCore", refs); + Assert.DoesNotContain("[StdBase", refs); + } + + [Fact] + public void Find_takes_extension_dot_field() + { + // The table extension "DemoCustomer.Contoso" adds ContosoSegment_Code. + var res = Q.Find("DemoCustomer.Contoso.ContosoSegment_Code", null, null, null, 40); + Assert.Contains("DemoCustomer.Contoso.ContosoSegment_Code (field)", res); + } + + [Fact] + public void Method_with_only_lines_shows_the_file() + { + var res = Q.Method("ContosoHelper", "null", null, null, "1-12", 3); + Assert.Contains("ContosoHelper.xml:1-12", res); + Assert.Contains(" 1: