diff --git a/fact.go b/fact.go new file mode 100644 index 0000000..c65a174 --- /dev/null +++ b/fact.go @@ -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: 727432 + // + // 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: + // + // 1 + // 3 + // + // + // 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 "" +} diff --git a/fact_test.go b/fact_test.go new file mode 100644 index 0000000..70f77b4 --- /dev/null +++ b/fact_test.go @@ -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 := `` + + 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 := `Some cool text block about concentrations.` + + 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 := `727432` + + 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 := `0.64` + + 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 := ` + 1 + 3 +` + + 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) + }) +} diff --git a/xbrl.go b/xbrl.go index dde5b99..1a5f249 100644 --- a/xbrl.go +++ b/xbrl.go @@ -3,4 +3,6 @@ package xbrl type XBRL struct { Contexts []Context `xml:"context"` Units []Unit `xml:"unit"` + + Facts []Fact `xml:",any"` }