Skip to content

feat(data): narrow an app's model to how that app uses it - #194

Draft
david-hudec-networg wants to merge 10 commits into
TALXIS:masterfrom
david-hudec-networg:feat/data-model-convert-detail-design
Draft

feat(data): narrow an app's model to how that app uses it#194
david-hudec-networg wants to merge 10 commits into
TALXIS:masterfrom
david-hudec-networg:feat/data-model-convert-detail-design

Conversation

@david-hudec-networg

@david-hudec-networg david-hudec-networg commented Sep 1, 2026

Copy link
Copy Markdown

Stacked on #190#191#192#193. Only the last commit belongs to this one. GitHub cannot express a cross-fork stack, so the base has to be master. Review in order.

--detail full|minimal replaces the --columns-used / --scan-code pair from the closed #193, so neither flag ever ships. minimal answers how was this app built; full stays what the inputs declare.

Why the existing filter cannot get there

--columns-used resolves a reference by name against one global set. A single occurrence of createdon anywhere 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-code had 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 PrimaryEntity in 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 layoutxml through 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. IsCustomField alone is wrong for this — 297 author-prefixed attributes across two real repositories carry 0, including primary name fields — so a publisher prefix read from the module's own Solution.xml overrides 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, systemuser alone 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.NotInApp rather than NotInSolution. The colour is chosen from the type, so that is where the distinction belongs � no extra field on Table, and DetailLevel never reaches a translator.

It applies wherever --app is given, not only under minimal: 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. One AppScopeTests assertion from #192 changes with it.

Reporting

Every dropped column in one warning is unreadable at this volume. DroppedColumn carries a reason (NoReferenceFound, NoOwnArtefacts, PlatformPlumbing) and the command writes counts per reason through WriteData. That also clears the TXC028 this 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:

columns it bought
walk a view's FetchXML to credit a linked table's columns 0 � 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 1, and it was owningbusinessunit � so not doing it is the better output
read IsCustomField 0 � the publisher prefix already answers it
classify an artefact-less table's columns rather than narrow them 0 � such tables are reported by name instead

What survived the same test: the unattributed-rescue gate (30 columns) and the .cs/.ts scan (14).

Cleanup carried here

Three duplications in code the earlier commits of this stack introduce, folded out rather than left for a follow-up:

  • ColourFor hand-rolled FNV-1a in eight lines where SHA256.HashData is one. The palette changes; no diagram has been generated from it yet.
  • The self-referencing check ran twice in the N:N branch, once on the synthesised column names and once on the tables' logical names.
  • isSelfReferencing was read on the line after it was assigned.

Measured

Project and product repositories at develop:

app detail tables columns refs enums audit columns
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

Dropped: 311 columns on the first (248 unreferenced, 63 plumbing), 136 on the second (107, 29).

Five targets × two apps × two levels: exit 0, @dbml/core PARSE_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, workflow PrimaryEntity, both fallback paths, the unattributed-cannot-rescue-a-platform-column rule, each categorical rule overriding a genuine reference, full leaving every design-only rule inert, and all five targets rendering after tables and columns are dropped.

AnEntityDeclarationDoesNotCountAsAReferenceToItsOwnColumns needed 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

  • The both-sides N:N rule can drop an app's own bespoke association, not only generic plumbing — on one app, collab-workspace ↔ document-type. Nothing cheap distinguishes "shared platform table" from "this app's edge to a shared concept". --detail full shows them.
  • sql / plainsql / edmx under minimal look complete but are not, and the formats cannot say so in-band. The help text does.
  • createdon survives 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

david-hudec-networg and others added 9 commits September 1, 2026 15:15
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 david-hudec-networg changed the title feat(data): a design-level view of an app's model feat(data): a minimal view of an app's model Sep 1, 2026
@david-hudec-networg
david-hudec-networg force-pushed the feat/data-model-convert-detail-design branch 2 times, most recently from 1f69aa6 to 0e5ee4a Compare September 2, 2026 06:11
@david-hudec-networg david-hudec-networg changed the title feat(data): a minimal view of an app's model feat(data): narrow an app's model to how that app uses it Sep 2, 2026
@david-hudec-networg
david-hudec-networg force-pushed the feat/data-model-convert-detail-design branch from 0e5ee4a to 7c41c13 Compare September 2, 2026 06:40
`--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
david-hudec-networg force-pushed the feat/data-model-convert-detail-design branch from 7c41c13 to 708e208 Compare September 2, 2026 06:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant