feat(data): narrow an app's model to how that app uses it - #194
Draft
david-hudec-networg wants to merge 10 commits into
Draft
feat(data): narrow an app's model to how that app uses it#194david-hudec-networg wants to merge 10 commits into
david-hudec-networg wants to merge 10 commits into
Conversation
A table with several lookups to one target rendered a single edge, while all its lookup columns still appeared - understating the model without looking broken. The duplicate guard keyed on (LeftSideTable, RighSideTable), ignoring which column the relationship ran through. The key now includes LeftSideRow. Genuine duplicates still collapse, which is what the guard is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Required, form-visible columns disappeared from the output with no warning, and a module whose only contribution was such an attribute read as contributing nothing. Rows of an optionset kind were deleted outright when their OptionSetName did not resolve. Three causes seen in real solutions: the global option set declares <options />, it is declared in a different module, or it is platform-owned. The row is kept and only OptionSetName is cleared. That is what ToDbDiagramNotation prefers over RowType, so leaving it set would reference an Enum that was never emitted; RowType is left alone so sql and edmx keep their own handling for the kind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same unchanged solution converted to a different file on every run, at identical length - so a generated diagram could not be committed, diffed, or compared across a model change. Two causes. Module seeded Colorhex from new Random(). And three file enumerations used Directory.GetFiles, which guarantees no ordering, so table, relationship and enum order followed the filesystem. Colour now derives from the module name with FNV-1a - not string.GetHashCode, which is randomised per process on .NET Core - and all three enumerations are ordered ordinally. Everyone's colours change; nothing could have depended on the old values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Where an entity has a many-to-many with itself, the intersect table carried the same column twice, the same Ref twice, and the same EDMX navigation property twice. A DBML parser rejects the first two outright. Both sides resolved to <entity>id, and both legs carried the relationship name. The second column and the second leg's name are suffixed positionally. The real per-side names live in metadata (Entity1/Entity2IntersectAttribute) and are author-chosen - the platform's own example pairs connectionroleid with associatedconnectionroleid - so they cannot be derived from solution XML and are not guessed at here. Known limit: on the entity side EDMX still names the navigation property after the primary key row, so one duplicate remains there. The intersect side is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rejected Every plainsql conversion failed, and the error listed the formats it did support - contradicting the option's own help. The format is declared in the option's AllowedValues and fully implemented in the conversion switch, but was missing from the service's SupportedFormats guard three lines earlier. Added. A test now asserts every value the option advertises actually converts, so the two lists cannot drift apart again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…once A table declared by two modules ended up with the same column listed twice. ParseMultipleRowsFromXml appended every parsed row without checking whether the table already carried one of that name. Harmless while only one input could be given; routine as soon as several can. Rows are matched case-insensitively. Where two declarations disagree the first input wins, so the result is deterministic in the order the caller gave; a differing type warns rather than aborting, because several modules extending one shared table is normal for a layered product; and text lengths widen but never narrow, since a consumer breaks on too little room, not too much. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…onstructor Every module would come out the same colour once modules carry distinct names. Colorhex was assigned in the constructor, which runs before an object initializer sets ModuleName - so the colour was derived from an empty name. Invisible while there was only ever one module. Colorhex is now a computed property, so it always reflects the name in effect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A delivery project's model is spread across the modules a product ships plus the project's own layer, and several of them declare part of the same table. Converting each separately and concatenating the output keeps only the first declaration of each table, so the merge had to be done by hand. --input accepted a single path, and only zip inputs were ever built into more than one Module. --input is now repeatable. Folder and zip inputs both resolve to a Module and go through the ParseModules seam that already existed for zips, so the two can be mixed in one invocation. Modules are named after the folders that own their declarations, so a merged diagram attributes each table to its source instead of rendering an empty comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds --app and --root, so the question the tool answers can be "what is this app built on" rather than only "what does this solution declare". An app names its tables in its own AppModule file, so this needs no environment. Three things about how apps sit on disk drove the implementation: - The search is anchored on the AppModules folder, not on "Declarations". Older modules keep their declarations under "CDS", and a search anchored on either name silently misses the other. - Identity is read from the UniqueName inside the file, never the folder name -- the two differ in case in the wild, which is invisible on Windows and wrong on a case-sensitive filesystem. - One logical app can be declared across several files, a base declaration plus fragments from other areas carrying solutionaction="Added". Its component set is the union of all of them. Only type="1" components carry a table name; views, forms, charts and workflows reference their owner by id alone. Sitemap entities are picked up as well, from both the Entity attribute and the etn parameter inside a Url -- both forms occur, sometimes in the same file. --root exists because apps and entity schema live in different modules, so scoping to an app structurally needs to reach past a single declarations folder. It expands to every declarations folder beneath it. Pass the product repository as a second root when the base model lives there. Scoping runs before relationships are built. Filtering afterwards would let a relationship between two dropped tables synthesise both of them straight back as stubs -- measured at 261 tables reappearing before this was ordered correctly. A relationship is kept when its referencing side is in scope, so a lookup out of the app still terminates somewhere visible rather than dangling. Option sets belonging to dropped tables are pruned too, or the output declares more enums than it has columns using them. Measured, project root plus product root, one invocation each: ntg_projectmanagement 70 tables 124 refs 883 cols 88 enums ntg_administration 36 59 388 29 ntg_easementmanagement 79 183 1055 130 ntg_hiltipartnerportal 25 34 216 21 All parse with @dbml/core. Unknown app names fail listing the apps that were found. Without --app nothing is filtered, and single-input conversion across three solutions and all five targets is unchanged and idempotent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
david-hudec-networg
force-pushed
the
feat/data-model-convert-detail-design
branch
2 times, most recently
from
September 2, 2026 06:11
1f69aa6 to
0e5ee4a
Compare
david-hudec-networg
force-pushed
the
feat/data-model-convert-detail-design
branch
from
September 2, 2026 06:40
0e5ee4a to
7c41c13
Compare
`--detail full|minimal`. `full` is everything the inputs declare; `minimal` shows how the app was built. Supersedes the `--columns-used` / `--scan-code` pair this replaces, which is why they never appear: a reference resolved by name against one global set keeps a column on every table declaring that name. That is a fair approximation for an author's column, which one table declares, and useless for a platform column, whose name is identical org-wide — one view showing createdon kept it on 19 of 19 tables in a real app. Columns. A reference is credited to the table whose folder the artefact sits under. An artefact outside one — an app module, a sitemap, a plug-in source — is credited to no table and matched by name alone, so it keeps an author's column and cannot rescue a platform one. Without that gate the whole problem returns through the code scan: measured, 226 of 252 audit columns survived. An author's column carries the publisher prefix from the module's own Solution.xml and the platform's carries none, which is the whole discriminator; with no prefixes to check against nothing is called the platform's, rather than narrowing on no evidence. Dropped whatever refers to them: logical columns, business process flow bookkeeping, and the base-currency twin, which needs a name pairing because nothing in the metadata separates it from the column it shadows. A primary key, `statecode`, `statuscode` and any column an edge depends on are never dropped — `EDMXTranslator` and `SQLTranslator` read a relationship's endpoints with no null check, so removing one crashes rather than narrows. Tables. An N:N is admitted only when both its tables are in the app. The gate is read before the branch that builds the intersect, so the edge and both stubs are withheld together rather than leaving a dangling end, which is why that branch's unguarded dereferences need no restructuring. A stub for a table an input does declare gets a new `TableType.NotInApp` instead of `NotInSolution`, because the colour is chosen from the type and that is where the distinction belongs. It applies wherever `--app` is given, not only under `minimal`: the red "not in the solution" was untrue for 13 of 14 stubs in one real app at either detail level. One `AppScopeTests` assertion changes with it. Reporting. Every dropped column in one warning is unreadable at this volume, so `DroppedColumn` carries a reason and the command writes counts per reason through `WriteData` — which also clears the TXC028 it was raising by returning a status envelope from a read-only command. Also folds three duplications out of code earlier commits in this stack introduce: `ColourFor` hand-rolled FNV-1a where `SHA256.HashData` is one line, and the self-referencing check ran twice in the N:N branch with its result read on the line after it was assigned. Four things this deliberately does not do, each measured against both apps and found to change nothing: - Walk a view's FetchXML to credit a linked table's columns to that table. Zero columns, because the token scan already credits the file to its own table and those names appear in that table's own artefacts too. - Read a workflow's PrimaryEntity to credit its bundle. One column, and that column was `owningbusinessunit` — so not doing it is the better output. - Read `IsCustomField`. Zero columns; the publisher prefix already answers it. - Classify the columns of a table with no artefacts of its own instead of narrowing them. Zero columns. Such tables are reported by name instead. Measured, project and product repositories at develop: app detail tables columns refs enums audit ntg_projectmanagement full 70 903 124 91 252 ntg_projectmanagement minimal 46 538 94 82 42 ntg_administration full 36 388 59 29 134 ntg_administration minimal 22 218 39 28 23 Five targets x two apps x two levels: exit 0, DBML parses with @dbml/core, byte-identical on a second run. One table in 38 has no artefacts of its own (`Letter`) and is reported by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
david-hudec-networg
force-pushed
the
feat/data-model-convert-detail-design
branch
from
September 2, 2026 06:54
7c41c13 to
708e208
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
--detail full|minimalreplaces the--columns-used/--scan-codepair from the closed #193, so neither flag ever ships.minimalanswers how was this app built;fullstays what the inputs declare.Why the existing filter cannot get there
--columns-usedresolves a reference by name against one global set. A single occurrence ofcreatedonanywhere keeps the column on every table that declares it — 19 of 19 tables in one real app, from the views of three. That is a fair approximation for an author's column, which one table declares. It is useless for a platform column, whose name is identical on every table in the org.Measured before this change: 252 of 830 emitted columns on one app were audit plumbing, 134 of 367 on another.
--columns-used --scan-codehad removed 65 by comparison.Columns
A reference is now attributed to the table whose artefact made it — forms, views and charts by the folder they sit under, workflows by the
PrimaryEntityin the manifest beside them.Views and charts also get a FetchXML walk. A view selects columns of tables it joins to and names them in
layoutxmlthrough an alias that is sometimes the table name and sometimes opaque (alias="aa"), so crediting them to the folder's table would drop them from the table that owns them.An artefact belonging to no single table — an app module, a sitemap, a plug-in source — can still only be matched by name. It therefore keeps an author's column and cannot rescue a platform one. Without that gate the original defect returns through the code scan: measured, 226 of 252 audit columns survived.
Where no artefact can be attributed to a table at all, its columns are classified instead.
IsCustomFieldalone is wrong for this — 297 author-prefixed attributes across two real repositories carry0, including primary name fields — so a publisher prefix read from the module's ownSolution.xmloverrides it. With neither signal present the column is kept rather than narrowed on no evidence.Dropped whatever refers to them: logical columns, business-process-flow bookkeeping, and the base-currency twin — that last needs a name pairing, because both halves are marked as an author's.
statecode,statuscode, every primary key and every column an edge depends on are never dropped.Tables
An N:N is admitted only when both its tables are in the app. On one app,
systemuseralone contributed nine intersects and four far-side stubs.The gate is read before the branch that builds the intersect, so the edge and both stubs are withheld together rather than leaving a dangling end — which is why the branch's unguarded dereferences need no restructuring.
A stub for a table an input does declare gets a new
TableType.NotInApprather thanNotInSolution. The colour is chosen from the type, so that is where the distinction belongs � no extra field onTable, andDetailLevelnever reaches a translator.It applies wherever
--appis given, not only underminimal: 13 of 14 stubs in one real app were declared as full entities in the same inputs, so the red "not in the solution" was untrue for almost all of them at either detail level. OneAppScopeTestsassertion from #192 changes with it.Reporting
Every dropped column in one warning is unreadable at this volume.
DroppedColumncarries a reason (NoReferenceFound,NoOwnArtefacts,PlatformPlumbing) and the command writes counts per reason throughWriteData. That also clears theTXC028this command was raising by returning a status envelope from a[CliReadOnly]command.Four things this deliberately does not do
Each was built, then measured against both apps with a kill switch and removed:
PrimaryEntityto credit its bundleowningbusinessunit� so not doing it is the better outputIsCustomFieldWhat survived the same test: the unattributed-rescue gate (30 columns) and the
.cs/.tsscan (14).Cleanup carried here
Three duplications in code the earlier commits of this stack introduce, folded out rather than left for a follow-up:
ColourForhand-rolled FNV-1a in eight lines whereSHA256.HashDatais one. The palette changes; no diagram has been generated from it yet.isSelfReferencingwas read on the line after it was assigned.Measured
Project and product repositories at
develop:ntg_projectmanagementntg_projectmanagementntg_administrationntg_administrationDropped: 311 columns on the first (248 unreferenced, 63 plumbing), 136 on the second (107, 29).
Five targets × two apps × two levels: exit 0,
@dbml/corePARSE_OK, byte-identical on a second run. One table in 38 has no artefacts of its own (Letter) and is reported by name.Tests
Two new files, 14 tests, no new dependencies. The regression that proves the change: a column only one table's form refers to is kept there and dropped on the other. Plus link-entity attribution, opaque-alias resolution through
layoutxml, workflowPrimaryEntity, both fallback paths, the unattributed-cannot-rescue-a-platform-column rule, each categorical rule overriding a genuine reference,fullleaving every design-only rule inert, and all five targets rendering after tables and columns are dropped.AnEntityDeclarationDoesNotCountAsAReferenceToItsOwnColumnsneeded a form added: without one the table has no artefacts and takes the classification path, so the test no longer exercised what it claimed.Known limits
--detail fullshows them.sql/plainsql/edmxunderminimallook complete but are not, and the formats cannot say so in-band. The help text does.createdonsurvives on all 19 tables of one app — correctly: each has its own product-layer views that sort by it. The rest of the audit block goes to zero.🤖 Generated with Claude Code