Skip to content

Commit 0ee6f21

Browse files
committed
Filter generated typings against OData CSDL $metadata
Fetches the OData $metadata on each CRM run and uses it as ground truth to filter attributes, ManyToOne, OneToMany, and ManyToMany relationships, ensuring generated typings reflect only what is accessible on the wire. Virtual attributes (shadow fields, yomi names, metadata-only columns) are now excluded via CSDL rather than SDK-side heuristics. Duplicate ManyToOne nav props collapsed by the CSDL no longer produce duplicate interface members. File-type columns are now typed as GUIDs, reflecting their actual wire format.
1 parent 7bf4a81 commit 0ee6f21

10 files changed

Lines changed: 123 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## [Unreleased]
4+
### Changed
5+
- Attributes and navigation properties are now filtered against the OData CSDL `$metadata`, ensuring generated typings reflect only what is accessible on the wire
6+
- Virtual attributes (shadow fields, `yomi*` names, metadata-only columns) are now excluded via CSDL rather than SDK-side heuristics
7+
- File-type columns are now typed as GUIDs, reflecting their actual wire format
8+
### Fixed
9+
- Duplicate ManyToOne navigation properties (collapsed by the CSDL) no longer generate duplicate interface members
10+
311
## [1.4.0] - 2026-04-28
412
### Changed
513
- WebEntities internal interfaces reorganized into sub-namespaces under `_`: `Scalars`, `Read`, `Write`, `Binds`, and `Lookup`, replacing the previous flat layout

src/CreateTypeScript/CreateWebEntities.fs

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -72,23 +72,12 @@ let getScalarType (attr: XrmAttribute) =
7272

7373
(** Variable functions *)
7474

75-
/// True when an attribute on the entity claims the same OData property name as the given
76-
/// navigation property name. Lookup-style attributes (specialType = EntityReference) are
77-
/// renamed to `_<name>_value` in the OData CSDL so they don't shadow; everything else
78-
/// (Uniqueidentifier, File, Image, plain scalars) keeps its natural name and forces
79-
/// Dataverse to drop the navigation property from the wire.
80-
let private isShadowedByScalar (attrMap: Map<string, XrmAttribute>) navPropName =
81-
Map.tryFind navPropName attrMap
82-
|> Option.exists (fun a -> a.specialType <> SpecialType.EntityReference)
8375

8476
let getBindVars (nameMap: Map<string, EntityInfo>) (filter: XrmAttribute -> bool) (entity: XrmEntity) =
8577
let attrMap = entity.attributes |> List.map (fun a -> a.logicalName, a) |> Map.ofList
8678

8779
entity.manyToOneRelationships
88-
|> List.filter (fun rel ->
89-
not (isNull rel.ReferencingEntityNavigationPropertyName)
90-
&& Map.tryFind rel.ReferencingAttribute attrMap |> Option.exists filter
91-
&& not (isShadowedByScalar attrMap rel.ReferencingEntityNavigationPropertyName))
80+
|> List.filter (fun rel -> Map.tryFind rel.ReferencingAttribute attrMap |> Option.exists filter)
9281
|> List.map (fun rel ->
9382
let eInfo = Map.find rel.ReferencedEntity nameMap
9483

@@ -121,13 +110,8 @@ let getLookupValueVars (attrs: XrmAttribute list) =
121110
let private toInterfaceName forWrite schemaName =
122111
if forWrite then $"{schemaName}.{CREATE_INTERFACE_NAME}" else schemaName
123112

124-
let getManyToOneVars nameMap (schemaNames: Set<string>) forWrite (entity: XrmEntity) =
125-
let attrMap = entity.attributes |> List.map (fun a -> a.logicalName, a) |> Map.ofList
126-
127-
entity.manyToOneRelationships
128-
|> List.filter (fun rel ->
129-
not (isNull rel.ReferencingEntityNavigationPropertyName)
130-
&& not (isShadowedByScalar attrMap rel.ReferencingEntityNavigationPropertyName))
113+
let getManyToOneVars nameMap (schemaNames: Set<string>) forWrite (rels: OneToManyRelationshipMetadata list) =
114+
rels
131115
|> List.map (fun rel ->
132116
let eInfo = Map.find rel.ReferencedEntity nameMap
133117

@@ -164,7 +148,6 @@ let getManyToOneVars nameMap (schemaNames: Set<string>) forWrite (entity: XrmEnt
164148

165149
let getOneToManyVars nameMap (schemaNames: Set<string>) forWrite (rels: OneToManyRelationshipMetadata list) =
166150
rels
167-
|> List.filter (fun rel -> not (isNull rel.ReferencedEntityNavigationPropertyName))
168151
|> List.map (fun rel ->
169152
let eInfo = Map.find rel.ReferencingEntity nameMap
170153
let inGeneration = schemaNames.Contains eInfo.SchemaName
@@ -190,10 +173,6 @@ let getOneToManyVars nameMap (schemaNames: Set<string>) forWrite (rels: OneToMan
190173

191174
let getManyToManyVars nameMap (schemaNames: Set<string>) forWrite (entity: XrmEntity) =
192175
entity.manyToManyRelationships
193-
|> List.filter (fun rel ->
194-
(entity.logicalName = rel.Entity1LogicalName || entity.logicalName = rel.Entity2LogicalName) // Filter intersect tables
195-
&& not (isNull rel.Entity1NavigationPropertyName)
196-
&& not (isNull rel.Entity2NavigationPropertyName))
197176
|> List.map (fun rel ->
198177
let navProp, partnerNavProp, otherLogical =
199178
if entity.logicalName = rel.Entity2LogicalName then
@@ -300,22 +279,12 @@ type EntityInterfaces = {
300279
read: Interface
301280
}
302281

303-
let getIntersectEntities (nameMap: Map<string, EntityInfo>) (entity: XrmEntity) =
304-
if not entity.isIntersect then []
305-
else
306-
match entity.manyToManyRelationships with
307-
| [] -> []
308-
| rel :: _ ->
309-
[ rel.Entity1LogicalName; rel.Entity2LogicalName ]
310-
|> List.map (fun ln -> Map.find ln nameMap)
311-
312282
let getBlankEntityInterfaces (nameMap: Map<string, EntityInfo>) (entity: XrmEntity) =
313283
let comment =
314284
Comment.Entity(
315285
entity.displayName,
316286
setName = entity.setName,
317287
isIntersect = entity.isIntersect,
318-
intersectEntities = getIntersectEntities nameMap entity,
319288
logicalName = entity.logicalName
320289
)
321290

@@ -402,15 +371,15 @@ let getEntityInterfaceLines nameMap (schemaNames: Set<string>) (entity: XrmEntit
402371
let readInterfaces =
403372
[
404373
entityInterfaces.readRelationships
405-
{ entityInterfaces.readManyToOne with vars = getManyToOneVars nameMap schemaNames false entity }
374+
{ entityInterfaces.readManyToOne with vars = getManyToOneVars nameMap schemaNames false entity.manyToOneRelationships }
406375
{ entityInterfaces.readOneToMany with vars = getOneToManyVars nameMap schemaNames false entity.oneToManyRelationships }
407376
{ entityInterfaces.readManyToMany with vars = getManyToManyVars nameMap schemaNames false entity }
408377
]
409378

410379
let writeInterfaces =
411380
[
412381
entityInterfaces.writeRelationships
413-
{ entityInterfaces.writeManyToOne with vars = getManyToOneVars nameMap schemaNames true entity }
382+
{ entityInterfaces.writeManyToOne with vars = getManyToOneVars nameMap schemaNames true entity.manyToOneRelationships }
414383
{ entityInterfaces.writeOneToMany with vars = getOneToManyVars nameMap schemaNames true entity.oneToManyRelationships }
415384
{ entityInterfaces.writeManyToMany with vars = getManyToManyVars nameMap schemaNames true entity }
416385
]

src/Generation/DataRetrieval.fs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,36 @@
11
module DG.XrmTypeScript.DataRetrieval
22

3+
open System.Net.Http
4+
open System.Xml
5+
6+
open Microsoft.OData.Edm.Csdl
7+
38
open Utility
49

510
open CrmBaseHelper
611
open CrmDataHelper
712
open Microsoft.Xrm.Sdk.Metadata
813
open Microsoft.Xrm.Sdk
9-
14+
open Microsoft.Xrm.Tooling.Connector
15+
16+
17+
/// Fetch the OData CSDL $metadata XML from the Web API
18+
let fetchCsdlXml (proxy: IOrganizationService) =
19+
printf "Fetching OData $metadata..."
20+
match proxy with
21+
| :? CrmServiceClient as client ->
22+
let baseUri = client.CrmConnectOrgUriActual
23+
let url = $"{baseUri.Scheme}://{baseUri.Host}/api/data/v9.2/$metadata"
24+
use http = new HttpClient()
25+
http.DefaultRequestHeaders.Authorization <- Headers.AuthenticationHeaderValue("Bearer", client.CurrentAccessToken)
26+
let xml = http.GetStringAsync(url).Result
27+
use reader = XmlReader.Create(new System.IO.StringReader(xml))
28+
let model = CsdlReader.Parse reader
29+
printfn "Done!"
30+
Some model
31+
| _ ->
32+
printfn "Skipped (proxy is not CrmServiceClient)"
33+
None
1034

1135
/// Connect to CRM with the given authentication
1236
let connectToCrm xrmAuth =

src/Generation/GenerationMain.fs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,25 @@ let retrieveRawState xrmAuth rSettings skipForms =
1313
let mainProxy = connectToCrm xrmAuth
1414

1515
let crmVersion = retrieveCrmVersion mainProxy
16+
17+
let csdlModel = fetchCsdlXml mainProxy
18+
1619
let entities =
1720
getFullEntityList rSettings.entities rSettings.solutions mainProxy
1821

1922
// Retrieve data from CRM
20-
retrieveCrmData crmVersion entities mainProxy rSettings.skipInactiveForms skipForms
23+
retrieveCrmData crmVersion entities mainProxy rSettings.skipInactiveForms skipForms, csdlModel
2124

2225
/// Main generator function
23-
let generateFromRaw (gSettings: XdtGenerationSettings) rawState =
26+
let generateFromRaw (gSettings: XdtGenerationSettings) csdlModel rawState =
2427
let crmVersion = gSettings.crmVersion ?| rawState.crmVersion
2528

2629
// Pre-generation tasks
2730
clearOldOutputFiles gSettings.out
2831

2932
// Interpret data and generate resource files
3033
let data =
31-
interpretCrmData gSettings rawState
34+
interpretCrmData gSettings csdlModel rawState
3235

3336
let defs =
3437
seq {

src/Generation/Setup.fs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
open System
44
open System.Collections.Generic
55

6+
open Microsoft.OData.Edm
7+
68
open IntermediateRepresentation
79
open InterpretEntityMetadata
810
open InterpretBpfJson
911
open InterpretFormXml
12+
open FilterByCsdl
1013

1114

1215
let intersectMappedSets a b = Map.ofSeq (seq {
@@ -67,14 +70,30 @@ let intersectForms formDict formsToIntersect =
6770
|> Seq.append formDict.Values
6871
|> Seq.toArray
6972

73+
let private buildEdmTypeMap (model: IEdmModel) =
74+
model.SchemaElements
75+
|> Seq.choose (function
76+
| :? IEdmEntityType as t -> Some(t.Name, t)
77+
| _ -> None)
78+
|> Map.ofSeq
79+
7080
/// Interprets the raw CRM data into an intermediate state used for further generation
71-
let interpretCrmData (gSettings: XdtGenerationSettings) (rawState: RawState) =
81+
let interpretCrmData (gSettings: XdtGenerationSettings) (csdlModel: IEdmModel option) (rawState: RawState) =
7282
printf "Interpreting data..."
7383

74-
let nameMap = rawState.info |> Array.map (fun e -> e.LogicalName, e) |> Map.ofArray
84+
let infoMap = rawState.info |> Array.map (fun e -> e.LogicalName, e) |> Map.ofArray
7585

7686
let entityMetadata =
77-
rawState.metadata |> Array.Parallel.map (interpretEntity nameMap gSettings.labelMapping)
87+
let interpreted = rawState.metadata |> Array.Parallel.map (interpretEntity infoMap gSettings.labelMapping)
88+
match csdlModel with
89+
| None -> interpreted
90+
| Some model ->
91+
let edmTypes = buildEdmTypeMap model
92+
interpreted
93+
|> Array.Parallel.choose (fun e ->
94+
match Map.tryFind e.logicalName edmTypes with
95+
| Some edmType -> Some (filterEntity edmType e)
96+
| None -> None)
7897

7998
let bpfControls = interpretBpfs rawState.bpfData
8099

@@ -86,5 +105,5 @@ let interpretCrmData (gSettings: XdtGenerationSettings) (rawState: RawState) =
86105
bpfControls = bpfControls
87106
forms = forms
88107
outputDir = gSettings.out
89-
nameMap = nameMap
108+
nameMap = infoMap
90109
}

src/Interpretation/FilterByCsdl.fs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
module internal DG.XrmTypeScript.FilterByCsdl
2+
3+
open Microsoft.OData.Edm
4+
open IntermediateRepresentation
5+
6+
7+
let private toCsdlPropName (attr: XrmAttribute) =
8+
match attr.specialType with
9+
| SpecialType.EntityReference -> $"_{attr.logicalName}_value"
10+
| _ -> attr.logicalName
11+
12+
let filterEntity (edmType: IEdmEntityType) (entity: XrmEntity) : XrmEntity =
13+
let csdlProps =
14+
edmType.StructuralProperties()
15+
|> Seq.map (fun p -> p.Name)
16+
|> Set.ofSeq
17+
18+
let csdlNavProps =
19+
edmType.NavigationProperties()
20+
|> Seq.map (fun p -> p.Name)
21+
|> Set.ofSeq
22+
23+
{ entity with
24+
attributes =
25+
entity.attributes
26+
|> List.filter (fun a -> Set.contains (toCsdlPropName a) csdlProps)
27+
28+
manyToOneRelationships =
29+
entity.manyToOneRelationships
30+
|> List.filter (fun r ->
31+
Set.contains r.ReferencingEntityNavigationPropertyName csdlNavProps)
32+
|> List.distinctBy (fun r -> r.ReferencingEntityNavigationPropertyName)
33+
34+
oneToManyRelationships =
35+
entity.oneToManyRelationships
36+
|> List.filter (fun r ->
37+
Set.contains r.ReferencedEntityNavigationPropertyName csdlNavProps)
38+
39+
manyToManyRelationships =
40+
entity.manyToManyRelationships
41+
|> List.filter (fun r ->
42+
let navProp =
43+
if entity.logicalName = r.Entity2LogicalName
44+
then r.Entity2NavigationPropertyName
45+
else r.Entity1NavigationPropertyName
46+
Set.contains navProp csdlNavProps) }

src/Interpretation/InterpretEntityMetadata.fs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,13 @@ let interpretNormalAttribute aType (options:OptionSet option) =
4141

4242
| XrmAttributeType.Uniqueidentifier -> TsType.String, SpecialType.Guid
4343

44-
| XrmAttributeType.File -> TsType.String, SpecialType.Default
44+
| XrmAttributeType.File -> TsType.String, SpecialType.Guid
45+
| XrmAttributeType.Image -> TsType.String, SpecialType.Default
4546

4647
| _ -> typeConv aType, SpecialType.Default
4748

4849
let interpretAttribute (nameMap: Map<string, EntityInfo>) labelMapping (a: AttributeMetadata) =
4950
let aType = XrmAttributeType.fromDisplayName a.AttributeTypeName
50-
if a.AttributeOf <> null ||
51-
aType = XrmAttributeType.Virtual ||
52-
a.LogicalName.StartsWith("yomi") then None, None
53-
else
5451

5552
let options =
5653
match a with
@@ -65,7 +62,7 @@ let interpretAttribute (nameMap: Map<string, EntityInfo>) labelMapping (a: Attri
6562

6663
let vType, sType = interpretNormalAttribute aType options
6764

68-
options, Some {
65+
options, {
6966
XrmAttribute.schemaName = a.SchemaName
7067
logicalName = a.LogicalName
7168
varType = vType
@@ -88,7 +85,6 @@ let interpretEntity (nameMap: Map<string, EntityInfo>) labelMapping (metadata:En
8885

8986
let attributes =
9087
attributes
91-
|> Array.choose id
9288
|> Array.toList
9389

9490
let optionSets =

src/TypeScript/Comment.fs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,11 @@ type Comment =
1111
| [ line ] -> [ $"/** {line} */" ]
1212
| _ -> [ "/**" ] @ (lines |> List.collect (fun l -> [ ""; l ]) |> List.tail |> List.map (sprintf " * %s ")) @ [ " */" ]
1313

14-
static member Entity(displayName, setName, ?logicalName, ?isIntersect, ?intersectEntities) =
14+
static member Entity(displayName, setName, ?logicalName, ?isIntersect) =
1515
let logicalName = defaultArg logicalName ""
16-
let intersectEntities = defaultArg intersectEntities []
1716

1817
[
1918
if defaultArg isIntersect false then yield "Intersect Table"
20-
match intersectEntities with
21-
| e1 :: e2 :: _ -> yield $"Intersects: {e1.DisplayName} (`{e1.LogicalName}`) ⟷ {e2.DisplayName} (`{e2.LogicalName}`)"
22-
| _ -> ()
2319
if not (IsNullOrWhiteSpace displayName) then yield $"**{displayName.Trim()}**"
2420
if not (IsNullOrWhiteSpace logicalName) then yield $"Logical Name: `{logicalName.Trim()}`"
2521
if not (IsNullOrWhiteSpace setName) then yield $"Set Name: `{setName.Trim()}`"

src/XrmTypeScript.fs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ type XrmTypeScript private () =
7171
try
7272
#endif
7373

74-
retrieveRawState xrmAuth rSettings gSettings.skipForms
75-
|> generateFromRaw gSettings
74+
let rawState, csdlModel = retrieveRawState xrmAuth rSettings gSettings.skipForms
75+
generateFromRaw gSettings csdlModel rawState
7676
printfn "\nSuccessfully generated all TypeScript declaration files."
7777

7878
#if !DEBUG
@@ -93,7 +93,7 @@ type XrmTypeScript private () =
9393
use stream = new FileStream(filePath, FileMode.Create)
9494

9595
retrieveRawState xrmAuth rSettings false
96-
|> fun state -> serializer.WriteObject(stream, state)
96+
|> fun (state, _) -> serializer.WriteObject(stream, state)
9797
printfn "\nSuccessfully saved retrieved data to file %s." (Path.GetFullPath filePath)
9898

9999
#if !DEBUG
@@ -137,7 +137,7 @@ type XrmTypeScript private () =
137137
serializer.ReadObject(stream) :?> RawState
138138
with ex -> failwithf "\nUnable to parse data file"
139139

140-
generateFromRaw gSettings rawState
140+
generateFromRaw gSettings None rawState
141141
printfn "\nSuccessfully generated all TypeScript declaration files."
142142

143143
#if !DEBUG

src/XrmTypeScript.fsproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
<Compile Include="Interpretation\InterpretCommon.fs" />
3939
<Compile Include="Interpretation\InterpretOptionSetMetadata.fs" />
4040
<Compile Include="Interpretation\InterpretEntityMetadata.fs" />
41+
<Compile Include="Interpretation\FilterByCsdl.fs" />
4142
<Compile Include="Interpretation\InterpretFormXml.fs" />
4243
<Compile Include="Interpretation\InterpretBpfJson.fs" />
4344
<Compile Include="CreateTypeScript\CreateCommon.fs" />
@@ -63,6 +64,7 @@
6364
</ItemGroup>
6465
<ItemGroup>
6566
<PackageReference Include="Microsoft.CrmSdk.XrmTooling.CoreAssembly" Version="9.1.1.65" />
67+
<PackageReference Include="Microsoft.OData.Edm" Version="7.*" />
6668
</ItemGroup>
6769
<ItemGroup>
6870
<Reference Include="mscorlib" />

0 commit comments

Comments
 (0)