Skip to content

feat(data): narrow an app's tables to the columns it refers to - #193

Closed
david-hudec-networg wants to merge 10 commits into
TALXIS:masterfrom
david-hudec-networg:feat/data-model-convert-columns-used
Closed

feat(data): narrow an app's tables to the columns it refers to#193
david-hudec-networg wants to merge 10 commits into
TALXIS:masterfrom
david-hudec-networg:feat/data-model-convert-columns-used

Conversation

@david-hudec-networg

Copy link
Copy Markdown

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

What it does

--app narrows a conversion to the tables an app is built on. This narrows those tables to the columns something in the app refers to.

txc data model convert --root . --root ../TALXIS --app ntg_projectmanagement \
                       --columns-used --scan-code --target dbml

Both flags are opt-in and off by default.

How the search works

Rather than parse each artefact type, it searches for the column names it already knows. By the time the filter runs the column set is settled, so the only question is which of those names appear anywhere in forms, views, workflows, sitemaps — and, with --scan-code, .cs/.ts sources.

That makes the scan indifferent to artefact schemas (cloud-flow JSON in particular has no fixed shape for "which attribute"), and it errs toward keeping a column: a name shared by two tables keeps the column on both. For a feature whose failure mode is a missing column, over-keeping is the right direction to be wrong in.

Entity declarations are excluded. An entity declares its own columns, so reading one would report every column as referenced by itself — a filter that looks like it works and does nothing.

The rule that must not break

A column an edge depends on is kept regardless, alongside every primary key.

EDMXTranslator and SQLTranslator read Relationship.LeftSideRow / RighSideRow without a null check, so dropping one does not produce a narrower diagram — it crashes. All five targets were run with filtering on across three solutions to confirm it holds, and a test asserts the translators render after filtering rather than only asserting the column survived.

--scan-code

Plug-in and client-script sources sit outside the declarations, so they are structurally invisible to the default scan. Measured across three solutions: 58 / 114 / 105 distinct attribute literals live in .cs/.ts, and none of those files is under a Declarations folder.

On one app the flag keeps 89 columns that would otherwise be dropped — the measure of how wrong the narrow scan would have been.

It is separate and off by default because it makes a solution converter read source files, which is a real scope expansion and worth an explicit opt-in rather than a surprise.

Nothing is dropped quietly

Each removed column is reported, and the run states which surfaces were searched and which were not. A dropped column is one no reference was found for, which is not the same as one that is unused — a name built at runtime cannot be found at all. That wording is in --help, in the log line, and in the returned report.

Measured

One app, ntg_projectmanagement, project root plus product root:

tables columns
--app only 70 883
+ --columns-used 70 727
+ --scan-code 70 816

Five targets × three solutions with filtering on: all exit 0, no crashes. Every output parses with @dbml/core.

Guards

--columns-used without --app, and --scan-code without --columns-used, both fail with a message explaining the dependency (exit 2 via ArgumentException, per the base command's contract).

Tests

tests/TALXIS.CLI.Tests/Data/DataModelConverter/ColumnScopeTests.cs — six tests: a form reference keeps a column and an unreferenced one is dropped and reported; the primary key is never dropped; a relationship's column survives and the SQL/EDSSQL/EDMX targets still render; a column only a plug-in mentions is dropped without --scan-code and kept with it; an entity declaration does not count as a reference to its own columns; and without the flag columns are left alone.

29 converter tests green across the stack.

🤖 Generated with Claude Code

@david-hudec-networg
david-hudec-networg force-pushed the feat/data-model-convert-columns-used branch from 595dfd0 to 9c77bed Compare September 1, 2026 12:38
david-hudec-networg and others added 10 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>
Adds --columns-used and --scan-code. Both opt-in, both off by default.

Rather than parse each artefact type, this searches for the column names it
already knows: by the time it runs the column set is settled, so the only
question is which of those names appear anywhere. That makes the scan
indifferent to artefact schemas and errs toward keeping a column -- a name
shared by two tables keeps it on both -- which is the safe direction for
something whose failure mode is a missing column.

Entity declarations are excluded from the scan. An entity declares its own
columns, so reading one would report every column as referenced by itself and
leave a filter that looks like it works and does nothing.

The rule that must not break: a column an edge depends on is kept regardless,
alongside every primary key. The SQL and EDMX translators read a relationship's
endpoints without a null check, so dropping one turns a narrower diagram into a
crash. All five targets were run with filtering on across three solutions.

--scan-code exists because plug-in and client-script sources sit outside the
declarations and are structurally invisible to the default scan. On one app it
keeps 89 columns that would otherwise be dropped, which is the measure of how
wrong the narrow scan would have been.

Nothing is dropped quietly: each removed column is reported, and the run says
which surfaces it searched and which it did not. A dropped column is one no
reference was found for, which is not the same as one that is unused -- stated
in --help, in the log line, and in the returned report.

Measured on one app: 883 columns unfiltered, 727 with --columns-used, 816 with
--scan-code as well. Every combination parses with @dbml/core.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@david-hudec-networg

Copy link
Copy Markdown
Author

Superseded by #194, which carries this wholesale rather than stacking on it.

The mechanism here resolves a reference by name against one global set, so a single occurrence of createdon anywhere keeps the column on every table that declares it — measured at 19 of 19 tables in a real app, from the views of three. That is a fair approximation for an author's column, which one table declares. It is not one for a platform column, whose name is identical on every table in the org, where it over-keeps every time.

#194 attributes each reference to the table whose artefact made it and retires --columns-used / --scan-code in favour of --detail full|minimal. Merging this first would introduce two flags and remove them one PR later, so it is closed instead of stacked.

What is kept from here, unchanged: the guardrail that a primary key and any column an edge depends on survive regardless, the Entity.xml exclusion, code scanning, and the wording that a dropped column is one no reference was found for — which is not the same as one that is unused.

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