Skip to content

Commit 2f03ca2

Browse files
committed
fragment-selectors: add per-instance xpathOf lookup (Phase 2)
Resolve the concrete, position-qualified W3C XPointer of a property on a parsed INSDC value, naming the property with a quotation: project |> BioProject.xpathOf <@ fun p -> p.Name @> // -> "#xpointer(/PROJECT/NAME)" project |> BioProject.xpathOf <@ fun p -> p.Identifiers.SecondaryId.[0].Value @> // -> "#xpointer(/PROJECT/IDENTIFIERS/SECONDARY_ID[1]/text())" A scalar value carries no back-reference to its property or position, so the property is named with a quotation rather than piped by value. The XPath is a pure function of the quotation plus the model's System.Xml.Serialization attributes; the instance is consulted only to validate array indices. No ConditionalWeakTable, no second parse, and the read/write surface is unchanged. - src/BioFSharp.IO.INSDC/Internal/XPathTracking.fs: quotation parser + attribute->step mapping + generic xpathOf. - per-entity xpathOf wrappers on all eight IO modules. - tests: scalar/attribute/nested/collection-index, resolution, out-of-range. - plans/xml-fragment-selectors.md: Phase 2 mechanism/files updated to match.
1 parent 878303c commit 2f03ca2

12 files changed

Lines changed: 256 additions & 33 deletions

File tree

‎plans/xml-fragment-selectors.md‎

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -154,51 +154,54 @@ at all). The caller passes the **object** (which has identity) plus a **property
154154
names the property without invoking it). The quotation also gives compile-time checking and IDE
155155
rename support.
156156

157-
**Mechanism — populate at parse time, no second parse.**
158-
- After `*.read` deserializes, walk the object graph and compute, for every populated leaf, its
159-
positional XPath. `System.Xml.Serialization` preserves document order within a collection and each
160-
collection property maps to exactly one repeating element name, so object index `i` ⇒ same-named
161-
XPath sibling `Y[i+1]` — the indices come from the object graph itself; the XML is not re-read.
162-
- Store the result in a module-level `ConditionalWeakTable<obj, IReadOnlyDictionary<string,string>>`
163-
keyed by the parsed root. **No model-type changes**: the generated classes stay pure
164-
`[Xml*]`-decorated DTOs (a dictionary property on a model type would make `XmlSerializer(typeof<_>)`
165-
throw at construction).
166-
- Keys are F#-side positional dotted paths (`"Collaborators[1].Name"`, **0-based** to match quotation
167-
indexing); values are full `#xpointer(...)` with **1-based** XPath predicates.
168-
169-
**`xpathOf`.** Parses the quotation AST (`PropertyGet` chain + `Item`/`get_Item` indexers) into a
170-
dotted key and looks it up in the table for the passed object. v1 supports literal/captured-constant
171-
indices; a non-constant index is a follow-up.
157+
**Mechanism — derive on demand from the quotation; no map, no second parse.**
158+
- `xpathOf` decomposes the quotation AST into an ordered chain of `(property, optional index)` steps:
159+
`PropertyGet` for members; `get_Item` / `GetArray` / indexer `PropertyGet` for `xs.[i]`, whose index
160+
attaches to the collection property that produced it.
161+
- Each step's relative XPath piece comes from the property's own `System.Xml.Serialization` attribute
162+
(`@accession`, `NAME`, `COLLABORATORS/COLLABORATOR`, `text()`) — the same mapping the Phase-1 engine
163+
uses. An indexed step appends a **1-based** predicate to the last segment (`COLLABORATOR` + index 1
164+
→ `COLLABORATOR[2]`).
165+
- The parsed `root` is walked **only along the selected chain**, to validate indices against the real
166+
data (out-of-range or an absent collection raises a clear exception) so the result points at a node
167+
that exists. There is **no full-graph walk, no per-instance map, and no `ConditionalWeakTable`** —
168+
the XPath is a pure function of the quotation plus the model's attributes, with the instance
169+
consulted only for index bounds. (`System.Xml.Serialization` preserves document order within a
170+
collection, so object index `i` ⇒ same-named XPath sibling `[i+1]`; the source XML is never re-read.)
171+
- **No model-type changes** and **no change to the `read`/`write` surface**: the lookup needs only the
172+
value the caller already holds, and the generated classes stay pure `[Xml*]`-decorated DTOs.
173+
174+
v1 supports literal/captured-constant indices in the quotation; a non-constant index is a follow-up.
172175

173176
**The Phase-1 maps stay.** They are the structural template Phase 2 resolves against and the only
174177
selectors available *without* a parsed instance (documentation, write-side, schema tooling) — not
175178
obsolete.
176179

177180
### Files to create / modify (Phase 2)
178181

179-
4. **Runtime tracking module — `src/BioFSharp.IO.INSDC/Internal/XPathTracking.fs` (new).** The
180-
object-graph walk, the `ConditionalWeakTable`, the quotation parser, and the generic
181-
`xpathOf : Expr<'T -> 'P> -> 'T -> string`. Reflects over the FileFormats model types (the IO
182-
project already references FileFormats). It re-implements the attribute→step logic from
183-
`FragmentSelectorTasks.fs` (which is build-only); the duplication is factorable later but not worth
184-
blocking on.
185-
5. **Wire population into `read` — `src/BioFSharp.IO.INSDC/Internal/XmlSerializer.fs`.** Register each
186-
deserialized result in the table as the seq is realized. `read`/`readString` signatures are
187-
**unchanged** (non-breaking).
188-
6. **Per-entity `xpathOf` — the `BioProject`/`Analysis`/`Experiment`/`BioSample`/`Run`/… modules.**
189-
Thin wrappers over the generic core so `BioProject.xpathOf` reads as in the example.
190-
191-
> **Scope note.** Phase 2 deliberately reaches into the **IO layer**, which Phase 1 excluded
192-
> ("No IO-layer changes"). It is the promotion of the Phase-1 "evaluate selector" follow-up into scope.
182+
4. **Runtime lookup module — `src/BioFSharp.IO.INSDC/Internal/XPathTracking.fs` (new).** The quotation
183+
parser, the attribute→step mapping, and the generic `xpathOf : Expr<'T -> 'P> -> 'T -> string`.
184+
Reflects over the FileFormats model types (the IO project already references FileFormats). It
185+
re-implements the attribute→step logic from `FragmentSelectorTasks.fs` (which is build-only); the
186+
duplication is factorable later but not worth blocking on.
187+
5. **Per-entity `xpathOf` — the `BioProject`/`Study`/`BioSample`/`Experiment`/`Run`/`Analysis`/
188+
`Submission`/`Receipt` modules.** One-line wrappers over the generic core so `BioProject.xpathOf`
189+
reads as in the example. `read`/`readString`/`write` are untouched — the IO read/write surface is
190+
unchanged.
191+
192+
> **Scope note.** Phase 2 adds files to the **IO layer**, which Phase 1 excluded ("No IO-layer
193+
> changes"). It is the promotion of the Phase-1 "evaluate selector" follow-up into scope — but it stays
194+
> additive: no existing IO function changes signature or behaviour.
193195
194196
### Verification (Phase 2)
195197

196-
- A `XPathLookupTests` type: scalar (`Name`), attribute (`Accession`), nested
197-
(`SubmissionProject.Organism.ScientificName`), and **collection-index** (`Collaborators.[i].…`)
198-
cases against a real fixture.
198+
- A `XPathLookupTests` type over the `PRJDB5192` BioProject fixture: scalar (`Name`), attribute
199+
(`Accession`), nested (`SubmissionProject.Organism.ScientificName`), and **collection-index**
200+
(`Identifiers.SecondaryId.[0].Value` → `…/SECONDARY_ID[1]/text()`) selectors.
199201
- **Round-trip the resolution:** for each, assert `xpathOf`'s XPath resolves (via `XmlDocument`) to
200202
the exact value `read` produced — proving the per-instance selector points at the right node.
201-
- Absent optionals / `*Specified = false` produce no entry (lookup returns `None` / raises a clear error).
203+
- An out-of-range collection index raises a clear exception (the index is validated against the
204+
instance).
202205

203206
## Out of scope
204207

‎src/BioFSharp.IO.INSDC/Analysis.fs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,9 @@ module Analysis =
2121
/// Serialize an INSDC Analysis `analysis` to an XML string.
2222
let writeString (analysis: Analysis) : string =
2323
XmlSerializer.writeString analysis
24+
25+
/// Resolve the position-qualified W3C XPointer fragment selector for a property of a parsed
26+
/// `analysis`. Name the property with a quotation, addressing array positions with `.[i]`:
27+
/// `analysis |> Analysis.xpathOf <@ fun a -> a.Files.[0].Filename @>`.
28+
let xpathOf selector (analysis: Analysis) : string =
29+
XPathTracking.xpathOf selector analysis

‎src/BioFSharp.IO.INSDC/BioFSharp.IO.INSDC.fsproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
<ItemGroup>
1111
<Compile Include="Internal\XmlSerializer.fs" />
12+
<Compile Include="Internal\XPathTracking.fs" />
1213
<Compile Include="BioProject.fs" />
1314
<Compile Include="Study.fs" />
1415
<Compile Include="BioSample.fs" />

‎src/BioFSharp.IO.INSDC/BioProject.fs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@ module BioProject =
2323
/// Serialize an INSDC BioProject `project` to an XML string.
2424
let writeString (project: BioProject) : string =
2525
XmlSerializer.writeString project
26+
27+
/// Resolve the position-qualified W3C XPointer fragment selector for a property of a parsed
28+
/// `project`. Name the property with a quotation, addressing array positions with `.[i]`:
29+
/// `project |> BioProject.xpathOf <@ fun p -> p.Name @>` -> `#xpointer(/PROJECT/NAME)`.
30+
let xpathOf selector (project: BioProject) : string =
31+
XPathTracking.xpathOf selector project

‎src/BioFSharp.IO.INSDC/BioSample.fs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@ module BioSample =
2323
/// Serialize an INSDC BioSample `sample` to an XML string.
2424
let writeString (sample: BioSample) : string =
2525
XmlSerializer.writeString sample
26+
27+
/// Resolve the position-qualified W3C XPointer fragment selector for a property of a parsed
28+
/// `sample`. Name the property with a quotation, addressing array positions with `.[i]`:
29+
/// `sample |> BioSample.xpathOf <@ fun s -> s.SampleName.ScientificName @>`.
30+
let xpathOf selector (sample: BioSample) : string =
31+
XPathTracking.xpathOf selector sample

‎src/BioFSharp.IO.INSDC/Experiment.fs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,9 @@ module Experiment =
2121
/// Serialize an INSDC Experiment `experiment` to an XML string.
2222
let writeString (experiment: Experiment) : string =
2323
XmlSerializer.writeString experiment
24+
25+
/// Resolve the position-qualified W3C XPointer fragment selector for a property of a parsed
26+
/// `experiment`. Name the property with a quotation, addressing array positions with `.[i]`:
27+
/// `experiment |> Experiment.xpathOf <@ fun e -> e.StudyRef.Accession @>`.
28+
let xpathOf selector (experiment: Experiment) : string =
29+
XPathTracking.xpathOf selector experiment
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
namespace BioFSharp.IO.INSDC.Internal
2+
3+
open System
4+
open System.Collections
5+
open System.Collections.Generic
6+
open System.Reflection
7+
open System.Text
8+
open System.Xml.Serialization
9+
10+
open Microsoft.FSharp.Quotations
11+
12+
/// Resolves the concrete, position-qualified W3C XPointer of a property on a parsed INSDC value.
13+
/// Public entity modules expose this as `<Entity>.xpathOf`. The property is named with a quotation
14+
/// (`<@ fun b -> b.Name @>`) rather than piped by value, because a scalar value carries no
15+
/// back-reference to its property, owner, or array position. The XPath is derived from the same
16+
/// `System.Xml.Serialization` attributes the serializer uses, with array positions taken from the
17+
/// indices written in the quotation and validated against the instance — so it points at exactly
18+
/// the node `read` produced. This is the runtime, per-instance counterpart of the generated,
19+
/// type-level `FragmentSelectors` maps in BioFSharp.FileFormats.INSDC.
20+
module XPathTracking =
21+
22+
/// The single attribute of type `'a` on a member, or None.
23+
let private tryAttr<'a when 'a :> Attribute> (m: MemberInfo) : 'a option =
24+
match Attribute.GetCustomAttribute(m, typeof<'a>) with
25+
| null -> None
26+
| a -> Some(a :?> 'a)
27+
28+
let private hasAttr<'a when 'a :> Attribute> (m: MemberInfo) : bool = (tryAttr<'a> m).IsSome
29+
30+
/// True for an element/array property whose value is a repeatable collection (not a string).
31+
let private isCollection (t: Type) : bool =
32+
t <> typeof<string>
33+
&& (t.IsArray
34+
|| t.GetInterfaces()
35+
|> Array.exists (fun i ->
36+
i.IsGenericType && i.GetGenericTypeDefinition() = typedefof<IEnumerable<_>>))
37+
38+
/// The relative XPath piece for a property and whether it is a repeatable collection, or None
39+
/// when the property is not serialized (`[XmlIgnore]` or unannotated).
40+
let private relativeStep (p: PropertyInfo) : (string * bool) option =
41+
let orElse fallback (s: string) = if String.IsNullOrEmpty s then fallback else s
42+
if hasAttr<XmlIgnoreAttribute> p then None
43+
else
44+
match tryAttr<XmlAttributeAttribute> p with
45+
| Some att -> Some("@" + (att.AttributeName |> orElse p.Name), false)
46+
| None ->
47+
match tryAttr<XmlArrayAttribute> p with
48+
| Some arr ->
49+
let arrName = arr.ElementName |> orElse p.Name
50+
let itemName =
51+
match tryAttr<XmlArrayItemAttribute> p with
52+
| Some item -> item.ElementName |> orElse arrName
53+
| None -> arrName
54+
Some(arrName + "/" + itemName, true)
55+
| None ->
56+
match tryAttr<XmlElementAttribute> p with
57+
| Some el -> Some(el.ElementName |> orElse p.Name, isCollection p.PropertyType)
58+
| None -> if hasAttr<XmlTextAttribute> p then Some("text()", false) else None
59+
60+
/// Append a 1-based positional predicate to the last element of a relative piece:
61+
/// `COLLABORATORS/COLLABORATOR`, index 1 -> `COLLABORATORS/COLLABORATOR[2]`.
62+
let private withIndex (piece: string) (index: int) : string =
63+
let slash = piece.LastIndexOf('/')
64+
if slash < 0 then sprintf "%s[%d]" piece (index + 1)
65+
else sprintf "%s%s[%d]" (piece.Substring(0, slash + 1)) (piece.Substring(slash + 1)) (index + 1)
66+
67+
/// The XML root element name for a type carrying `[XmlRoot]`.
68+
let private rootElementName (t: Type) : string =
69+
match tryAttr<XmlRootAttribute> t with
70+
| Some r -> r.ElementName |> fun s -> if String.IsNullOrEmpty s then t.Name else s
71+
| None -> failwithf "Type '%s' has no [XmlRoot] and cannot anchor an absolute XPath." t.Name
72+
73+
/// Evaluate a quotation index expression to an int (a literal, or a captured value).
74+
let private evalIndex (e: Expr) : int =
75+
match e with
76+
| Patterns.Value(v, _) when (v :? int) -> unbox<int> v
77+
| _ -> unbox<int> (Microsoft.FSharp.Linq.RuntimeHelpers.LeafExpressionConverter.EvaluateQuotation e)
78+
79+
/// Flatten a selector body into an ordered list of (property, optional index) steps. An indexer
80+
/// (`xs.[i]`, however the quotation encodes it) attaches its index to the collection property
81+
/// that produced it.
82+
let rec private parseChain (e: Expr) : (PropertyInfo * int option) list =
83+
let attach target idx =
84+
match List.rev (parseChain target) with
85+
| (p, _) :: rest -> List.rev ((p, Some(evalIndex idx)) :: rest)
86+
| [] -> failwithf "Index in selector has no preceding collection property: %A" e
87+
match e with
88+
| Patterns.Var _ -> []
89+
| Patterns.PropertyGet(Some target, prop, []) -> parseChain target @ [ prop, None ]
90+
| Patterns.PropertyGet(Some target, _, [ idx ]) -> attach target idx
91+
| Patterns.Call(Some target, mi, [ idx ]) when mi.Name = "get_Item" -> attach target idx
92+
| Patterns.Call(None, mi, [ target; idx ]) when mi.Name = "GetArray" -> attach target idx
93+
| _ ->
94+
failwithf "Unsupported selector shape: %A. Use a property/index chain like <@ fun x -> x.A.[0].B @>." e
95+
96+
/// Materialize a collection value's items (for index bounds checking and navigation).
97+
let private items (value: obj) : obj[] = (value :?> IEnumerable) |> Seq.cast<obj> |> Seq.toArray
98+
99+
let private build (rootType: Type) (steps: (PropertyInfo * int option) list) (root: obj) : string =
100+
let sb = StringBuilder("/").Append(rootElementName rootType)
101+
let mutable current = root
102+
for (prop, idxOpt) in steps do
103+
match relativeStep prop with
104+
| None -> failwithf "Property '%s' is not serialized to XML." prop.Name
105+
| Some(piece, repeatable) ->
106+
let value = if isNull current then null else prop.GetValue current
107+
match idxOpt with
108+
| Some i ->
109+
if not repeatable then
110+
failwithf "Property '%s' is not a collection and cannot be indexed." prop.Name
111+
if isNull value then failwithf "Collection '%s' is absent in this instance." prop.Name
112+
let xs = items value
113+
if i < 0 || i >= xs.Length then
114+
failwithf "Index %d is out of range for '%s' (count %d)." i prop.Name xs.Length
115+
sb.Append('/').Append(withIndex piece i) |> ignore
116+
current <- xs.[i]
117+
| None ->
118+
sb.Append('/').Append(piece) |> ignore
119+
current <- value
120+
sb.ToString()
121+
122+
/// Resolve the `#xpointer(...)` fragment selector for the property named by `selector` on the
123+
/// parsed `root` value, with real array positions taken from the indices in the quotation.
124+
let xpathOf (selector: Expr<'Root -> 'P>) (root: 'Root) : string =
125+
let steps =
126+
match (selector :> Expr) with
127+
| Patterns.Lambda(_, body) -> parseChain body
128+
| _ -> failwith "Expected a selector lambda, e.g. <@ fun x -> x.Prop @>."
129+
"#xpointer(" + build typeof<'Root> steps (box root) + ")"

‎src/BioFSharp.IO.INSDC/Receipt.fs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,9 @@ module Receipt =
2222
/// Serialize an INSDC Receipt `receipt` to an XML string.
2323
let writeString (receipt: Receipt) : string =
2424
XmlSerializer.writeString receipt
25+
26+
/// Resolve the position-qualified W3C XPointer fragment selector for a property of a parsed
27+
/// `receipt`. Name the property with a quotation, addressing array positions with `.[i]`:
28+
/// `receipt |> Receipt.xpathOf <@ fun r -> r.Submission.Accession @>`.
29+
let xpathOf selector (receipt: Receipt) : string =
30+
XPathTracking.xpathOf selector receipt

‎src/BioFSharp.IO.INSDC/Run.fs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,9 @@ module Run =
2121
/// Serialize an INSDC Run `run` to an XML string.
2222
let writeString (run: Run) : string =
2323
XmlSerializer.writeString run
24+
25+
/// Resolve the position-qualified W3C XPointer fragment selector for a property of a parsed
26+
/// `run`. Name the property with a quotation, addressing array positions with `.[i]`:
27+
/// `run |> Run.xpathOf <@ fun r -> r.ExperimentRef.Accession @>`.
28+
let xpathOf selector (run: Run) : string =
29+
XPathTracking.xpathOf selector run

‎src/BioFSharp.IO.INSDC/Study.fs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,9 @@ module Study =
2121
/// Serialize an INSDC Study `study` to an XML string.
2222
let writeString (study: Study) : string =
2323
XmlSerializer.writeString study
24+
25+
/// Resolve the position-qualified W3C XPointer fragment selector for a property of a parsed
26+
/// `study`. Name the property with a quotation, addressing array positions with `.[i]`:
27+
/// `study |> Study.xpathOf <@ fun s -> s.Descriptor.StudyTitle @>`.
28+
let xpathOf selector (study: Study) : string =
29+
XPathTracking.xpathOf selector study

0 commit comments

Comments
 (0)