Skip to content

Commit

Permalink
fact models and basic unmarshal tests (#5)
Browse files Browse the repository at this point in the history
* fact models and basic unmarshal tests

* single fact model with validator

* update comments
  • Loading branch information
mmoghaddam385 authored Jun 8, 2021
1 parent 48ea688 commit 5196bb4
Show file tree
Hide file tree
Showing 3 changed files with 279 additions and 0 deletions.
167 changes: 167 additions & 0 deletions fact.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package xbrl

import (
"encoding/xml"
"errors"
"strconv"
)

type FactType string

const (
// FactTypeNil is a fact which has an `xsi:nil` attribute set to a truthy value.
// A nil fact is only guaranteed to have an XMLName and ContextRef.
FactTypeNil FactType = "nil"

// FactTypeNonNumeric is a non-nil fact that does not describe a numeric value (ie text, dates, encoded binary data, etc).
// A non-numeric fact is guaranteed to have an XMLName, ContextRef, and ValueStr.
FactTypeNonNumeric FactType = "non_numeric"

// FactTypeNonFraction is a non-nil fact describing a numeric value that can precisely expressed as a simple value.
// A non-fraction fact is guaranteed to have an XMLName, ContextRef, UnitRef, ValueStr, and exactly one of Precision or Decimals.
//
// For example: <ci:capitalLeases contextRef="c1" unitRef="u1" precision="3">727432</ci:capitalLeases>
//
// Use Fact.NumericValue() for easy access to the numeric value as a float64.
FactTypeNonFraction FactType = "non_fraction"

// FactTypeFraction is a non-nil fact describing a numeric value that is the result of a numerator / denominator.
// Usually the numeric value that these facts describe cannot be precisely expressed by a float64 (ie 1/3 = 0.3333...)
// A fraction fact is guaranteed to have an XMLName, ContextRef, UnitRef, Numerator, and Denominator.
//
// For example:
// <myTaxonomy:oneThird id="oneThird" unitRef="u1" contextRef="numC1">
// <numerator>1</numerator>
// <denominator>3</denominator>
// </myTaxonomy:oneThird>
//
// Use Fact.NumericValue() for easy access to the numeric value as a float64,
// but be aware that the float64 representation may not be able to precisely represent the Facts actual value.
FactTypeFraction FactType = "fraction"
)

// ErrNonNumericFactType is returned when a fact is expected to be numeric, but is not.
var ErrNonNumericFactType = errors.New("fact is not of type FactTypeFraction or FactTypeNonFraction")

// Fact represents an item in an XBRL document.
// A Fact is a single value which is tied to a context that gives the fact more meaning.
//
// This struct contains fields that may or may not be nil depending on what type of Fact you're dealing with.
// See Fact.Type() to determine what type of fact you're dealing with, and Fact.IsValid() to be confident all the expected fields exist.
// Then the various FactTypes to understand what fields in this struct are expected to exist for each FactType.
//
// For general information and details on XBRL Facts, see here:
// https://www.xbrl.org/Specification/XBRL-2.1/REC-2003-12-31/XBRL-2.1-REC-2003-12-31+corrected-errata-2013-02-20.html#_4.6
type Fact struct {
XMLName xml.Name

// ID uniquely identifies a fact within an XBRL document.
// The spec does not require an ID attribute, but many items have an ID attribute, which is why it's included in this model.
ID string `xml:"id,attr"`

// Nil is an attribute denoting whether or not this fact is expressed as nil.
Nil *bool `xml:"nil,attr"`

// ContextRef is the ID of the context in the XBRL document that gives more meaning to this fact.
ContextRef string `xml:"contextRef,attr"`

// UnitRef is the ID of the unit in the XBRL document that this fact is expressed in.
// It is non-nil for numeric facts only.
UnitRef *string `xml:"unitRef,attr"`

// Precision conveys the arithmetic precision of a measurement.
// It can be either a non-negative integer or the special value "INF", which represents infinite precision.
// If this is a numeric fact but NOT a fraction type, Precision will be non-nil if Decimals is nil,
//
// Examples and more info here:
// https://www.xbrl.org/Specification/XBRL-2.1/REC-2003-12-31/XBRL-2.1-REC-2003-12-31+corrected-errata-2013-02-20.html#_4.6.4
Precision *string `xml:"precision,attr"`

// Decimals specifies the number of decimal places to which the value of the fact represented may be considered accurate.
// It can be either an integer (positive or negative) or the special value "INF", which represents accuracy to infinite decimal places.
// If this is a numeric fact but NOT a fraction type, Decimals will be non-nil if Precision is nil,
//
// Examples and more info here:
// https://www.xbrl.org/Specification/XBRL-2.1/REC-2003-12-31/XBRL-2.1-REC-2003-12-31+corrected-errata-2013-02-20.html#_4.6.5
Decimals *string `xml:"decimals,attr"`

// ValueStr will be non-nil unless this is a numeric fraction type Fact.
// Use NumericValue() to easily get the numeric value that this fact represents, regardless of whether or not it's a fraction type.
ValueStr *string `xml:",chardata"`

// Numerator and Denominator will be non-nil values if this is a fraction type Fact.
// Use NumericValue() to easily get the numeric value that this fact represents, regardless of whether or not it's a fraction type.
Numerator *float64 `xml:"numerator"`
Denominator *float64 `xml:"denominator"`
}

// Type returns the type of this Fact. See the comments on the various FactTypes for more information.
// Note that this function returning a particular type does not necessarily mean that the fact is semantically correct.
// See IsValid() to be certain that the fact is valid.
func (f Fact) Type() FactType {
// If the nil attribute exists and is true, this is simply a nil fact
if f.Nil != nil && *f.Nil {
return FactTypeNil
}

// If the unitRef attribute exists, this is some kind of numeric attribute
if f.UnitRef != nil {
// If we have a numerator and denominator, it's a fraction fact
if f.Numerator != nil && f.Denominator != nil {
return FactTypeFraction
}

// Otherwise it's a simple non fraction numeric type.
return FactTypeNonFraction
}

// All that's left is a plain non-numeric fact type
return FactTypeNonNumeric
}

// IsValid confirms that f has at least the required fields that the FactType requires.
// Note that this function is not strict about extra fields existing.
func (f Fact) IsValid() bool {
// All facts must have a context ref
if f.ContextRef == "" {
return false
}

// Some types have particular rules beyond what Type() checks for that must be true to be considered valid.
switch f.Type() {
case FactTypeFraction:
// Fraction must have a non-zero Denominator
return *f.Denominator != 0
case FactTypeNonFraction:
// NonFractions must have either a non-nil Precision or non-nil Decimals field
return (f.Precision == nil) != (f.Decimals == nil)
case FactTypeNonNumeric:
return f.ValueStr != nil
default:
return true
}
}

// NumericValue attempts to return the numeric value this fact represents.
// This function returns
// If this fact is a fraction type, this function returns the value of numerator / denominator.
// Note that fraction type facts generally cannot be precisely represented as a float64 and may have some rounding error.
func (f Fact) NumericValue() (float64, error) {
switch f.Type() {
case FactTypeFraction:
return *f.Numerator / *f.Denominator, nil
case FactTypeNonFraction:
return strconv.ParseFloat(*f.ValueStr, 64)
default:
return 0, ErrNonNumericFactType
}
}

// Value returns the ValueStr of this Fact, or empty string if f.ValueStr is nil.
func (f Fact) Value() string {
if f.ValueStr != nil {
return *f.ValueStr
}

return ""
}
110 changes: 110 additions & 0 deletions fact_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package xbrl

import (
"encoding/xml"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestUnmarshalFact(t *testing.T) {
t.Run("nil fact", func(t *testing.T) {
// language=xml
factXML := `<myns:sillyFact contextRef="c1" xsi:nil="true"/>`

var fact Fact
require.NoError(t, xml.Unmarshal([]byte(factXML), &fact))

assert.Equal(t, xml.Name{Space: "myns", Local: "sillyFact"}, fact.XMLName)
assert.Equal(t, FactTypeNil, fact.Type())
assert.True(t, fact.IsValid())
assert.Equal(t, "c1", fact.ContextRef)
})

t.Run("non-numeric fact", func(t *testing.T) {
// language=xml
factXML := `<ci:concentrationsNote contextRef="c1">Some cool text block about concentrations.</ci:concentrationsNote>`

var fact Fact
require.NoError(t, xml.Unmarshal([]byte(factXML), &fact))

assert.Equal(t, xml.Name{Space: "ci", Local: "concentrationsNote"}, fact.XMLName)
assert.Equal(t, FactTypeNonNumeric, fact.Type())
assert.True(t, fact.IsValid())
assert.Equal(t, "c1", fact.ContextRef)
assert.Nil(t, fact.UnitRef)
assert.Equal(t, "Some cool text block about concentrations.", fact.Value())
})

t.Run("simple numeric fact", func(t *testing.T) {
// language=xml
factXML := `<ci:capitalLeases id="id123" contextRef="c1" unitRef="u1" precision="3">727432</ci:capitalLeases>`

var fact Fact
require.NoError(t, xml.Unmarshal([]byte(factXML), &fact))

assert.Equal(t, xml.Name{Space: "ci", Local: "capitalLeases"}, fact.XMLName)
assert.Equal(t, FactTypeNonFraction, fact.Type())
assert.True(t, fact.IsValid())
assert.Equal(t, "id123", fact.ID)
assert.Equal(t, "c1", fact.ContextRef)
require.NotNil(t, fact.UnitRef)
assert.Equal(t, "u1", *fact.UnitRef)
require.NotNil(t, fact.Precision)
assert.Equal(t, "3", *fact.Precision)
assert.Nil(t, fact.Decimals)

val, err := fact.NumericValue()
require.NoError(t, err)
assert.EqualValues(t, 727432, val)
})

t.Run("simple decimal numeric fact", func(t *testing.T) {
// language=xml
factXML := `<us-gaap:EarningsPerShareBasic contextRef="i0ad" decimals="2" id="id3Vyb" unitRef="usdPerShare">0.64</us-gaap:EarningsPerShareBasic>`

var fact Fact
require.NoError(t, xml.Unmarshal([]byte(factXML), &fact))

assert.Equal(t, xml.Name{Space: "us-gaap", Local: "EarningsPerShareBasic"}, fact.XMLName)
assert.Equal(t, FactTypeNonFraction, fact.Type())
assert.True(t, fact.IsValid())
assert.Equal(t, "id3Vyb", fact.ID)
assert.Equal(t, "i0ad", fact.ContextRef)
require.NotNil(t, fact.UnitRef)
assert.Equal(t, "usdPerShare", *fact.UnitRef)
assert.Nil(t, fact.Precision)
require.NotNil(t, fact.Decimals)
assert.Equal(t, "2", *fact.Decimals)

val, err := fact.NumericValue()
require.NoError(t, err)
assert.EqualValues(t, 0.64, val)
})

t.Run("fraction type numeric fact", func(t *testing.T) {
// language=xml
factXML := `<myTaxonomy:oneThird id="oneThird" unitRef="u1" contextRef="numC1">
<numerator>1</numerator>
<denominator>3</denominator>
</myTaxonomy:oneThird>`

var fact Fact
require.NoError(t, xml.Unmarshal([]byte(factXML), &fact))

assert.Equal(t, xml.Name{Space: "myTaxonomy", Local: "oneThird"}, fact.XMLName)
assert.Equal(t, FactTypeFraction, fact.Type())
assert.True(t, fact.IsValid())
assert.Equal(t, "oneThird", fact.ID)
assert.Equal(t, "numC1", fact.ContextRef)
require.NotNil(t, fact.UnitRef)
assert.Equal(t, "u1", *fact.UnitRef)
assert.Nil(t, fact.Precision)
assert.Nil(t, fact.Decimals)

val, err := fact.NumericValue()
require.NoError(t, err)
assert.EqualValues(t, 1.0/3.0, val)
})
}
2 changes: 2 additions & 0 deletions xbrl.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ package xbrl
type XBRL struct {
Contexts []Context `xml:"context"`
Units []Unit `xml:"unit"`

Facts []Fact `xml:",any"`
}

0 comments on commit 5196bb4

Please sign in to comment.